Skip to main content
工具调用是 Agent 最基础也最容易翻车的一层:Schema 怎么设计、工具多了怎么选、参数错了怎么定位,这三件事我在线上都踩过。

Q1: 一次 Tool Calling 的完整链路和不变量是什么?

每一段都有自己的不变量: 一次真实的请求长这样(OpenAI Chat Completions 风格):
第一个该看的就是 finish_reason。 它是 tool_calls 还是 stop,决定了你后面查”选择”还是查”生成”。

Q2: 工具 Schema 应该怎么设计?

Schema 不是类型声明,是给模型看的接口契约。分四层设计:

第一层:命名

第二层:描述

描述要回答五个问题,而不是一句”查询订单”:

第三层:结构

结构设计经验:
  • 扁平优先。 两层以上的嵌套对象会显著提高参数生成错误率;能用 userId + orderId 就别传一个 filter 对象。
  • 枚举优于自由字符串。 status 给 ["paid","shipped","refunded"],不要让模型猜。
  • 数量有边界。 limit 写明默认值和最大值,避免一次拉十万条。
  • 单位写进字段名或描述。 amountCents 比 amount 少一类事故。
  • 时间用 ISO 8601 并给例子。 时区必须明确。
  • 避免 anyOf / oneOf / 深层 $ref。 各家对 JSON Schema 子集支持不同,复杂模式在 strict 模式下可能直接编译失败。

第四层:负面约束

同一个问题有两个工具都能答,是最常见的选错原因:

Schema 演进规则

Schema 的目标是”让模型在正确的时机、用正确的参数、只调用一次”,“能通过校验”只是最低要求。

Q3: strict 模式、JSON Mode、结构化输出有什么区别?

三者经常被混为一谈,但约束点完全不同: OpenAI 的函数工具支持 strict,Anthropic 的工具定义支持 strict: true,其机制是语法约束采样(grammar-constrained sampling):把 Schema 编译成语法,采样时只允许生成合法 token 序列。Anthropic 的文档明确要求所有对象加 additionalProperties: false,并只支持 JSON Schema 的一个子集。

三层防线,不要只靠一层

strict 不能替代后两层:它保证 passengers 是整数,但保证不了这个用户有没有权限订 4 张票;它保证 orderId 是字符串,但保证不了这个订单属于当前用户。

常见坑

  • Schema 太复杂导致被拒。 深层嵌套、oneOf、递归 $ref 在部分 Provider 上不支持 strict。
  • strict 打开后模型”变笨”。 约束越强,模型越难表达”我需要更多信息”,可能直接编一个合法但错误的值。此时应该在工具之外提供澄清路径(Elicitation 或普通提问)。
  • 把 JSON Mode 当 Schema。 {"type":"json_object"} 只保证是 JSON,不保证字段。
  • 忽略 additionalProperties。 不写 false 时模型可能塞额外字段,执行器若做宽松解析,会把错误藏起来。

Q4: 工具太多怎么办?动态工具与 tool search 怎么落地?

工具数量与选择准确率并非线性关系:过了某个点会开始明显退化。Anthropic 在 Tool Search 文档里给的经验是:可用工具超过 30–50 个后选择能力下降;一个典型的多 Server 配置(GitHub、Slack、Sentry、Grafana、Splunk)光工具定义就能占约 55k tokens。 三种解法,成本依次上升:

Tool Search 的工作方式

以 Anthropic 的实现为例(字段名以官方文档为准):
  • defer_loading: true 的工具不进入初始上下文;
  • 模型先调用检索工具,服务端返回 tool_reference 块(默认最多 5 个),再自动展开成完整定义;
  • 至少保留一个非延迟工具(通常就是检索工具本身),否则模型没有入口。
OpenAI 的 Responses API 也提供了对应机制:工具可以设 deferLoading 被检索发现,并可用 allowedCallers 区分 DIRECT 与 PROGRAMMATIC 调用,用 namespace 做分组。

落地要点

  1. 描述就是索引。 检索质量取决于工具名、描述、参数描述,不是取决于向量库多高级。写描述时把用户可能的说法放进去。
  2. 保留一个 always-on 工具。 检索工具本身、或一个 ask_user / list_capabilities。
  3. 检索结果要可解释。 记录命中了哪些工具、为什么,否则线上只能看到”模型调了个奇怪的函数”。
  4. 工具定义变化会破坏 prompt cache。 动态挂载要按会话稳定,不要每轮都改工具列表。
  5. 评测要覆盖”该检索却没检索”和”检索到错的工具”两类错误。

