上一篇文章里,我们把 Agent 的最小骨架搭起来了:本质上就是循环——把用户问题交给大模型,拿到回答,再交给用户。但你很快会发现,这个骨架单独跑起来没什么用。用户问“今天北京适合穿什么衣服”,模型只会给你一段“我无法获取实时天气数据”的道歉。原因很简单:LLM 是个离线的大脑,它的知识在训练时就冻结了,它没有任何“手”去查天气、查数据库、调接口。
这一篇要解决的问题,就是给这个大脑装上手。我们从零写一个能被 LLM 调用的工具(tool)系统,完整跑通“用户提问 -> 模型决定调用工具 -> 代码执行工具 -> 结果返回模型 -> 模型组织答案”这条链路。
我默认你已经读过了这个系列的第一篇,至少理解了“Agent = 模型 + 循环 + 工具”这个基础框架。如果你还没读过,也不影响,这篇文章会从工具调用的原理开始讲,代码部分我会给到可以直接跑的最小实现。整个项目用 Python + 智谱 GLM-4-Flash 免费模型 + OpenAI SDK 完成,全程没有高门槛依赖,下载完依赖就能跑。
1. 为什么 Agent 非得“自己动手”调工具:从一次失败对话说起
先看一个跑过第一篇文章代码的人大概率都会遇到的场景。用户问:
帮我查一下最近三个月这个账号的订单总数,然后按月份汇总。
如果你的 Agent 只有模型没有工具,模型只能回答类似“我无法访问您的订单数据”这句话,或者更气人的是,它可能会编一个数字给你——这就是行业里常说的幻觉。这个时候你作为一个开发者,心里一定在骂:数据库连接串都写在配置文件里了,查询逻辑也写好了,就差一个让它调用查询函数的方法。
1.1 “让 LLM 调用工具”到底是什么意思
很多人第一次接触 function calling(也叫 tool calling / 工具调用)时会有一个误解:以为是大模型直接执行了代码。不是的。大模型没有执行环境,它做的事情很“笨”——它只是从你提供的工具列表里选一个,按你定义的 JSON Schema 生成一个调用参数,然后返回一堆结构化的 JSON,里面写着“我要调用 get_order_stats 这个函数,参数是 { period: '3m' }”。
真正执行这个函数的是你的代码。执行完之后,你把函数返回的结果——不管是一个数字、一个 JSON 还是一个错误提示——作为一条消息传回对话上下文,模型再基于这个结果组织语言回复用户。
这个过程本质上是一个协议:你(开发者)负责执行,模型(大脑)负责决策。模型不关心函数内部是查询数据库还是请求第三方 API,它只关心两件事:这个工具是干什么的、参数要我传什么。这就是为什么工具描述(description)和参数定义(parameters)写得清不清楚,直接决定模型调用得准不准。
1.2 传统提示词方案为什么不行
读到这里你可能会问:我不搞这套协议,直接把需求写在 System Prompt 里,让模型输出“请调用 get_order_stats(3m)”这句话,我再从回答里用正则提取,不行吗?
可以,但非常脆弱。第一,模型可能把函数名写成 get_order_stats(三个月) 或者 getOrderStats(period='3m'),同一个意思三种写法,正则得写多少种匹配才能兜住?第二,多工具场景下,模型可能在一个回答里既想调用 A 又要调用 B,你无法稳定地从自然语言里拆出结构化调用意图。第三,最关键的是,模型的输出是概率性的,同一句话每次的措辞都有细微差异,任何依赖文本格式的约定都等于在沙滩上盖楼。
function calling 协议这套东西,本质上就是把“调用函数”这个动作从“自然语言约定”变成了“结构化协议约定”,模型在训练阶段就见过海量的这种 JSON 格式输出,它不需要你提示也知道该输出什么格式。你只需要把函数定义按它的规范传过去就行。
1.3 为什么用智谱 GLM 而不是 OpenAI
为了让这篇文章的代码大家都能直接跑通,我选了智谱的 GLM-4-Flash 模型,原因有三:第一,它在智谱开放平台上是免费额度,个人开发者注册就有,不用绑卡,非常适合做实验;第二,它原生支持 OpenAI SDK 兼容接口,也就是说你用 from openai import OpenAI 然后改一下 base_url 和 api_key 就能用,代码成本为零;第三,GLM-4 系列对 function calling 的支持比较标准和稳定,我在实际测试中按 OpenAI 格式传 tools 参数时,没有遇到协议上的兼容性问题。
当然,这篇文章的核心逻辑是通用的,你换成 OpenAI 的 GPT、DeepSeek、Qwen 的 API,或者本地部署的 vLLM 服务,只需要改 base_url 和模型名就可以,后面我会专门写一段讲协议格式在不同平台上的差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把工具变成 LLM 看得懂的说明书:注册与 Schema 设计
要让模型“知道”有哪些工具可用,你不能把 Python 函数源码直接丢给它——它没有执行环境,也看不懂源码。你需要把每个函数翻译成一份结构化的说明书,这份说明书在 OpenAI 协议里叫 Function Schema,和 API 一起传给模型。
2.1 一个天气预报工具的最小 Schema
假设我们写一个查天气的函数:
python复制def get_current_weather(location: str, unit: str = "celsius"):
"""查询指定城市当前天气"""
# 模拟真实查询
return {"location": location, "temperature": 22, "unit": unit}
这个函数要被模型调用,你需要给它配一份这样的“说明书”:
python复制tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "获取指定城市当前的天气状况,包括温度、天气现象、风力等信息",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称,例如:北京、上海、广州"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位,摄氏或华氏"
}
},
"required": ["location"]
}
}
}
]
这段结构看起来啰嗦,但每一个字段都有它的用处。name 是函数名,模型输出时会原样引用这个名字,你的代码靠它来路由(route)到对应的 Python 函数;description 是模型判断“这个工具要不要用”的依据,描述写得越清楚,模型选错工具的概率越低;parameters 是参数的 JSON Schema 定义,模型会严格按照这个结构生成参数对象,你不需要自己写解析逻辑。
2.2 参数 Schema 的经验法则
我调过不少模型的工具调用,说几个踩坑得出来的经验。
description 里一定要写“什么时候该用这个工具”。比如价格查询工具,不要只写“查询商品价格”,要写“当用户询问任意商品的价格、折扣、促销信息时使用”。模型在决策时本质是在做语义匹配,你描述里给的触发条件越明确,它越不容易把问题分配给错误的工具。
required 字段要尽量精简。只把业务上绝对必须的参数标成 required,其他都设为可选。为什么?因为模型在生成参数时,如果你把 5 个字段全标成必填,它可能会因为猜不准某几个字段的值而拒绝调用,或者随便填一个默认值进去,导致业务逻辑出错。给它留出“可以偷懒”的空间,调用成功率反而更高。
参数类型不要用 number 就用 integer,不要用 array 就用 array,该给 enum 就给 enum。模型是按概率生成 JSON 的,你的 Schema 约束得越严格,它生成非法参数的可能性越低。比如 unit 字段你给了 enum: ["celsius", "fahrenheit"],模型就只会在这两个值里选,不会突然给你来个 "C" 或者 "摄氏"。
2.3 从 Python 函数自动生成 Schema
手工写 Schema 在工具少的时候没问题,但工具一多(超过 5 个),手工维护就很不现实。我习惯用 Pydantic 来维护工具定义,让 Schema 从类型注解自动生成。下面是一个简化版:
python复制from pydantic import BaseModel, Field
class GetWeatherParams(BaseModel):
location: str = Field(description="城市名称,例如:北京")
unit: str = Field("celsius", description="温度单位", enum=["celsius", "fahrenheit"])
def get_current_weather(params: GetWeatherParams):
"""获取指定城市当前的天气状况"""
return {"location": params.location, "temperature": 22, "unit": params.unit}
def build_tool_schema(fn, params_model):
schema = params_model.model_json_schema()
return {
"type": "function",
"function": {
"name": fn.__name__,
"description": fn.__doc__,
"parameters": schema
}
}
这样当你加新工具时,只需定义一个新的 Pydantic 参数模型和一个函数,Schema 就会自动生成。等工具数量到了两位数,你会感谢自己当初做了这个封装。后面如果上框架(比如 LangChain、LlamaIndex),它们的 @tool 装饰器本质上也是帮你做了同样的事,把函数名字、docstring、类型注解翻译成 Schema,只是封装得更好用。但手写一遍能让你真正理解底层在发生什么。
3. 写一个工具调用主循环:代码从 0 到能跑
有了 Schema 还不够,真正的核心在于整套循环逻辑。这个过程分为四步:
- 把用户问题和 tools 定义一起发给模型。
- 模型返回两种可能:直接回答文本,或者返回 tool_calls 调用请求。
- 如果返回了 tool_calls,你执行对应的 Python 函数,把结果作为 tool 消息发回模型。
- 重复 2-3,直到模型不再返回 tool_calls,拿到最终回答。
3.1 完整的可运行代码
下面这段代码就是最小可用的工具调用 Agent,我加了详细注释:
python复制import json
from openai import OpenAI
client = OpenAI(
api_key="你的API_KEY",
base_url="https://open.bigmodel.cn/api/paas/v4/"
)
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "获取指定城市当前的天气状况,当用户询问任意城市的天气时使用",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名称,例如:北京、上海"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位"}
},
"required": ["location"]
}
}
}
]
def get_current_weather(location: str, unit: str = "celsius"):
"""模拟天气查询接口,真实场景替换为 API 调用"""
fake_db = {
"北京": {"temperature": 22, "condition": "晴"},
"上海": {"temperature": 25, "condition": "多云"},
"广州": {"temperature": 30, "condition": "小雨"},
}
info = fake_db.get(location, {"temperature": 20, "condition": "未知"})
return json.dumps({"location": location, **info, "unit": unit}, ensure_ascii=False)
# 初始消息
messages = [
{"role": "system", "content": "你是一个有帮助的助手。"},
{"role": "user", "content": "北京今天天气怎么样?适合穿短袖吗?"}
]
response = client.chat.completions.create(
model="glm-4-flash",
messages=messages,
tools=tools,
tool_choice="auto",
)
# 取出模型回复
assistant_msg = response.choices[0].message
messages.append(assistant_msg)
# 如果模型发起了工具调用
while assistant_msg.tool_calls:
for tool_call in assistant_msg.tool_calls:
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
print(f"[Agent] 调用工具: {fn_name}, 参数: {fn_args}")
if fn_name == "get_current_weather":
fn_result = get_current_weather(**fn_args)
else:
fn_result = json.dumps({"error": f"未找到工具: {fn_name}"}, ensure_ascii=False)
# 把工具结果作为 tool 消息追加到对话
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": fn_result
})
# 把包含工具结果的完整消息列表再发给模型
response = client.chat.completions.create(
model="glm-4-flash",
messages=messages,
tools=tools,
tool_choice="auto",
)
assistant_msg = response.choices[0].message
messages.append(assistant_msg)
# 模型最终回答
print(assistant_msg.content)
代码不长,30 多行,但这是 Agent 工具调用最核心的原型。你运行一下,模型会先输出 "[Agent] 调用工具: get_current_weather, 参数: {'location': '北京'}",然后基于函数返回的 JSON 生成最终回答。
3.2 这条消息循环协议里最容易被忽略的规则
这段代码里有一个规则必须理解:assistant 消息一旦带 tool_calls,它的后面必须紧跟对应 tool_call_id 的 tool 消息。你不能把模型第一次带 tool_calls 的消息留到下一轮再处理,也不能在工具结果返回之前插入其他角色消息,否则 API 会直接报错(后面我会详细讲)。这是 OpenAI 协议设计上的硬性要求,也是从 Chat Completion 切换到工具调用模式之后最容易踩的坑。
另外注意,我在代码里把 assistant 的回复 messages.append(assistant_msg) 之后,再逐条追加 tool 消息。同一轮里有多个 tool_calls 时,所有 tool 消息必须保持顺序一致,模型才能把结果对应到每个调用上去。
3.3 为什么要用 while 而不是 if
你可能注意到了,我用的是 while assistant_msg.tool_calls 而不是 if。这是因为模型可能需要多轮工具调用才能完成一个任务。举个典型场景:用户问“北京和上海的天气怎么样,哪个更冷?”模型第一步可能会调用两次 get_current_weather(一次北京一次上海),然后把两个结果都拿回来对比再回答。但如果用户问的是“对比北京、上海、广州、深圳四个城市的天气”,模型可能需要分两轮调用——第一轮调两个,第二轮再调两个,然后汇总。这个自行决定要调几轮工具的行为,是 Agent 和普通 API 调用之间最本质的区别:它有循环、有决策、有中间状态。
在你把项目规模扩大后,这条 while 循环里还可以加入最大调用轮数限制(比如最多 5 轮),防止模型陷入“工具调用死循环”。我见过模型在一个任务里反复调用同一个工具 20 多次的情况,不加限制就等于把账单无限拉高。
4. 让 Agent 一次调用多个工具:并行工具调用的实现与边界
上面代码在模型决定一次调用多个工具时,其实已经能工作了——它会返回多个 tool_calls,你循环里逐个执行就行。这里我把多工具调用的机制单独拿出来讲,因为它的执行顺序、消息拼接和错误处理都和多轮调用不同。
4.1 并行调用时消息拼接的坑
当模型返回两个 tool_call,比如一个查天气一个查机票,我在代码里是顺序执行这两个函数的。这种实现简单,但要注意一个细节:你不能把两个函数结果合并成一条 tool 消息返回,即使它们是并行调用。每条 tool 消息的 tool_call_id 必须一一对应,ids 必须和 assistant 回复里的完全一致。我最初实现时图省事,把两个结果拼成一个 JSON 塞进一条 tool 消息里,结果 API 直接报错“An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'”。
4.2 用 ThreadPoolExecutor 加速并行调用
如果两个工具互相没有依赖(查天气和查机票互不影响),完全可以用线程池并发执行,省掉一半的等待时间。这个优化在工具是外部 API 时收益特别明显——查天气要 1 秒,查机票要 2 秒,串行就是 3 秒,并发就是 2 秒:
python复制from concurrent.futures import ThreadPoolExecutor
def execute_tool_call(tool_call):
fn_name = tool_call.function.name
fn_args = json.loads(tool_call.function.arguments)
print(f"[Agent] 调用工具: {fn_name}, 参数: {fn_args}")
if fn_name == "get_current_weather":
return {
"role": "tool",
"tool_call_id": tool_call.id,
"content": get_current_weather(**fn_args)
}
# 其他工具...
raise ValueError(f"未知工具: {fn_name}")
with ThreadPoolExecutor(max_workers=4) as executor:
tool_messages = list(executor.map(execute_tool_call, assistant_msg.tool_calls))
messages.extend(tool_messages)
注意几点:线程池里跑的函数要做异常捕获,不能因为一个工具抛异常导致其他工具结果也丢了;tool_call_id 必须在并发执行时原样保留;如果多个工具之间存在依赖(比如先查城市 ID 再查天气),就不能并行,必须分轮串行。
4.3 模型什么时候会选择并行调用
我自己的观察是,模型倾向于把“明显独立”的几个查询合并到同一轮调用里。比如“北京天气和上海天气”会走并行,“帮我订机票再订酒店”通常会先订机票拿到订单号再订酒店(因为后者依赖前者的输出)。这是模型训练时形成的规划能力,我们不需要人为控制,但可以在工具描述里主动标注依赖关系,比如在订酒店工具的描述里写“该工具需要先调用订机票接口获取 order_id 后使用”,模型就知道要等上一轮结果了。
4.4 tool_choice 参数:让模型“必须用工具”或“指定用某个工具”
默认的 tool_choice="auto" 表示模型自己决定要不要调用工具。但在实际业务里,有两种场景需要你干预:
场景一:强制模型必须调用工具。比如你做一个客服质检系统,模型的作用是从对话文本里提取结构化信息并调用记录工具。如果模型偶尔不调用工具直接回答,你的流水线就断了。这时候设置 tool_choice="required",模型就算回答“我不知道”也会先生成一个工具调用(参数可能为空对象)。
场景二:限定用某一个工具。比如你在做一个意图分类器,只希望模型调用 train_intent 这个工具,其他工具都不要碰,那就设 tool_choice={"type": "function", "function": {"name": "train_intent"}}。这比你在 System Prompt 里写一万遍“不要调用其他工具”都管用,协议层面直接限死了。
这是我在做信息抽取类 Agent 时最常用的两个参数配置,比调 prompt 性价比高得多。
5. 工具返回值不是字符串:结构化结果与错误反馈设计
刚开始写工具调用时,我习惯让工具返回一个纯字符串,比如“北京今天 22 度,晴”。后来模型返回的回答质量参差不齐,我才意识到问题出在工具返回的数据形态上——字符串已经丢失了结构化信息,模型在重新组织语言时只能靠猜。
5.1 工具返回 JSON 字符串,永远不要返回格式化文本
正确做法是让工具返回结构化数据(字典转 JSON 字符串),把“怎么表达”这件事交给模型。比如天气工具返回:
json复制{"location": "北京", "temperature": 22, "condition": "晴", "humidity": 45, "wind": "北风3级"}
模型拿到这个 JSON 后,可以自己决定回答“北京目前 22 度,天气晴朗,湿度 45%,北风 3 级”,还是“北京挺舒服的,22 度大晴天,适合出门”。如果你在工具里就把话术定死,模型就没有发挥空间了。
我要特别提醒:哪怕工具返回的数据最终用户根本不会看到,也建议返回 JSON 而不是格式化文本。因为模型可能需要基于这个数据做二次推理,比如对比两座城市温度差,它需要的是数值,不是夹着中文描述的长字符串。
5.2 工具内部异常必须在工具内部消化
这是我从实践中总结出的最重要一条经验:永远不要让异常逃出工具函数。
假设 get_current_weather 内部调用了一个第三方天气 API,API 超时抛异常。如果异常直接冒出来,你的 while 循环就会崩掉,整个 Agent 对用户一直处于“无响应”状态。更差的处理是你在主循环里捕获异常然后停止对话,用户只会看到一句干巴巴的“系统错误”。
正确做法是在工具函数内部就捕获一切异常,然后把错误信息转成结构化的 JSON 返回给模型:
python复制def get_current_weather(location: str, unit: str = "celsius"):
try:
# 模拟调用第三方 API
resp = requests.get(f"https://api.weather.com/v1/{location}", timeout=5)
resp.raise_for_status()
data = resp.json()
return json.dumps(data, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"天气接口调用失败: {str(e)}", "location": location}, ensure_ascii=False)
这样当模型收到 {"error": "天气接口调用失败: timeout"} 时,它会基于这个错误信息生成一句给用户的回复:“抱歉,我暂时无法获取北京的天气信息,可能是天气服务暂时不可用。”用户至少得到了一个体面的反馈,而不是空白的错误页。
如果你希望模型在工具失败时采取更聪明的行动——比如换一个备用工具、换一种查询方式——那就在工具描述里写明失败时的处理建议:“如果该工具返回 error,请尝试调用 get_city_code 获取城市代码后重试”。模型会遵循这个建议进行下一轮调用。
5.3 把工具调用过程打印出来:调试状态的可观测性
最后一个建议听起来很土,但非常实用:在 while 循环里加打印。
我见过太多人调 Agent 时像是面对一个黑盒——也不知道模型调了哪个工具、传了什么参数、返回了什么结果,就只看 final answer。但 Agent 最容易出的问题恰恰在中间的调用链路上:工具选错了、参数传偏了、结果解析失败。给每条工具调用加一行 print,把 fn_name、fn_args、fn_result(截断前 200 字符)打出来,排查问题的效率不止翻一倍。
生产环境里,这行 print 应该换成日志系统(比如 loguru、structlog),把每轮工具调用记录成结构化日志,方便以后回溯和评估模型行为。
6. 从 OpenAI 格式到 MCP / OCI:工具调用的通用协议认知
当你写完上面这套手写工具调用流程后,你已经具备了理解当下各种 Agent 框架的基础。因为不管框架怎么包装,底层都跑着同一套协议逻辑:你给模型一份工具清单,模型给出一个调用决策,你的代码执行并返回结果。
6.1 各家平台协议差异对照
换到不同模型服务时,工具调用协议可能有细微差别。我踩过的平台不算多,但足够给你一张避坑表:
| 平台/模型 | 协议类型 | 需要注意的点 |
|---|---|---|
| OpenAI | function calling | 事实标准,tool_calls 结构最完整 |
| 智谱 GLM | OpenAI 兼容 | 直接使用 OpenAI SDK + 换 base_url 即可 |
| DeepSeek | OpenAI 兼容 | 同样兼容,但参数 Schema 对 enum 支持不如 OpenAI 严格 |
| Qwen(通义千问) | OpenAI 兼容/原生 | 原生 API 的 tool 消息必须在 assistant 带 tool_calls 后立即返回,否则报错 |
| vLLM 本地部署 | OpenAI 兼容 | 需要单独指定 --enable-auto-tool-choice 和 --tool-call-parser 才能让部分模型支持工具调用 |
| Claude | 原生 tool use | 协议格式不同,但概念完全一致 |
这张表不需要背,你只需要记住:OpenAI 的 function calling 格式是当前事实标准,绝大多数平台都提供兼容接口。你写代码时优先按 OpenAI 格式写,遇到问题再查对应平台的文档。
6.2 MCP 不是“调函数”,是“借用协议”
近两年 MCP(Model Context Protocol)越来越火,很多人以为 MCP 是 Agent 调工具的新方式,甚至以为有了 MCP 就不用 function calling 了。实际上不是这样。MCP 解决的是工具的定义、发现和分发问题——它定义了一个客户端和服务器之间的标准协议,让任意 Agent 都能发现并调用任意 MCP server 提供的工具。
在 Agent 内部,MCP 获取到的工具依然会被翻译成 OpenAI 风格的 tools 列表,走本文讲的那套 function calling 流程。你可以把 MCP 理解成“工具的中台”:它负责把散落在各处的工具(本地 Python 函数、远程 API、数据库查询)统一注册、统一暴露,而真正让模型“学会调用”这些工具的,还是 function calling 协议。
所以我的建议是:先把手写这套流程吃透,再去看 LangChain、LlamaIndex、Dify 这些框架里的 MCP 集成,你会瞬间理解它们让你配置的那些东西到底在干什么。
6.3 Agent 工具调用的未来形态
最近的大模型(尤其是 GPT-4o、Claude 3.5+ 这一代)开始支持更复杂的并行工具调用,也在尝试把“工具调用”和“任务规划”合并在一起——模型不再是简单地“调工具”,而是能自己规划一个多步任务执行计划,然后逐步执行。但这种演进不会改变本文讲的核心循环,只是让模型在循环里的每一步都变得更聪明。
7. 实测记录:四个我在写工具调用时踩过的坑
这部分是实战排坑,全部来自我自己的真实开发经历。每个坑背后都对应一个具体的报错或异常行为,如果你也遇到类似问题,可以参考排查思路。
7.1 坑一:Qwen 模型报 “An assistant message with 'tool_calls' must be followed by tool messages”
这个报错我在切换 Qwen 模型做对比测试时遇到。报错信息非常直白:assistant 带了 tool_calls 之后,没有跟着 tool 消息。我排查了一下,发现问题出在我把工具执行结果延迟到了下一轮才返回——代码里第一轮拿到 tool_calls 后先回复用户“请稍等”,第二轮才补 tool 消息。这在 OpenAI 上是允许的吗?不是,OpenAI 也要求紧跟。只不过 Qwen 的兼容层对错误消息的提示更严格,直接把请求拒了,而其他平台可能自动忽略后面的消息。
修复方式就是严格按照协议:assistant 发出 tool_calls 后,你必须在下一轮请求前,把所有对应 tool 消息都拼在它后面,中间不插任何其他角色消息。
7.2 坑二:vLLM 部署的模型报 “auto tool choice requires --enable-auto-tool-choice and --tool-call-parser”
如果你想在本地部署一个开源模型来做工具调用,就很可能遇到这个报错。原因是 vLLM 默认的 OpenAI 兼容服务器没有开启工具调用解析功能,它不知道该怎么把模型生成的内容解析成 tool_calls 结构。需要在你启动服务时加上两个参数:
bash复制python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--enable-auto-tool-choice \
--tool-call-parser hermes
--tool-call-parser 可填的值取决于模型,常见的有 hermes、mistral、qwen。如果你的模型不在官方支持列表里,还需要自己实现一个解析器。这个坑提醒我:工具调用的“最后一公里”往往不是模型能力,而是推理服务对协议的支持完整度。部署模型之前,务必先确认你选的推理框架对 function calling 的支持情况。
7.3 坑三:函数名不合法导致 400 报错
我一度喜欢给工具起很“语义化”的名字,比如 get_user_info!2 或者 订单查询_by_status。结果 OpenAI 直接返回 400,提示 function name 不合法。查了协议规范才发现:函数名只能包含 a-zA-Z0-9_-,最长 64 个字符,不能用中文、不能用感叹号、不能有空格。
后来的习惯是:工具名统一用 snake_case 英文,比如 get_order_stats_by_status。工具描述里可以写中文,但名字必须合规。这个坑很蠢,但确实浪费了我十几分钟,放在这里提醒一下。
7.4 坑四:流式请求中使用 tools 参数偶发 409 错误(Request coalescing detected)
做流式输出时,我遇到了一个奇怪的 409 错误,提示 “Request coalescing detected”。一开始完全摸不着头脑,后来查 OpenAI 社区才知道,这是服务端的一个并发保护机制:当两个完全相同的请求(相同的 model、messages、tools、stream 参数)在极短时间内并发到达时,服务端会认为这是重复请求,直接拒绝其中一个,防止重复计费。
解决方式很简单:第一,客户端做请求去重或串行化,确保相同 payload 不会同时发出去;第二,如果是测试环境,把 requests 的并发数调到 1。这个问题虽然不常见,但在做自动化评测时频繁重试同一批测试用例就容易撞上。
8. 最后:从我自己的实践中提炼的几点判断
走到这一步,你的 Agent 已经不再是一个光会聊天的大模型了,它已经能主动调工具、获取外部数据、基于数据回答用户。接下来我聊聊在做这个项目的过程中总结的几个判断,希望能帮你少走弯路。
关于“要不要用框架”。我的答案很直接:如果你还在学习阶段,或者工具数量不超过 10 个,不要上框架。手写这套循环最多几百行代码,但你能得到的底层理解是任何框架都给不了的。等工具多了、需要评估、需要记忆、需要多 Agent 协作,再考虑 LlamaIndex 或 LangGraph 这类框架,你会发现自己上手的难度降低了一大截,因为你已经知道它们封装的每一层在做什么。
关于工具划分的粒度。工具不是越细越好,也不是越粗越好。太细会导致模型需要调用很多次才能完成一个任务,增加延迟和出错率;太粗会导致模型不好传参。我的经验是,一个工具应该对应一个完整的“业务动作”,比如“查天气”“下单”“查库存”,而不是“设置请求头”“解析响应”“格式化输出”这种内部实现步骤。工具是给模型看的 API,不是给你代码库做重构的函数。
关于模型选型。不是所有模型都适合做 Agent。工具调用的核心是“指令跟随”和“结构化输出”,这两项能力在开源小模型(7B 或以下)上往往不稳定,经常出现模型编造函数名、参数类型错误导致解析失败的情况。如果做生产级 Agent,优先选择工具调用能力经过验证的商业 API 或 70B 以上的开源模型。
我在实际开发中发现,判断一个模型适不适合做 Agent 的最快方法不是看 benchmark,而是拿 20 个典型的工具调用 case 跑一遍,统计:工具选对率、参数合法率、多轮调用成功率。三项都超过 90%,这个模型基本可用;低于 80%,你后面会在提示词工程上投入大量时间做补偿。
最后再分享一个小技巧:如果你调试完一个 Agent,记得把那些暴露问题的对话保存下来,做成回归测试集。下次改代码或者换模型时,跑一遍全部用例,不要让自己在同样的地方跌倒两次。这套习惯我第一次做 Agent 项目时没养成,后来模型厂商发了新版本,我兴冲冲地替换版本,结果发现旧版本能跑的工具调用全崩了,花了一个下午才排查清楚。吃过亏,才长记性。
