一个金融科技团队花了三个月开发智能客服系统,核心功能是将用户的自然语言查询转换成结构化的JSON格式,再传递给后端API处理。测试环境下一切正常,但上线第一天就收到大量报错——JSON解析失败。排查日志发现,模型输出的JSON有的缺少逗号,有的多了尾随逗号,有的字段名拼错了,还有的直接在JSON后面加了一段"解释说明"。

这些错误并非随机出现。同样的查询,在不同时间、不同模型版本下,产生的错误类型各不相同。团队不得不在代码中加入了大量的正则表达式修复逻辑和重试机制,但这又引入了新的问题——有时修复后的JSON虽然语法正确,但语义已经偏离了用户的原始意图。

这不是个例。2025年的一项调查显示,在生产环境中使用Prompt工程让大模型输出JSON的开发者,平均会遇到约15%的格式错误率。对于需要高可靠性的企业应用来说,这个数字几乎是不可接受的。


自回归生成的结构性困境

理解这个问题的核心,需要回到大语言模型的根本工作原理。

Transformer架构的生成过程是自回归的——模型逐个预测下一个token,每次预测只依赖于已经生成的前缀内容。数学上,这可以表示为:

$$P(x_1, x_2, ..., x_n) = \prod_{i=1}^{n} P(x_i | x_1, ..., x_{i-1})$$

这个公式揭示了一个关键事实:模型在生成第 $i$ 个token时,只能"看到"它前面的内容,对后面需要生成什么一无所知。当模型生成 { "name": "张三" 这段文本时,它并不知道接下来需要添加逗号还是右括号,也不知道整个JSON结构需要多少个字段。

这种"近视"特性在自然语言生成中是优势——它赋予了模型创造性和灵活性。但在结构化输出场景中,它成了致命缺陷。一个有效的JSON对象需要在结构上完全闭合:每个 { 必须有对应的 },每个 [ 必须有对应的 ],每个字符串必须有配对的引号。模型缺乏全局视角,无法在生成早期就规划好整个结构的闭合。

更深层的问题在于概率分布与确定性格式的矛盾。语言模型的输出本质上是概率性的——它为词表中的每个token分配一个概率,然后从中采样。即使是贪婪解码(总是选择概率最高的token),结果也取决于模型在特定上下文下的概率分布。但JSON格式是确定性的——{"name": "value"} 的正确性不取决于概率,只取决于语法规则。

Tokenization的结构性障碍

即使模型"想"输出正确的JSON,分词器的存在也会制造意想不到的麻烦。

现代大语言模型普遍使用BPE(Byte Pair Encoding)或类似的子词分词算法。这些算法的设计目标是优化自然语言处理效率,而非支持结构化格式。结果是,同一个字符在不同上下文中可能被分成不同的token。

以GPT系列使用的分词器为例:字符串 "name" 可能被编码为单个token,而 "user_id" 可能被拆分成 ["user", "_", "id"] 三个token。更棘手的是,JSON的特殊字符——{}[]",——在模型词表中可能是独立token,也可能与其他字符组合成更长的token。

这种不一致性带来了两个问题:

第一,Token边界与语法边界不对齐。当模型试图输出 {"key": "value"} 时,如果 {" 被合并成一个token,模型就无法单独控制左括号的输出。这意味着即使模型"知道"正确的JSON结构,它也可能因为token边界的限制而输出错误的格式。

第二,约束解码的Token映射难题。当我们试图通过技术手段强制模型输出有效JSON时,需要在每个生成步骤判断哪些token是"有效的"。如果当前状态需要输出一个右括号 },但词表中没有单独的 } token(只有 } 和其他字符组合的token),约束系统就会陷入困境。

2024年的一篇论文《UTF-8 Plumbing: Byte-level Tokenizers Unavoidably Enable LLMs to Generate Ill-formed UTF-8》从理论上证明了这个问题:任何包含不符合UTF-8规范的字节级token的词表,都可能生成无效的UTF-8序列。这对于需要输出非ASCII字符(如中文、emoji)的JSON尤其致命。

Prompt工程的局限:四种典型失败模式

在约束解码技术普及之前,大多数开发者依赖Prompt工程来引导模型输出结构化数据。典型做法是在系统提示中加入类似这样的指令:

请以JSON格式输出结果,格式如下:
{
  "name": "字符串类型",
  "age": "数字类型",
  "email": "字符串类型"
}
不要输出任何额外的解释文字。

这种方法在简单场景下经常有效,但在生产环境中暴露出多种失败模式:

语法错误:最常见的失败类型。模型可能输出缺少逗号的JSON({"name": "张三" "age": 25}),包含尾随逗号({"name": "张三",}),或使用单引号而非双引号({'name': '张三'})。这些错误在Python中可以解析,但在严格的JSON解析器中会直接报错。

