跳到主要内容

结构化生成:提升生产环境中 LLM 输出的可信度

· 阅读需 11 分钟
Tian Pan
Software Engineer

大多数基于 LLM 的应用中都潜伏着一个隐形 Bug。它不会出现在单元测试中。在前一千次请求中也不会触发。它会一直潜伏,直到用户输入了带有引号的内容,或者模型出于某种莫名其妙的原因决定将其 JSON 响应包裹在 Markdown 代码块中,再或者将 "count" 字段作为字符串 "three" 而非整数 3 返回。这时,你的生产流水线就会崩溃。

“LLM 是文本生成器”与“我的应用需要结构化数据”之间的鸿沟,是大多数可靠性问题产生的原因。弥补这一鸿沟并非 Prompt 工程问题,而是一个基础设施问题。在 2026 年,我们终于拥有了能够正确解决这一问题的工具。

为什么正则和 JSON.parse() 还不够

天真的做法通常是这样的:要求模型返回 JSON,然后对响应执行 JSON.parse()。当这种方法失效时,添加正则表达式来剥离 Markdown 代码块。再失效时,添加更多正则。依然失效时,添加 try/catch 并重试。

这种做法基于六个 LLM 经常违反的假设:

  1. 输出是有效的 JSON
  2. 所有必填字段都存在
  3. 字段类型与你声明的匹配
  4. 数值在可接受的范围内
  5. 不会出现预料之外的字段
  6. 格式在不同输入之间保持一致

