工具调用:让模型能动手
模型自己查不到实时数据,也不能执行任何操作。给它一个真实的工具,去 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 积分。内容经审核后公开。
正在加载讨论…