跳到主要内容

JSON 模式是一种方言,而非标准:你备选路径中的隐形崩溃

· 阅读需 13 分钟
Tian Pan
Software Engineer

我第一次看到备用路由引发的事故比它试图缓解的停机故障还要严重时,复盘文档的标题是这样写的:“主服务降级 11 分钟。备用服务导致我们的解析器降级了 6 天。” 没人写错代码。没人跳过 Schema 评审。18 个月前连接备用服务时的二级供应商集成测试还是通过的。其间发生的事情是,两个供应商之一悄悄收紧了其枚举强制转换(enum coercion)策略,而我们下游解析器所遵循的契约——我们认为“或多或少就是 JSON Schema”的契约——已经从共享标准漂移成了两个略微不兼容的方言。

这是我不断看到的失败模式,而且它总能让那些本该更明白的团队感到惊讶。“JSON 模式”听起来像是一个你开启的功能。其实不然。它是一个你需要维护的契约——针对你可能路由到的每一个供应商分别维护——而且随着供应商演进其结构化输出技术栈,这个契约每季度都会发生漂移。你签署合同时供应商文档中所暗示的“无缝替换”,在生产环境中其实是一个需要维护的转换层。如果没有这个层,你的备用路径就会变成一个纸面上的合规产物:存在于架构图中,但在你真正需要它的那天却是坏的。

你未曾察觉自己签署的契约

直到 2024 年左右,各供应商的“JSON 模式”确实大致意味着相同的事情:模型会输出语法正确的 JSON,没有 Schema 强制执行,你解析它并祈祷一切正常。那个世界已经消失了。到 2026 年,每个主要供应商都推出了更严格、感知 Schema 的模式,并冠以不同的名称——OpenAI 称之为 Structured Outputs(较旧的 json_object 模式现在被明确标记为“旧版”),Anthropic 在 2025 年 11 月在 Claude Sonnet 4.5 和 Opus 4.1 上发布了 Structured Outputs 公测版,Google 在其早期的 OpenAPI-3.0 风格的 Schema 对象之上,为 Gemini API 构建了 JSON Schema 支持,而 xAI、Mistral、Cohere 和 Bedrock 都有各自的变体。营销口号完全一致:“保证 JSON,符合你的 Schema。”但语义则不然。

方言差异大致分为四个方面,每一方面都会带来不同的麻烦。

必填与可选的默认值。 OpenAI 要求嵌套的每一层字段都必须声明为 required;可选性通过与 null 的联合类型来表达。Gemini 则相反:每个字段默认都是必填的,你通过一个单独的 optionalProperties 数组(在 Firebase AI Logic SDK 中)或者在 Vertex 中手动切换 nullable 标志来表达可选性。同样的 Schema 形状,在两者之间移植,意味着完全不同的含义——而且“移植”后的版本在编译和运行时不会报错,所以你只有在下游代码开始看到以前总是存在的字段变成 null(反之亦然)时,才会注意到这种分歧。

additionalProperties 的处理。 OpenAI 要求在严格模式下每个对象层级都要有 additionalProperties: false;如果你忘记了,请求会在 API 边界处被拒绝。Anthropic 的结构化输出模式则更宽松——它不要求这个标志,约束较弱的 Schema 也可以运行,但模型被允许输出你的解析器从未考虑过的额外键(key)。那个针对 OpenAI 设计 Schema 并在 CI 中针对 Anthropic 进行测试的团队,会看到绿色的构建结果,以及生产日志中缓慢增长的“Payload 中出现意外键”的警告。没人会去处理这些警告,因为从单条来看,它们并不算错误。

JSON Schema 子集的覆盖范围。 没有哪个供应商实现了完整的 JSON Schema 规范。OpenAI 的严格模式会悄悄丢弃 minLengthmaxLengthminItemsmaxItems 以及复杂的正则模式——你的 Schema 可能声明了它们,API 会接受该 Schema,而模型会产生违反这些约束的输出,且不报错。Gemini 实现了不同的子集,有着不同的缺失项。Bedrock 又是另一套。在你的消费端校验逻辑开始断言生产者从未强制执行的属性之前,“将同一个 Schema 发送给所有三家”的模式几乎是奏效的。

