跳转到内容

第 41 章 结构化输出:建立机器可验证的数据契约

41.1 人能读懂“750 元”,程序该收到什么?

Section titled “41.1 人能读懂“750 元”,程序该收到什么?”

第 40 章的证据支持每间夜 750 元。人能读懂这句话,但程序若要把金额用于费用计算,就需要更明确的约定:哪一部分是数字,按什么单位计算,什么来源支持它,不知道金额时又该怎么办?

最简单的办法似乎是从句子里找出第一串数字。但“原来的 600 元限额已改为 750 元”会让它取错数;“750 没有依据”也会被取成 750。问题不只是排版,而是需要一份数据契约(data contract):先让机器能够检查表示形式,再分别检查含义和证据。

第 29 章讲过分数如何变成输出 token。“请返回 JSON”会影响这个过程,却不会自动限制所有可能的续写。本章区分三件事:请求某种格式,在解码过程中约束格式,以及检查已经生成的结果。

例 41.1:带类型和证据的答案。 下面是作者给定的目标对象,引用的是虚构现行制度。契约已规定金额单位为元/间夜。

{
"schema_version": "ka-answer-v1",
"status": "answered",
"answer": "750 元/间夜。",
"amount_yuan": 750,
"citations": [{
"source_id": "travel-v2",
"version": "v2",
"line_start": 1,
"line_end": 3,
"quote": "虚构北京差旅住宿制度;金额单位为元/间夜。\n自 2026-01-01 起生效,替代 travel-v1。\n住宿限额为 750 元。"
}],
"reason_code": null
}

最外层花括号围住一个对象(object),其中包含若干有名称的字段。冒号把字段名和值连起来,逗号分隔各字段,名称用双引号包住。"answered" 这样的带引号值是字符串(string),也就是一串字符。不带引号的 750 是数值(number),而 "750" 是由数字字符组成的字符串。解析程序不应悄悄把两者当成一样。

citations 后的方括号表示数组(array),也就是有顺序的列表。本例列表中只有一个对象,行号范围包含起止两行。null 是 JSON 的特殊值,在本契约中表示这个位置没有值。它不是字符串 "null",不是零,也不是省略字段。JSON 还提供布尔值 true 和 false,表示两种逻辑状态,不能把它们当作金额 1 和 0。

