跳到主要内容
推理优化

8.1 结构化输出:用约束解码让模型输出合法 JSON

受约束解码原理、JSON Schema / 正则 / EBNF 语法三种约束、xgrammar 与 cFSM 加速、vLLM 结构化输出 API 与后端选型

结构化输出受约束解码JSON SchemaxgrammarcFSMEBNF

把模型接进业务系统时,第一个痛点往往是:“模型输出的 JSON 偶尔不合法”。一个字段少了引号、多了一个逗号,下游解析直接崩。8.1 解决这个问题:受约束解码(Constrained Decoding)——在生成时就强制模型只输出合法 token,让”JSON 不合法”从”概率事件”变成”不可能事件”。这一节讲原理(为什么能做)、技术(cFSM 怎么加速)和 vLLM 的用法。

📑 目录


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 两个关键性质

  1. 正确性:合法集合由语法精确定义——只要集合计算正确,输出必然合法(100%,不是 99.9%)
  2. 无损性:在合法集合内仍按模型概率采样——不改变相对概率分布,只是砍掉了不可能合法的选项。模型”最想要的合法输出”依然是概率最高的

💡 提示:受约束解码与采样参数(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) 查表)
  1. 编译:把 JSON Schema/正则/EBNF 编译成 FSM(状态 = 语法位置,转移 = 接受哪些 token)
  2. 缓存:把 FSM 的”每个状态 → 合法 token 集合”预计算成掩码表,缓存编译结果(相同 schema 只编译一次——cFSM 的 “c” 就是 cacheable)
  3. 逐 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字符级 FSMPython 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 优化实践

  1. schema 固定复用:业务 schema 写成常量,吃满编译缓存
  2. schema 精简:只约束必要字段,不要堆 additionalProperties: false 这类严格项
  3. 避免超深嵌套:递归 schema(如树结构)的 FSM 状态爆炸,约束+宽松 prompt 结合
  4. 与 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 写一个完整示例?

📚 参考资料