博客

生产环境中的 Tool Calling:Schema 设计、tool_choice、并行调用与循环

Tool calling 演示起来容易,难的是让它一直波澜不惊:schema 需要可强制执行,一轮对话要有明确规定好的调用次数,循环要对失败有预案。这是生产视角。

生产环境中的 Tool Calling:Schema 设计、tool_choice、并行调用与循环

Tool calling 上手并不难;难的是让它一直保持“无聊”。demo 一个下午就能跑通,然后生产环境会提出更难的问题:schema 到底会不会被强制执行、一轮对话里可以包含多少次调用、并行调用到来时怎么办、结果如何缝合回去、工具失败时由谁处理。两家主要厂商都把这些答案写得很精确,而它们之间的差异正是集成变得有意思的地方。本指南遵循那些在 demo 之外依然站得住的 function calling 最佳实践:循环的结构、schema 要求、tool_choice 参数、并行调用、结果处理,以及决定你如何编写编排代码的跨厂商语义。它与本周早些时候的结构化输出指南一文配套阅读:那篇文章讲的是模型被允许说什么,这一篇讲的是它被允许做什么。

工具调用的往返流程,分三层

OpenAI 的 function calling 文档以三层结构确立了让本文余下部分得以清晰展开的术语体系。工具(tools)是我们交给模型的功能:一个用 JSON schema 定义的函数;文档明确指出,函数只是工具的一种,与之并列的还有 custom tools(自由格式文本进、自由格式文本出)和平台内置工具。工具调用(tool calls)是模型请求使用其中某个工具,它以一类特殊响应的形式返回。工具调用输出(tool call outputs)是我们生成的内容:真正执行工具后的结果,并回指到某次具体调用,这样对话才能保持连贯。

文档给出的流程是五步,读一遍是对抗意大利面式编排最好的防御:带上可用工具发起请求;收到一次工具调用;用提供的参数执行你的代码;再发起第二次请求,携带工具输出;收到最终响应,或者如果模型还需要更多步骤就继续。Anthropic 的概览从另一侧描述了同样的形状:Claude 依据请求和工具描述决定调用某个工具,返回一个由你的应用执行的结构化调用,然后你的结果被送回以生成最终答案。整个设计目标就写在 OpenAI 指南的一行里:把工具定义、原始 prompt、模型的调用和输出一起发回去,才是让模型完成任务的关键。

function calling 最佳实践从 schema 开始

OpenAI 文档里最有价值的一句话是:设置strict: true可确保函数调用可靠地遵循函数 schema,而不是“尽力而为”,并且建议始终开启它。相比过去只能“好好请求”的糟糕年代,这是一次升级,但这种 strict 模式的函数 schema 有硬性要求,因为它在底层借用了结构化输出的机制:

  1. additionalProperties必须设置为false,作用范围是parameters中的每个对象。
  2. 其中的所有字段properties都必须标记为required。

可选字段要用结构化输出的方式表达:加上null作为一个类型选项,而不是把这个键从required里省略。不满足这些要求的 schema 会被拒绝,并给出缺失约束的详细信息,这是一种良好的失败方式。

陷阱在于默认值。如果你省略strict,行为就取决于用哪个 API:在 Responses API 上,服务会尽可能把你的 schema 规范化成 strict 模式,做不到就退回非 strict 的尽力而为式 function calling;这种回退是可见的,因为响应里的 tool 会显示strict: false。Chat Completions 的请求默认仍然是非 strict 的。运营层面的解读,这里沿用文档的结构:不要以为“没写 strict”就是尽力而为,也不要以为“写了 strict”就一定有保证,因为 API 表面和静默回退都会改变答案。把你响应中各工具实际生效的 strict 状态记进日志,当它与预期不一致时告警。

schema 和模型变更带来同样的教训:切换之前,先用 golden set 与 shadow run 流程。

tool_choice 与并行工具调用

默认情况下,由模型决定何时使用工具、用多少个;tool_choice 参数可以覆盖这一点。文档列出的选项:"auto"(默认值,零个、一个或多个函数)、"required"(一个或多个函数)、强制调用某一个指定名称的函数、allowed-tools 列表(把模型限制在一个子集里,同时保留完整工具列表,这样 prompt caching 仍能看到稳定的前缀),以及"none",它相当于完全不传函数。子集这个变体比听起来更有用:链路上的工具相同,每轮的决策空间更小。

在并行工具调用上,事实比感觉更重要。在以 GPT-5 开头的受支持模型上,当内置工具也可用时,函数可以被并行调用,但有一个前提:内置工具不能包含在并行的函数调用批次里。如果你希望每轮精确地只有零次或一次调用,把parallel_tool_calls设为false。有两条文档脚注应当写进你的设计评审。第一,如果你用的是微调模型,而它在一轮里调用了多个函数,这些调用的 strict 模式会被关闭。第二,gpt-4.1-nano-2025-04-14快照在启用并行调用时有时会对同一个工具发出多次调用,文档建议在那里关掉这个特性。通用原则:并行换来吞吐、付出确定性;任何有副作用的工具要么做成幂等,要么禁用并行调用。

结果往返与错误处理

