工具呼叫:讓模型能動手
模型自己查不到即時資料,也不能執行任何操作。給它一個真實的工具,去 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,說明模型已經給出最終回答,結束。如果有,就逐個執行,把結果作為 role 為 tool 的訊息加回歷史,再呼叫一次模型。
幾個要注意的地方:
- 模型的呼叫請求要放回歷史。
messages.append(message.model_dump(exclude_none=True))把模型這條包含tool_calls的訊息原樣加回去。少了這一步,模型下一輪看到一堆工具結果,卻不知道是誰要的。 tool_call_id要對上。每個呼叫請求都有一個id,對應的工具結果要帶上同樣的tool_call_id。模型一次要求呼叫多個工具時,它靠這個 ID 知道哪個結果對應哪個請求。- 參數是字串形式的 JSON。
call.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_reason 是 tool_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 上的最新信息如下:
(后面的回答内容和不开思考时相近,这里省略)
一個常見的寫法是隻把 content 和 tool_calls 挑出來,手動拼成一個字典放回歷史。這樣寫在不開思考時沒問題,開了思考就會丟掉 reasoning_content。用 model_dump 原樣放回,最省心。
模型亂傳參數怎麼辦
模型填的參數不一定對:可能是一個不存在的包名,可能少了必填參數,可能型別不對。幾道防線:
- 說明書寫清楚。參數的
description裡寫明格式和例子,"PyPI 上的包名,例如 httpx"比只寫"包名"好。 - 在函數里檢查。不要假設參數一定合法,比如包名裡有沒有奇怪的字元、數值在不在合理範圍內。
- 把錯誤返回給模型。上面的程式碼裡,函式丟擲的任何異常都被捕獲,變成
{"error": "..."}返回。PyPI 上找不到的包,函式本身也會返回一條錯誤說明。模型看到錯誤,通常會自己糾正或者如實告訴使用者。 - 嚴格模式。第 02 模組第 4 課講過 DeepSeek 的嚴格模式(
strict: true),能保證參數符合 schema 的結構,但保證不了內容是對的。
常見問題
模型該呼叫工具時沒有呼叫,直接編了個答案:檢查工具的 description 有沒有清楚說明它能做什麼。也可以在 system 訊息裡寫明"涉及包的版本資訊時,必須用 get_pypi_info 查詢,不要憑記憶回答"。確定必須呼叫某個工具時,可以用 tool_choice 強制。
報錯說訊息順序不對:通常是 tool 訊息前面沒有對應的、帶 tool_calls 的 assistant 訊息,或者 tool_call_id 對不上。按上面的程式碼,先放模型的訊息,再逐個放工具結果。
練習
- 問一個 PyPI 上不存在的包,比如 "httpxx 的最新版本是多少",看看工具返回的錯誤資訊,以及模型怎麼回應使用者。
- 再加一個工具
get_github_stars(repo),呼叫 GitHub 的公開介面https://api.github.com/repos/{repo}查倉庫的星數(不需要金鑰,但每小時有次數限制)。問"httpx 的最新版本和 GitHub 星數是多少",看模型會不會同時呼叫兩個不同的工具。 - 把
messages.append(message.model_dump(exclude_none=True))改成只放content和tool_calls的手寫字典,用--think執行,看看會發生什麼。
自測
1. 工具呼叫時,是模型執行了 get_pypi_info 函式嗎?
不是。模型只是輸出了"我要呼叫 get_pypi_info,參數是 httpx"這樣一段結構化的請求。真正執行函式的是你的程式。執行權完全在程式手裡,你可以檢查、修改或拒絕模型的呼叫請求。
2. 模型一次要求呼叫了兩個工具,你怎麼把兩個結果分別告訴它?
每個呼叫請求都有唯一的 id。為每個呼叫各新增一條 role 為 tool 的訊息,並在 tool_call_id 裡填上對應請求的 id,模型靠它把結果和請求對應起來。
3. 開啟思考模式使用工具時,需要注意什麼?
之前每一輪模型訊息裡的 reasoning_content 都要原樣傳回給 API。最省事的辦法是用 message.model_dump(exclude_none=True) 把模型的整條訊息放回歷史,不要隻手動挑出 content 和 tool_calls。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…