违反约束时的行为。 当模型无法满足 Schema 时会发生什么,这是最重要但文档记录最少的差异。OpenAI 的严格模式会中止生成。有些供应商会退而生成质量下降的纯文本。有些则会悄悄进行强制转换——将模型想输出的枚举值(“urgent_billing”)转换为允许集合中的近邻(“urgent”),且不向调用者发出任何强制转换发生的信号。你的下游代码无法区分成功的结构化响应和经过强制转换的响应,而这种模糊性正是隐性损坏潜伏的地方。

故障转移陷阱

如果你只与一家供应商对接,方言问题虽然烦人但还在可控范围内——你学习方言,发布变通方案,然后继续工作。当你接入备用路由时,陷阱就闭合了。正是在那一刻,你的单供应商集成变成了多供应商集成,而我见过的大多数接入备用路由的团队都把它当作一个为期一天的项目,之后再未复盘。

导致崩溃的架构是这样的。一个请求进来。你的网关一次性准备好工具定义和 Schema,针对主模型进行规范化,并将准备好的 Payload 传递给执行器。如果主服务失败——无论是频率限制、超时、5xx 错误,还是内容策略拒绝,这都不重要——执行器会针对二级供应商重新发出相同的准备好的 Payload。Schema 的准备工作在故障转移的上游;而故障转移在准备工作的下游。因此,针对 OpenAI 严格模式约束(没有 minLength,处处 additionalProperties: false,可选字段作为可为空的联合类型)精心规范化的 Schema,撞上了 Gemini 端点,后者将 nullable 解释为“该字段有时字面上就是 JSON null”;或者撞上了 Anthropic 端点,后者根本不强制执行 additionalProperties: false,并开始输出你的下游解析器无法识别的键。

有时二级供应商会因为 Schema 校验错误直接拒绝 Payload,你的备用方案会以失败(fail closed)告终——这虽然烦人,但是可见且可修复的。更糟糕的情况是,它接受了 Payload,生成了在人类看来没问题的输出,而你的解析器悄悄产生了违反下游代码依赖的约束条件的记录。一个本应是整数的字段变成了字符串 "$500,000",因为二级供应商的强制转换更宽松。一个枚举值出现了一个在生产流量中从未见过的取值。一个绝不该为空的列表字段变成了 [],因为二级供应商对带有可为空项类型的“必填”解释产生了不同的默认值。六周后,一位分析师注意到数据库中所有高价值合同的风险暴露显示为零,这时你才发现你的解析器悄悄地把每个经过强制转换的字符串都变成了 0。

设计主路径的团队很少是在故障转移期间凌晨 3 点被叫醒的那个团队。在正常路径(happy path)上行之有效的 Schema 规范,在无人关注二级供应商的情况下,恰恰会在面对漂移时瓦解。

方言映射规程的具体面貌

没有任何巧妙的工具能解决这个问题。每个处理得好的团队最终都汇聚到了大致相同的四个实践上,而这些实践往往并不光鲜。

维护一份文档化的方言矩阵。 一个表格——不是一个会随时间过时的 wiki 页面,而是仓库中的一个真实制品——列出你降级列表中的每个供应商,行则是你依赖的每个 schema 特性:additionalProperties 的处理、required-default 行为、支持的关键词子集、枚举强制策略、违规时的失败行为、根类型约束(OpenAI 要求根节点必须是对象,且不允许顶层的 anyOf;其他供应商则不然)、最大嵌套深度、最大属性数量。编写矩阵的过程会让团队一直以来作为“部落知识”存在的方言差异浮出水面。每季度第一周更新一次;它确实会变。

针对降级列表中的每个供应商进行契约测试。 不是那种仅仅断言“模型返回了 JSON”的测试——而是针对你可能路由到的每个供应商,测试你特定 schema 形状的边缘情况。深层嵌套的联合类型(unions)。可选的鉴别器(discriminators)。显而易见的邻近值也是有效值的枚举。必填数组的空列表情况。模型倾向于输出货币格式字符串的数字字段。要定期运行这些测试,而不仅仅是在 schema 变更时,因为你关心的测试点是“供应商的行为是否发生了漂移?”——而供应商的行为漂移与你的代码无关。

