跳到主要内容

结构化输出并非经过验证的输出

· 阅读需 10 分钟
Tian Pan
Software Engineer

你的团队启用模式约束解码(schema-constrained decoding)的那一天感觉像是一个里程碑。解析错误停止了。JSONDecodeError 警报消失了。从文本中抓取字段的脆弱正则表达式也被删除了。有人在站会上说“模型现在返回有效的 JSON 了”,结构化输出的任务单随之关闭。

那句话正是麻烦的开始。“模型现在返回有效的 JSON 了”是正确性工作的开始,而不是结束。JSON 模式和约束解码保证了响应的形状(shape)——即 quantity 是一个整数,status 是三个枚举值之一,对象包含你要求的键。它们完全无法保证 quantity 是否是正确的数字,status 是否反映了真实发生的情况,或者 sku 字段是否指向了目录中存在的商品。

模式(schema)只是一个类型签名。它告诉你函数返回一个 int。它并没有告诉你这个 int 是正确的。将通过模式检查等效于通过正确性检查,是结构化输出流水线将“格式正确的胡言乱语”推向生产环境最常见的方式——而且因为这些胡言乱语格式正确,你的解析器、类型检查器和仪表板在问题发生时都会显示为绿色。

模式到底证明了什么

约束解码通过掩码(masking)Token 来工作。在每个生成步骤中,解码器计算哪些下一个 Token 能让输出保持在仍能满足语法的路径上,将所有其他 Token 的概率设为零,并进行重新归一化。模型字面上无法在错误的地方产生花括号,也无法在模式要求数字的地方产生字符串。

这是一个真正的保证,值得拥有。但请注意它涵盖的具体范围:结构一致性。语法知道 price 必须是一个数字。它不知道这个产品的价格是 49.99。它知道 currency 必须是一个三个字母的枚举值。它不知道订单是以欧元结算的。约束作用于 Token 语法;而正确性存在于语法从未获得的语义中。

掩码机制中还隐藏着一个更微妙的问题。模型对下一个 Token 的概率计算是在不感知约束的情况下进行的。解码器在事后才应用掩码。因此,当模型“想要”说一些语法禁止的内容时,约束解码并不会让它重新考虑——它只是强迫模型选择概率最高的被允许的 Token。强制执行约束不仅会改变答案的格式,还会改变其内容。如果让模型自由发挥,它可能会对一个确实无法确定的字段写下 "unknown",但在约束下,它可能会被迫选择一个具体的枚举值,因为 "unknown" 不在枚举中,而语法必须在那里填入某些内容。你得到了一个干净的模式,作为交换,你得到了一个自信的错误答案。

最典型的例子是数字字段。你将 speed 定义为一个数字。模型已经内化了答案是“fast”——这是一个描述性概念,而非测量值。如果没有约束,它会写下 {"speed": "fast"}。有了约束,它做不到这一点,于是它输出了 {"speed": 9999}{"speed": 0}——这是一个数字,模式有效,但毫无意义。你以前能捕获的格式错误,现在变成了你无法察觉的语义错误。

读时模式(Schema-on-read)陷阱

这是我最常看到的失败模式。团队采用了结构化输出,将响应直接接入 Pydantic 模型或 TypeScript 接口,而在反序列化成功的那一刻,数据就被视为可信的。验证器和解析器被默默地合并成了一个步骤。解析成功,因此数据就是好的。

但解析只能证明字节匹配类型。这是最松散意义上的“读时模式”:你将字段读取为字符串,然后你就相信了它。我们将这类 Bug 称为“格式正确的胡言乱语”。每个值都是正确的类型。每个必需的键都存在。每个枚举都是合法的。但记录仍然是错误的:

  • sku 是一个格式完美的字符串——"PRD-44182"——但在产品表中匹配不到任何行。
  • ship_date 是一个有效的 ISO-8601 日期——2026-02-30——但在日历上并不存在。
  • discount_pct 是一个数字 140,而有意义的范围仅为 0 到 100。
  • assignee 是你提示词中列出的有效枚举成员,但下游服务在上季度已停用,不再路由到该成员。
  • total 是一个数字,且它不等于 sum(line_items),因为模型是独立计算它的。
加载中…
References:Let's stay in touch and Follow me for more thoughts and updates