模組 03 · 第 3 課

工具呼叫:讓模型能動手

模型自己查不到即時資料,也不能執行任何操作。給它一個真實的工具,去 PyPI 查包的最新版本,把工具呼叫的完整流程走一遍,包括並行呼叫、出錯處理和思考模式下的注意事項。

  • 約 45 分鐘
  • 難度:進階
  • 實測:2026-09-14 deepseek-flash

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

問模型"httpx 的最新版本是多少",它只能憑訓練資料回答,可能是一年前的版本號,也可能是編的。問它"幫我在日曆上加個會議",它只能回答"好的,我已為你新增",然後什麼都沒發生。

模型只能輸出文字。要讓它查到即時的資料、真的去做事,需要給它工具:你寫好函式,告訴模型有哪些函式可以用;模型判斷需要時,輸出"我要呼叫某個函式,參數是什麼";你的程式真的去執行這個函式,把結果告訴模型;模型根據結果回答使用者。這個機制叫工具呼叫(tool calling),也叫函式呼叫(function calling)。

這是第 05 模組智慧體的基礎,這一課把它的每個環節都弄清楚。

流程

先看全貌。一次帶工具呼叫的問答,至少要呼叫兩次模型:

你的程序                                           模型
   │  1. 用户问题 + 工具说明书                          │
   │ ────────────────────────────────────────────▶ │
   │                    2. "请调用 get_pypi_info(httpx)" │
   │ ◀──────────────────────────────────────────── │
   │  3. 程序自己执行 get_pypi_info("httpx")            │
   │     拿到结果 {"version": "0.28.1", ...}           │
   │  4. 之前的全部消息 + 工具结果                       │
   │ ────────────────────────────────────────────▶ │
   │                    5. "httpx 的最新版本是 0.28.1"   │
   │ ◀──────────────────────────────────────────── │

關鍵在第 2 步和第 3 步:模型從來不執行任何程式碼。它只是輸出一段結構化的"呼叫請求",執行權完全在你的程式手裡。你可以檢查它要呼叫什麼、參數對不對,決定執行還是拒絕。這一點對安全非常重要,05 模組第 8 課會展開講。

第一步:寫工具,寫說明書

工具就是一個普通的 Python 函式。這裡寫一個真實可用的:呼叫 PyPI 的公開介面,查一個包的最新版本。

import httpx


def get_pypi_info(package: str) -> dict:
    """真正干活的函数:调用 PyPI 的公开接口。"""
    r = httpx.get(f"https://pypi.org/pypi/{package}/json", timeout=10)
    if r.status_code == 404:
        return {"error": f"PyPI 上没有叫 {package} 的包"}
    info = r.json()["info"]
    return {"name": info["name"], "version": info["version"], "summary": info["summary"],
            "requires_python": info["requires_python"]}

順便一提,這裡用來發 HTTP 請求的正是 httpx。安裝 openai 時它已經作為依賴裝好了。

然後寫一份"說明書"告訴模型這個工具的存在。模型看不到你的函式程式碼,它只能看到這份說明書

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_pypi_info",
            "description": "查询一个 Python 包在 PyPI 上的最新版本、简介和支持的 Python 版本。",
            "parameters": {
                "type": "object",
                "properties": {
                    "package": {"type": "string", "description": "PyPI 上的包名,例如 httpx"},
                },
                "required": ["package"],
            },
        },
    }
]
FUNCTIONS = {"get_pypi_info": get_pypi_info}

name 是工具名,description 說明它能做什麼,parameters 用 JSON Schema 描述參數。模型根據 description 判斷什麼時候該用這個工具,根據 parameters 決定怎麼填參數。說明書寫得好不好,直接決定模型會不會用、用得對不對,05 模組第 3 課會專門講怎麼寫。

FUNCTIONS 是一個從工具名到真正函式的對映,程式收到呼叫請求後,用它找到要執行的函式。

第二步:迴圈

