工具调用的四段循环
模型本身不能查数据库、不能发请求、也不能读文件。它能做的是输出一段"我要调用某个工具、参数是这些"的结构化内容,真正执行的是宿主程序。
这个分工是整个 Agent 能力的基础,也是很多人误解的地方:模型没有"调用"任何东西,它只是表达了意图。理解这一点,后面所有工程问题都清楚了。
1. 一次完整的循环
四段,缺一不可。
1.1 第一段:模型声明要调什么
请求里带上工具定义(名称、描述、参数的 JSON Schema),模型返回的是工具调用的意图,不是结果。
返回形态各家略有差别:OpenAI 是消息上的 tool_calls 数组,Anthropic 是消息内容里的 tool_use 块。共同点是都带一个调用 ID,后面回传结果时要靠它对上号。
1.2 第二段:宿主执行
这一步完全在你的代码里。模型给的是"想调 searchOrder,参数是 orderId=xxx",你去查库、调接口、读文件,得到结果。
关键是:宿主有否决权。 参数不合法可以不执行、工具不在白名单可以不执行、需要确认的可以先暂停。模型只能"请求",不能"执行"——这个边界是安全设计的基础。
1.3 第三段:结果回写
把结果作为一条新消息追加进对话,带上对应的调用 ID,然后重新请求模型。
多个并行调用时,每个调用都要各回一条、各带自己的 ID。少回一条不是"模型以为还在等",而是直接报错:OpenAI 侧存在未回结果的 tool_call_id 会返回 400;Anthropic 要求同一条 user 消息里对每个 tool_use 都给 tool_result,缺了同样报错。
真正会表现为"卡住 / 变傻"的是另一种情况:历史裁剪时把 assistant 的 tool_calls 和对应的 tool 结果裁散了,配对关系断了。所以裁剪逻辑必须以「调用 + 结果」为最小单位,不能按消息条数切。
1.4 第四段:模型再决策
拿到结果后,模型决定是继续调别的工具、还是直接回答。
循环在这里闭合。必须有退出条件:最大轮数、总超时、连续失败次数。没有退出条件的循环,代价是账单和卡死的用户界面——而且这类故障在测试环境很少复现,因为测试时模型通常一次就答对。
2. 工具定义:Schema 是契约,不是注释
模型看不到工具的实现,只看得到名字、描述和参数 Schema。所以这三样东西的质量直接决定调用质量。
const tools = [
{
name: 'searchOrder',
description: '按订单号查询订单状态。只读操作,不修改任何数据。',
input_schema: {
type: 'object',
properties: {
orderId: { type: 'string', description: '订单号,形如 202601010001' },
},
required: ['orderId'],
additionalProperties: false,
},
},
]
几条实测有效的规则:
描述要写"什么时候用",不只是"是什么"。 只写"查询订单",模型不知道该在什么时候用它;写上"只读、不修改数据",它才敢在需要确认的场景外自动调用。
参数描述要给格式和示例。 只写 orderId: string,模型不知道该拼成什么样。写上形如 202601010001,它就能照着拼。
边界写进描述。 副作用、幂等性、是否需要确认——这些写清楚之后,模型越界的概率明显下降。
别让两个工具职责重叠。 重叠会导致模型在两者间摇摆。能合并就合并。
2.1 strict 模式与参数校验
OpenAI 的 strict: true 会让模型输出严格符合 Schema(底层走结构化输出能力),代价是 Schema 要满足两条约束:每个对象都要 additionalProperties: false,所有字段都要进 required(可选字段用 ["string", "null"] 这种联合类型表达)。
但有一条纪律与是否开 strict 无关:无论开不开,都要在自己的代码里重新校验参数。官方文档也是这个说法——模型生成的参数受 Schema 引导,但不保证一定合法。把模型输出当外部输入处理,这是基本假设。
const parsed = SearchOrderSchema.safeParse(JSON.parse(call.arguments))
if (!parsed.success) {
// 把校验失败的原因回传给模型,让它自己修正参数重试一次
return toolResult(call.id, { error: '参数不合法:' + parsed.error.message })
}
把错误原因回传而不是直接失败,模型通常能自己改对一次。这也是循环的价值所在。
3. 并行的开关
多数平台默认允许模型在同一轮里发起多个调用(查三个城市的天气、读两个文件)。这在读操作上是好事:省两轮往返。
但有两个场景要关掉:
工具之间有顺序依赖。 先创建草稿再往里加内容,并行会让顺序错乱。OpenAI 用 parallel_tool_calls: false,Anthropic 用 tool_choice 里的 disable_parallel_tool_use: true。
工具会改状态。 并行写操作一旦部分失败,回滚很麻烦。这类工具宁可串行。
4. 强制与限制
各家都提供了干预"模型是否调用工具"的开关,形态接近:
| 意图 | OpenAI | Anthropic |
|---|---|---|
| 自主决定 | tool_choice: "auto" | type: "auto" |
| 必须调用至少一个 | "required" | type: "any" |
| 必须调指定的那个 | { type: "function", name: ... } | { type: "tool", name: ... } |
| 不调用工具 | "none" | type: "none" |
另外 OpenAI 还有 allowed_tools 形态:把候选工具限制在某个子集里,但不改变请求里携带的工具列表——这样可以让工具定义保持不变以命中提示缓存,同时又能限定本轮可用范围。这是个很实用的成本技巧。注意它必须带 mode 字段:
{
"type": "allowed_tools",
"mode": "auto",
"tools": [{ "type": "function", "name": "search_docs" }]
}
少了 mode 会被当成非法参数——这是照抄文档示例时最常漏的一行。
5. 失败处理:区分三种失败
循环里最容易被写错的是错误处理。三种失败必须分开:
参数不合法 —— 回传校验错误,让模型自己改。通常一次就改对。
业务上没找到 —— 明确告诉模型"没有这个订单"。这是正常结果,不是错误。
系统出错 —— 超时、500、限流。这类要如实上报错误类型,不要翻译成"查询失败"这种模棱两可的话——模型会把它当成"不存在",然后给用户一个确定但错的结论。
限流还需要单独处理:把 Retry-After 之类的信息带给上层做退避重试,不要在工具内部静默重试,否则一次调用可能变成十次。
6. 和 MCP 的分工
容易混淆:Function Calling 是模型能力层(决定调不调、传什么),MCP 是协议层(工具怎么被发现、怎么被调用)。两者互补,不是替代关系——这一点在《MCP 的工具、资源和提示模板》里讲过。
实现层面:Function Calling 的 Schema 可以手写在请求里,也可以来自 MCP Server 自述的工具列表。后者让工具描述可发现、可版本化,是更稳的做法,《无状态 MCP Server 要点》里有具体的实现方式。
7. 工具多到装不下时:tool search
一个绕不过去的算术题:一个有 300 个工具的 Agent,每轮要把 300 份 JSON Schema 塞进上下文。按每份 200 token 算就是 6 万 token——还没开始干活,预算已经用掉大半,而且工具越多模型选错的概率越高(相似的工具描述互相干扰)。
过去的常规做法是"拆成多个 Agent",现在多了一条更轻的路:工具定义不进上下文,按需检索。
Anthropic 的 Tool Search 是这么做的:请求里只带一个"工具检索器",其余工具标记 defer_loading: true。模型先检索(正则或 BM25),命中后再把那几个工具的完整定义加载进来。官方给出的数据是单请求可以 defer 上万个工具,token 消耗大幅下降,而选择准确率反而上升——因为候选集小了,干扰也少了。
OpenAI 侧对应的能力叫 tool search,思路一致。
它对架构的影响比看上去大:
- 不再需要为了"装得下"而拆系统,工具可以按领域注册进来,运行时按需取
- 工具定义不必为了省 token 而压缩描述,写得越清楚,检索和调用都越准
- 权限过滤变自然了:检索时带上用户权限,只返回他能用的工具,而不是把全部工具塞进去再靠提示词约束
代价是多一次检索调用(通常很便宜,且可缓存),以及要维护工具的可检索性——名字和 description 的写法直接决定能不能被检到。这和《Agent Skills 三层加载》里"description 决定加载"是同一个道理。
8. 流式下的参数拼接
最后补一个实现细节,几乎每个手写流式解析的人都会踩:工具调用的参数是增量送来的,tool_calls[i].function.arguments 每次只给你一小段 JSON 字符串。
三条规则:
- 按
toolCallId(或 index)分别累积,不要用一个字符串接所有工具的参数,否则并行调用会串成坏 JSON - 累加期间不要 parse,只拼接、只展示原始字符;等
finish_reason为tool_calls时再解析 - 解析失败要能定位到原始串,把它一起打进日志——否则你只能看到"JSON 不合法",不知道是模型的问题还是拼接的问题
Anthropic 侧同理,只是字段名不同(input_json_delta 的 partial_json)。
至于"该给 Agent 多少个工具、工具边界怎么划",那是护栏设计的问题,属于《Agent 和写死流程的差别》讨论的范畴——先确定这件事该不该交给模型决定,再谈工具怎么给。
工具集规模本身也是个会反噬的变量:注册得越多,模型越容易选错、参数越容易填错,甚至干脆不用工具自己编。怎么判断已经超了、怎么在不砍功能的前提下收窄选择面,见《工具有多少才合适》。