「请只输出 JSON,不要有多余文字」——这句提示词在真实业务里几乎必然翻车:模型会加上 ```json 代码块、会补一句「好的,以下是结果」、会在字段后多一个逗号。对靠 json.loads() 解析的下游系统来说,这是灾难。受限解码(Constrained Decoding) 换了个思路:不靠模型自觉,而是在每个解码步物理屏蔽掉不合法的 token,让模型「想输出非法字符都输出不了」。本文讲清这套机制的原理、实现路径与工程代价。
前置:vLLM 深度解析 、投机采样与解码优化 、LLM 服务可观测性 。
一、为什么提示词约束不可靠
先用一张表说明「提示词约束」与「受限解码」的本质差异:
| 维度 | 提示词约束 | 受限解码 |
|---|---|---|
| 保证程度 | 概率性(会失败) | 确定性(结构必然合法) |
| 失败代价 | 解析异常、重试、下游崩溃 | 无(结构永远合法) |
| 语义正确性 | 靠模型 | 仍需模型(只保结构) |
| 额外开销 | 无 | 每步 token 掩码计算 |
| 适用场景 | 宽松、容错 | 严格 API、函数调用、Agent |
提示词约束的常见翻车方式:
□ 前置寒暄:「好的,我来为你生成……{...}」
□ 代码块包裹:```json ... ```(多了围栏)
□ 尾随逗号:{"a": 1,}(JSON 不允许)
□ 字段缺失 / 类型漂移:数字写成字符串
□ 幻觉字段:多出 schema 里没有的 key
□ 截断:max_tokens 用尽,JSON 半截
即便用「重试 + 解析修复」,在高并发下重试会放大延迟与成本。对 Agent / 工具调用这种「结构必须对」的场景,确定性约束是唯一可靠解。
工程要点:提示词约束的本质是**「求模型配合」,受限解码的本质是「让模型没得选」**。前者在简单 schema 上还行,schema 一复杂(嵌套、枚举、正则)失败率飙升。凡是要喂给下游代码解析的输出,都应上受限解码。
二、受限解码原理:FSM + Token Mask
核心机制可以用一句话概括:把「合法输出」编码成有限状态机(Finite State Machine,FSM),解码时只允许能推动状态机的 token。
2.1 状态机如何约束解码
以 JSON {"name": "x"} 为例:
状态 S0:期望 '{' → 只允许 token '{'(或含 '{' 的开头)
状态 S1:期望 '"' → 只允许 '"'
状态 S2:key 字符串 → 允许字母(不能是 '"')
状态 S3:期望 ':' → 只允许 ':'
...
状态 Sn:期望 '}' → 只允许 '}' 或空白
状态 S_end:结束 → 只允许 EOS(或合法的后续)
每一步:把「所有 token」映射到「该状态下合法的 token 集合」,
非法 token 的 logits 置为 -inf,再做采样。
2.2 从 logits 到 token 掩码
模型每步输出的是整个词表的 logits 向量。受限解码在采样前做一次掩码:
# 概念示意(真实实现用编译好的 FSM/索引加速)
def constrained_sample(logits, state, tokenizer):
allowed_ids = fsm.allowed_tokens(state) # 当前状态合法 token 集合
mask = torch.full_like(logits, float("-inf"))
mask[allowed_ids] = 0.0 # 合法位置保留
masked_logits = logits + mask # 非法位置 -inf
token = sample(masked_logits) # 采样(temperature 等照常)
return token, fsm.step(state, token) # 状态推进
关键点:掩码只改「能不能选」,不改「选哪个」。所以在合法 token 之间的采样仍然遵循温度、top-p 等策略,生成质量不受影响——受限解码约束的是结构,不是内容。
掩码的两个技术难点:
① 词表遍历开销:每步要对 10 万+ 词表算合法性
→ 用预编译的「状态 → 合法 token 集合」索引
② token 跨字符边界:一个 token 可能含多个字符(如 "ab{")
→ 需要「token → 字符序列」展开后再匹配 FSM
工程要点:受限解码的两大性能瓶颈是**「词表规模」与「token 跨字符边界」**。朴素实现每步遍历整个词表,延迟直接翻倍。成熟方案(XGrammar、Outlines)靠「预编译索引 + 缓存状态转移」把每步开销压到微秒级。理解这两点,才能判断一个实现是否「真的快」。
三、JSON Schema 到语法的编译流程
业务侧写的是 JSON Schema,推理侧要的是 FSM。中间是一段「编译」过程:
编译流水线:
JSON Schema
→ 归一化(展开 $ref、补全类型、处理 default)
→ 语法描述(正则 / 上下文无关文法 CFG / GBNF)
→ 有限状态机 / 下推自动机
→ 词表级状态转移表(每个 token 触发的状态迁移)
→ 运行时查表掩码
3.1 支持的约束表达力
| 约束类型 | Schema 表达 | 是否可用 FSM |
|---|---|---|
| 枚举(enum) | "enum": ["a","b"] | ✅ 简单 FSM |
| 正则(pattern) | "pattern": "^\\d{4}$" | ✅ 编译为正则 FSM |
| 嵌套对象 | properties 递归 | ✅ 状态栈(下推) |
| 数组(定长/变长) | items/minItems | ✅ 带计数器 |
| 数值范围 | minimum/maximum | ⚠️ 需数字化 FSM |
| 递归结构 | 自引用 $ref | ⚠️ 需下推自动机 |
| 语义约束 | 「和 > 100」 | ❌ 超出文法能力 |
重要边界:受限解码只保证「结构合法」,不保证「语义正确」。
□ 结构:字段名、类型、嵌套、枚举值 → 保证
□ 语义:「age 是合理年龄」「金额单位正确」→ 不保证,仍需校验
3.2 用 Pydantic / Schema 定义约束
主流框架让开发者用类型系统描述约束:
from pydantic import BaseModel, Field
from typing import Literal
class WeatherQuery(BaseModel):
city: str = Field(description="城市名")
unit: Literal["celsius", "fahrenheit"] = "celsius"
days: int = Field(ge=1, le=14)
# 该 schema 会被编译成 FSM,约束模型输出
# 输出必然是 {"city": ..., "unit": "celsius"|"fahrenheit", "days": 1..14}
工程要点:编译流程决定了「你能约束什么」。枚举、正则、嵌套对象是 FSM 的舒适区;数值范围和递归结构需要更强的自动机(下推/计数器);「跨字段语义约束」根本不在文法能力范围内。设计 schema 时,把能靠结构表达的约束尽量结构化,语义校验留给后置。
四、三条实现路径对比
4.1 主流方案一览
| 方案 | 形式 | 典型集成 | 特点 |
|---|---|---|---|
| Outlines | Python 库 | vLLM / Transformers | 索引缓存,正则/Schema→FSM |
| XGrammar | C++ 引擎 | vLLM / SGLang / TRT-LLM | 预编译 + 状态缓存,吞吐友好 |
| GBNF | 文法文件 | llama.cpp | 手写文法,灵活但需自维护 |
| Guidance / LMQL | 模板语言 | 多后端 | 约束与模板混写 |
| 服务端原生 | API 参数 | OpenAI / vLLM guided_json | 零集成成本,黑盒 |
4.2 vLLM 中的结构化输出
vLLM 内置了结构化输出支持(后端可切换 Outlines / XGrammar / Guidance):
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
llm = LLM(model="Qwen/Qwen2.5-7B-Instruct")
# 方式一:JSON Schema 约束
params = SamplingParams(
temperature=0.7,
max_tokens=256,
guided_decoding=GuidedDecodingParams(json=schema_dict),
)
out = llm.generate(["提取城市和天数"], params)
# 方式二:正则约束(如固定格式编号)
params = SamplingParams(
guided_decoding=GuidedDecodingParams(regex=r"\d{4}-\d{2}-\d{2}"),
)
4.3 llama.cpp 的 GBNF 示例
# grammar.gbnf —— 手写文法约束输出为「键值对列表」
root ::= "{" pair ("," pair)* "}"
pair ::= string ":" value
string ::= "\"" [a-zA-Z_]+ "\""
value ::= string | number
number ::= [0-9]+
./llama-cli -m model.gguf --grammar-file grammar.gbnf \
-p "输出一个包含 name 和 age 的 JSON"
选型建议:
□ 已在 vLLM/SGLang 上 → 直接用原生 guided_decoding(默认 XGrammar)
□ 端侧 / llama.cpp → GBNF 或 json_schema 参数
□ 自研引擎 → 集成 Outlines/XGrammar 库
□ 闭源 API → 用其原生 structured output 参数,别自己解析
工程要点:别自己从零实现 FSM 编译器——token 跨边界、词表索引、状态缓存这些坑太多。优先用引擎原生能力(vLLM 的 guided_decoding、llama.cpp 的 GBNF),它们是热路径优化过的。自研只在你需要「约束与模板混写」等特殊能力时才值得。
五、性能开销与优化
受限解码不是免费的,开销来自「每步多算一次掩码」。
5.1 开销从哪来
① 每步掩码计算
朴素实现:遍历词表判合法性 → O(V) per step(V 常 5万~15万)
→ 延迟显著上升,尤其短输出场景
② 状态转移与缓存
复杂 schema 的 FSM 状态多,转移表大 → 缓存未命中代价高
③ 编译开销
Schema → FSM 的编译有一定固定成本
→ 应在服务启动时预编译,别每次请求编译
5.2 优化手段
| 手段 | 做法 | 收益 |
|---|---|---|
| 预编译 | 启动时编译 schema,缓存 FSM | 省每请求编译开销 |
| 索引缓存 | 缓存「状态→合法 token」位图 | 掩码降到微秒级 |
| 后端选择 | 用 XGrammar 等高效引擎 | 相比朴素实现数倍加速 |
| 简化 schema | 减少枚举/嵌套深度 | 状态数下降 |
| 与投机采样配合 | 草案模型也受约束 | 保持加速同时不破坏合法性 |
一个关键认知:受限解码对「长输出」的相对开销小,
对「短输出」的相对开销大(固定开销摊薄不了)。
→ 短输出 + 高 QPS 场景,务必用高效后端 + 预编译缓存。
工程要点:受限解码的开销主要集中在掩码计算。用「预编译 + 索引缓存 + 高效后端」三件套能把开销压到可接受范围。记住:开销是每步固定成本,短输出场景占比更高,别用「平均开销」估算——要按你的实际输出长度分布算。
六、工程陷阱与最佳实践
陷阱 1:只约束结构,不校验语义
→ schema 保证 {"age": 999} 合法,但不保证合理
→ 解:结构约束 + 业务校验双层
陷阱 2:max_tokens 太小导致截断
→ 受限解码会让模型「必须写完整」,截断时 JSON 不闭合
→ 解:max_tokens 留足余量,或容忍未完成时返回错误
陷阱 3:schema 过复杂拖慢编译与推理
→ 深层嵌套 + 大枚举 = 巨量状态
→ 解:拆分为多次小请求,或简化枚举
陷阱 4:忽略掩码与采样参数的交互
→ 极端低 temperature 下,受限解码可能「卡死」在某状态
→ 解:保持合理温度,避免全 greedy + 强约束
陷阱 5:跨请求复用状态机出错
→ 并发请求共享 FSM 实例导致状态串扰
→ 解:每请求独立状态实例,FSM 定义只读共享
最佳实践清单:
□ 用引擎原生 guided decoding,别自研
□ 服务启动预编译 schema,缓存 FSM
□ 结构约束 + 语义校验双层防护
□ max_tokens 留足,避免截断
□ 监控「约束失败率」(理想为 0)与「掩码耗时」
□ 与函数调用/工具调用规范对齐(见跨专题文章)
结构化输出是 Agent 与函数调用的地基。与 LLM 结构化输出与函数调用 的应用层实践、MCP 工具设计模式 的协议侧约定配合使用,才能构建稳定的工具调用链路。服务侧的延迟与失败率指标,纳入 LLM 服务可观测性 的监控体系。
七、速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 为什么不用提示词 | 概率性会失败,受限解码是确定性保证 |
| 核心机制 | JSON Schema → FSM → 每步屏蔽非法 token |
| 约束边界 | 只保结构合法,不保语义正确 |
| 性能瓶颈 | 每步词表掩码 + token 跨字符边界 |
| 优化三件套 | 预编译 + 索引缓存 + 高效后端(XGrammar) |
| 实现路径 | Outlines / XGrammar / GBNF / 引擎原生 |
| 首选方案 | vLLM guided_decoding、llama.cpp GBNF |
| 常见翻车 | 截断、语义未校验、schema 过复杂、状态串扰 |
| 开销规律 | 短输出占比高,长输出摊薄 |
| 配合优化 | 与投机采样兼容,草案也受约束 |
一句话记忆:受限解码 = 把 JSON Schema 编译成 FSM + 每步屏蔽非法 token + 只保结构不保语义 + 用预编译/索引缓存/高效后端压开销 + 引擎原生优先——「不靠模型自觉,让非法输出物理上不可能」。
延伸阅读
- vLLM 深度解析:连续批处理与内存高效推理
- 投机采样与解码优化
- NVIDIA Triton Inference Server 生产部署
- LLM 服务可观测性
- LLM 结构化输出与函数调用 — 应用层实践
- MCP 工具设计模式 — 协议侧的工具约定
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。