生产环境中的结构化输出:如何用 LLM 生成可靠的 JSON
大语言模型是文本生成器。你的应用程序需要数据结构。这两个事实之间的差距,正是生产环境中的错误滋生之地。
每个使用大语言模型构建产品的团队都会遇到这个瓶颈。模型在游乐场中表现出色——它返回的内容看起来像 JSON,字段大多正确,通常能通过 JSON.parse。然后你将其部署上线,你的解析层却在凌晨两点开始抛出异常。响应中多了一个逗号。或者是一个 Markdown 代码围栏。或者模型决定在 JSON 前面添加一段解释性文字。又或者它幻觉出一个字段名。
业界已经花了三年时间来解决这个问题。这就是目前解决方案的收敛点,以及仍然让团队头疼的问题。
三个成熟度级别
团队处理结构化输出的方式有着清晰的进展,每个级别都有一个实际的可靠性上限。
级别 1:提示工程。 你写道:“只用以下格式的有效 JSON 回复:”并展示一个示例。对于简单模式,这在 80-95% 的情况下有效。失败模式很微妙:模型在复杂提示中添加前言,在模式变长时将 JSON 包装在代码块中,或者静默地省略可选字段。你添加一个正则表达式清理步骤和一个 try/catch,然后说服自己这样没问题。
对于任何重要的应用来说,这都远不够好。95% 的解析成功率听起来很高,直到你有一个 10 步的智能体链:0.95^10 ≈ 0.60。十次智能体运行中,有六次会失败。这个数学计算是无情的。
级别 2:函数调用/工具使用。 所有主要提供 商都暴露了一个 API,你可以在其中定义 JSON 模式,模型应该填充它。这能让你达到 95-99% 的可靠性。但问题是:模式是一个提示,而不是一个约束。模型将模式视为其上下文的一部分并学习遵循它——但解码过程中的任何环节都无法阻止它生成无效 token。提供商仍然可能返回格式错误的数据包,尤其是在处理复杂模式或边缘情况输入时。
级别 3:带有约束解码的原生结构化输出。 这是在数学上保证 100% 模式有效性的地方。推理引擎根据你的模式构建一个有限状态机,并在每个生成步骤中屏蔽无效 token。模型实际上无法生成无法解析的输出。OpenAI 结合 json_schema 的 response_format、Gemini 的 response_schema,以及 Outlines 等开源框架都使用了这种方法。
如果你正在构建任何需要可靠下游解析的东西——分类管道、智能体工具调用、数据提取——你都会需要级别 3。
约束解码的实际工作原理
理解其实现方式是值得的,因为它决定了你能使用和不能使用哪些模式。
在每个生成步骤中,模型都会在其整个词汇表(50,000 多个 token)上生成一个概率分布。通常,你会从该分布中采样。而使用约束解码时,你首先构建一个有限状态机,代表你的 JSON 模式中的每个有效路径。在采样之前,你会计算一个token 掩码:一个布尔向量,其中 false 意味着“给定 FSM 中的当前状态,这个 token 不能出现在这里”。你将这些 logits 置零,然后从剩余的 logits 中采样。
结果是:模型只能生成那些能够推进模式有效完成的 token。这不是后处理——它融入到每一个解码步骤中。
早期的实际开销曾是一个担忧。为复杂模式构建初始 FSM 可能需要 50-200 毫秒。但像 XGrammar(来自 MLC 团队)这样的引擎能够在每个 token 不到 40 微秒内完成 token 掩码生成,后续请求则重用缓存的 FSM,开销几乎为零。对于简单模式,延迟影响低于 5%。对于具有大型枚举集的深度嵌套模式,延迟可能达到 30-60%——这真正提示你需要简化你的模式了。
模式设计:团队常犯的错误
即使约束解码强制执行了语法有效性,糟糕的模式设计仍然会导致语义失败。以下是困扰大多数团队的模式:
将推理置于结论之前。 如果你的模式有一个 reasoning 字段和一个 classification 字段,请将 reasoning 放在前面。大语言模型从左到右生成 token。当模型在确定分类之前写出其推理时,它会产生更好的分类。如果你将答案字段放在前面,模型会在思考之前确定一个标签,然后才在推理字段中进行合理化。这听起来像大语言模型的一个怪癖,但它确实能持续地将准确性提高几个百分点。
扁平化你的模式。 嵌套是可靠性的大敌。OpenAI 的原生结构化输出最多支持 5 层嵌套和 100 个总属性。超出这个范围,语法编译时间会飙升,每个 token 的开销也会增加。更重要的是,即使有约束解码,具有 4 层以上深度嵌套的模式也表现出明显更高的错误率— —模型有更多机会失去上下文。如果你的模式深度嵌套,请问问自己,这种嵌套是反映了实际的数据层次结构,还是仅仅是组织偏好。
描述每个字段。 Pydantic 的 Field(description=...) 值作为内联指令传递给模型。如果没有描述,模型将仅从字段名推断语义。confidence: float——它是 0-1 还是 0-100?status: str——有效值是什么?字段描述不是文档;它们是直接影响输出质量的提示指令。
明确处理可选性。 OpenAI 的结构化输出不支持你期望的那种可选字段。如果一个字段可以不存在,请将其建模为带有默认值 None 的 Optional[str],而不是仅仅是 没有默认值的 str | None。提供商对这种区别处理不同,如果处理不当,会在运行时产生神秘的“无效模式”错误。
避免复杂模式。 带有复杂模式的正则表达式约束字段、具有多个分支的 oneOf 和递归模式会在 FSM 中造成组合爆炸。如果你需要“一个或多个与某个模式匹配的项”,请考虑将问题分解为多个顺序调用,而不是在单个模式中表达它。
实践中的服务商格局
每个主要的服务商都有不同的 API 接口,其抽象层无法清晰地在不同服务商之间转换。
OpenAI 提供了最成熟的实现。你可以使用 client.beta.chat.completions.parse() 搭配 Pydantic 模型,它能处理模式转换并返回一个类型化的 Python 对象。使用原始 JSON 模式的 response_format 方法也有效,但需要手动构建模式。.parse() 方法是正确的默认选择。
Anthropic 没有专用的结构化输出 API。其惯用模式是强制使用工具:将你的模式定义为一个工具,然后设置 tool_choice 强制模型调用它。如果没有 tool_choice: {type: "tool", name: "your_tool"},模型可能会选择根本不使用该工具。这并非约束解码——它仍然是 Level 2——但比提示工程要可靠得多。
Google Gemini 提供了带有约束解码的 response_schema,类似于 OpenAI 的方法。该 API 接受原始 JSON 模式而非 Pydantic 模型,所以你需要模式转换工具。
对于跨多个服务商工作的团队来说,Instructor 库抽象了这些差异。它在 OpenAI、Anthropic、Gemini 等平台之间提供了一个统一的 client.chat.completions.create(response_model=YourPydanticModel) 接口。Instructor 还能处理验证失败时的自动重试——如果模型返回了未能通过 Pydantic 验证的内容,它会附带错误信息重新提示并再次尝试。
验证三明治
即使在使用原生结构化输出时,也务必在其之上添加一个验证层。这并非多疑——这是为了防范语法约束无法捕捉的语义失败。
from openai import OpenAI
from pydantic import BaseModel, field_validator
class ClassificationResult(BaseModel):
reasoning: str
label: str
confidence: float
@field_validator("confidence")
def confidence_must_be_normalized(cls, v):
if not 0.0 <= v <= 1.0:
raise ValueError(f"confidence must be between 0 and 1, got {v}")
return v
@field_validator("label")
def label_must_be_valid(cls, v):
valid_labels = {"positive", "negative", "neutral"}
if v not in valid_labels:
raise ValueError(f"label must be one of {valid_labels}, got {v}")
return v
client = OpenAI()
result = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[...],
response_format=ClassificationResult,
)
# result.choices[0].message.parsed is already a ClassificationResult
# but Pydantic validators run during construction, so they've already fired
模式强制结构。Pydantic 验证器强制语义。两者你都需要。
约束解码保证的是语法有效性,而非语义正确性。模型可以在一个浮点数字段中返回 confidence: 1.7 并满足模式。它也可以从模式的枚举中返回一个对于输入而言语义 上错误的标签。验证器能捕捉前者;评估则捕捉后者。
智能体链中的结构化输出
在多步骤工作流中,可靠性计算会变得更糟。每个返回结构化数据的工具调用都是一个模式验证可能失败的步骤。通过 Instructor 的重试行为,失败会附带错误上下文进行重试——但重试会消耗 token 和延迟,而且某些故障模式会循环。
这里有两种有助于解决问题的模式:
在每个步骤中缩小你的模式。 不要将一个庞大复杂的模式贯穿于每个工具调用。在每个步骤中,只提取你需要用于下一步的数据。更小的模式具有更低的失败率和更少的开销。
每次调用都记录模式版本。 模式会演变,而错误往往源于模式变更未在所有地方传播。将模式版本与提示和响应一同记录。当出现问题时,你可以根据当时生效的模式重放精确的输入。
仍然存在的问题
约束解码解决了解析问题,而非建模问题。无论模式强制如何,一些故障模式依然存在:
幻觉枚举值。 如果你的模式允许 enum: ["gpt-4", "claude-3-5-sonnet", "gemini-2-0-flash"],而你添加了一个新模型却忘记更新模式,模型将被迫返回其中一个有效值——但它可能会自信地返回错误的值。模式约束并不能让模型准 确;它们只是让模型可解析。
长链中的语义漂移。 在多步骤管道中,第 N 步的结构化输出会作为第 N+1 步的提示输入。意义上的错误(而非格式上的)会以解析检查无法检测到的方式累积。这时,评估和抽查比工具更重要。
调用者之间的模式不匹配。 在包含多个服务的生产系统中,调用服务中的模式定义与下游消费者期望的模式定义不一致是很常见的。将你的 Pydantic 模型视为事实的唯一来源,并将其作为一个包共享,而不是复制粘贴的字典。
默认应该是 Level 3
原生结构化输出的工程论点很简单:提示工程增加了重试复杂性,函数调用增加了验证复杂性,两者都增加了在凌晨 2 点调试起来令人烦恼的故障模式。带有 Pydantic 验证层的原生结构化输出为你提供了最强的保证,并消除了一整类生产事故。
工具链已成熟。XGrammar 使约束解码足够快,对于简单模式而言,延迟很少成为问题。Instructor 库消除了服务商特定的样板代码。在 2025 年,没有充分的理由使用 Level 1 解析交付新的 LLM 管道。
唯一的真正代价是模式设计规范。扁平模式、描述性字段、显式可选性、先推理后结论的顺序——这些都不是复杂的要求,但它们需要刻意为之。这种规范正是区分在演示中有效与在生产中可靠运行的 LLM 功能的关键。
