引言
使用大模型时,我们常常希望它返回结构化的数据——JSON、表格、枚举值等,而不是一段自由文本。但在实际使用中,模型输出格式混乱、JSON 解析失败、字段遗漏等问题层出不穷。
为什么?因为大模型的本质是下一个 Token 预测器,它天生不会遵循你定义的格式。那业界是怎么解决这个问题的?这个思路的演进过程,就是输出格式约束的完整技术地图。
一、为什么需要输出格式约束
使用场景
- 数据提取:从非结构化文本中提取实体、关系、结构化信息
- API 调用:模型输出作为下游系统的输入,必须符合约定 Schema
- Agent 工具调用:Function Calling 需要严格的参数格式
- 多步工作流:链式调用需要中间结果结构一致
核心挑战
大模型本质是概率语言模型:
它不知道什么是 JSON,什么是 Schema。它的"学习"来自于训练语料中见过的格式模式——这意味着格式保证是概率性的,不是确定性的。
二、输出格式约束的技术演进
第一阶段:Prompt 引导
最原始的方法:在 System Prompt 里描述格式要求。
请以 JSON 格式输出,格式如下:
{
"name": "...",
"age": 25
}
优点:零额外成本 缺点:靠模型"自觉",中小模型经常不遵守;JSON 中嵌入 Markdown(json);字段名不一致
第二阶段:后处理验证 + 重试
模型生成自由文本后,用正则/JSON Schema 验证,失败则重试。
def safe_json_parse(text, schema, max_retries=3):
for i in range(max_retries):
try:
data = json.loads(extract_json_from_text(text))
jsonschema.validate(data, schema)
return data
except (json.JSONDecodeError, ValidationError):
text = llm.generate(prompt + "\n前次格式错误,请修正。")
raise ValueError("格式始终不符合要求")
优点:简单可靠,任何模型都可用 缺点:重试消耗 Token,延迟不定;改模型也改不好格式时就死循环了
第三阶段:约束解码(Constrained Decoding)—— 核心突破
这是真正的硬约束方案。不是在生成后检查,而是在生成过程中就确保每个 Token 都合法。
核心原理:Token Masking
大模型的生成流程:
输入 → 模型前向推理 → Logits(词汇表概率分布)→ 采样 → 下一个 Token
约束解码在这个流程中插入一个 Mask 步骤:
输入 → 模型推理 → Logits → [Token Mask 过滤非法 Token] → 采样 → 合法 Token
具体来说,在每个 generation step:
- 根据当前已生成的 Token 序列和约束规则,计算当前状态下合法的 Token 集合
- 将非法 Token 对应的 Logits 设为 (使其概率为 0)
- 在合法 Token 中采样
实现机制:有限状态机(FSM)
约束规则通过**有限状态机(Finite State Machine)**来实现。以 JSON Schema 约束为例:
JSON Schema → 正则表达式 → 确定性有限状态机(DFA)
DFA 的每个状态代表已生成内容的语法位置。在每个 step:
- 当前状态 已知
- 对于词汇表中的每个 Token,检查从 出发是否可以通过该 Token 到达下一状态
- 能到达的 Token 标记为合法,不能的标记为非法
例子:约束 role 字段只能是 "admin"、"user" 或 "guest"
当模型输出到 role: " 时,FSM 状态表明接下来必须枚举 role 字段。非法 Token 如 xyz 被 Mask 掉,只有 admin、user、guest 对应的 Token 允许通过。
支持多种约束类型
| 约束类型 | 描述 | 示例 |
|---|---|---|
| JSON Schema | 结构+类型约束 | {"type": "object", "properties": {...}} |
| 正则表达式 | 字符串模式匹配 | r"\w+@\w+\.com" |
| 枚举选择 | 可选列表 | ["positive", "negative"] |
| EBNF 文法 | 上下文无关文法 | SQL 查询、自定义 DSL |
第四阶段:API 原生结构化输出
OpenAI 在 2024 年正式推出 Structured Outputs,将约束解码内置到推理引擎中。
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[...],
response_format=UserSchema, # Pydantic model
)
Gemini API 也支持 JSON Schema 约束,并且保持了 Schema 中字段的书写顺序。
Anthropic 没有提供原生的 Constrained Decoding,而是利用 Tool Calling(Function Calling)的参数约束来"曲线救国"——将输出 Schema 包装成 Tool 定义,强制模型以 Tool Call Arguments 的形式输出。
关键区分:
- OpenAI/Gemini:推理引擎层做约束(硬保证)
- Anthropic:Tool Schema 只是提示,不是强制(软保证)
三、主流方案对比
框架对比
| 维度 | Outlines | Guidance | XGrammar | vLLM |
|---|---|---|---|---|
| 原理 | JSON→Regex→FSM | Token 修复+CFG | 编译 JSON Schema→确定性自动机 | 集成 Outlines/XGrammar 作为后端 |
| 支持格式 | JSON, Regex, Choice, CFG | JSON, Regex, Select, CFG | JSON Schema, CFG | JSON, Regex, Choice, CFG |
| 性能 | 中等(Python 层 FSM) | 中等 | 高性能(C++ 层 FSM) | 高性能(集成 XGrammar) |
| 集成 | Transformers, vLLM, llama.cpp | Transformers, llama.cpp | Transformers, vLLM | vLLM 内置 |
| 使用门槛 | 第三方库 | 第三方库 | 需编译 | 开箱即用 |
推理性能对比
引入约束解码必然带来额外开销。根据业界测试数据:
- Outlines:Python 层实现 FSM,每个 Token 需要约 0.1-0.5ms 的 Mask 计算
- XGrammar:C++ 层实现,Mask 计算时间可忽略不计,相比无约束场景 TPOT(Time Per Output Token)增加约 5-10%
- vLLM + XGrammar:官方宣称可实现 5x 的 TPOT 提升(相比 Outlines)
各平台 API 支持对比
| 平台 | Structured Output | 约束类型 | 保证程度 |
|---|---|---|---|
| OpenAI | ✅ 原生 | JSON Schema | 硬约束 |
| Gemini | ✅ 原生 | JSON Schema | 硬约束 |
| Mistral | ✅ 原生 | JSON Schema | 硬约束 |
| Anthropic | ❌ 无原生(Tool Calling Workaround) | Tool Schema | 软约束 |
| Ollama | ✅ 原生 | JSON Schema | 硬约束 |
| vLLM 服务 | ✅ 原生 | JSON/Regex/Choice/Grammar | 硬约束 |
四、实践示例
示例:用 Python 实现一个简易的约束解码
以下示例展示约束解码的核心思想——通过 Logits Mask 实现 Token 级别的约束:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
def apply_logits_mask(logits, allowed_token_ids):
"""将非法 Token 的 Logits 设为 -inf"""
mask = torch.full_like(logits, float("-inf"))
mask[:, allowed_token_ids] = logits[:, allowed_token_ids]
return mask
# 约束:必须输出 "positive" 或 "negative"
allowed_tokens = tokenizer.encode(["positive", "negative"], add_special_tokens=False)
inputs = tokenizer("Classify: This movie is great!", return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
next_token_logits = outputs.logits[0, -1, :]
# Mask 掉非法 Token
masked_logits = apply_logits_mask(
next_token_logits.unsqueeze(0),
[token_id for ids in allowed_tokens for token_id in ids]
)
predicted_id = torch.argmax(masked_logits, dim=-1).item()
print(tokenizer.decode(predicted_id)) # 输出 "positive"
示例:使用 vLLM 的结构化输出
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
# JSON Schema 约束
response = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[
{"role": "user", "content": "生成一个用户信息"}
],
extra_body={
"structured_outputs": {
"json": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"role": {"type": "string", "enum": ["admin", "user", "guest"]}
},
"required": ["name", "age", "role"]
}
}
}
)
print(response.choices[0].message.content)
示例:使用 Ollama 的结构化输出
from ollama import chat
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
role: str
response = chat(
model='llama3.1',
messages=[{'role': 'user', 'content': '生成一个用户信息'}],
format=User.model_json_schema(),
)
user = User.model_validate_json(response.message.content)
print(f"name={user.name}, age={user.age}, role={user.role}")
五、约束解码的影响
结构化输出对模型的影响因任务类型而异,这一点需要特别注意。
推理任务(GSM8K、Last Letter 等):
- JSON 模式可能导致模型放弃 CoT,直接输出答案而非逐步推理
- 原因是 JSON 的 Key 顺序改变了模型的输出顺序,破坏了思维链
- 性能下降可达 38%
分类任务:
- JSON 模式通常表现更好,因为答案空间被严格限制
- 解析失败率接近零(主流模型 < 1%)
实践建议:
- 推理密集型任务:使用宽松的格式约束,或采用两阶段法(先生成自然语言推理过程,再转为结构化格式)
- 分类/提取任务:使用严格的 JSON Schema 约束
六、总结
大模型输出格式约束的技术演进可以概括为:
Prompt 引导 → 后处理验证 → 约束解码 → API 原生结构化输出
这个过程中,核心思想从生成后检查转变为生成中约束——从概率保证走向了确定保证。
对于开发者,选型建议如下:
| 场景 | 推荐方案 |
|---|---|
| 简单格式要求 | Prompt 引导 + 后处理验证 |
| 严格 Schema | OpenAI/Gemini/Mistral API + JSON Schema |
| 自部署模型 | vLLM + XGrammar(推荐) |
| 复杂 DSL/文法 | Guidance / Outlines |
| 多平台兼容 | AI SDK + 适配层 |
约束解码代表了 LLM 应用开发的一个重要方向:不再把模型当作文本生成器,而是把它当作受控的数据生成器。当模型的输出格式可以预期,构建可靠的应用系统就不再是运气活。