与 MCP 的关系

MCP 的 tools/list 解决”有哪些工具”,Tool Search 解决”这次该让模型看到哪些工具”。二者可以叠加:网关先按租户裁剪 tools/list,Harness 再按任务做一次检索。不要把上千个 MCP 工具直接全量塞进模型上下文,这是线上最常见的上下文浪费。

Q5: 并行调用什么时候安全?结果怎么配对?

模型在一轮里返回多个 tool_calls 就是并行调用。OpenAI 用 parallel_tool_calls 控制,Anthropic 在同一轮返回多个 tool_use 块。

可以并行的条件

只要有一条不满足,就应该串行或分批:

结果配对

三个细节:
  • 必须用 tool_call_id 配对,不能靠顺序。 并行完成顺序和返回顺序不一定一致。
  • Promise.allSettled 而不是 Promise.all。 一个失败不应该丢掉其他结果;模型需要看到”哪些成功、哪些失败”。
  • 并行度要有上限。 例如 p-limit(5),否则 20 个调用会同时打爆下游。

Provider 差异

同一轮多个工具结果必须放在一条 user 消息的 tool_result 块里。分成多条消息,会被判定为”缺少工具结果”,轻则行为异常,重则直接请求失败。另外 tool_result 要排在文本块之前。

Q6: 工具结果回灌时,怎么防止上下文被撑爆?

工具结果是上下文膨胀的第一大来源:一次 cat 可能返回几十万字符,一次搜索可能返回 200 条结果。

预算视角

给每类工具设一个结果预算,而不是等上下文满了再救:

截断策略

为什么保留头尾: 结构化信息通常在开头(JSON 字段名、文件 import),错误详情通常在结尾(堆栈、失败原因)。但截断不是万能的——如果结果是 JSON,截断后就不是合法 JSON 了,所以结构化结果应该先摘要再序列化,不要直接截字符串。

回灌的五个原则

  1. 统一外壳:{ ok, data, meta },模型不用猜结构。
  2. 只给决策字段:模型不需要 createdAt 的内部主键,就别给。
  3. 大对象给引用:返回 URI / 文件路径 / 查询游标,需要时再取。
  4. 敏感字段脱敏:手机号、地址、令牌在回灌前处理。
  5. 标明截断:truncated: true 和 nextCursor,否则模型会以为看到的是全量。
截断会悄悄改变模型的世界观:它可能基于不完整的结果下结论,而且不知道自己没看全。所以截断必须显式,并在 meta 里说明”还有多少没返回、怎么取”。静默截断比不截断更危险。

Q7: 工具报错时应该返回什么?协议错误和业务错误怎么分?

错误信息本身就是给模型的重试指令。好的错误要回答三件事:发生了什么、能不能重试、下一步怎么做。

协议错误 vs 工具执行错误

MCP 规范把错误分成两类,这个分类对任何 Harness 都适用: 关键判断:错误里有没有”模型可以据此改变行为”的信息。 参数格式错、值越界、资源不存在,都属于可行动错误,必须让模型看到;鉴权失败、代码 bug、数据库宕机,模型看了也只能瞎猜,应该由 Harness 处理并返回简短说明。

错误码分类

实现

不要做的事

  • 不要把堆栈直接给模型。 既浪费 token,又可能泄露内部路径。
  • 不要返回 {"error": "操作失败"}。 模型只能原样重试。
  • 不要把所有错误都标成 retryable。 会造成重试风暴。
  • 不要把业务错误抛成 500。 Harness 会当成基础设施故障,触发错误的重试策略。

Q8: 重试为什么要分层?幂等怎么保证?

三层职责

双重重试是隐形放大器。 工具层重试 3 次 × 模型层重试 3 次 = 9 次真实调用。写操作必须让其中一层”不重试”,通常是把工具层重试限制在幂等请求上。

幂等键设计

要求:
  • 稳定:同一逻辑操作在重试中必须生成同一个键;不能用 Date.now() 或随机数。
  • 可追溯:键里带 taskId / stepId,出问题能反查。
  • 服务端去重:键存在则返回第一次的结果,而不是再执行一次。
  • 有 TTL:幂等记录不能永久增长,通常 24 小时到 7 天。

