大模型输出格式约束原理

"大模型输出格式约束"原理

当 AI 回复 "{": 那一刻,它后面只能跟什么?

一、为什么需要输出格式约束?

使用大模型时,我们常希望它返回结构化的数据,比如 JSON、表格、枚举值等,而不是一大段自由文本。经典场景包括:

  • Function Calling / Tool Use :模型需要输出 {"name": "get_weather", "arguments": {"city": "北京"}} 才能正确调用工具
  • 信息抽取 :从文档中提取结构化字段,如 { "公司名": ..., "日期": ..., "金额": ... }
  • 分类任务 :模型只能输出 "positive""neutral""negative" 三者之一

然而大模型本质上是概率语言模型------它在预测"下一个最可能的 token",而不是"符合语法的 token"。即使你把格式要求写进 prompt,模型仍可能:

json 复制代码
{
  "name": "北京",
  "temperature": 25,
   // 模型可能在这里输出一段散文而不是继续 JSON
}

这就引出了输出格式约束技术。


二、技术演进路线

大模型输出格式约束经历了四代演进:

阶段 方法 可靠性 性能开销
① Prompt 引导 在提示词中写"请输出 JSON" ❌ 低
② 后处理验证 生成完后用正则/模式匹配校验+重试 ⚠️ 中 高(多次重试)
③ 约束解码 解码时屏蔽非法 token ✅ 高 极低
④ API 原生支持 OpenAI Structured Outputs 等 ✅ 最高 无额外开销

三、核心原理:约束解码(Constrained Decoding)

这是目前最主流的方案。理解它需要先了解大模型生成文本的基本过程。

3.1 大模型生成的基本流程

大模型生成文本是一个逐 token 预测的过程:

css 复制代码
输入: "北京的天气是"
                    ↓
模型计算词汇表中每个 token 的概率:
     P("晴")   = 0.6
     P("雨")   = 0.2
     P("很")   = 0.1
     P("沙")   = 0.05
     ...       = 0.05
                    ↓
从概率分布中采样 → 输出 "晴"
                    ↓
新输入: "北京的天气是晴"
                    ↓
继续预测下一个 token...

每一步,模型对所有 token(通常几万个)计算一个概率分布(logits),然后从中选择。

3.2 约束解码的核心思想

核心思想 :在每一步生成时,不是让模型在所有 token 中自由选择,而是根据预先定义的格式约束 (JSON Schema、正则、文法),计算出当前哪些 token 是合法 的,将非法 token 的概率强制设为 0

ini 复制代码
正常解码:
   词汇表: [a, b, c, d, ...]   ← 几万个候选
       ↓
   模型计算概率分布
       ↓
   采样输出

约束解码:
   词汇表: [a, b, c, d, ...]
       ↓
   约束检查器 ──→ 当前状态
       ↓              ↓
   合法 token: [b, d]   非法 token: [a, c, ...] ← 概率设为 0
       ↓
   模型只在合法 token 中计算/采样
       ↓
   输出

3.3 Token Masking(令牌掩码)的具体实现

实现上,这是通过 logit 偏置(logit bias) 完成的:

ini 复制代码
原始 logits:     [5.2,  3.1,  2.8,  4.0, ...]
                 |    |    |    |
合法 mask:       [1,   0,   0,   1,   ...]   ← 1=合法, 0=非法
                 |    |    |    |
masked logits:   [5.2, -inf, -inf, 4.0, ...] ← 非法 token 设为负无穷

softmax 后概率:
                 [0.77, 0,    0,    0.23, ...]

非法 token 的概率为 0,模型根本无法选择它们,就像这些 token 不存在一样。

3.4 状态机维护

约束解码的核心是维护一个状态机,它跟踪当前已生成的内容,并告诉下一步哪些 token 合法。

例:JSON 生成的状态流转

假设约束是输出一个 JSON 对象 {"name": string}