在实践中,模型会将 JSON 包裹在 Markdown 围栏中(```json),添加前置文本(“这是你要求的结构化数据:”),返回 JSONL 而非 JSON,悄悄省略你的下游代码视为必填的字段,或者产生类型不匹配的情况——例如在你需要 ISO 8601 格式时返回一个人类可读的字符串时间戳,或者将数值计数作为一个单词返回。

“前 10,000 次请求正常,第 10,001 次失败”的模式是真实存在的。带有未转义引号、撇号或特殊 Unicode 字符的用户输入,往往会在最糟糕的时机破坏这种天真的提取方式。你的系统处理的输入越多,遇到正则表达式无法处理的边界情况就越具有必然性。

逐个修补这些故障就像在跑步机上原地踏步。正确的答案是从一开始就将结构化输出视为基础设施层面的关注点。

输出控制的三个级别

并非所有的结构化输出技术都能提供同等的保证。从可靠性层级的角度来思考会有所帮助。

级别 1:Prompt 工程

你在系统 Prompt 中描述你想要的格式。“返回一个包含字段 name(字符串)、score(0–100 的整数)和 reasoning(字符串)的 JSON 对象。”这种方法在大多数情况下都有效——大约 80–95% 的请求会以正确的形式返回。它的失效模式是隐形的:当模型发生偏离时,你的应用要么崩溃,要么会悄悄损坏数据。

这一层级适用于原型设计或对偶尔出现的错误响应可以丢弃的低风险任务。对于追求可靠性的生产流水线来说,这是不可接受的。

级别 2:函数调用 / 工具使用

现在大多数 LLM 提供商都开放了基于 Schema 的函数调用。你定义一个 JSON Schema,模型返回一个符合该 Schema 的对象——或者至少尝试这样做。这能让你达到 95–99% 的可靠性。剩下的失败通常是语义层面的而非结构层面的:string 类型的字段永远会是字符串,但它可能是错误的字符串。整数字段会是整数,但可能超出你的预期范围。

函数调用还会增加一些额外开销,并要求你的代码处理工具使用模式,而不是将 LLM 调用视为简单的文本补全。对于生产环境,这种权衡通常是值得的。

级别 3:原生结构化输出 / 受约束的解码

在这一层级,你获得的是数学上的保证,而非统计学上的概率。其机制是受约束的解码(Constrained Decoding):在每个 Token 生成步骤中,模型的下一个 Token 概率分布会被遮掩(Mask),从而只有那些能让输出保持在 Schema 路径上的 Token 才是可选的。无效的 Token 概率被置为零——模型在字面上无法生成它们。

当需要 JSON 左大括号时,只有 { 和空白符 Token 是有效的。当你在一个声明为 type: integer 的字段内时,非数字字符会被遮掩。输出不可能违反 Schema,因为 Schema 是在生成层面强制执行的。

2024 年至 2026 年间,各大厂商都具备了这种能力。OpenAI 的 Strict Mode(2024 年 8 月发布)使用了从 JSON Schema 编译而来的有限状态机。Gemini 随后也推出了同等功能。Anthropic 在 2025 年底实现了原生结构化输出。自托管的推理栈可以使用 Outlines 库或 llguidance 引擎(OpenAI 后来承认该引擎是其实现的基础)。

受约束的解码:它的实际原理

了解底层机制是值得的,因为它决定了你可以在 Schema 中表达什么,以及不能表达什么。

当你提供一个 JSON Schema 时,推理引擎会将其编译为一个有限状态机。每个状态代表语法中的一个位置——在对象内部、期待字段名、在字符串值内部等。在每个 Token 生成步骤,引擎会识别哪些状态是从当前状态可达的,并遮掩概率分布以仅允许与有效转换相对应的 Token。

对于简单的扁平 Schema,这带来的延迟微乎其微。对于具有许多可选字段的深层嵌套 Schema,状态机可能会很大,编译开销可能会变得显著。实际建议:尽量保持 Schema 扁平,避免深层嵌套的可选数组,并预编译 Schema 而不是在每次请求时编译。

这里有一个重要的限制:上述受约束的解码仅在你能够访问模型的 Token 概率分布时才可用。对于自托管模型(通过 Transformers、llama.cpp、vLLM),这始终是可用的。对于 API 托管的模型(OpenAI、Anthropic、Google),你取决于提供商是否将结构化输出作为核心功能开放。提供商在他们那一端处理受约束的解码——你只需指定 Schema。你无法自行对提供商的 API 应用 Token 级别的约束。

基于语法的方法(使用 GBNF 格式的上下文无关语法)比基于正则的方法更具表现力,并且能更清晰地处理嵌套 JSON 或变长数组等递归结构。在你可以控制服务栈的自托管推理中,这是首选机制。

验证三明治

原生结构化输出保证了输出在结构上是有效的 —— 所有必需字段都存在,且类型正确。但它并不保证值在语义上是有效的。timestamp 字段在结构上作为一个字符串可能是有效的,但在语义上可能是错误的(例如,当你需要 ISO 8601 格式时,它返回了 "yesterday")。email 字段在结构上可能是一个字符串,但并不是一个有效的电子邮件地址。

对于生产系统,请在结构化输出层之后添加一个验证层。该模式如下:

  1. 生成时的 Schema 强制执行 —— 使用原生结构化输出或函数调用(function calling)来保证结构有效性
  2. 应用边界的语义验证 —— 通过 Pydantic (Python) 或 Zod (TypeScript) 模型运行结构化输出,以强制执行业务规则:有效范围、有效的枚举值、跨字段约束
  3. 区分结构故障与语义故障的错误处理 —— 结构故障(在原生结构化输出中很少见)通常需要使用更清晰的提示词进行重试;语义故障则需要优化提示词或建立验证反馈循环

像 Instructor (Python) 这样的库使这种模式变得非常易用。它通过 Pydantic 验证封装了你的 LLM 供应商,并自动处理重试逻辑:如果验证失败,它会将验证错误作为上下文传回给模型并进行重试。这把原本会导致崩溃或静默损坏的情况变成了一个自我修正的反馈循环。

核心洞察是:对待 LLM 的输出要像对待来自不受信任的外部 API 的输入一样。你不会在没有验证的情况下盲目信任第三方 REST 接口的响应。对模型的输出也应应用同样的规范。

针对不同场景选择合适的方法

合适的工具取决于你的部署环境:

API 托管模型 (OpenAI, Anthropic, Gemini): 在可用处使用带有严格模式 (Strict Mode) 的原生结构化输出。在其上层叠加 Pydantic/Zod 验证。使用 Instructor 或同等工具来处理重试逻辑。这可以让你在结构合规性方面达到接近 100% 的可靠性,并获得强大的语义验证。

自托管模型 (vLLM, llama.cpp, Ollama): 使用 Outlines 或 llguidance 进行 Token 级的受限解码。你将获得与原生结构化输出相同的数学保证,并能够强制执行任意的上下文无关文法 —— 这对于 JSON 之外的特定领域格式非常有用。

混合或多供应商设置: 像 BAML 这样的库采用了容错解析方法 —— 它们使用基于 Rust 的解析器,能够优雅地处理格式错误的 JSON,并在不同供应商之间统一工作。权衡之处在于,容错解析不像受限解码那样能保护你免受语义违规的影响。

高吞吐量流水线: 预编译 Schema 并缓存它们。Schema 编译(特别是对于复杂的文法)具有不可忽视的延迟。在每分钟处理数千个请求的流水线中,为每个请求支付编译开销是昂贵的。大多数推理框架都支持 Schema 缓存。

Schema 排序问题

一个常让团队感到意外的细节是:模型是从左到右生成 Token 的,它们并不会提前预知你的约束。这意味着 Schema 中字段的顺序会影响输出质量,而不仅仅是输出结构。

当模型在一个较长的 JSON 对象后期遇到受限字段时,它已经提交了上游的推理。如果受限字段强制要求一个与模型已经表述过的内容相冲突的值,结果可能会出现内部不一致 —— 结构有效但在语义上不连贯。

实用的规则:将锚定推理的字段放在前面。如果你的 Schema 有一个 intent 字段决定了其他字段应包含的内容,请将 intent 放在依赖它的字段之前。这模拟了模型的自然推理方式 —— 迫使模型在填写细节之前先决定高层答案。

同样地,务必在系统提示词中记录你的 Schema。模型仅根据它见过的 Token 生成预测;如果它不知道预期的 Schema 是什么,受限解码将迫使它生成有效的 Token,但这些 Token 可能无法反映其最佳答案。在要求模型生成输出之前,先向模型展示预期输出的形状。

这对构建 LLM 应用的团队意味着什么

非结构化的 LLM 输出是生产环境中的一项债务。过去两年中,围绕结构化生成的工程规范已趋于成熟,在任何严肃的应用中,都没有理由再通过临时的解析来处理原始的模型响应。

这项投资是适度的:定义一次 Schema,为你的供应商和部署环境选择正确的强制执行机制,添加验证层,并妥善处理重试。作为回报,你消除了一整类生产故障 —— 静默数据损坏、意外崩溃,以及在每个跳过这一步的团队中缓慢累积的“我们需要再加一个正则表达式”的工单。

底层的模型没有改变。你的提示词没有改变。改变的是你的应用与模型之间的契约 —— 在基础设施层面强制执行该契约,意味着你不再需要与格式 Bug 作斗争,而可以开始专注于模型真正掌握的知识。

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