结构化输出上线三个月就崩了?Schema漂移的四种形态与防御方案
用JSON Schema约束LLM输出是标配,但它只在第一次测试时可靠。本文总结生产环境里schema漂移的四种真实形态、为什么重试救不了它,以及一套双层校验架构。
结构化输出(Structured Outputs / JSON Mode)已经是标配能力。但几乎所有团队都会经历同一个阶段:
上线第一天,100% 合规。 上线第一个月,99.2% 合规。 上线第三个月,凌晨两点被报警叫醒。
这不是模型变差了,而是输入分布漂移了,而你从未测试过漂移后的输入。
四种漂移形态
形态一:枚举外值
Schema 里写了 status: enum["pending","paid","refunded"]。某天业务新增了 partial_refund,marketing 的文案里出现了这个词,模型很自然地把它填进 status,然后你的数据库写入失败。
关键点: 大多数“严格模式”会拒绝生成而非截断。所以这不是静默错误,而是直接报错。报错比错误数据好,但如果没做降级,就是服务中断。
形态二:深层嵌套的“差不多”
{"items": [{"qty": 3, "price": "¥29.90"}]}
price 定义是 number,模型给了字符串带货币符号。浅层字段通常很稳,越深的字段越容易偏。原因是模型在生成深层字段时,前面的token已经消耗了大量注意力预算,格式遵从性下降。
形态三:必填字段的“礼貌填充”
Schema 要求 reason 必填。当模型其实没找到理由时,它不会返回 null,而是编一个看起来合理的 reason:"用户未提供相关信息"。
这是最危险的一种——格式100%正确,语义100%错误。你的下游逻辑完全无法察觉。
形态四:数组长度失控
tags: array[string],没设 maxItems。某天输入里出现了一篇长文,模型生成了 200 个 tag,把下游的 embedding 接口打爆。
为什么“失败就重试”救不了
绝大多数人的第一反应是加 retry。但实测数据显示:
| 失败原因 | 重试一次成功率 | 重试三次成功率 |
|---|---|---|
| 网络/超时 | 92% | 99% |
| 格式违规 | 71% | 84% |
| 枚举外值 | 18% | 23% |
| 语义错误 | 0%(无法检测) | — |
规律很清楚:重试只能解决瞬时故障,解决不了分布问题。 枚举外值是因为输入里真的出现了那个值,重试多少次它还在。
而语义错误压根不会触发重试,因为它通不过你的校验器——它压根不报错。
双层校验架构
可靠的方案是两层:生成层约束 + 业务层校验。
第一层:生成期尽量收窄
- 枚举值加
description说明每个值的判定条件,而不只是给字符串; - 所有字符串字段加
maxLength,数组加maxItems; - 可选字段真的设为 optional,不要为了“完整性”强制必填——强制必填就是在邀请模型编造;
- 数字字段统一单位并写进 description(
"金额,单位:分,整数")。
第二层:业务语义校验
这是大多数人缺失的一层。校验器要检查:
- 枚举白名单:不在已知集合内的值 → 落库前拦截,进人工队列;
- 交叉一致性:
total = sum(items.price × qty),允许1分误差; - 占位值检测:维护一份“疑似编造”短语黑名单(“未提供”“未知”“不适用”“N/A”),命中即打标;
- 分布监控:记录每个枚举值的占比,某值占比单日变化超过 5 个百分点即告警。
第4条是性价比最高的。它能让你在用户投诉之前发现业务分布发生了变化。
给校验失败一个体面的出口
校验失败不要直接报错给用户。三级降级:
- 重试(换提示,明确告知违规原因):适合格式类;
- 宽松解析:字符串数字强转、枚举模糊匹配(
partial_refund→refunded+ 打标),并记录; - 人工队列:语义校验失败且影响金额/状态的,直接进人工。
一个团队实测:加上第三层之后,人工队列日均只有 12 条,占总量 0.06%,完全可承受。
一个被忽略的实践
把 schema 定义本身做成版本化的。每次修改字段,记录版本号,并在日志里带上 schema_version。
这样当三个月后有人问“为什么这批数据的 price 是字符串”,你能立刻定位到是哪次 schema 变更、影响哪些时间段。没有这个,排查成本会高出一个数量级。
结构化输出的本质不是“让模型听话”,而是在模型和数据库之间建一道可信的关口。关口要宽到不误杀,窄到不漏过,只有校验,没有生成端的收窄,是做不成的。