解析器(parser)把 JSON 文本转换为程序中的值。没写完的 {"amount_yuan": 或末尾多出的逗号会被拒绝。但 {"amount_yuan":"750"} 本身完全可以解析。于是我们必须分别回答:“它是合法 JSON 吗?”以及“它符合接收方需要的对象结构吗?”

41.3 Schema 约定形状,应用决定后果

Section titled “41.3 Schema 约定形状,应用决定后果”

JSON Schema 用来描述允许出现的值。这里明确采用 Draft 2020-12,不假定所有 Schema 系统都支持同样的关键字。现在可以逐项读懂下面的核心片段:

{
"type": "object",
"properties": {
"schema_version": {"const": "ka-answer-v1"},
"status": {"enum": ["answered", "abstained", "refused", "error"]},
"answer": {"type": ["string", "null"]},
"amount_yuan": {"type": ["integer", "null"], "minimum": 0},
"citations": {"type": "array"},
"reason_code": {"type": ["string", "null"]}
},
"required": ["schema_version", "status", "answer", "amount_yuan", "citations", "reason_code"],
"additionalProperties": false
}

type 限制值的种类,integer 表示没有小数部分的整数,minimum 排除负金额。enum 要求从列出的值中准确选一个,"success" 就不等于 "answered"。const 只允许一个确定的版本标签。required 要求字段存在,并不要求其值一定非空。additionalProperties: false 拒绝额外字段,因此把 amount_yuan 错写成 ammount_yuan 不会被默默忽略。

JSON Schema 检查的是数学上的值,所以 750.0 也符合整数要求。通过校验后,适配器会把这类值保存为 Python 整数,数值本身不变。这不意味着可以把字符串 "750" 或布尔值 true 转换成金额。

这个片段还不是完整校验器。共享 Schema 还检查每条引用的字段和正整数行号,并使用条件规则:状态为 answered 时,答案不能为空字符串,引用至少有一项,原因必须为 null;其他状态下,答案和金额必须为 null,引用为空列表,原因是非空字符串。应用还会检查结束行号不能小于开始行号。来源是否存在、引文是否准确、日期是否适用、证据是否支持主张,仍须另外核查。

表 41.1:看起来相近的响应,可能停在不同关口。

对例 41.1 的改动首先出现的结果原因
去掉最后一个右花括号解析失败文本未完成
把数值 750 改成字符串 "750"Schema 校验失败类型不符
删除 amount_yuanSchema 校验失败必填字段缺失,不知道金额也不能省略
增加 approved: trueSchema 校验失败多出未约定字段;即使存在也不会授予权限
引用从第 3 行到第 1 行应用校验失败两个数都是正整数,但范围颠倒
金额和答案改为 600,仍引用 v2结构通过,支持关系失败Schema 不知道哪版制度规定了什么
返回 abstained、空答案和金额、空引用及原因合法拒答契约明确提供了无法作答的分支

版本标签让生产方和接收方能够发现不兼容的变化。假设 v2 把金额单位从元改为分,旧程序就不能把 75000 仍当成元。应提供新 Schema 和经过测试的迁移规则,不能看数字大小猜单位。本书的 v1 保持固定,拒绝未知版本。

例 41.2:限制下一步能选什么。 假设生成到某个位置时,只允许 750 和 600 两个下一 token 候选。为便于演算,设这两个数在本例中各占一个 token。未施加约束时,750 的概率为 0.50,600 为 0.20,引号为 0.20,某个字母为 0.10。这些是作者给定的教学值,不是模型实测。

当前数值字段的语法规则排除后两项,剩余概率总和为 0.50+0.20=0.700.50+0.20=0.70。重新归一化后,

P(750∣allowed)=0.500.70=57,P(600∣allowed)=0.200.70=27.P(750\mid\text{allowed})=\frac{0.50}{0.70}=\frac57,\qquad P(600\mid\text{allowed})=\frac{0.20}{0.70}=\frac27.

分母取允许候选的概率总和,而不是原来的总和 1。这与前面学过的条件概率是一回事。约束解码(constrained decoding)在生成过程中限制后续选择,事后校验则检查已经产生的对象。实际分词器可能把数字切成多段,解码器必须判断一个 token 的字符能否继续当前语法,不能只给完整字段值列一张白名单。

两个金额都仍然合法,因为 Schema 允许非负整数。约束解码可以消除一种结构错误,却仍留下事实错误的可能。无论如何采样,语法规则都不能成为“750 今天适用”的证据。

完整输出
  1. 模型之外检查停止原因
  2. 处理步骤解析 JSON → 校验 Schema
  3. 文本片段600 或 750:都是合法整数

服务拒绝或截断进入明确的独立分支。

证据关口
  1. 文本片段主张:600 [v2]
  2. 模型之外v2 第 1—3 行:750
  3. 处理步骤拒绝无支持主张

对象合法,仍然没有完成有引用答案的任务。

图 41.1:结构关口不能证明金额正确。 原创教学图示 · CC BY-SA 4.0

图 41.1 分开了响应是否完成、JSON 能否解析、结构能否通过校验,以及证据是否支持答案。模型服务拒绝请求,或者流式响应中断时,可能根本没有普通答案对象。要先处理外层响应状态,再尝试解析,不能把半截答案拼进下一次请求就宣称成功。

41.5 证据不足、服务拒绝和技术失败不是同一种结果

Section titled “41.5 证据不足、服务拒绝和技术失败不是同一种结果”

本契约用 abstained 表示现有证据不足以支持回答,用 refused 表示模型服务拒绝请求,用 error 表示技术失败或数据契约被违反。三者的金额都为 null,但后续处理不同。证据缺失可能需要补来源,超时需要有上限的恢复,而服务拒绝不能被伪装成一个空答案。

带日期的实现资料 docs/part-vii-online.md 记录了当前厂商接口示例:解码语法只支持 Schema 的一个子集,停止原因也可能改变响应路径。稳定的原则是,即使服务提供结构化输出,应用仍然要做本地校验。本书没有调用付费模型,也没有测量约束解码准确率。

恶意来源同样可能影响一个格式完全正确的对象。调用方不能把“有引用”当成“有根据”,也不能把 approved 这个词当成授权。解析、校验、构造对象,都不会执行订单操作。输出现在更容易检查了,却没有因此获得权限。

contracts.validate_output 在解析时拒绝重复字段名和 JSON 不允许的数值,随后用完整共享 Schema 校验,并构造不可重新赋值的 Answer 数据对象。provider_boundary 先处理服务拒绝和响应未完成,再进入解析。遇到不合格响应,它返回明确的错误对象,不猜缺失金额,也不无限重试直到得到方便使用的答案。

练习 Notebook 把作者构造的语法错误、类型错误、缺字段和无依据主张交给真实校验器。这些是故障注入测试,不是从 LLM 采样得到的输出。测试特意保留一个格式正确却把现行限额写成 600 的对象,防止把“格式全部通过”误报为“事实评测通过”。两种语言使用同一份 Schema 和实现,本地化答案放在内容资源中。

JSON 是表示方法,Schema 是机器可检查的形状,约束解码则在生成途中限制可走的路径。合法对象仍需接受应用和证据检查。明确版本与失败分支,接收方才能知道如何处理结果。第 42 章将让模型以对象形式提出工具调用,再由应用判断是否执行。

  1. "amount_yuan": null 和省略该字段是否等价?在完整契约中,拒答应怎样填写?
  2. 将例 41.2 中两个允许候选的概率改为 0.30 和 0.10,其余候选总计 0.60。重新归一化后的概率各是多少?错误金额是否因此被排除?
  3. 响应的 v2 引用格式正确,答案却写 600 元。哪些检查已经通过?下一步需要什么证据?为何把 Schema 改为只允许 750 不是通用修复?
  4. 一份响应因 max_tokens 停止,前缀看起来像合法答案;另一份是服务拒绝。它们分别进入什么分支?为什么两者都不能证明发生过订单操作?
  1. 不等价。Null 是明确的值,省略则是必填名称缺失。合法拒答应将金额和答案写成 null,引用写成空数组,并提供非空原因。删除金额字段会导致 Schema 校验失败。
  2. 剩余概率总和为 0.40,所以得到 0.30/0.40=0.750.30/0.40=0.75 和 0.10/0.40=0.250.10/0.40=0.25。两个金额仍然可能出现。约束改变的是结构上允许的选择,不是制度事实。
  3. JSON 解析、字段类型和引用形状可能已经通过。还要取回被引原文,核对版本与日期,再比较金额、单位和答案主张。把 750 写死,会把证据任务藏起来,还会错误地拒绝历史问题的正确答案 600,以及未来可能更新的金额。
  4. 截断进入 error,原因为 incomplete_response;服务拒绝进入 refused,原因为 provider_refusal。两者金额均为 null。这些只是响应结果,没有建立执行记录,也没有证明存在授权。
  • RFC 8259,§§2—7、9。说明例题中使用的 JSON 语法和不同值类型。
  • JSON Schema Validation, Draft 2020-12,§§6.1—6.5,以及 Core,§§10.2—10.3。定义类型、必填、条件和对象字段规则,不负责证明事实正确。
  • Anthropic,Structured outputs,核验于 2026-09-14。说明约束解码、支持的 Schema 子集,以及拒绝和截断例外;字段与迁移限制记在在线实现资料中。