Schema漂移:模型可能擅自添加未定义的字段,或遗漏必需的字段。比如要求输出 {"name", "age", "email"},模型却输出了 {"name": "张三", "age": 25, "address": "北京"},缺少了email字段,多出了address字段。

格式污染:模型可能在JSON前后添加解释性文字。例如:

好的,以下是提取的信息:
{
  "name": "张三",
  "age": 25
}
这个JSON包含了用户的基本信息。

这种输出无法直接被 JSON.parse() 解析,需要额外的后处理。

语义漂移:最隐蔽的失败模式。模型输出的JSON语法完全正确,字段名也符合预期,但值的语义与用户意图不符。比如要求提取"产品价格",模型输出了 {"price": "中等偏高"} 而非预期的数字格式 {"price": 299.00}

2025年的一项基准测试显示,即使使用精心设计的Prompt,GPT-4o-mini在复杂JSON Schema上的首跳成功率仅为89%左右。这意味着每10次请求中就有1次需要重试或修复——对于高吞吐量的生产系统来说,这是难以接受的。

约束解码:Token级别的格式控制

约束解码(Constrained Decoding)是解决这个问题的核心技术路径。它的核心思想很简单:在每个生成步骤,只允许模型从"语法上有效"的token中进行采样

Token Masking机制

具体实现上,约束解码通过修改模型输出的logits分布来工作。假设在某个生成步骤,当前前缀是 {"name": "张三"。根据JSON语法规则,接下来只有两种有效的选择:

  1. 逗号 , —— 继续添加下一个字段
  2. 右括号 } —— 结束对象

约束解码系统会计算当前状态下的有效token集合,然后将所有无效token的logit设为负无穷大($-\infty$),使它们在softmax后的概率为零。数学表达为:

$$P'(x_i) = \frac{P(x_i) \cdot \mathbb{1}_{x_i \in V}}{\sum_{x_j \in V} P(x_j)}$$

其中 $V$ 是当前状态下有效token的集合,$\mathbb{1}$ 是指示函数。

这种方法的关键优势在于:它保留了模型在有效选项中的相对偏好。如果模型原本认为逗号的概率是0.7,右括号的概率是0.2(还有0.1的概率分配给其他无效token),约束后会变成逗号0.78,右括号0.22——两者的相对比例被保留。

约束解码Token Masking示意图

图片来源: Aidan Cooper - A Guide to Structured Generation Using Constrained Decoding

语法引导生成

实现Token Masking需要一种方法来判断"哪些token在当前状态下是有效的"。最强大的方法是基于上下文无关文法(Context-Free Grammar, CFG)。

以JSON为例,可以定义一个语法规则来描述所有有效的JSON结构:

json ::= object | array | string | number | "true" | "false" | "null"
object ::= "{" "}" | "{" members "}"
members ::= pair | pair "," members
pair ::= string ":" json
array ::= "[" "]" | "[" elements "]"
elements ::= json | json "," elements

这个语法规则可以转换成一个有限状态机(Finite State Machine, FSM)。在生成过程中,系统维护一个当前状态,每次生成一个token后,状态转移到对应的下一个状态。每个状态都关联着一组"允许的下一个token",这就是Token Masking的依据。

主流的约束解码框架——如Outlines、Guidance、XGrammar——都实现了某种形式的FSM或等价机制。它们之间的差异主要在于:

  • 编译效率:将JSON Schema或正则表达式转换成FSM的速度
  • 运行时开销:每个生成步骤的状态转移计算成本
  • 表达能力:支持的约束类型(JSON Schema、正则表达式、自定义文法)

JSONSchemaBench:基准测试的启示

2025年1月,一组研究者发布了JSONSchemaBench,这是目前最全面的约束解码基准测试。它包含了从GitHub、Kubernetes、API定义等来源收集的10,000个真实JSON Schema,覆盖了从简单到极端复杂的各种场景。

测试结果揭示了一些反直觉的发现:

约束解码可以加速生成:传统观点认为,约束解码会增加计算开销。但实际上,约束解码可以通过减少搜索空间来加速生成——在某些情况下提速高达50%。这是因为有效token的集合通常远小于整个词表,模型不需要为无效token计算概率。

框架之间存在显著差异:在Empirical Coverage(模型输出确实符合Schema的比例)指标上,表现最好的Guidance达到了96%,而表现最差的框架仅有7%。这种差异主要来自于对复杂JSON Schema特性的支持程度——如条件验证(if-then-else)、枚举约束(enum)、嵌套对象深度等。

