第 3 章 把输出收敛成契约:结构化输出与工具调用
难度 ★4 | 约 4 学时 | 6 节
这一章解决一件事:让模型的输出变成「可以被程序判断」的东西。结构化输出解决「格式能不能断言」,工具调用解决「动作能不能约束」——而两者的前提是同一句:模型给的一切,都是不可信输入。
学习目标:能判断哪些输出必须结构化,并把结构化落到可校验的 schema 上。
本章交付物:一组可校验的输出 schema + 一份「参数白名单与限权」说明
3.1 自由文本是不可测试的
这一节的判断很硬:只要输出是自然语言,断言就只能靠人看。
这句话是对的,而且它的推论很严重:不能自动断言的东西,就不能进回归。 不能进回归,意味着你改一版提示词、换一次模型、动一下工具,都没有任何机制告诉你「有没有把别的弄坏」。第 10–12 章会把这件事做成流水线;而流水线的前提,是输出本身先变成可断言的东西。
所以「能结构化却不结构化,等于主动放弃自动化」这句话成立。
但「能结构化」这个判断需要定界,否则读者会走向另一个极端:把所有输出都塞进 JSON。
三条边界,请一起记住。
边界一:「能不能结构化」不取决于输出长什么样,而取决于「有没有稳定的判定规则」。 一份「会议纪要」看起来是自由文本,但如果你的产品要求它必须包含「决议 / 责任人 / 截止日期」三项,那它就可以结构化——因为你能说清「缺了哪一项算不合格」。反过来,一句「请写得温暖一点」,即使你把它塞进一个 { tone: string } 的字段里,它依然不可断言——你只是把一段不可测的文本换了个容器。
边界二:有些输出本来就不该结构化,而且这是正确选择。 面向人的长文、创意写作、开放式解释——把它们切碎成字段,会同时损失可读性和表达能力,而换来的「可断言」是假的(你还是得人看)。这类输出的正确做法不是结构化,而是抽样评审 + 关键结论抽检(第 10 章)。
边界三:结构化会改变模型的行为。 一旦要求输出 JSON,模型会更倾向于「填满字段」而不是「说不知道」。这是一个真实的副作用:字段存在,就有被填的冲动。所以结构化输出的设计里,必须显式留一个「我不知道 / 信息不足」的表达方式(第 3.3 节会把它落成 schema 里的一格)。
合起来,准确的表述是:能结构化且判定规则稳定的,一定要结构化;判定规则不稳定的,结构化只是换了个壳;面向人的长文,结构化会得不偿失。
3.2 结构化输出有两层保障:模型侧约束 + 应用侧再校验
这一节要拆掉一个很常见的误解:「我开了结构化输出,所以格式一定对。」
先看模型侧到底做了什么。主流实现用的是约束解码:把你的 schema 编译成一个语法,在生成每一个 token 时,把不合法的候选直接掩掉。官方的文案是「确保模型始终生成符合你所提供 JSON Schema 的响应」。这确实是相当强的保证——强到容易让人以为到此为止了。
但有两件事必须同时知道。
第一件:JSON 模式不等于 schema 模式。 官方明确区分了这两种:
| 模式 | 保证什么 | 不保证什么 |
|---|---|---|
| JSON 模式 | 输出是合法 JSON | 不保证符合你的 schema(字段可能缺、类型可能错) |
| schema 模式 | 输出符合给定的 schema | 不保证语义正确、不保证值合法 |
这个区别极其容易混,因为两者在代码里往往只差一个参数。「是合法 JSON」与「符合 schema」是两件事——前者只保证能被 JSON.parse 解析,后者才保证能被你的业务代码读。
第二件:拒答是程序可检测的,而且必须单独处理。 当模型因为安全策略拒绝时,结构化结果可能是空的、同时有一个专门的拒答标记被置位。很多人把「空结果」当成解析失败,于是去重试——而重试只会得到同样的拒绝。 正确动作是:先看拒答标记,再看字段。
那应用侧的再校验是干什么的?它不是「不信任模型」,而是「不信任链路」。 至少三种情况会让一个本该正确的结构在到达你手里时已经变形:
① SDK 版本差异。 升级一个次版本,字段名或嵌套层级可能变。 ② 代理层改写。 网关、聚合层、缓存中间件都可能对响应做处理。 ③ 缓存了畸形响应。 如果你的缓存键设计不当(比如没把 schema 版本算进键),你可能把一次坏响应缓存下来,之后一直返回它。
所以落地的写法是两层都做,而且第二层要写得像「不信任任何人」:
const raw = await callModel({ schema, messages });
if (raw.refused) return { kind: 'refused' as const }; // 先处理拒答
const parsed = schema.safeParse(raw.json); // 再校验结构
if (!parsed.success) {
log.error('schema-mismatch', { issues: parsed.error.issues });
return { kind: 'invalid' as const }; // 不是重试,是报错
}
注意这段代码里两处「不是重试」:拒答不重试,结构不符也不重试。 前者重试无用,后者重试有害——它会把一个应该被发现的 bug 变成一次偶发失败。
最后一条口径纪律:不同提供方的机制不同名(有的叫响应格式参数,有的是独立配置项)。正文只写「模型侧约束」与「应用侧校验」这两类机制,具体的参数名一律查官方文档,不写进书里。
3.3 schema 要写成「能被拒绝」的样子
一个宽松的 schema 会给你一种虚假的安全感:它几乎从不拒绝任何东西,所以你的解析代码从来不会报错。 而错误并没有消失,它只是被推迟到了下游——延迟到某个统计脚本算出负数、某个报表多出一行、某个工单被派给了不存在的部门。
所以本节的主张是:schema 的写法目标不是「能通过」,而是「能被拒绝」。
先说官方的硬性约束,这几条不是建议,是要求:
① 所有字段都必须在 required 里。 想表达「可选」,不能靠「不写进 required」。
② 额外属性必须被禁止。 也就是 additionalProperties: false——不然模型可以往里塞你没定义的字段,而你的代码看不见它。
③ 「可选」要用与 null 的联合类型表达。 也就是说,字段必须存在,值可以是 null。这个写法看起来别扭,但它换来的是「结构永远可预测」。
于是「能被拒绝」具体写成什么样?三类约束,每类给一个例子:
{
"type": "object",
"additionalProperties": false,
"required": ["decision", "reason", "confidence", "missingInfo"],
"properties": {
"decision": { "enum": ["approve", "reject", "need_more_info"] },
"reason": { "type": "string", "maxLength": 300 },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"missingInfo": {
"type": ["array", "null"],
"items": { "type": "string", "maxLength": 80 },
"maxItems": 5
}
}
}
这段 schema 里最值得注意的不是 enum,是 missingInfo。它把 3.1 说的「必须留一个『我不知道』的表达方式」落成了一个真实字段,而且落得很硬:need_more_info 是 decision 的合法值之一,模型不需要编一个理由,它可以直接说「信息不足」。
再往下,是一个反直觉的纪律:遇到 schema 报错时,正确动作是「简化」,而不是「加更多约束」。
理由来自平台的能力上限。不同提供方对 schema 的支持边界不同,已知的限制至少有四类:
① 不支持递归结构(自引用的类型); ② 枚举值过多会被拒绝; ③ 嵌套过深会直接报参数错误; ④ 只支持 JSON Schema 的一个子集(某些关键字会被忽略或报错)。
官方文档对此的建议很直接:简化失败的那个 schema——更短的属性名、更少的嵌套、更少的约束。 也就是说,当你发现「加了约束反而报错」时,那不是模型能力不够,而是你把 schema 写到了平台的边界之外。
最后一层是下游受害场景,写出来才算完整:宽 schema 的代价,总是由最不会报错的那一层承担。 数据库会接受空字符串,报表会把空字符串算成零,而你的告警永远不会响——因为从系统的角度看,一切都很正常。
3.4 工具调用是「意图声明」,不是「执行」
这一节要纠正一个几乎人人都犯过一次的错:以为模型「调用了」工具。
它没有。模型做的是「声明意图」:它输出一个结构化的片段,说明「我想调用这个工具,参数是这些」。真正执行的是你的代码。 这个区别不是学术性的,它决定了三件事归谁。
归你的三件事:执行、鉴权、幂等。
执行——你去查库、去发请求、去改状态。模型碰不到你的数据库。 鉴权——这次调用允不允许,由你的代码判断。模型的「请求」里没有身份,身份是你补上去的。 幂等——如果这次工具调用因为网络原因重放了两次,后果由你的幂等键决定。
这三件事合起来,说明一件重要的事:工具调用是把「不可信的意图」翻译成「受控的动作」的那一层。 这一层写得好不好,直接决定第 15 章那些安全风险会不会真的发生。
具体到代码形状,有两条硬纪律。
第一:绝不在工具执行路径上抛异常,要把错误转成「错误结果」返回给模型。
这条反直觉,但理由很实在:模型需要知道发生了什么,才能决定下一步。 一个抛出去的异常会被你的框架吞掉,模型看到的是「没有结果」,于是它可能重试同样的调用、或者编一个答案。而一个结构化返回的错误结果,模型能读到「参数不合法」「没有权限」「查无此单」,它就有机会改成正确的调用,或者向你的人报告。
async function runTool(name: string, args: unknown) {
const spec = registry[name];
if (!spec) return { ok: false, error: 'unknown_tool' };
const parsed = spec.schema.safeParse(args); // 3.5:先校验
if (!parsed.success) return { ok: false, error: 'invalid_args', issues: parsed.error.issues };
if (!(await allowed(user, spec))) return { ok: false, error: 'forbidden' };
try {
return { ok: true, data: await spec.run(parsed.data) };
} catch (e) {
return { ok: false, error: 'tool_failed', message: String(e) }; // 不抛,返回
}
}
第二:只暴露「你愿意让它做」的工具,而不是「你手上有」的工具。
工具集是你的攻击面(第 15 章会把它写成风险清单里的头号缓解措施)。把内部管理接口顺手注册成工具,是这一章最贵的一个错误——它把一个「只读问答」的功能,变成了一个「可以改数据」的功能,而产品经理并不知道。
最后一条边界:工具的数量本身有成本。 每个工具的定义都要计入输入 token,并且每次调用都要重复付费(2.1)。所以「多注册几个,说不定用得上」这句话,在成本和风险上是双重亏。
3.5 模型给的参数是「不可信输入」
请把这一节当成整个第 3 章的重心:模型生成的工具参数,和用户直接在输入框里敲进来的字符串,是同一类东西。
这句话为什么必须说得这么绝对?因为它们的来源是同一条链路——用户输入 → 模型 → 参数。中间那一步不构成净化,它只是加了一层「看起来像结构化」的包装。而权威风险清单里,这类问题有明确的位置:提示注入连续排在前列,而缓解措施里写得很清楚——限制模型能调用的工具、限制每个工具能做的事、只给完成该操作所需的最低权限。
请注意这三条缓解措施的位置:它们都不在模型里,都在你的代码里。
具体的落地纪律有四条。
① 参数不许直通下游。 「模型给了个 id,我就拿它去查库」——这是最常见的一行代码,也是危害最大的一行。至少要做三件事:校验形态(是不是你要的类型与格式)、限定范围(这个 id 是不是这个用户能碰的)、拒绝多余键(多出来的字段一律丢弃,不要透传)。
② 白名单优先于黑名单。 「把 ../ 和 -- 过滤掉」这种写法永远漏。正确的方向是反过来:只允许出现在白名单里的值。
③ 限权要落在「这一次调用」上,不是「这个人」。 同一个人,在正常页面上可以有删除权限;但在「AI 助手」这条链路里,它不应该有。这就是「最低权限」在 AI 场景里的具体含义:按操作授权,而不是按人授权。
④ 高影响或不可逆的操作,执行前要有人工确认点。 这不是保守,这是风险清单给的明确建议。「不可逆」是判据:删除、转账、发布、对外发送——这四类动作上,模型只能提议,不能决定。
// 参数校验不是「格式检查」,是三道关
const a = argsSchema.parse(raw); // ① 形态
assertInScope(a.orderId, user); // ② 范围(这个用户能碰吗)
const safe = pick(a, ['orderId', 'reason']); // ③ 丢弃多余键,不透传
最后说一条容易被忽略的注入入口:工具返回值也是不可信输入。
很多人的模型里,「工具的结果」被当成可信数据直接拼进上下文。但那个结果可能来自一个用户可编辑的工单、一封外部邮件、一个第三方接口。它同样是注入载体。 所以正确的写法是:工具结果进上下文时,也要被标记为「外部内容」,并且不参与指令解释——这条会在第 6 章与第 15 章反复出现。
3.6 一个可观测的验证:喂三种畸形参数,看它被拦住还是透传
这一节的动作很小,但它能把前面五节全部验证一遍:给你的工具写 schema,然后故意喂三种畸形参数。
三种参数,对应三类真实会发生的事故:
① 类型不对。 该是数字的地方给字符串("12"),该是枚举的地方给一个不在枚举里的值("maybe")。
② 范围越界。 该是正数的地方给负数,该是本用户订单的地方给一个别人的订单号。
③ 多余字段。 在你定义的参数之外,多塞一个 "admin": true 或者 "__proto__" 之类的键。
每种都喂两次:一次走正常路径,一次走「模型被诱导」的路径(在提示词里要求它「一定要带上 admin 字段」)。第二次才是真正的测试——第一次验的是你的校验器,第二次验的是你的校验器在对抗条件下还在不在。
判据只有三种结果,而且必须诚实地记录是哪一种:
| 结果 | 含义 | 处置 |
|---|---|---|
| 被 schema 拦住 | 形态层有效 | 合格 |
| 通过 schema 但被业务校验拦住 | 形态层不足、范围层有效 | 补形态约束 |
| 一路透传到下游 | 两道都没有 | 立即修,不能上线 |
这一节最容易被跳过,因为它「看起来只是个测试」。但它其实是第 1 章那张能力边界卡的执行版——卡上写着「绝对不能错:只能操作本用户的订单」,而这一次测试就是在证明那句话是不是真的。
最后一条:把这三组参数留在你的测试集里。 它们不是一次性脚本,而是回归用例——因为第 12 章会告诉你,换一版提示词、换一个模型,这些防线可能会悄悄失效,而你不会有任何感觉。