yaml 复制代码
状态 0: 期望 {          → 合法 token: ["{"]   ← 只有 { 合法
状态 1: 期望 "name"    → 合法 token: ['"']   ← 必须是引号开头
状态 2: 期望 :          → 合法 token: [":"] 
状态 3: 期望 string 值  → 合法 token: ['"']   ← 字符串值以引号开始
状态 4: 期望 字符串内容  → 合法 token: [所有 ASCII 字符]
状态 5: 期望 }          → 合法 token: ['"', '}'] ← 字符串结束或对象结束
...

每一步,状态机根据当前生成到哪个字符,输出当前允许的 token 集合

对于更复杂的结构,如 JSON Schema、正则表达式、上下文无关文法(CFG),状态机的构建方式不同:

约束类型 状态机类型 示例
正则表达式 DFA(确定性有限自动机) [A-Z]{2}\d{6}
JSON Schema Schema Walker(模式遍历器) {"type": "object", "properties": {...}}
上下文无关文法 PDA(下推自动机) SQL、编程语言

3.5 关键技术挑战:Token 边界问题

一个容易被忽视的技术细节是 token 边界问题。大模型使用的 tokenizer 可能将一个完整的概念切分成多个 token。

例如,词汇表中可能同时存在:

  • Token "foobar"(一个完整的 token)
  • Token "fo" 和 Token "obar"(两个 token)

如果约束要求输出 "foo",而词汇表中没有 "foo" 这个完整的 token,只有 "f""oo" 等子 token,就需要框架做 token 修复(token healing)字节级约束,确保即使在 token 边界处也能正确约束。


四、主流实现框架对比

4.1 Outlines(Python)

原理 :将 JSON Schema 或正则表达式编译成有限状态机(FSM),推理时逐 token 检查合法性。

python 复制代码
import outlines

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "number"}
    }
}

generator = outlines.generate.json(model, schema)
result = generator("Extract person info from: 张三25岁")
# 保证输出一定是符合 schema 的 JSON

优点 :接口简洁、框架无关、支持多后端

缺点:FSM 在 token 级别运行,每一步只能推进一个 token,深度嵌套时性能下降

4.2 Guidance

原理 :通过 Handlebars 模板语言交错 prompt 和生成步骤,在生成过程中插入约束指令。

bash 复制代码
{{#system~}}
你是信息抽取专家
{{~/system}}

{{#user~}}
从以下文本中提取信息:{{input}}
{{~/user}}

{{#assistant~}}
{
  "name": "{{gen 'name'}}",
  "age": {{gen 'age' pattern='\\d+'}}
}
{{~/assistant}}

特点:细粒度控制、支持 token healing 解决边界问题

4.3 XGrammar(UC Berkeley / vLLM)

原理 :通过自适应 token 掩码缓存将词汇表分为"上下文无关 token"和"上下文相关 token"。

  • 上下文无关 token:可以在编译时预先计算好合法性缓存
  • 上下文相关 token:需要在运行时动态检查

这种分区使得平均每个 token 的掩码计算时间从毫秒级降到微秒级(35μs/token),几乎零开销。

bash 复制代码
性能对比(Json Schema掩码延迟):
  Outlines:     ~150μs/token
  llama.cpp:    ~200μs/token
  XGrammar:     ~35μs/token   ← 几乎无感知

4.4 vLLM Structured Output

vLLM 集成了 XGrammar 作为默认后端,通过 OpenAI 兼容 API 直接支持:

python 复制代码
completion = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B",
    messages=[{"role": "user", "content": "..."}],
    extra_body={
        "guided_json": {
            "type": "object",
            "properties": {
                "company": {"type": "string"},
                "price": {"type": "number"}
            }
        }
    }
)

4.5 API 原生结构化输出

OpenAI Structured Outputs(2024 年推出)将约束解码内置到 API 服务端:

python 复制代码
response = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[...],
    response_format=MyPydanticModel,  # 传入 Pydantic 模型
)

模型内部通过服务端约束解码保证输出严格符合 Schema,用户无需额外处理。

同样,Google GeminiAnthropic Claude 也提供了类似的 Tool Use / Structured Output 支持。


五、整体对比

特性 Prompt 后处理 约束解码 API 原生
格式保证率 ~60% ~90%(多轮重试) ~99.9% ~99.9%
额外延迟 高(重试+校验) 极低(~35μs/token)
灵活性 中(需预先定义 Schema) 低(依赖平台)
实现成本 中(需框架集成) 无(开箱即用)
适用场景 原型验证 低要求生产 高可靠性生产 闭源模型 API

六、总结

大模型输出格式约束从"写 prompt 靠自觉"进化到了解码层面的硬约束。核心原理是:

  1. 逐 token 检查:每生成一个 token 前,用状态机(FSM/PDA)判断当前哪些 token 合法
  2. Logit Masking:将非法 token 的概率设为 -inf,使模型无法选择
  3. 零额外推理成本:XGrammar 等框架将掩码计算优化到微秒级,几乎不影响生成速度

理解这些原理后,你在使用 OpenAI Structured Outputs、vLLM 部署、或自己实现约束解码时,就能更清楚地知道底层在做什么------它不是魔法,是状态机 + logit masking


如果你觉得这篇文章有帮助,欢迎点赞、收藏、关注。