写操作的三种安全模式

卡死检测

最大步数只能防”无限”,防不了”无效重复”。还要检测相同调用与相同结果:
命中后不要只是”停止”,而要给模型一个明确的收尾指令:"检测到重复调用,请基于已有信息总结,或说明缺少什么。"

Q9: 不同 Provider 的 Tool Calling 有哪些实质差异?

迁移策略

  1. 内部维护一份 canonical 工具定义(name / description / JSON Schema / 副作用等级 / 超时 / 幂等策略),再由 adapter 转成各家格式。
  2. 不要依赖某家的错误文案,把错误归一化成自己的 ToolError。
  3. 保存 golden trace:同一组输入下的工具调用序列,换 Provider 后逐条比对,而不是只看最终回答。
  4. 注意消息顺序约束:Anthropic 的 tool_result 必须紧跟对应的 tool_use,且同轮结果放在一条消息里;OpenAI 的 tool 消息按 tool_call_id 配对。
工具定义通常位于 prompt 前部。工具列表顺序不稳定、描述频繁变化、动态挂载每轮不同,都会破坏 prompt cache,成本和延迟同时上升。工具列表要确定性排序(MCP 规范也要求 Server 尽量返回稳定顺序),动态变更要按会话而不是按轮次。

Q10: 怎么评测工具调用质量?

不要只测最终回答。按层拆指标:

Fixture 设计

覆盖四类用例:正例、负例(不该调用)、边界(格式奇怪但合法)、对抗(诱导越权/注入)。

评测的四个陷阱

  1. 用 LLM 当裁判评”工具选得对不对”。 选择对错是可判定的,用规则比对,别引入随机性。
  2. 只测单轮。 真实故障多发生在第 3–5 轮,必须有多步任务集。
  3. 改了 Prompt 不重跑基线。 工具描述变化会改变选择分布,必须回归。
  4. 只看平均值。 某个高频工具 99% 正确、低频高危工具 60% 正确,平均值会掩盖灾难。

Q11: 线上四类症状怎么排?给出可执行的决策树

先统一日志字段,否则后面全是猜:

症状 A:完全不触发 tool_calls

对照实验:同一问题、同一模型、同一参数,只改一个变量(描述 / 工具数量 / tool_choice),观察 tool_calls 是否出现。不要凭感觉说”模型变笨了”。

症状 B:调用了错误的工具

量化方法:把工具描述做 embedding,算两两相似度。相似度高的工具对,就是误选的高发区。

症状 C:工具对了,参数错

能自动修的自动修,不能的交给模型。 日期格式、数字字符串转数字可以白名单修正;选错用户 ID、猜错订单号必须让模型重来,自动”猜”会把错误藏起来。

症状 D:循环停不下来或漏步

五分钟排查顺序

  1. 看 finish_reason,确认是”没选”还是”选了没执行”。
  2. 看 tools_hash 与 tool_count,确认模型看到的工具是否和你以为的一致。
  3. 看 tool_calls[].args 原文,不要看 Harness 解析后的对象。
  4. 看 validation_error 与 retry_count,判断是契约问题还是运行时问题。
  5. 用单变量 fixture 复现,再决定改描述、改 Schema 还是改执行层。

Q12: 深度追问(Tool Calling)

