约束解码:LLM 输出格式化的底层暴力美学
约束解码不是"用 prompt 让模型输出 JSON",而是在 token 生成过程中直接屏蔽非法 token——逻辑上极其简单,工程上极其暴力。
每次写 Agent 工具调用时,你有没有想过一个问题:LLM 到底是怎么保证输出的 JSON 是合法的?
大部分人第一反应是"prompt 告诉它输出 JSON 格式就行了"。但用过 GPT-3.5 时代的人都知道,prompt 说一百遍也不管用——模型偶尔还是会在 JSON 末尾接一句"希望这些信息对你有帮助"。
今天的 LLM 之所以能稳定输出结构化数据,靠的是一套跟 prompt 完全无关的机制:约束解码(Constrained Decoding)。它在 token 生成的最底层拦截非法输出,原理简单到让你觉得像作弊。
logits 层面的"红绿灯"
LLM 生成下一个 token 的过程分两步:
- 模型前向传播,输出一个概率分布 —— 每个候选 token 对应一个 logit 值
- 采样策略(top-p、temperature 等)从这个分布里挑一个 token
约束解码就是在第二步之前插了一道闸门:用一个有限状态机(FSM)来定义当前状态下哪些 token 是合法的,然后把非法 token 的 logit 值设为 -inf。
从数学上看,它什么都没做——没有重新训练模型,没有微调参数,只是把一些候选值抹掉了。但这个"抹掉"的动作,让 Agent 的工具调用从"偶尔能用变成了接近 100% 可靠"。
Python 代码表达出来大概是这样:
def constrained_sample(logits, grammar_state):
# grammar_state 是当前 FSM 的状态
allowed_tokens = grammar_state.get_allowed_token_ids()
mask = torch.full_like(logits, float('-inf'))
mask[:, allowed_tokens] = 0
masked_logits = logits + mask # 非法 token 的 logit ≈ -inf
probs = torch.softmax(masked_logits, dim=-1)
return sample(probs), grammar_state.advance(selected_token)
整个约束解码就这么点核心逻辑。剩下的全是工程细节——而且这些细节才是真正的复杂度所在。
FSM 是怎么构造的
不同场景需要不同的约束方式,对应的 FSM 也从简单到复杂:
-
JSON 约束:最简单的场景。定义一个 JSON 的上下文无关文法(CFG),构建一个接受合法 JSON 的 DFA。tokenizer 的分词粒度导致每个合法字符组合要映射到 token ID 集合——"这是一个 O(#tokens) 的表查询"。
-
JSON Schema 约束:比纯 JSON 更严格。不仅要求格式合法,还要求字段名、类型、取值范围符合预定义的 schema。比如
age字段必须是整数且在 0-150 之间。这需要 FSM 的状态跟踪你当前在哪个字段、是什么类型。 -
正则约束:你可以说"输出必须匹配
^[A-Z][a-z]+ [A-Z][a-z]+$"。FSM 会把它编译成 NFA 再转 DFA,然后在解码时逐 token 匹配。 -
上下文敏感约束(Grammar-guided):最复杂的一种。比如 SQL 生成——不仅语法要对,表名和字段名还必须是数据库里真实存在的。这需要 FSM 在构造时注入外部信息,变成一个动态生成的约束图。
性能代价:一个被低估的问题
约束解码有一个逃不掉的性能开销:每次采样前都要查表。
在 batch size = 1 的时候,这个开销可以忽略。但在大规模 serving 场景下(batch size = 64 甚至更大),每个序列的约束状态都不同,无法批量计算允许的 token 集合。这意味着:
- 无法用单一的
torch.where做向量化 mask - 每个序列需要独立查表
- 更糟的是,采样策略(top-p、top-k)在 mask 之后才能计算——打破了原本可以预计算的一些优化
业界已经有一些优化方案:
- outlines 库的做法是预计算 grammar 和 tokenizer 的"prefix tree",把查表从 O(V) 降到 O(|prefix|)
- Guidance 用了一种"分块生成"的方法——当 FSM 状态只有一个合法 token 时(比如 JSON 的固定关键字
" :),就直接硬写这个 token,跳过模型前向传播 - llama.cpp 用位图(bitmask)来高效编码每个状态下允许的 token 集合,内存占用小且位运算快
vLLM 的做法:结构化推理
vLLM 在 2025 年接入了 xgrammar 库,把约束解码从实验性功能变成了生产级特性。它的做法值得一提:
- 提前编译:在部署时把 json schema / grammar 编译成 token-level 的转移表,推理时直接查表,不做运行时编译
- Tokenization-aware:不同 tokenizer 会分割同一段 JSON 的不同方式,xgrammar 对每个 tokenizer 独立预计算
- Batch-friendly:通过"压缩状态表示"让不同序列的约束状态可以用同一种数据结构管理
这些优化让约束解码的 overhead 从每次采样 10-15% 降到了 1-2%。
为什么 Agent 框架最后还是要做 post-processing
看到这里你可能疑惑:既然约束解码这么强,为什么 Agent 框架在拿到 tool call 之后还要做 JSON parse、做格式校验、做 retry?
因为约束解码只保证"语法正确",不保证"语义正确"。
一个典型的翻车场景:你的 tool 的 JSON schema 要求 date 字段是 "YYYY-MM-DD" 格式,约束解码确实能保证输出合法字符串,但它不会检查这个日期是否存在——"2026-02-30" 完美通过约束解码,但在实际调用时会直接崩掉。
所以生产级的 Agent 框架都是两层校验:
- 解码层:约束解码保证输出符合语法
- 应用层:应用逻辑校验保证数据合理
两者缺一不可。把责任全部交给约束解码,你会碰到奇怪的"合法但荒谬"的错误;把责任全部交给后处理,你会浪费大量 token 在"合法 JSON 但字段名拼错了"的调用上。
审美意义上的"正确做法"
约束解码给我的最大启发不是技术本身,而是一个更通用的工程原则:能在系统边界解决的问题,不要在系统内部解决。
JSON 格式错误本质上是"这个 token 根本就不该被生成",而不是"生成了再修复它"。约束解码选择在 token 生成的瞬间掐断问题,而不是等输出完整 JSON 再解析修复。这个"往前多想一步"的思路,在 Agent 系统的很多地方都值得借鉴——比如 tool 参数校验、状态转移合法性检查,都应该在入口处拦截,而不是在运行时崩溃。
这大概就是底层思路的暴力美学:与其擦屁股,不如别拉。
评论
发表评论