messages = [{"role": "user", "content": "httpx 和 requests 在 PyPI 上的最新版本分别是多少?各自要求什么 Python 版本?"}]

for step in range(1, 6):  # 最多 5 轮,防止意外的死循环
    response = client.chat.completions.create(
        model=MODEL,
        messages=messages,
        tools=TOOLS,
        extra_body={"thinking": {"type": "enabled" if THINKING else "disabled"}},
    )
    message = response.choices[0].message
    print(f"第 {step} 轮:finish_reason={response.choices[0].finish_reason}")

    if not message.tool_calls:
        print("最终回答:", message.content)
        break

    # 把模型的这条消息原样放回历史。开思考时,里面的 reasoning_content 也必须带上
    messages.append(message.model_dump(exclude_none=True))

    for call in message.tool_calls:
        args = json.loads(call.function.arguments)
        print(f"  模型要求调用 {call.function.name}({args})")
        try:
            result = FUNCTIONS[call.function.name](**args)
        except Exception as e:  # 工具出错也要告诉模型,而不是让程序崩掉
            result = {"error": f"{type(e).__name__}: {e}"}
        print(f"  返回:{result}")
        messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False)})

每一輪:帶著 tools 呼叫模型。如果回答裡沒有 tool_calls,說明模型已經給出最終回答,結束。如果有,就逐個執行,把結果作為 roletool 的訊息加回歷史,再呼叫一次模型。

幾個要注意的地方:

  • 模型的呼叫請求要放回歷史messages.append(message.model_dump(exclude_none=True)) 把模型這條包含 tool_calls 的訊息原樣加回去。少了這一步,模型下一輪看到一堆工具結果,卻不知道是誰要的。
  • tool_call_id 要對上。每個呼叫請求都有一個 id,對應的工具結果要帶上同樣的 tool_call_id。模型一次要求呼叫多個工具時,它靠這個 ID 知道哪個結果對應哪個請求。
  • 參數是字串形式的 JSONcall.function.arguments'{"package": "httpx"}' 這樣的字串,要用 json.loads 解析。
  • 工具出錯不要讓程式崩掉。把錯誤資訊作為結果返回給模型,模型往往能根據錯誤調整,比如換個參數再試一次,或者如實告訴使用者查不到。
  • 設一個輪數上限。模型可能反覆呼叫工具停不下來,上限是最後一道保險。

執行結果

第 1 轮:finish_reason=tool_calls
  模型要求调用 get_pypi_info({'package': 'httpx'})
  返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
  模型要求调用 get_pypi_info({'package': 'requests'})
  返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:

| 包名 | 最新版本 | 要求 Python 版本 | 简介 |
|---|---|---|---|
| **httpx** | 0.28.1 | >=3.8 | The next generation HTTP client. |
| **requests** | 2.34.2 | >=3.10 | Python HTTP for Humans. |

几点说明:

- **httpx** 支持范围更宽,Python 3.8 及以上都能用,兼容性更好。
- **requests** 这边要求 Python 3.10 及以上,门槛更高一些。
- 光看"最低版本要求"的话,httpx 覆盖的老版本 Python 更多;但如果你跑在 3.10+ 环境上,两者都没问题。

如果你告诉我项目所用的 Python 版本,我可以帮你判断具体该选哪个。

(這是 2026 年 9 月 14 日查到的版本號,你執行時 PyPI 上的版本可能已經更新了。)

第 1 輪的 finish_reasontool_calls,這就是第 00 模組第 3 課那張表裡的第三種情況。而且模型在同一輪裡要求呼叫了兩次:一次查 httpx,一次查 requests。這叫並行工具呼叫,模型判斷兩次查詢互不依賴,就一起提出來,省掉了一輪來回。第 2 輪,模型拿到兩份真實資料,給出了最終回答,版本號都來自 PyPI,不是它編的。

開著思考模式時

