"大模型输出格式约束"原理
当 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 Gemini 和 Anthropic Claude 也提供了类似的 Tool Use / Structured Output 支持。
五、整体对比
| 特性 | Prompt | 后处理 | 约束解码 | API 原生 |
|---|---|---|---|---|
| 格式保证率 | ~60% | ~90%(多轮重试) | ~99.9% | ~99.9% |
| 额外延迟 | 无 | 高(重试+校验) | 极低(~35μs/token) | 无 |
| 灵活性 | 高 | 高 | 中(需预先定义 Schema) | 低(依赖平台) |
| 实现成本 | 零 | 低 | 中(需框架集成) | 无(开箱即用) |
| 适用场景 | 原型验证 | 低要求生产 | 高可靠性生产 | 闭源模型 API |
六、总结
大模型输出格式约束从"写 prompt 靠自觉"进化到了解码层面的硬约束。核心原理是:
- 逐 token 检查:每生成一个 token 前,用状态机(FSM/PDA)判断当前哪些 token 合法
- Logit Masking:将非法 token 的概率设为 -inf,使模型无法选择
- 零额外推理成本:XGrammar 等框架将掩码计算优化到微秒级,几乎不影响生成速度
理解这些原理后,你在使用 OpenAI Structured Outputs、vLLM 部署、或自己实现约束解码时,就能更清楚地知道底层在做什么------它不是魔法,是状态机 + logit masking。
如果你觉得这篇文章有帮助,欢迎点赞、收藏、关注。