你的团队启用模式约束解码(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),因为模型是独立计算它的。
这些都不会触发模式检查。它们全是 Bug。模式验证了你收到了正确种类的对象;它无法验证该对象描述的是否是真实的事物。陷阱在于,绿色的解析结果看起来像是一个验证通过,因此一个绝不会信任未经验证的用户表单的团队,会欣然信任一个未经验证的模型输出——因为模型输出自带了一个模式。
语义验证究竟存在于何处
一旦你接受了模式只是类型签名这一观点,修复方法就是结构性的:在反序列化之后、数据被使用之前,加入一个真正的验证层。该层检查语法从根本上无法检查的事项。以下三类涵盖了大部分情况:
引用完整性。 模型命名的每个实体是否真实存在?sku 必须解析为真实的产品。user_id 必须解析为真实的账户。category 必须是你当前分类法中包含的类别——而不是两个模型版本前有效的类别。模型正在根据分布生成 Token;它与你的数据库没有实时连接。它会生成看起来合理、格式正确但完全虚构的 ID。模型输出中的每个外键在经过查询验证之前都是不可信的。
范围和定义域检查。 数字是数字,并不等同于数字在范围内。百分比应在 0–100 之间。日期必须存在,且通常必须落在一个合理的窗口内——1970 年或 2099 年的 ship_date 无论 ISO 格式如何都是 Bug。数量应为非负数。这些是编写成本最低的检查,也是最常被跳过的检查,因为字段“已经是数字了”,这感觉已经足够了。
跨字段约束。 这是昂贵且重要的部分。end_date 不得早于 start_date。total 必须与明细项(line items)一致。如果 status 是 "shipped",那么 tracking_number 必须存在。如果 payment_method 是 "invoice",那么 due_date 是必需的。模式孤立地验证每个字段;它没有描述字段间关系的词汇。约束解码让情况变得更糟,而不是更好——通过强制每个字段独立地符合格式,它产生的对象看起来内部一致,实则不然。
实践规则:模式检查属于 API 边界,而语义验证器属于反序列化和使用之间的一个独立的、可测试的函数。在代码中保持它们分离。当它们合二为一时,在截止日期压力下第一个被丢弃的,就是没人能看到失败的那一半。
将校验失败视为信号,而不只是护栏
语义验证器不仅是拒绝错误记录的门禁。它是你拥有的最高质量的评估(eval)信号,在实时流量中免费生成,但大多数团队都将其弃之不用。
当引用完整性(referential integrity)失效时,你捕捉到了模型在虚构实体 —— 一个幻觉出的 SKU,或者一个不存在的受让人(assignee)。当跨字段检查失败时,你捕捉到了内部矛盾。这些失败是 带标签的模型错误示例,产自生产环境,且无需标注成本。记录它们。统计它们。一个在失败时默默重试且从不发出指标的验证器,正将你真实的缺陷率隐藏在绿色仪表盘之后 —— 这正是结构化输出(structured outputs)本应解决的问题,只是现在又在下一层重新出现了。
这重新定义了“验证失败即重试”模式的本质。将验证错误反馈给模型进行重新提示(re-prompting)作为一种恢复策略是没问题的。但如果你不同时追踪重试触发的 频率 以及 哪项 检查失败了,那么你构建的其实是一个将模型错误转化为延迟和成本,却依然汇报成功的机器。语义验证器的失败率是模型质量的领先指标。模型升级后的失败率激增就是你的迁移回归警报。请将其接入与其他 SLO 相同的地方。
这里还有一个深层观点。一旦语义验证成为一个拥有自己指标的、真实且具名的层,它就变成了你可以 测试 的东西。你可以编写“格式正确但内容错误”的输出固件(fixtures),并断言你的验证器能捕捉到它们。你可以像衡量测试覆盖率一样衡量验证器覆盖率。Schema 免费为你提供了类型签名;而验证器才是你真正编码业务领域含义的地方,这也是值得你刻意投入去拥有的代码。
核心启示
结构化输出解决了一个真实存在的问题。在受限解码(constrained decoding)出现之前,LLM 流水线中相当大一部分工程精力都花在处理格式错误的响应上 —— 正则抓取、宽松的解析器、围绕 JSONDecodeError 的重试循环。那个问题已基本消失,这是真正的进步。
但它解决的是那个 简单 的问题。格式错误的 JSON 之所以简单,正是因为它动静很大:解析器抛错,告警触发,你一眼就能发现。而“格式正确但毫无意义”的内容之所以难,是因为它是无声的。它能被干净地反序列化,满足所有类型约束,直接流入你的业务逻辑,而麻烦出现的第一个迹象可能就是客户被扣错了款,或者订单被发往了一个不存在的 SKU。
所以,当团队中有人说“模型现在返回有效的 JSON 了”时,你要正确理解这句话的含义。它意味着结构层(structural layer)已经搞定了。但也意味着语义层(semantic layer) —— 引用完整性、取值范围、跨字段一致性 —— 还没有开始。构建这一层,将其与解析器分离,为其建立指标,并将其失败视为宝贵的免费生产环境评估数据。Schema 告诉你答案的形状。而答案是否正确,仍然是你的职责所在。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部