返回路径的规定比请求路径随意,团队糟糕的即兴发挥正出在这里。你在 call-output 消息里回传的结果通常应该是字符串,格式随你选:JSON、错误码、纯文本;由模型来解释。唯一的结构性要求是回指到具体某次调用,这样并行或串行的调用就不会被搞混。对于返回图片或文件的工具,可以接受相应对象的数组来代替字符串。

两个做法能把它从 demo 变成可运营的系统。第一,把工具失败当作可读的结果:当工具背后调用的 API 返回错误时,把简明的失败描述作为工具输出喂回去,而不是让循环崩溃或静默重试;模型往往能绕开被清楚描述的失败,而对话记录里也能看到真实经过。第二,像工程师而不是旁观者那样记录往返过程:工具名、调用引用、耗时、重试次数和 request ID;这份日志该记哪些字段,包括值得保存的厂商请求标识,本站的日志指南里有讲。当循环本身被 HTTP 错误码绊住时,错误码指南就是分诊路径;agent 循环正是 401 和 429 藏在重试里的地方,这也是为什么 CI 指南里的护栏对 Web 后端和对流水线一样适用。

非 JSON 输出:custom tools 与文法

并非每个工具都想要 JSON。OpenAI 记录了接受并返回自由格式文本的 custom tools;当输出必须遵循特定形状、不能套 JSON 外壳时,可以给 custom tool 附加一个文法,支持两种语法:Lark 和 regex。文法机制是一个上下文无关文法,文档对它的代价说得很直接:把文法限制在工具真正需要的规则和模式上,因为过于复杂的文法可能导致 API 错误,而复杂的文法往往需要在文法、prompt 和工具描述三者之间一起迭代。有两个文档细节值得内化:锚点之间的自由文本要用单个有界终结符,因为词法分析器是贪婪匹配的,把自由文本拆到多条规则里就失去了控制;还要记住终结符和规则是不同层次,regex 语法遵循 Rust regex crate 的语法,而不是 Python 的re模块。文法很强大,而像大多数强大的东西一样,它的维护账单随复杂度一起增长。

Token 与成本核算

工具定义不是免费的,文档给出了原因:函数会被注入到 system message,所以可调用的函数定义会占用上下文上限,并按 input tokens 计费。文档给出的缓解办法都很实际:限制一开始加载的函数数量、尽可能缩短描述,并使用 tool search 让延迟加载的工具只在需要时载入。这与你集成中其他部分的成本纪律是同一回事,而让这件事可控的归属习惯——按调用方和功能给用量打标签——在用量归属指南里有讲。只增不减的工具列表就是悄无声息的成本上涨;像修剪依赖那样修剪它。

跨厂商:同样的循环,不同的语义

Anthropic 的 tool use 概览读起来像是同一批概念的译文,有两处差异值得提前规划。在请求侧,你传入的工具带一个input_schema;在响应侧,Claude 返回一个或多个tool_use块,指明工具名称及其参数,并带有一个stop_reason为"tool_use";你的代码执行后,第二次请求携带tool_result块,这些块通过tool_use_id一一配对回来。配对用的标识符,而不是位置顺序,才是契约。

第二处差异是执行位置。Anthropic 把 client tools(包含你的 custom tools,以及 Anthropic 定义的 schema,如bash和text_editor,它们在你的应用里运行)与 server tools(如web_search、web_fetch、code_execution和tool_search)区分开来:它们运行在 Anthropic 的基础设施上,并直接返回你能看到的结果。server tools 不需要执行 harness,但文档提到一条交互规则:当 server tool 与 client tool 共享同一个并行批次时需注意,所以在一轮里混用两者之前,先读 stop-reason 相关文档。在并行控制上,同一个tool_choice块接受一个禁用并行工具调用的标志,文档表述是要求每轮最多一次工具调用。

跨厂商的编排教训是:OpenAI 的循环用显式调用引用把结果与调用配对,Anthropic 的循环用tool_use_id;两者都是基于 ID 的契约,都不容忍假设顺序的循环。按 ID 来写编排层,同一套逻辑就能在厂商之间移植。如果你通过一个兼容端点访问模型,兼容性清单里的形状检查对工具调用和对文本一样适用,而你服务的端点背景资料在它的文档里。

生产环境清单

  1. 让每个 schema 都符合 strict 的要求:additionalProperties: false处处都要有,每个属性都要进required。
  2. 按 API 表面逐一核实实际生效的 strict 状态,并在回退产生strict: false时告警。
  3. 按端点决定一轮里允许多少次工具调用;不要稀里糊涂地继承默认值。
  4. 让有副作用的工具幂等,或者对它们禁用并行调用。
  5. 把失败作为可读结果返回,并记录工具名、call ID、耗时、重试次数和 request ID。
  6. 保持工具定义精简,目录变大时用 tool search 延迟加载。
  7. 按 call ID 而不是按顺序来写循环,这样换厂商的成本才低。
  8. 模型升级时重新跑一遍工具调用回归,模型轮换时按弃用应对手册里的迁移习惯来做。

Tool calling 奖励那些把它当作有明确边界的协议、而不是当作魔法的团队。读这两家的指南,尊重基于 ID 的契约,循环就会以最好的方式无聊下去:无人值守、可观测、无聊。