Tool Calling 的底层实现:从协议到约束解码
从 API 协议到 token 级实现,拆解 LLM 的 tool calling 机制——以及它对 agent 框架设计的启示。
使用 LLM 构建 agent 时,tool calling(函数调用)是最基础的交互模式——模型决定调用哪个工具、生成什么参数,你解析结果并执行。但大多数时候,我们只知道调用接口,不知道背后发生了什么。
这篇文章从底层走一遍 tool calling 的完整链路。
第一步:协议层——Chat Completion API 的扩展
Tool calling 不是 LLM 原生就有的能力,它是通过 API 协议层"注入"的。
在 OpenAI 的 Chat Completion API 中,当你传了 tools 参数时,底层实际发生了两件事:
第一,schema 序列化。你把 Python 函数签名的 JSON Schema(用 description、parameters、required 描述)作为请求的一部分发给模型。这个 schema 本身不是给模型"看"的——模型看不懂 JSON Schema 的元语义,而是给 API 网关用的。
第二,prompt 注入。API 网关把你的 tools 定义转换成自然语言描述,拼接到系统提示词的末尾。不同模型有不同的拼接方式,但本质都是告诉模型:"当你想调用工具时,请输出一个符合特定格式的字符串。" 对 OpenAI 来说,这种格式是 {"name": "xxx", "arguments": {...}} 的 JSON。对 Anthropic 来说,格式是 <function_calls> 和 <invoke> 标签包裹的 XML。
所以本质上是:模型在"假装"输出工具调用。模型只是一个文本生成器,它根据训练数据中见过的工具调用模式,在合适的时机输出格式正确的"工具调用文本"。API 层负责识别这段文本,解析它,然后返回一个结构化的 tool_calls 字段给你。
第二步:训练层——模型怎么学会调用工具的?
早期的模型(GPT-3 时代)无法可靠地调用函数,因为它们没有被训练过这么做。工具调用能力是后来通过指令微调(instruction tuning) 灌进去的。
具体做法是合成大量"对话-工具调用"对:
用户:帮我查一下北京的天气
模型:{"name": "get_weather", "arguments": {"city": "北京"}}
模型学会的是:在对话中,遇到「需要查天气」的语境时,输出一个特定格式的 function call。这个过程和它学会"用中文回答"或"写诗"没有本质区别——都是条件概率分布的学习。
但这里有一个关键的设计选择:模型到底是在"调用函数",还是在"模仿调用函数的文本"?
答案是后者。模型不「知道」函数调用是什么,它只是被训练成在某些条件下生成具有特定模式的 token 序列。这意味着工具调用的可靠性完全取决于:
- 训练数据中工具调用模式的一致性和丰富度
- 模型对输出格式的遵守程度
这引出了第三个问题:如果模型不愿意遵守格式怎么办?
第三步:约束层——强制输出合法工具调用
ChatGPT 刚推出 function calling 时,很多人遇到过模型"拒绝调用"或者"参数格式错误"的问题。OpenAI 的解决方案是在 API 层增加一个约束(constraint) 机制。
当你设置 tool_choice: "required" 或 tool_choice: {"type": "function", "function": {"name": "xxx"}} 时,API 层会做两件额外的事:
第一,强制前缀注入。模型在生成时,API 层会在采样之前强制将第一个 token 设为工具调用的起始 token(比如 { 或 <invoke>)。这个操作在 transformer 的推理过程中叫 logit bias——把候选 token 的概率分布中,除了你想强制的 token 之外的所有 token 的概率设为 -inf。
第二,结构化解码。一旦模型开始输出工具调用,API 层可以切换到一个约束解码器——这个解码器知道 JSON 语法(或 XML 语法),在每一步只允许模型采样合法的下一个 token。比如如果当前已经输出了 {"name": "get_weather", "arguments": {,那么下一个 token 只能是合法的 JSON 属性名(字符串),而不能是 } 或数字。
这个机制在学术界被称为 constrained decoding 或 guided generation,在工业界最早由 OpenAI 的 function calling 大规模应用。后来开源社区也实现了类似方案——比如 outlines 库用正则表达式做约束解码,guidance 库用领域特定语言(DSL)做约束,lm-format-enforcer 用 JSON Schema 直接生成 token-level 的 mask。
从工程视角看,约束解码是一个典型的安全与效率的权衡:
- 不做约束:模型自由度最高,但经常输出无效格式
- 做强约束(每一步都校验):输出 100% 合规,但推理速度下降(每次 token 采样前都要做一次格式校验)
- 做懒约束(在 API 层 post-process):推理速度不受影响,但需要 fallback 逻辑
OpenAI 选择了混合方案——优先使用 logit bias 做轻量化约束,只在必要时启用完整的结构化解码。这样大多数请求的速度影响很小。
第四步:架构层——对 agent 框架的启示
理解了 tool calling 的底层机制后,agent 框架设计中有几个容易被忽视的优化点:
1. 工具描述的重要性高于参数 Schema。 因为模型"读"的是自然语言描述,不是 JSON Schema。很多框架盲目地把参数类型、默认值等信息塞进 schema,但忽略了工具的 description 字段。一个好的工具描述应该告诉模型:什么时候该用这个工具、参数的含义是什么、预期的返回值是什么。它不是在描述类型系统,而是在"教授"模型决策边界。
2. tool_choice 不是银弹。 强制调用工具虽然解决了"模型拒绝调用"的问题,但引入了新问题:模型可能在不需要工具时也强行调用。更优雅的做法是设计一个"思考-行动"循环——让模型先决定是否要调用工具,再决定调用哪个。这对应到 agent 设计就是 ReAct 模式(思考 → 行动 → 观察)vs OpenAI 的纯 function calling 模式。
3. 约束解码带来的格式保证意味着什么? 如果你的 agent 框架构建在支持约束解码的模型之上(比如本地部署的 LLM 用 outlines 做约束),你可以抛弃传统的"解析-重试"循环。模型输出的参数 100% 符合 schema,你只需要做执行。这让 agent 的可靠性上了一个台阶,也减少了大量样板代码。
4. 异步工具调用的隐藏成本。 当模型一次性调用多个工具时(parallel tool calling),API 层会在同一个 generation 中输出多个函数调用。但模型在生成第二个函数调用时,"不知道"第一个调用的结果。这会导致工具之间依赖关系的错误推断。如果你的 agent 场景中工具有依赖关系(比如先查用户 ID,再用 ID 查订单),最好把 parallel tool calling 关掉,用顺序调用。
最后
Tool calling 是 LLM 从"聊天机器人"进化到"agent"的关键桥梁。但它不是魔法——它是一层层的工程折中堆叠出来的:训练数据的质量决定基础可靠性,API 层的注入决定交互方式,约束解码决定输出质量,框架设计决定使用体验。
理解这些底层机制后,下次遇到 agent 不调用工具、或者参数解析失败时,你大概能猜到问题出在哪一层了。
评论
发表评论