跳到主要内容

超越 JSON 模式:在生产环境中获取可靠的 LLM 结构化输出

· 阅读需 12 分钟
Tian Pan
Software Engineer

你部署了一个从支持工单中提取客户意图的流水线。你已经对其进行了广泛测试。它运行良好。发布三天后,一个警报被触发:下游服务因 KeyError: 'category' 而崩溃。模型开始返回 ticket_category 而不是 category —— 提示词(prompt)没有改动,只是你的提供商悄悄推行了一次模型更新。

这就是结构化输出问题。而 JSON 模式并不能解决它。

让 LLM 生成可靠符合特定形状的机器可读输出,是那种看起来微不足道 —— “只需告诉它返回 JSON 即可” —— 直到它在凌晨 3 点于生产环境中崩溃的问题。失败模式很微妙,解决方案是分层的,而不同方法之间的权衡非常重要,具体取决于你是在运行云端 API 还是自托管推理。

为什么“返回 JSON”不是一种策略

想要通过类似 "Respond only with valid JSON in the following format: ..." 的提示词来解决结构化输出问题的直觉是可以理解的。它在测试中运行得足够好,让人觉得问题已经解决。但仅靠提示词的 JSON 提取失败率在 5–20% 之间(取决于 Schema 的复杂程度),而且这些失败往往以最糟糕的方式成簇出现。

最常见的失败情况:

  • 前导语污染 (Preamble contamination)。模型输出 "Sure! Here's the JSON you requested: {...}" —— 这对任何 JSON 解析器来说在语法上都是错误的。
  • 幻觉键名 (Hallucinated keys)。模型发明了 Schema 中不存在的字段名称。你要求 status,却得到了 current_state。两者对模型来说在语义上都有意义;但对你的解析器来说只有一个是正确的。
  • 缺失必需字段 (Missing required fields)。模型没有为它不知道的字段返回 null,而是直接忽略了它们。
  • 类型漂移 (Type drift)。你在需要整数 42 的地方得到了字符串 "42"
  • 结构中途截断 (Truncation mid-structure)。在接近 Token 限制的长输出中,模型会突兀地结束,留下未闭合的括号。

JSON 模式(response_format: { type: "json_object" })解决了第一个问题 —— 它防止了明显的 JSON 格式错误 —— 但对其他四个问题无能为力。它保证的是有效的 JSON 语法,而不是 Schema 一致性。

解决方案的四个阶段

该领域已经演变出四种不同的方法,每种方法都解决了问题的不同层面。

第一代:提示工程 (Prompt Engineering)

这是基准方法。适用于低流量、简单 Schema 且对错误宽容的场景。但在大规模运行、复杂 Schema 或模型更新后会失效。正确的心理模型是,基于提示词的方法将 Schema 一致性视为对模型的“软指令”,而不是硬性约束。模型可以而且一定会发生偏离。

第二代:函数调用与工具使用 (Function Calling and Tool Use)

从 2023 年开始,主要的模型提供商开始微调模型,以生成符合 JSON Schema 的函数参数。当你定义一个带有 Schema 的工具或函数时,模型会产生紧贴 Schema 的语法有效输出。这并非数学上的保证 —— 模型是被微调以将 Schema 视为指令 —— 但与仅靠提示词的方法相比,可靠性有了巨大提升。

函数调用目前是大多数云端 API 用户的实际默认选择。它在 OpenAI、Anthropic、Cohere 和 Groq 上都能运行,且行为基本一致,尽管边缘情况有所不同。

第三代:原生 Schema 强制执行 API

2024 年中期,OpenAI 和 Google 都发布了 API 级别的 Schema 强制执行功能。OpenAI 带有 strict: trueresponse_format: { type: "json_schema" } 以及 Google Gemini 的 response_schema 参数,可以保证接受的 Schema 实现 100% 的一致性。这些 API 在底层使用了限制性解码(constrained decoding) —— 在生成的每一步都会掩盖无效的 Token,使得违反 Schema 在物理上变得不可能。