及格回答: 能,开了 strict 就不会传错类型。满分回答: 只能保证”形状正确”,保证不了”语义正确”。strict 通过语法约束采样,让输出符合 Schema:类型对、必填字段在、枚举值合法。但它不知道这个 orderId 是否属于当前用户,也不知道 passengers: 4 是否超过该航班的余票。所以生产上要三层:strict 保证形状,运行时 Schema 校验保证版本和子集一致,业务规则校验保证权限与状态正确。另外 strict 对 JSON Schema 子集有限制,复杂 Schema 可能编译失败,需要降级到普通模式并加强事后校验。
及格回答: 因为模型要选的东西太多,容易选错。满分回答: 两个机制叠加:一是选择空间变大,相似描述之间的区分难度上升;二是所有工具定义都占上下文,真正与任务相关的信息被稀释,而且工具定义通常在前部,会挤占指令和示例的位置。Anthropic 的公开文档给的经验是超过 30–50 个工具后选择能力明显下降,多 Server 场景光定义就可能占约 55k tokens。量化方法是构造”意图 × 工具”的混淆矩阵,统计误选率和漏调率;再把工具描述做 embedding 算相似度,相似度高的工具对通常就是误选高发区。解法优先级是静态裁剪 → 分组/子 Agent → Tool Search。
及格回答: 写操作不能并行,其他都可以。满分回答: 四类坑。第一是依赖:后一个调用需要前一个的结果,必须分轮。第二是副作用与竞态:两个写操作并行可能互相覆盖,需要串行、加锁或幂等键。第三是结果配对:必须用 tool_call_id 关联,不能靠返回顺序;一个失败不能让其他结果丢失,所以用 allSettled 而不是 all。第四是资源:并行度要有上限,否则会同时打爆下游限流。协议层还有格式差异:OpenAI 每个结果一条 tool 消息,Anthropic 要求同一轮所有 tool_result 放在同一条 user 消息里且排在文本之前。
及格回答: 截断,保留前面一部分。满分回答: 先看结果类型。结构化结果应该先摘要再序列化,只保留决策字段,并带上 total / returned / truncated / nextCursor;纯文本可以保留头尾、截断中段,因为结构化信息在头、错误在尾。大文件不要内联,返回路径或 resource link,让模型按需分段读。二进制和图片转成引用或缩略描述。最关键的是截断必须显式:静默截断会让模型以为看到了全量,基于不完整信息下结论,比直接报错更危险。另外截断 JSON 字符串会破坏可解析性,所以顺序应该是”先结构化摘要,再考虑截断”。
及格回答: 加最大步数,超了就停。满分回答: 最大步数只是兜底,不是修复。先看它为什么重复:如果错误信息只有”操作失败”,模型没有新信息,只能原样重试;如果结果为空但没说明原因,模型会以为再试一次就有了。正确做法是给结构化错误(code、retryable、hint),明确告诉它”下一步该查什么”或”不要再试”。同时加卡死检测:连续两次相同工具、相同参数、相同结果就判定卡死,注入一条收尾指令,让它基于已有信息总结或说明缺什么。最后从工具设计上解决:补前置查询工具、把多步任务拆开、给缺失状态一个明确的 NOT_FOUND 分支。
及格回答: 换个 Prompt 试试,不行就回滚模型。满分回答: 用同一套 fixture 做分层对比,不要只看最终回答。分别统计:工具选择准确率、参数合法率、多步任务完成率、平均步数、错误恢复率。如果只有选择下降,可能是新模型对描述更敏感或对工具数量更敏感;如果参数错误多,可能是 strict 支持差异或 Schema 子集差异;如果多步任务下降,可能是并行调用策略或结果消息格式不同(比如 tool_result 的位置);如果错误恢复率下降,可能是新模型对 hint 的利用方式不同。定位到具体层再改,不要整体回滚。同时检查 tools_hash 和消息顺序,迁移时常在这两处出错。
及格回答: 在 Prompt 里告诉模型不要听工具结果里的指令。满分回答: 不能只靠 Prompt。工具结果是第三方内容,必须按不可信数据处理:指令只从 system/user 消息进入,工具结果只作为数据;对结果做来源标记和可疑片段过滤;高风险动作(转账、删除、发邮件)强制人工确认,并在确认 UI 展示真实参数;网络出口做限制,凭据不进入上下文,降低”被说服后能造成的破坏”。这就是间接提示注入的防御思路:不追求 100% 识别,而是让被攻陷后的影响面可控。MCP 规范也明确说工具描述和注解应视为不可信,除非来自可信 Server。
及格回答: 记录每次调用的工具名、参数、耗时和是否成功。满分回答: 至少要能回答四个问题:模型看到了什么、决定了什么、执行发生了什么、最后为什么停。所以一条 trace 要包含:trace_id、step、provider/model、tools_hash 与 tool_count、tool_choice、finish_reason、tool_calls[].name/args(参数存摘要或脱敏)、validation_error、retry_count、latency_ms、prompt/completion tokens、result_bytes 与 truncated。指标上按层拆分:选择准确率、参数合法率、执行成功率、P95 延迟、错误码分布、平均步数、单任务成本。再加两类专项:安全审计(越权、注入命中)和回归(fixture 通过率)。最后把这些字段和用户反馈关联起来,才能从”用户说不好用”定位到”第 3 步选错了工具”。

延伸阅读与引用参考