DeepSeek 的模型預設開啟思考。思考模式下使用工具,有一條規則:之前每一輪的 reasoning_content 都要原樣傳回給 API。不帶工具時,傳不傳都無所謂,伺服器會忽略;帶了工具就必須傳。

上面的程式碼用 message.model_dump(exclude_none=True) 把模型的整條訊息轉換成字典放回歷史,裡面自然包括了 reasoning_content,所以開思考也能正常工作:

python tool_calling.py --think
第 1 轮:finish_reason=tool_calls
  模型要求调用 get_pypi_info({'package': 'httpx'})
  返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
  模型要求调用 get_pypi_info({'package': 'requests'})
  返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:
(后面的回答内容和不开思考时相近,这里省略)

一個常見的寫法是隻把 contenttool_calls 挑出來,手動拼成一個字典放回歷史。這樣寫在不開思考時沒問題,開了思考就會丟掉 reasoning_content。用 model_dump 原樣放回,最省心。

模型亂傳參數怎麼辦

模型填的參數不一定對:可能是一個不存在的包名,可能少了必填參數,可能型別不對。幾道防線:

  1. 說明書寫清楚。參數的 description 裡寫明格式和例子,"PyPI 上的包名,例如 httpx"比只寫"包名"好。
  2. 在函數里檢查。不要假設參數一定合法,比如包名裡有沒有奇怪的字元、數值在不在合理範圍內。
  3. 把錯誤返回給模型。上面的程式碼裡,函式丟擲的任何異常都被捕獲,變成 {"error": "..."} 返回。PyPI 上找不到的包,函式本身也會返回一條錯誤說明。模型看到錯誤,通常會自己糾正或者如實告訴使用者。
  4. 嚴格模式。第 02 模組第 4 課講過 DeepSeek 的嚴格模式(strict: true),能保證參數符合 schema 的結構,但保證不了內容是對的。

常見問題

模型該呼叫工具時沒有呼叫,直接編了個答案:檢查工具的 description 有沒有清楚說明它能做什麼。也可以在 system 訊息裡寫明"涉及包的版本資訊時,必須用 get_pypi_info 查詢,不要憑記憶回答"。確定必須呼叫某個工具時,可以用 tool_choice 強制。

報錯說訊息順序不對:通常是 tool 訊息前面沒有對應的、帶 tool_callsassistant 訊息,或者 tool_call_id 對不上。按上面的程式碼,先放模型的訊息,再逐個放工具結果。

練習

  1. 問一個 PyPI 上不存在的包,比如 "httpxx 的最新版本是多少",看看工具返回的錯誤資訊,以及模型怎麼回應使用者。
  2. 再加一個工具 get_github_stars(repo),呼叫 GitHub 的公開介面 https://api.github.com/repos/{repo} 查倉庫的星數(不需要金鑰,但每小時有次數限制)。問"httpx 的最新版本和 GitHub 星數是多少",看模型會不會同時呼叫兩個不同的工具。
  3. messages.append(message.model_dump(exclude_none=True)) 改成只放 contenttool_calls 的手寫字典,用 --think 執行,看看會發生什麼。

自測

1. 工具呼叫時,是模型執行了 get_pypi_info 函式嗎?

不是。模型只是輸出了"我要呼叫 get_pypi_info,參數是 httpx"這樣一段結構化的請求。真正執行函式的是你的程式。執行權完全在程式手裡,你可以檢查、修改或拒絕模型的呼叫請求。

2. 模型一次要求呼叫了兩個工具,你怎麼把兩個結果分別告訴它?

每個呼叫請求都有唯一的 id。為每個呼叫各新增一條 roletool 的訊息,並在 tool_call_id 裡填上對應請求的 id,模型靠它把結果和請求對應起來。

3. 開啟思考模式使用工具時,需要注意什麼?

之前每一輪模型訊息裡的 reasoning_content 都要原樣傳回給 API。最省事的辦法是用 message.model_dump(exclude_none=True) 把模型的整條訊息放回歷史,不要隻手動挑出 contenttool_calls

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…