但这也存在明显的局限性。OpenAI 的实现仅支持 JSON Schema 的子集:minLengthmaxLengthminItemsmaxItems 以及复杂的正则表达式模式被排除在外。所有对象必须设置 additionalProperties: false,否则模型可能会生成额外的键。这两家提供商的实现都不支持现实世界中完整的 JSON Schema 宽度。

第四代:自托管模型的限制性解码

对于运行本地推理的团队,限制性解码运行在推理引擎层。引擎不是通过提示或微调,而是在生成时修改 Token 概率分布 —— 掩盖每一个会导致输出违反目标文法的 Token。

两种算法方法占据主导地位:

有限状态机 (FSM),由 Outlines 开创,将 JSON Schema 或正则表达式编译成状态机。在每个生成步骤中,仅允许使 FSM 保持在有效状态的 Token。编译是开销较大的操作 —— 复杂的 Schema 可能需要 8–60 秒来编译;包含大量枚举联合的极端 Schema 编译时间甚至超过 10 分钟。生成本身开销很低,且文法(grammars)是可以缓存的。

下推自动机 / 上下文无关文法 (Context-Free Grammar),由 XGrammar(来自 CMU 的 MLC 团队)使用,通过基于堆栈的状态跟踪来处理上下文无关文法。XGrammar 现在是 vLLM 中默认的结构化输出后端,基准测试显示,与原始的文法限制方法相比,它的速度提升高达 100 倍,且每个 Token 的延迟开销几乎为零。llguidance(微软的文法库)支持了 OpenAI 自己的实现,实现了 6–9ms 的单 Token 延迟,而基于 Outlines 的方法则为 15–46ms。

选择库

库的生态已经成熟且各具特色。你的选择应取决于你的部署模式。

针对云端 API (OpenAI, Anthropic 等): Instructor 是务实的首选。它封装了 15 个以上的供应商,拥有统一的接口,集成了 Pydantic 用于模式定义和验证,并包含可配置的重试逻辑。拥有每月超过 300 万次的下载量,它在各种生产负载中经过了实战检验。其 API 感觉更像是对原生客户端的轻量封装而非抽象——你依然保有控制权。

针对本地高吞吐量推理: 使用带有 XGrammar(vLLM 0.6.x 以来的默认设置)的 vLLM,或者将 Outlines 作为备选。两者都提供 Token 级别的保证。此外,Outlines 拥有简洁的 Python API,可以将 Schema 定义为 Pydantic 模型。

针对本地模型的最大 Schema 覆盖范围: Guidance(由 llguidance 支持)比其他框架支持更广泛的 JSON Schema 子集,并在独立基准测试中实现了最低的单 Token 延迟——这在吞吐量受限时非常重要。

针对 OpenAI 上最简单的 API: Marvin 提供 cast() / extract() / classify() 接口,只需极少的样板代码。适合内部工具;但对于多供应商的生产用途来说,其约束性过强。

2025 年一项针对来自 GitHub 和 Kubernetes 生产配置的 10,000 个真实 JSON Schema 的基准测试发现,性能最好的受限解码框架支持的 Schema 数量大约是性能最差框架的两倍——如果你的应用使用非琐碎的 Schema,这是一个显著的差距。

Schema 设计比你想象的更重要

即使在语法层面实现了完美的强制执行,设计拙劣的 Schema 也会带来运行时痛苦。适用于所有方法的一些原则:

保持扁平化。 深度嵌套的 Schema 会让提示词导向模型和受限解码器都感到困惑。如果你的 Schema 有 5 层深,考虑它是否真的有必要——或者反规范化(denormalization)是否能更好地服务于你的消费者。

直观地命名键 (Key)。 模型是在现实世界的 JSON 上训练的。符合通用惯例的键(如 first_namestatuscreated_at)比晦涩的缩写(如 fnstca)能产生更符合语义的填充内容。在受限解码下,键名不影响语法合规性——但它们会影响内容是否正确。

