Tool Calling 与结构化输出:守得住的契约
24 次调用、0 个破损 JSON、2 个可用日期。再看同一端点配上更好描述后,以及 schema 无法修复的问题。
本页内容
给模型一个航班搜索工具,让它找一班从马德里到柏林的航班。返回的是这样:
<tool_call>
{"name": "search_flights",
"arguments": {"from": "Madrid", "to": "Berlin", "date": "3rd October 2026"}}
</tool_call>JSON 是有效的。工具名是对的。每个必填字段都在。而这次调用毫无用处:没有航班 API 会在需要机场代码时接受 "Madrid",也不会在需要日期时接受 "3rd October 2026"。
这个差距——语法完美、语义不可用——正是本章要讨论的内容,而首先要明确的是:这不是一个 JSON 问题。在使用这个工具的 24 次请求中,模型生成了 24 次有效的工具调用,0 个破损 JSON。它一次都没有在大家最常调试的部分失败。
模型并不执行任何事
链接到此部分:模型并不执行任何事在讲机制之前,先说一句最能避免混淆的话:工具调用是请求,不是动作。
模型发出一条结构化消息,意思是:我想用这些参数调用 search_flights。然后它就停止了。你的代码收到这条消息,决定是否接受它,调用该调用的东西,再把结果作为另一条消息发回去。模型从未碰过你的数据库,从未发起 HTTP 请求,也从未拥有凭据。
第 30 章里关于 agent 安全的一切都来自这个分工,第 23 章里关于 agent 设计的一切也是如此:模型提出方案,你的代码做决定,而所有保证都存在于代码里。
所以,去掉术语外衣,一个工具就是两件事:
一个 schema。 一份描述函数的 JSON Schema:它的名称、用途,以及它接受哪些参数、这些参数的类型和约束。这会进入 prompt,也是模型唯一能看到的东西。
一个端点。 你代码中的一个函数,接收这些参数并返回某些东西。模型永远看不到它,不知道它用什么语言写,也分不清数据库查询和硬编码字符串。
你随请求发送 schema
链接到此部分:你随请求发送 schema工具定义会进入 prompt,并序列化成模型训练时见过的某种格式。它们在每一次调用中都会消耗 token——这个事实会在本章后面带着数字回来。
模型用一次调用而不是文本作答
链接到此部分:模型用一次调用而不是文本作答响应不再是散文,而是包含一条结构化请求,API 也会报告一个表示这一点的结束原因。这个原因很重要:你的代码正是靠它知道该运行工具,而不是把答案展示给用户。
你的代码运行它——或拒绝它
链接到此部分:你的代码运行它——或拒绝它这一步里没有模型。根据 schema 验证参数,判断这个调用方是否被允许这么做,然后执行。
你把结果作为消息发回去
链接到此部分:你把结果作为消息发回去结果会成为对话中的又一轮,并使用一个专门保留的角色。模型会像读取任何其他上下文一样读取它。
模型回答,或请求另一个工具
链接到此部分:模型回答,或请求另一个工具这就是第 23 章的循环,也是一个请求能变成十几次往返的原因。
这一切都不是涌现出来的。正如第 11 章所说明的,tool calling 是一种训练出来的行为:1 在后训练期间,模型见过成千上万段正是这种形状的对话。这就是为什么格式会因模型而异,为什么大小相近的模型可靠性会差这么多,也解释了为什么模型能调用一个它从未见过的工具——形状是训练出来的,具体工具来自你的 prompt。
糟糕 schema 的代价,实测
链接到此部分:糟糕 schema 的代价,实测下面是大多数人最开始会写出的工具。注意,它没有任何地方是错误的;只是太薄:
{
name: "search_flights",
description: "Search for flights.",
parameters: {
type: "object",
properties: {
from: { type: "string", description: "Airport." },
to: { type: "string", description: "Airport." },
date: { type: "string", description: "The date." },
},
required: ["from", "to", "date"],
},
}24 次请求,6 组城市配对,交叉 4 种日期表达方式("the 3rd of next month"、"next Friday"、"15 December"、"tomorrow"),使用贪心解码以便结果可复现:
| 调用了工具 | 破损 JSON | ISO 日期 | 机场为 IATA | 全部正确 | |
|---|---|---|---|---|---|
| 上面的 schema | 24/24 | 0 | 2/24 | 4/24 | 1/24 |
先看前两列,再看后三列。模型每次都调用了正确工具,每次都生成了格式良好的 JSON。失败完全出在值上,而这些值不可用:"Madrid" 而不是 MAD,"3rd October 2026" 而不是 2026-10-03。
这一点值得反复强调,因为它决定了出问题时你该去哪里找原因。本能反应是加一个带重试的 JSON 解析器,或者更强硬地要求模型给出有效 JSON。但这两者都没有处理这里实际发生的事。
现在只改描述
链接到此部分:现在只改描述同一个端点。背后同一份代码。同一个模型,同样的 prompts,同样的解码方式。唯一改变的是 schema 里的文本:
{
name: "search_flights",
description: "Search scheduled flights between two airports on a given day.",
parameters: {
type: "object",
properties: {
from: {
type: "string",
description: "Departure airport as a three-letter IATA code, e.g. MAD for Madrid. Never a city name.",
pattern: "^[A-Z]{3}$",
},
to: { /* same */ },
date: {
type: "string",
description: "Departure date as an ISO 8601 calendar date, YYYY-MM-DD. Resolve relative dates against today before calling.",
format: "date",
pattern: "^\\d{4}-\\d{2}-\\d{2}$",
},
},
required: ["from", "to", "date"],
},
}| 日期 FORMAT | 日期 VALUE | 机场 FORMAT | 机场 VALUE | |
|---|---|---|---|---|
| 薄 schema | 2/24 | 1/24 | 4/24 | 4/24 |
| 有描述的 schema | 24/24 | 12/24 | 16/24 | 8/24 |
日期格式从 24 次里的 2 次,变成了 24 次里的 24 次。只改一段文本,不碰代码,也没有重试逻辑,就做到了完美。如果你只从本章带走一个运行习惯,那就是:当一个工具被错误调用时,修复几乎总是在描述里,而且这是系统里成本最低的修复。
现在看第二列,那才是更重要的一半。
schema 约束形状。它无法提供知识。
链接到此部分:schema 约束形状。它无法提供知识。日期 24 次里 24 次都是 ISO 格式。但它是正确那一天的次数只有 12/24。
也就是说,现在有一半调用携带了格式完美却日期错误的日期。描述告诉模型要产出什么形状,模型也毫无瑕疵地产出了——但把 "next Friday" 变成 2026-09-11 需要知道今天的日期并做日历运算,而再多描述也无法提供这一点。机场也是同样的故事:格式从 4 次提高到 16 次,但值只从 4 次提高到 8 次,因为写出 MAD 需要知道马德里的机场是 MAD。
这个区别是本章的承重概念:
schema 是一个关于形式的契约。它能让模型输出可解析、有类型且一致。它不能让输出变得真实,而所有在好 schema 之后仍然存在的失败模式,都是知识失败,不是格式失败。
这两者需要不同修复,把它们混在一起会浪费数周时间。格式失败要在描述里修,或用下面的约束解码修。知识失败要靠把知识放进 prompt 来修——把当前日期放进系统消息,让模型先调用一个作为第二工具的机场查询,或者在集合足够小时把 enum 放进 schema。注意这三者的共同点:它们都把问题从模型的记忆中移到模型的输入里,而这正是第 24 章的全部内容。
结构化输出,以及“约束解码”到底是什么
链接到此部分:结构化输出,以及“约束解码”到底是什么上面的所有内容仍然依赖模型选择产出正确形状。还有一种更强的保证可用,也是第 17 章里最有价值的回报。
回想生成是如何工作的:在每一步,模型都会为词表中的每个 token 生成一个 logit,然后采样器选择一个。约束解码会在中间插入一步。给定一个语法——由你的 JSON Schema 推导而来——它会计算哪些 token 可以合法地接在后面,把所有其他 token 的 logit 设为负无穷,然后让采样器从剩下的 token 中选择。
如果 schema 说下一个东西必须是 {,那么每一个不是 { 的 token 概率都是零。不是“不太可能”:是零。模型无法发出无效 JSON,因为无效 token 在采样之前就已经从分布中移除了。
这就是“结构化输出”、“JSON 模式”和“引导生成”的底层原理,也解释了它们的两个属性。对于语法能表达的任何东西——类型、必填字段、enum、嵌套——保证都是完全的,因为它是机械强制执行的,而不是礼貌请求来的。它也不说明内容:语法可以强制 "date" 成为匹配日期模式的字符串,却不能强制它是正确那一天。这和上一节遇到的是同一堵墙,只是从另一侧抵达。
两个实践提示。它不是免费的:每一步都必须计算掩码,复杂语法会带来可测量的延迟。它也会改变模型正在做的事——当模型被引导离开自己偏好的 token 时,可能在产出完美结构的同时产出更差内容;所以对简单形状来说,“好好请求并验证”仍然是合理默认,而当形状复杂或消费方要求严格时,约束解码才值得付出成本。
副作用,以及唯一重要的属性
链接到此部分:副作用,以及唯一重要的属性第 14 章测过一次超时后重试,导致为了一个答案计费两次生成。有了工具,同样的失败会更糟,因为工具可以真的做事。
如果你的代码调用 charge_card,超时,然后重试,你就有两笔扣费。模型完全不知道这些发生过;它只看到一个工具结果。修复方式和任何分布式系统一样,而且这不是模型的问题:通过给调用一个 key,让操作具备幂等性,这样第二次执行会识别第一次并返回它的结果,而不是再次做同样的工作。
由此得出的设计规则值得直说。在你的工具目录里把读和写分开。 读操作可以自由重试、并行运行、缓存。写操作不行,而且应该带有 key、权限检查,以及——对于任何用户会想在发生前知情的事——一个 approval 步骤,把人放在请求和动作之间。这个 approval 步骤不是礼貌:它是 prompt injection 和真实后果之间少数几道防线之一——而且第 30 章测到,它是其中最弱的一道。
多少工具会开始退化?
链接到此部分:多少工具会开始退化?坊间说法是加载很多工具会让模型选错。与其重复这个说法,不如测量一下:同样的 24 次请求,航班工具加上一组不断增多的其他工具——其中包括 3 个刻意容易混淆的工具(火车时刻表、渡轮航线、公交路线)。
| 加载的工具数 | prompt token | 选择了 search_flights | ISO 日期 |
|---|---|---|---|
| 1 | 353 | 24/24 | 24/24 |
| 5 | 730 | 24/24 | 24/24 |
| 10 | 1,193 | 21/24 | 21/24 |
| 20 | 2,119 | 24/24 | 24/24 |
选择没有退化。加载 20 个工具,其中 3 个有可信的混淆可能,一个 5 亿参数模型仍然 24 次里 24 次选对。10 个工具时的下滑来自 3 次调用命名了不同工具,但这个现象并没有延续到 20 个工具。
这是一个负结果,也应该按负结果报告:在这个任务、这些工具上,“工具太多”不是问题。 真正单调增长、并且增长到 6 倍的是 prompt:从 353 个 token 到 2,119 个 token,在对话中的每个请求上都要付费,永远如此,无论是否使用了任何工具。
所以,这个坊间说法的诚实版本谈的是成本和上下文,而不是准确率。20 个工具是每条消息上的永久税,而第 16 章已经展示过永久前缀会如何在 40 轮对话中推高账单。当人们报告很多工具损害质量时,其机制通常是工具定义挤掉了真正重要的上下文——这是披着第 18 章外衣的第 24 章问题。真正彼此接近重复的工具也是个真实问题,而它们的修复方式不是减少工具,而是更好的描述和命名空间:按系统加前缀(crm.search_customer、billing.search_customer),这样两个团队合并来的两个目录不会冲突,模型也有东西可区分。
三类工具,以及开启下一部分的那一种
链接到此部分:三类工具,以及开启下一部分的那一种按工具会对世界做什么来分类会很有帮助,因为每一类所需的工程方式都不同。
数据工具负责读取:搜索、获取、查询。可重试、可并行、可缓存。它们的失败方式是返回不了有用内容,而主要风险在于把不受信任的文本带入上下文——这正是第 30 章的整个攻击面。
动作工具负责写入:发送、创建、扣费、删除。没有 key 就不能重试,不能安全并行,也是 approval 流程存在的原因。
编排工具会调用其他模型。它的实现是另一个 agent,拥有自己的 prompt、自己的工具和自己的循环——而对调用它的模型来说,它看起来和另外两类完全一样,因为一个 schema 加一个端点就是模型所能看到的全部。
第三类不是猎奇。它是第 25 章中“agent-as-a-tool”那一半背后的机制——另一种拓扑 handoff 会把对话交出去,并且再也拿不回来——而它之所以可行,正是因为本章的接口足够窄,窄到整个 agent 都能藏在后面。
下一步去哪里
链接到此部分:下一步去哪里现在你有了一个能请求事物的模型,也有了一个能让请求可解析的契约。你还没有的是:除了 prompt 里能装下的内容之外,它还有什么可请求的对象。
生产中最常见的工具,远远超过其他类型,是对一批模型训练时从未见过的文本进行搜索:你的文档、工单、合同。听起来像是一个已经解决的问题——embedding 它、找到最近邻、粘贴进去——但那些尚未解决的部分,才决定答案是否可信:文本在 embedding 之前如何切分,相似度阈值低到什么程度才意味着我不知道,以及 citation 如何附着到一个 claim 上,让读者可以检查它。
第 19 章讲的是检索,而在那一章里,错误答案不再只是一个有趣现象,而会开始成为责任。
来源与方法
链接到此部分:来源与方法本章的测量来自 Qwen/Qwen2.5-0.5B-Instruct,使用贪心解码,覆盖 24 个生成请求:6 组城市配对交叉 4 种日期表达方式,并使用模型自己的聊天模板来提供工具定义。它们可以精确复现,而且用的是小模型:请把 format/value 的分裂理解为机制演示,而不是当前模型能力的基准。前沿模型更常能正确解析 "next Friday"——但仍然无法被 schema 强制做到这一点,这才是可泛化的部分。
上文使用的 JSON Schema 词汇(type、properties、required、pattern、format、enum)由你的提供商文档所指向的 JSON Schema 草案规定;有用的子集很小,各提供商之间也相同,而确实存在的差异——哪些关键词会由约束解码强制执行,而哪些只是传给模型——值得去读提供商的结构化输出指南,而不是凭空假设。
至于约束解码这项技术,guidance 风格的库和 outlines 项目都以一种能直接映射到第 17 章采样器的方式,记录了从语法到 logit 掩码的构造。至于往返本身,最清晰的说明不是教程,而是协议:第 26 章会逐行阅读它。
参考资料
链接到此部分:参考资料-
Ouyang, L. et al. Training language models to follow instructions with human feedback. arXiv:2203.02155 (2022)。这篇论文让后训练配方成为标准;tool calling 的形状正是在那里从示范中学到的,就像答案的形状一样。 ↩