structured-output-schema-drift · ZH · 2026-10-22

同一份 JSON Schema 跑六个后端:字段类型和可空性在哪里开始漂移

本文给出一个可复用的方法:用同一份 JSON Schema 与提示词,在多模型 API 聚合站上逐个调用不同后端,记录返回结构在字段类型、可空性、必填性上的漂移,并据此为下游校验建立按后端分类的漂移日志。

多后端聚合的最大诱惑是「换模型不改代码」。现实是:提示词不变、schema 不变,返回的 JSON 结构仍可能在细节上漂移。这类漂移不会让请求失败,却会让下游的严格校验在某个后端上突然报错。本文给出一套可落地的方法:同一份 schema 跑遍所有后端,把差异记成一份按后端索引的漂移日志。

为什么结构漂移比内容漂移更危险

内容差异(措辞、详略、语气)通常能被业务容忍,结构差异不行。下游代码往往假设 id 是字符串、tags 是数组、price 是数字或 null。一旦某个后端把整数解析成字符串、把空数组返回成 null、或者干脆省掉一个可选字段,类型断言和 required 校验就会失败。

漂移通常出现在几类边界上:

  • 枚举之外的「近似值」,比如 "N/A"、"unknown"、空字符串
  • 空集合的表示,[] 与 null 与缺字段三选一
  • 数字与数字字符串的混用,尤其出现在被模型「顺手」加引号的场景
  • 布尔字段的替代写法,false、"false"、0
  • 嵌套对象的层级不一致,同一语义被放进不同深度

这些都不是模型「错」,而是模型对 schema 中留白处的不同解释。留白越多,漂移越多。

准备一份可对比的测试载荷

对比要成立,输入必须完全一致。建议固定三样东西:

  • 一份 schema,字段数量适中,故意包含几个容易出问题的点:数字、布尔、可空字段、数组、嵌套对象、枚举
  • 一段提示词,明确要求「只输出 JSON」并把 schema 原文贴进去
  • 一组测试样本,覆盖空值、长文本、多语言、边界数值

把 schema 与提示词版本号写进文件名,例如 schemav3.json 与 promptv3.md。后续每次改 schema 都重新跑一遍,日志才有可比性。

逐后端调用并保存原始响应

在一个 API key 下依次指定不同模型,对每个后端跑同一组样本。关键点是保存原始响应文本,而不是解析后的对象。解析会掩盖掉很多差异,比如多余的空格、包裹的代码围栏、以及解析失败时被静默丢弃的部分。

每次调用至少记录:后端标识、schema 版本、提示词版本、样本编号、原始响应、耗时、是否成功解析。这些是漂移日志的原始素材。

漂移日志的字段设计

日志不是结果列表,而是可检索的差异记录。建议每条记录包含:

  • backend:后端标识,便于按后端聚合
  • field_path:JSON Pointer 形式的字段路径,如 /items/0/price
  • expected:schema 声明的类型与可空性
  • observed:实际观测到的类型或值形态
  • category:漂移分类,如 typemismatch、nullvsmissing、enumfallback、array_shape
  • sample:最小复现片段
  • firstseen / lastseen:出现时间范围
  • schema_version:便于 schema 变更后重新比对

分类比字段更重要。按类别聚合能看到「某个后端系统性偏好把数字写成字符串」,而不仅是零散报错。

把可空性和必填分开看

这两件事经常被混为一谈。null 是字段存在但值为空,字段缺失是结构不完整,二者在下游的处理完全不同:前者可以直接判空,后者往往触发反序列化失败或默认值悄悄介入。

对比时对每个字段分别记录三种状态:存在且非空、存在且为 null、字段缺失。三个后端给出三种不同状态,是漂移日志里最有价值的一条。

用日志驱动校验策略

有了按后端的漂移日志,下游校验可以从「一套严格 schema 打天下」改成两层:

  • 核心层:只校验业务真正依赖的字段与类型,保持严格
  • 宽容层:对已知漂移点做归一化,比如把数字字符串转数字、把 null 与缺失统一成空集合

归一化规则要写在代码里并关联日志条目,而不是散落在各处 try/except。当某个后端修复了漂移,日志里标记为已解决,归一化规则可以逐步收紧。

把漂移检测变成常规流程

一次性对比的价值有限,模型版本更新会让旧结论过期。把检测做成常规步骤:

  • schema 或提示词变更时,全量重跑
  • 后端列表变化时,对新后端补跑
  • 定期抽样重跑,观察漂移是收敛还是扩散

聚合站的好处是同一 key 切换后端成本很低,这让「全后端重跑」从负担变成几分钟的例行操作。每次跑完更新日志,漂移就从意外变成已知,下游代码也就能按后端做有针对性的防御,而不是靠一个越来越宽松的 schema 兜底。