约束解码:LLM 输出格式化的底层暴力美学

约束解码不是"用 prompt 让模型输出 JSON",而是在 token 生成过程中直接屏蔽非法 token——逻辑上极其简单,工程上极其暴力。

每次写 Agent 工具调用时,你有没有想过一个问题:LLM 到底是怎么保证输出的 JSON 是合法的?

大部分人第一反应是"prompt 告诉它输出 JSON 格式就行了"。但用过 GPT-3.5 时代的人都知道,prompt 说一百遍也不管用——模型偶尔还是会在 JSON 末尾接一句"希望这些信息对你有帮助"。

今天的 LLM 之所以能稳定输出结构化数据,靠的是一套跟 prompt 完全无关的机制:约束解码(Constrained Decoding)。它在 token 生成的最底层拦截非法输出,原理简单到让你觉得像作弊。

logits 层面的"红绿灯"

LLM 生成下一个 token 的过程分两步:

  1. 模型前向传播,输出一个概率分布 —— 每个候选 token 对应一个 logit 值
  2. 采样策略(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 也从简单到复杂:

  1. JSON 约束:最简单的场景。定义一个 JSON 的上下文无关文法(CFG),构建一个接受合法 JSON 的 DFA。tokenizer 的分词粒度导致每个合法字符组合要映射到 token ID 集合——"这是一个 O(#tokens) 的表查询"。

  2. JSON Schema 约束:比纯 JSON 更严格。不仅要求格式合法,还要求字段名、类型、取值范围符合预定义的 schema。比如 age 字段必须是整数且在 0-150 之间。这需要 FSM 的状态跟踪你当前在哪个字段、是什么类型。

  3. 正则约束:你可以说"输出必须匹配 ^[A-Z][a-z]+ [A-Z][a-z]+$"。FSM 会把它编译成 NFA 再转 DFA,然后在解码时逐 token 匹配。

  4. 上下文敏感约束(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 库,把约束解码从实验性功能变成了生产级特性。它的做法值得一提:

  1. 提前编译:在部署时把 json schema / grammar 编译成 token-level 的转移表,推理时直接查表,不做运行时编译
  2. Tokenization-aware:不同 tokenizer 会分割同一段 JSON 的不同方式,xgrammar 对每个 tokenizer 独立预计算
  3. 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 参数校验、状态转移合法性检查,都应该在入口处拦截,而不是在运行时崩溃。

这大概就是底层思路的暴力美学:与其擦屁股,不如别拉。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个