供应商与消费者之间的归一化层。 无论来自哪个供应商,每一条结构化响应在接触下游代码之前,都要经过一个内部类型系统。Pydantic、Zod 或是你自己的 DTO 层——具体工具并不重要,重要的是“任何消费者都看不到原始的供应商负载”这一约束。归一化层是进行显式强制转换和记录日志的地方:如果 Gemini 输出了一个 OpenAI 会拒绝的字段,你会将其转换为你的规范形状,并触发一个指标,使这种分歧可见。如果没有这一层,方言差异就会泄露到业务逻辑中,而业务逻辑会意外地将主要供应商的奇癖固化为系统的固有不变性。

定期将主要供应商的请求镜像到次要供应商。 每周一次——不是每季度一次,也不是“想起来的时候”——将生产环境真实请求的样本在次要供应商上重放,并对比结构化输出。不是对比散文式的相似度(那是噪音);而是对比 schema 形状。次要供应商是否开始产生主要供应商没有的键?枚举分布是否发生了偏移?“字段 X 为空”的比例是否发生了变化?镜像测试(Shadowing)是金丝雀,它能在降级发生前发现方言漂移,而且其成本相对于长达 6 天的隐性损坏窗口的成本来说微乎其微。

没人诚实计算过的经济账

我经常看到团队把一个逻辑搞反:即假设纸面上最便宜的次要供应商在实践中也是最便宜的。每 token 的定价是出现在云账单上的条目,也是采购方案中进行比较的条目。集成债务(Integration debt)则是直到事故发生才会显现的条目,而集成债务与方言同主要供应商的接近程度大致成反比。

如果你的主要供应商是 OpenAI,而你选择了一个方言最接近 OpenAI strict-mode 契约的次要供应商,那么你的集成几乎是免费的——同样的预制 schema 大多能用,你的归一化层需要编写的转换更少,你的契约测试需要覆盖的边缘情况也更少。如果你选择了一个方言有实质性差异的次要供应商——不同的必填与可选默认值、不同的支持关键词子集、不同的强制转换策略——你要么预先支付集成成本(投入数个工程人月进行方言映射和契约测试),要么(更常见的情况是)将其推迟到最终的事故中。

真实的成本等式不是“每百万 token 的价格”。它是“每百万 token 的价格”加上“分摊的方言维护工程人月”加上“每 N 个季度发生一次方言相关事故的预期成本”,对于任何 AI 功能处于关键路径上的团队来说,第三项成本压倒一切。明确进行这种计算的团队往往要么 (a) 为方言更接近主要供应商的次要供应商支付略高的费用,要么 (b) 致力于工程投入以维护一个真正的转换层。而不进行这种计算的团队往往会选择纸面成本最低的选项,然后一次又一次地将事故视为偶然事件。

架构层面的认知

这是我希望每个 AI 平台团队都能内化的版本:结构化输出的可移植性不是“JSON”的一种属性。JSON 本身是可移植的。JSON Schema 规范也是可移植的。不可移植的是每个供应商选择实现的、受方言限制的 JSON Schema 特定子集,以及当生成无法满足该 schema 时每个供应商表现出的特定行为。

无论你是否编写了转换层,降级路径本身就是一个转换层。如果你没有编写,那么这种转换就是隐式的,存在于主要供应商和次要供应商输出的差异中,并表现为下游消费者的 bug。如果你编写了转换层——并维护它、对其进行镜像测试、通过它运行契约测试——那么转换就是显式的、受控的,且故障模式对于拥有它们的团队来说是可见的。

我观察到处理得最好的团队会像成熟的分布式系统团队对待 RPC 帧协议(RPC framing)那样对待他们的结构化输出层:将其视为一个必须定义、版本化、跨实现测试并监控漂移的契约。而处理得最差的团队则将其视为“JSON”——一种他们假设通用的交换格式,直到降级路由器证明事实并非如此的那一天。

如果你的平台有降级路径,本季度要问的问题不是“我们是否配置了次要供应商?”而是:上次有人通过该次要供应商运行结构化请求并对比主要供应商的 schema 形状是什么时候?如果答案是“我不知道”或“在集成的时候”,那么你的降级路径只是一个纸面上的摆设,而下一次停机就是你没写的那个测试。

References:Let's stay in touch and Follow me for more thoughts and updates