框架 GlaiveAI覆盖率 GitHub Hard覆盖率 编译时间
Guidance 96% 41% ~0s
Llama.cpp 95% 39% 0.05s
XGrammar 93% 28% 0.12s
Outlines 95% 3% 3.48s

数据来源: JSONSchemaBench论文,表格简化展示

约束解码可能影响输出质量:这是一个重要的权衡。当约束过于严格,与模型自然偏好冲突时,输出可能出现语义不连贯或"机械化"的问题。研究建议在设计Schema时考虑模型的表达习惯,避免过于反直觉的字段名或值格式。

主流平台的结构化输出方案

各大大模型服务商都推出了某种形式的结构化输出支持,但实现方式和保证程度各不相同。

OpenAI:Structured Outputs的演进

OpenAI在2024年8月正式推出了Structured Outputs功能,这是其JSON Mode的升级版本。两者的关键区别在于:

  • JSON Mode:保证输出是有效的JSON,但不保证符合特定的Schema。模型可能输出 {"foo": "bar"} 而非你要求的 {"name": "...", "age": ...}
  • Structured Outputs:保证输出严格符合指定的JSON Schema,包括字段名、类型、枚举值等。

OpenAI的实现采用了约束解码技术。当你提供一个JSON Schema时,API会在后端编译成相应的约束规则,并在生成过程中强制执行。一个重要的技术细节是,OpenAI要求所有字段都是 required,不允许真正意义上的"可选字段"——如果需要模拟可选字段,必须使用联合类型 "type": ["string", "null"]

from openai import OpenAI
from pydantic import BaseModel

class UserInfo(BaseModel):
    name: str
    age: int
    email: str

client = OpenAI()
response = client.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[{"role": "user", "content": "提取用户信息:张三,28岁,[email protected]"}],
    response_format=UserInfo,
)

Google Gemini:响应Schema绑定

Gemini采取了类似的方法,通过 response_schema 参数指定输出结构。一个有趣的差异是,Gemini允许更灵活的Schema定义,包括可选字段和复杂的嵌套结构。

import google.generativeai as genai

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"},
        "email": {"type": "string"}
    },
    "required": ["name", "age", "email"]
}

response = genai.generate_content(
    model="gemini-2.0-flash",
    contents="提取用户信息...",
    generation_config=genai.GenerationConfig(
        response_mime_type="application/json",
        response_schema=schema
    )
)

Anthropic Claude:通过Tool Use实现

Claude没有直接的"JSON Schema Mode",而是通过Tool Use(函数调用)机制来实现结构化输出。开发者需要定义一个"工具",其参数Schema就是你想要的输出结构,然后强制模型调用这个工具。

from anthropic import Anthropic

client = Anthropic()
tool = {
    "name": "extract_user_info",
    "input_schema": {
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "age": {"type": "integer"},
            "email": {"type": "string"}
        },
        "required": ["name", "age", "email"]
    }
}

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    tools=[tool],
    tool_choice={"type": "tool", "name": "extract_user_info"},
    messages=[{"role": "user", "content": "提取用户信息..."}]
)

这种方法的优势在于Tool Use是Claude的原生能力,模型经过专门训练来生成符合Schema的工具调用参数。缺点是语义上有些"绕"——你并非真的想调用工具,只是借用它的Schema验证机制。

开源方案:vLLM与llama.cpp

对于需要在本地部署模型或使用私有化部署的场景,vLLM和llama.cpp提供了更灵活的约束解码支持。

vLLM支持通过XGrammar或Guidance作为后端实现结构化输出。XGrammar是一个专门优化的语法引擎,可以高效地将JSON Schema编译成约束规则。vLLM的文档显示,在启用结构化输出后,吞吐量可能略有下降(约5-15%),但格式正确率可以达到100%。

llama.cpp采用了GBNF(GGML BNF)语法格式来定义约束。这是一种类似Backus-Naur Form的语法描述语言,可以表达JSON、正则表达式甚至编程语言的语法规则。

from llama_cpp import Llama, LlamaGrammar

grammar = LlamaGrammar.from_string(r'''
    root ::= "{" ws "name" ws ":" ws string ws "age" ws ":" ws number ws "}"
    ws ::= [ \t\n]*
    string ::= "\"" [^"]* "\""
    number ::= [0-9]+
''')

llm = Llama(model_path="model.gguf")
output = llm.create_chat_completion(
    messages=[{"role": "user", "content": "提取用户信息..."}],
    grammar=grammar
)

权衡与局限:约束解码并非万能药

约束解码技术虽然强大,但并非没有代价。理解这些权衡对于正确使用这项技术至关重要。

计算开销与延迟

