JSON 模式是一种方言,而非标准:你备选路径中的隐形崩溃
我第一次看到备用路由引发的事故比它试图缓解的停机故障还要严重时,复盘文档的标题是这样写的:“主服务降级 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 的严格模式会悄悄丢弃 minLength、maxLength、minItems、maxItems 以及复杂的正则模式——你的 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"
