结构化输出上线三个月就崩了?Schema漂移的四种形态与防御方案

📅 2026/10/27 ✍️ 小文 📖 约 1 分钟

用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("金额,单位:分,整数")。

第二层:业务语义校验

这是大多数人缺失的一层。校验器要检查:

  1. 枚举白名单:不在已知集合内的值 → 落库前拦截,进人工队列;
  2. 交叉一致性:total = sum(items.price × qty),允许1分误差;
  3. 占位值检测:维护一份“疑似编造”短语黑名单(“未提供”“未知”“不适用”“N/A”),命中即打标;
  4. 分布监控:记录每个枚举值的占比,某值占比单日变化超过 5 个百分点即告警。

第4条是性价比最高的。它能让你在用户投诉之前发现业务分布发生了变化。

给校验失败一个体面的出口

校验失败不要直接报错给用户。三级降级:

  • 重试(换提示,明确告知违规原因):适合格式类;
  • 宽松解析:字符串数字强转、枚举模糊匹配(partial_refund → refunded + 打标),并记录;
  • 人工队列:语义校验失败且影响金额/状态的,直接进人工。

一个团队实测:加上第三层之后,人工队列日均只有 12 条,占总量 0.06%,完全可承受。

一个被忽略的实践

把 schema 定义本身做成版本化的。每次修改字段,记录版本号,并在日志里带上 schema_version。

这样当三个月后有人问“为什么这批数据的 price 是字符串”,你能立刻定位到是哪次 schema 变更、影响哪些时间段。没有这个,排查成本会高出一个数量级。

结构化输出的本质不是“让模型听话”,而是在模型和数据库之间建一道可信的关口。关口要宽到不误杀,窄到不漏过,只有校验,没有生成端的收窄,是做不成的。

📤 分享到