虽然JSONSchemaBench的研究表明约束解码可能加速生成,但这取决于具体场景。对于复杂的嵌套Schema或包含大量枚举值的Schema,编译和运行时状态转移的开销可能抵消搜索空间缩减带来的收益。

在实际应用中,约束解码的首次请求通常有额外延迟(Schema编译时间),后续请求则可以利用缓存的编译结果。Outlines框架的编译时间可达数秒,而Guidance和XGrammar则将编译时间压缩到毫秒级。

语义质量的风险

约束解码保证了输出的语法正确性,但不保证语义正确性。一个更微妙的问题是:当模型被强制输出它"不愿意"输出的内容时,输出的语义质量可能下降。

研究者举了一个例子:当模型被约束从 ["Donald Duck", "Millard Fillmore"] 中选择一个"美国总统"时,贪婪的token选择策略可能会选择"Donald Duck"——因为"Donald"这个token在训练语料中出现频率更高。这不是模型"愚蠢",而是约束解码的token级决策没有考虑完整token序列的语义合理性。

缓解这个问题的方法包括:

  • 使用Beam Search而非贪婪解码,让模型考虑完整序列的概率
  • 在Prompt中提供足够的上下文,让模型"愿意"输出正确的内容
  • 避免设计与模型知识冲突的约束

表达能力的边界

并非所有需求都能用JSON Schema或正则表达式表达。例如:

  • “输出必须是事实准确的”——这是语义约束,无法通过语法规则强制
  • “字段值必须来自外部数据库查询结果”——需要运行时验证,不是预定义的约束
  • “输出必须符合某种业务规则”——复杂的逻辑约束可能超出JSON Schema的表达能力

对于这些场景,通常需要结合约束解码与后处理验证:先用约束解码保证格式正确,再用业务逻辑验证内容正确性。

实践指南:选择适合的方案

面对不同的应用场景,如何选择最合适的结构化输出方案?

简单场景(Schema简单、失败容忍度高)

  • 使用Prompt工程即可,配合重试机制
  • 适合快速原型开发或内部工具

中等场景(Schema有一定复杂度、需要高可靠性)

  • 使用OpenAI/Gemini的Structured Outputs功能
  • 或使用Claude的Tool Use机制
  • 配合客户端Pydantic验证作为兜底

复杂场景(复杂嵌套Schema、需要完全控制)

  • 使用开源约束解码框架(Guidance、Outlines、XGrammar)
  • 配合vLLM或llama.cpp等推理引擎
  • 需要评估不同框架在目标Schema上的覆盖率

私有化部署场景

  • llama.cpp的GBNF语法提供最大的灵活性
  • XGrammar + vLLM提供更好的性能优化
  • 需要在编译时间和运行效率之间权衡

无论选择哪种方案,一个通用建议是:不要过度依赖结构化输出的语义正确性。约束解码解决的是格式问题,语义问题仍然需要通过Prompt设计、模型选择和后处理验证来解决。


结构化输出的技术演进反映了一个更广泛的趋势:大语言模型从"通用的文本生成器"向"可靠的软件组件"演进。约束解码是这个演进过程中的关键技术之一,它通过在生成过程中注入结构约束,弥合了概率模型与确定性系统之间的鸿沟。

但这项技术并非终点。2025年的研究已经开始探索更高级的形式——如上下文敏感语法语义约束解码与外部工具的深度集成。未来的结构化输出可能不再局限于JSON Schema,而是能够表达更复杂的业务规则和领域知识。

对于开发者而言,理解约束解码的原理和局限,比记住特定API的用法更重要。当你下次遇到模型"不听话"输出格式问题时,希望你能回想起:这可能不是Prompt写得不够好,而是自回归生成的结构性困境。解决之道,在于在生成过程中注入约束,而非在生成后祈祷结果正确。


参考文献

  1. Willard, B., & Louf, R. (2023). Efficient Guided Generation for LLMs. arXiv:2307.09702.
  2. Dong, Y., et al. (2024). XGrammar: Flexible and Efficient Structured Generation for LLMs. arXiv:2411.15159.
  3. Guidance AI. (2025). Generating Structured Outputs from Language Models: Benchmark and Studies. arXiv:2501.10868.
  4. OpenAI. (2024). Introducing Structured Outputs in the API. OpenAI Blog.
  5. Cooper, A. (2024). A Guide to Structured Generation Using Constrained Decoding.
  6. Brenndoerfer, M. (2025). Constrained Decoding: Grammar-Guided Generation for Structured LLM Output.
  7. Vivien, L. (2024). UTF-8 Plumbing: Byte-level Tokenizers Unavoidably Enable LLMs to Generate Ill-formed UTF-8. OpenReview.
  8. Google. (2025). Gemini API: Controlled Generation with Response Schema.