结构化输出与受限解码:从 JSON Schema 到 Token 掩码

让 LLM 稳定输出合法 JSON,靠提示词祈祷是不够的。受限解码(Constrained Decoding)把 JSON Schema 编译成有限状态机,在每个解码步屏蔽掉不合法的 token,从生成源头保证输出合法。本文讲清 FSM/Token Mask 原理、JSON Schema 到语法的编译流程、Outlines/XGrammar/GBNF 三条实现路径的对比、性能开销与工程陷阱。

「请只输出 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 主流方案一览

方案形式典型集成特点
OutlinesPython 库vLLM / Transformers索引缓存,正则/Schema→FSM
XGrammarC++ 引擎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 + 只保结构不保语义 + 用预编译/索引缓存/高效后端压开销 + 引擎原生优先——「不靠模型自觉,让非法输出物理上不可能」。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ai」更多文章

  1. 排序学习与搜索召回排序系统
  2. 数据版本控制与血缘:DVC 与 LakeFS
  3. 模型可解释性:SHAP、LIME 与注意力归因