积极使用枚举 (Enums)。 对于任何具有有限有效值集的字段,请使用 enum 类型。这对两种方法都有利:受限解码器可以枚举允许的 Token,而提示词导向模型则能看到明确的约束。

明确将所有字段标记为 required 不要依赖模型来推断可选性。如果一个字段可以缺失,请给它一个带有显式 null 选项的可空类型,而不是将该字段设为可选。

在所有地方设置 additionalProperties: false 这是 OpenAI 严格模式的要求,也是通用的最佳实践。如果没有它,模型会臆造键。

避免在复杂 Schema 中使用 anyOfoneOf 联合类型会显著增加 FSM(有限状态机)的编译复杂度。尽可能使用可辨识联合(通过一个 type 字段确定形状)或将联合扁平化为单独的调用。

添加 description 字段。 即使受限解码保证了结构,模型仍然需要填充内容。Schema 属性描述有助于模型理解在每个字段中填入什么——对于键名存在歧义的字段尤其有用。

每种方法都会触及的瓶颈

有一个限制是任何强制执行技术都无法解决的:受限解码保证的是语法一致性,而非语义正确性

一个拥有完美 Schema 强制执行的系统可以可靠地生成 {"sentiment": "positive"}——有效的 JSON、正确的类型、有效的枚举值。情感标签对于输入文本是否真的准确,则是另一个完全不同的问题。Schema 无法表达“情感应当准确”。这是一个内容质量问题,它需要评估 (evals) 和 LLM-as-judge 工具,而不是 Schema 强制执行。

当团队在不衡量结构化数值是否真的正确的情况下,就庆祝“100% 结构化输出可靠性”时,这种区别就显得尤为重要。语法可靠性是生产级输出的必要条件,但非充分条件。

经得起考验的生产模式

携带错误上下文进行重试。 在不使用受限解码的情况下操作时,捕获解析失败,并使用错误消息和简洁的内联 Schema 示例重新提示。大多数模型在第二次尝试时都能自我修正。三次重试已经足够;超过这个次数,模型不太可能在当前输入上恢复。

json_repair 作为缓冲。 在尝试解析之前,先通过 json_repair 处理响应——这是一个可以修复常见微小语法错误(如多余的逗号、未加引号的键、缺失的闭合括号)的库。这可以处理很大一部分生成失败,而无需进行完整的重试往返。

缓存已编译的语法。 如果你正在运行 Outlines 或 XGrammar,编译是昂贵的,而生成是廉价的。在启动时编译 Schema 并重用它们。一个需要 10 秒钟编译的 Schema 在后续请求中几乎没有成本。

Schema 版本管理。 将 Schema 视为有版本的接口,而不是偶然产生的 JSON 数据块。添加必填字段是一项破坏性变更。更改字段类型是一项破坏性变更。当 Schema 演进时,应明确进行版本化,并审慎地迁移消费者。

监控语义偏移,而不仅仅是解析失败。 在解决了语法合规性之后,剩下的风险是结构上有效的值在语义上是错误的——例如提取了源文本中不存在的实体,或者模型更新后分类发生了偏移。建立下游一致性检查,在这些静默的错误输出传播之前将其捕获。

从哪里开始

如果你正在使用云端 API 并且目前依赖原始 JSON 模式:请采用带有 strict: true 的 Instructor 以及用于定义 Schema 的 Pydantic 模型。这种迁移是机械式的,且可靠性的提升是立竿见影的。

如果你正在进行一定规模的本地推理:请迁移到配合 XGrammar 使用的 vLLM。基础设施的开销会通过减少重试延迟和消除解析错误处理而获得回报。

无论哪种方式,都请从扁平化 Schema、明确的必填字段以及 additionalProperties: false 开始。在进行任何库或 API 更改之前,这三个决定就能消除生产环境中大部分的结构化输出故障。

“仅仅要求 JSON” 的时代已经结束了。正确执行此操作的工具链已经成熟,文档齐全,且在大多数情况下只需极少的代码改动即可采用。

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