8.1 结构化输出:用约束解码让模型输出合法 JSON
受约束解码原理、JSON Schema / 正则 / EBNF 语法三种约束、xgrammar 与 cFSM 加速、vLLM 结构化输出 API 与后端选型
把模型接进业务系统时,第一个痛点往往是:“模型输出的 JSON 偶尔不合法”。一个字段少了引号、多了一个逗号,下游解析直接崩。8.1 解决这个问题:受约束解码(Constrained Decoding)——在生成时就强制模型只输出合法 token,让”JSON 不合法”从”概率事件”变成”不可能事件”。这一节讲原理(为什么能做)、技术(cFSM 怎么加速)和 vLLM 的用法。
📑 目录
- 1. 为什么模型会输出非法 JSON
- 2. 受约束解码:把语法变成 token 白名单
- 3. 三种约束形式:JSON Schema / 正则 / EBNF 语法
- 4. cFSM:约束编译与逐 token 掩码
- 5. vLLM 结构化输出:API 与后端
- 6. 约束解码的性能代价与优化
- 7. 选型与最佳实践
- 📝 总结
- 🎯 自我检验清单
- 📚 参考资料
1. 为什么模型会输出非法 JSON
模型学的是”自然语言的 token 分布”——JSON 只是它见过的无数文本模式之一。采样时它按概率挑 token:
- 绝大多数时候会延续合法 JSON 的模式
- 但概率分布的尾部存在非法续写(多写一个逗号、漏掉闭引号)
- 温度越高、top-p 越宽,尾部被采中的概率越大
合法续写 token: '"' ':' ',' '}' ...
非法续写 token: 'a' 'zz' ':' (错误位置) ...
📌 关键点:非法输出不是 bug,是采样的固有概率。靠 prompt(“请输出合法 JSON”)只能把非法率从 10% 压到 1%——在 99.9% 可靠性要求的业务系统里依然不可接受。唯一可靠的办法是不给模型非法选项。
2. 受约束解码:把语法变成 token 白名单
2.1 核心思想
正常解码:每步从整个词表里按概率采样。
受约束解码:每步先把词表过滤成”合法集合”(符合语法约束的 token),再在合法集合内按概率采样:
正常采样: P(token) 覆盖全部 128K 词表
受约束采样: P(token) 只在"合法集合"上归一化
(如 JSON 的 key 位置只允许 '"' 开头)
2.2 两个关键性质
- 正确性:合法集合由语法精确定义——只要集合计算正确,输出必然合法(100%,不是 99.9%)
- 无损性:在合法集合内仍按模型概率采样——不改变相对概率分布,只是砍掉了不可能合法的选项。模型”最想要的合法输出”依然是概率最高的
💡 提示:受约束解码与采样参数(temperature/top-p)正交——约束只做”过滤”,概率整形照常进行。这也意味着结构化输出可以和 beam 式并行采样(
n)等组合使用。
2.3 一个朴素实现的问题
朴素实现:每步枚举”所有合法 token”(需要模拟所有候选 token 拼上当前前缀是否被语法接受)——对 128K 词表做一次完整语法检查,每步开销巨大。这就是 cFSM(第 4 节)存在的理由。
3. 三种约束形式:JSON Schema / 正则 / EBNF 语法
vLLM 支持三种约束语言,覆盖从”简单格式”到”复杂 DSL”的需求:
| 约束 | 描述 | 适用场景 |
|---|---|---|
| JSON Schema | 声明式描述 JSON 结构(字段、类型、必填、枚举) | 绝大多数 API 对接场景 |
| 正则(regex) | 传统正则表达式 | 简单格式(邮箱、日期、ID) |
| EBNF 语法 | 上下文无关文法 | SQL、DSL、嵌套结构语言 |
3.1 JSON Schema 示例
from pydantic import BaseModel
class CarDescription(BaseModel):
brand: str
model: str
year: int
# 请求时把 schema 传给 vLLM
completion = client.chat.completions.create(
model="...",
messages=[...],
extra_body={
"structured_outputs": {
"json": CarDescription.model_json_schema()
}
},
)
3.2 正则示例
extra_body={
"structured_outputs": {"regex": r"\w+@\w+\.com\n"},
"stop": ["\n"],
}
3.3 EBNF 示例(SQL)
root ::= select
select ::= "SELECT" column ("," column)* "FROM" table
column ::= "id" | "name" | "price"
table ::= "products" | "orders"
📌 关键点:三种形式在 vLLM 里都通过
structured_outputs字段传入(v0.26 的新 API,替代了旧版guided_json/guided_regex/guided_grammar)。后端只是实现细节,约束语言才是你要学的——JSON Schema 用 pydantic 自动生成最省事。
4. cFSM:约束编译与逐 token 掩码
cFSM(cacheable Finite State Machine,可缓存有限状态机) 是 xgrammar 库的核心加速技术,让受约束解码从”每步全词表检查”变成”每步查表”:
4.1 三步流程
第 1 步:编译(一次) 第 2 步:缓存(一次) 第 3 步:查掩码(每步)
JSON Schema ──→ FSM FSM ──→ token 掩码表 状态 + 前缀 ──→ 合法集合
(语法 → 状态机) (掩码预计算并缓存) (O(1) 查表)
- 编译:把 JSON Schema/正则/EBNF 编译成 FSM(状态 = 语法位置,转移 = 接受哪些 token)
- 缓存:把 FSM 的”每个状态 → 合法 token 集合”预计算成掩码表,缓存编译结果(相同 schema 只编译一次——cFSM 的 “c” 就是 cacheable)
- 逐 token 掩码:每步根据当前状态查掩码,得到合法 token 集合,交给采样器
4.2 为什么快
- 编译一次、查表 N 次:schema 通常固定(如业务 JSON 结构),编译开销摊薄到每步只剩查表
- 掩码是位图(每个 token 1 bit),内存占用小、查询快
- 与 CUDA Graph 兼容:掩码查询可以并入采样 kernel
4.3 与 tokenizer 的关系
一个工程细节:合法集合是按 token 算的(不是按字符)。"key": 可能是一个 token 也可能是多个 token 的组合——FSM 必须处理”一个 token 跨多个语法位置”的情况(如 \n 与 "\n")。这就是为什么掩码计算要结合词表的 token 化结构——xgrammar 会对每个 token 预计算其”字符序列”与 FSM 转移的关系。
💡 提示:理解 cFSM 后你就知道为什么”xgrammar 是默认后端”——编译缓存 + 位图掩码让它几乎不拖慢生成速度。对比早期的朴素实现(每步正则全匹配),cFSM 是结构化输出能上生产的关键。
5. vLLM 结构化输出:API 与后端
5.1 支持的四种后端
| 后端 | 特点 | 约束语言支持 |
|---|---|---|
| xgrammar(默认,auto 首选) | cFSM 加速、编译缓存 | JSON Schema / regex / EBNF |
| guidance | 基于 guidance 库,模板驱动 | 模板 + JSON |
| outlines | 经典库,Python 正则 | JSON / regex / CFG |
| lm-format-enforcer | 字符级 FSM | Python re 模块语义 |
📌 关键点:正则方言差异是隐蔽的坑——xgrammar / guidance / outlines 用 Rust 风格正则,lm-format-enforcer 用 Python
re模块。同一正则在不同后端行为可能不同(如命名组、Unicode 类),跨后端迁移时务必回归测试。
5.2 后端选择
# 默认 auto:根据请求类型自动选
vllm serve meta-llama/Llama-3.1-8B-Instruct
# 显式指定后端
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--structured-outputs-config.backend xgrammar
- auto(默认):按请求细节自动挑选合适的后端
- 显式指定:统一行为、便于排错(不同后端输出可能有细微差异)
5.3 API 一览(OpenAI 兼容)
| 需求 | 用法 |
|---|---|
| JSON Schema 约束 | extra_body={"structured_outputs": {"json": schema}} |
| 正则约束 | extra_body={"structured_outputs": {"regex": pattern}} |
| EBNF 约束 | extra_body={"structured_outputs": {"grammar": grammar}} |
| 离线推理 | LLM(...).generate(..., StructuredOutputsParams(json=...)) |
💡 提示:v0.26 起旧字段(
guided_json等)已废弃——用structured_outputs新 API。升级注意:guided_decoding_backend字段也移除了,改由服务端--structured-outputs-config.backend控制。
6. 约束解码的性能代价与优化
6.1 代价在哪
- 每步多一次掩码查询:与采样 kernel 合并后开销很小(cFSM 位图查询)
- 首次编译延迟:第一个请求要编译 schema(毫秒级),缓存后消失
- 约束收紧时的质量影响:约束太紧(如超长 JSON 嵌套)可能让模型”卡住”(合法 token 概率极低,被迫选低概率 token)
6.2 优化实践
- schema 固定复用:业务 schema 写成常量,吃满编译缓存
- schema 精简:只约束必要字段,不要堆
additionalProperties: false这类严格项 - 避免超深嵌套:递归 schema(如树结构)的 FSM 状态爆炸,约束+宽松 prompt 结合
- 与 stop 序列配合:正则约束 + stop token(如
\n)组合,控制输出边界
📌 关键点:约束解码的黄金原则——约束”格式”,不约束”内容”。格式(JSON 结构、字段名)交给 FSM,内容(生成什么文字)留给模型。把内容也塞进约束(比如限定值枚举),模型自由度被过度压缩,质量会下降。
7. 选型与最佳实践
7.1 技术选型
| 场景 | 推荐 |
|---|---|
| API 对接(默认) | JSON Schema + pydantic 生成 + xgrammar |
| 简单格式校验 | 正则 |
| SQL / DSL 生成 | EBNF 语法 |
| 严格跨后端兼容 | 固定后端(xgrammar)+ 回归测试 |
7.2 完整实践模板
from pydantic import BaseModel, Field
class WeatherResponse(BaseModel):
city: str = Field(description="城市名")
temperature: float = Field(description="温度(摄氏度)")
condition: str = Field(description="天气状况,如 sunny/rainy")
# 服务端已开 xgrammar 后端
resp = client.chat.completions.create(
model="...",
messages=[{"role": "user", "content": "上海今天天气如何?"}],
extra_body={"structured_outputs": {"json": WeatherResponse.model_json_schema()}},
)
data = WeatherResponse.model_validate_json(resp.choices[0].message.content)
# 到这里 100% 是合法 WeatherResponse,无需 try/except 兜底
💡 提示:生产落地建议——结构化输出 + 解析失败兜底双保险:理论上 100% 合法,但模型输出长度超限(max_tokens 截断)仍可能产生不完整 JSON。解析失败时降级重试一次(放宽约束或降低温度),保证链路鲁棒。
📝 总结
- 问题本质:非法 JSON 是采样概率的固有尾部,prompt 只能压到 1%,业务需要 0
- 受约束解码:每步把词表过滤成语法合法集合,再在集合内采样——100% 合法且无损
- 三种约束:JSON Schema(API 默认)/ 正则(简单格式)/ EBNF(DSL)
- cFSM:schema → FSM → 掩码表缓存,逐 token 查表 O(1)——结构化输出能上生产的关键
- 四后端:xgrammar(默认)/ guidance / outlines / lm-format-enforcer,正则方言有差异
- 性能:掩码查询并入采样 kernel 几乎无感;首次编译一次,之后吃缓存
- 原则:约束格式不约束内容;截断兜底重试
🎯 自我检验清单
- 为什么说”非法输出不是 bug 而是采样概率”?
- 受约束解码的”正确性”和”无损性”分别指什么?
- 三种约束形式各适合什么场景?
- cFSM 的三步流程?“c” 代表什么?
- 四个后端有什么差异?为什么 xgrammar 是默认?
- 约束解码的性能代价在哪?怎么优化?
- 用 pydantic + structured_outputs 写一个完整示例?
📚 参考资料
- vLLM 官方文档:Structured Outputs(https://docs.vllm.ai/en/latest/features/structured_outputs.html)
- xgrammar 项目(cFSM 技术)(https://github.com/mlc-ai/xgrammar)
- outlines 项目(https://github.com/dottxt-ai/outlines)
- guidance 项目(https://github.com/guidance-ai/guidance)
- Willard & Louf, Efficient Guided Generation for Large Language Models(https://arxiv.org/abs/2307.09702)