18. JSON Mode 和 Constrained Decoding 是怎么实现的?
整理 Constrained Decoding 的核心机制、几种主流实现与工程取舍。
简单回答
Constrained Decoding 是在 LLM 解码每一步实时干预 token 概率分布,强制只允许"合法的下一个 token"被采样到,从而保证最终输出严格符合某种约束(JSON Schema、正则、context-free grammar 等)。实现上通常是在每步采样前根据当前已生成的部分推算"哪些 token 是合法的",把非法 token 的 logits 设成 -inf 再做 softmax。OpenAI 的 JSON Mode、Outlines、Guidance、xgrammar 等库都是这套思路的不同实现。和 Prompt 工程专题里讲的"Prompt 里说请输出 JSON"完全不同——后者只是软约束,constrained decoding 是硬保证。
详细解释
为什么单靠 Prompt 不够
最朴素的"结构化输出"做法是 Prompt 里写"请按以下 JSON 格式输出"。这种方式叫 soft constraint——靠模型自己理解和遵守。问题是模型不可能 100% 遵守。
常见失败模式:JSON 字段名拼错、缺逗号、字符串里出现没转义的引号、数字被加上不必要的引号变成字符串、最后多了一个逗号、嵌套层级错位。哪怕是 GPT-4 级别的模型,复杂 schema 上输出非法 JSON 的概率也有几个百分点。
工程上的应急方案是用 LLM 重试 + JSON parser 容错,但这会带来延迟和不确定性。在 Tool Calling、Agent、数据抽取这种对结构严格要求的场景里这个不确定性是不可接受的。
Constrained Decoding 的目标是把"输出符合结构"从概率事件变成确定性保证——只要解码完成,输出一定合法。
核心机制:每步 token mask
LLM 解码本质上是在词表上做采样:每步 forward 出一个 logits 向量(shape = vocab_size),softmax 后采样一个 token。Constrained Decoding 在 softmax 之前加一步——把"不合法"的 token 的 logits 设成 -inf,让它们的概率变成 0,自然不会被采样到。
伪代码大致是:
关键是 grammar.next_allowed_tokens(generated_so_far) 这一步——给定当前已经生成了什么,下一步合法的 token 集合是什么?这个判断依赖具体的约束形式。
不同约束类型的实现
正则表达式(regex)
把正则编译成 DFA(确定性有限自动机),跟踪当前所处的状态,每步问"DFA 在当前状态下能接受哪些字符"。对应到 token 级别要做一步映射——把 DFA 的字符级转移转成 token 级转移。这一步可能很复杂,因为 tokenizer 把多个字符合成一个 token,DFA 状态推进必须按 token 粒度走。
Outlines 等库会预先构造 vocab × state 的转移表(哪个 state 下哪些 token 合法),运行时只是查表,开销很小。
JSON Schema
JSON 比正则强一级(context-free grammar),需要保留一个解析栈跟踪嵌套结构(当前在哪个对象、哪个字段、哪种类型)。每步根据栈顶状态推断下一个合法 token。
复杂的 Schema(带 oneOf、anyOf、enum)需要更精细的状态管理。但本质思路一样——把 schema 编译成自动机,运行时按 token 推进状态。
Context-Free Grammar
更通用的形式是直接用 BNF / EBNF 描述语法(SQL、代码、专用 DSL)。Guidance、xgrammar 等库支持用户自定义 CFG。底层用 Earley 或 LL 解析器跟踪可能的状态集,每步算合法 token。
几个主流实现的差异
OpenAI JSON Mode:API 级别的 response_format: {type: "json_object"} 保证输出是合法 JSON,但不强制具体 schema。response_format: {type: "json_schema", schema: ...}(Structured Outputs,2024 年发布)能强制具体 schema。OpenAI 的实现细节没公开但效果上和 constrained decoding 一致——文档承诺"100% 符合 schema"。
Outlines:开源库,把 regex 和 JSON Schema 都编译成 FSM(有限状态机),运行时查表 mask。性能很好,是目前开源生态最成熟的方案之一。
Guidance(Microsoft):更通用,支持模板嵌入约束、控制流(条件、循环)、多模型协作。表达能力强但学习曲线高。
xgrammar(CMU):专门优化 CFG 解析效率的库,比 Outlines 更快,集成进了 vLLM 等推理框架作为 grammar 后端。它解决的核心问题是 vocab × state 转移表对大词表(128K)会爆炸,xgrammar 用各种压缩技巧让查询保持低延迟。
Llama.cpp 的 GBNF:在 llama.cpp 里直接支持 BNF 语法约束,社区用得很广。
性能开销
Constrained Decoding 不是免费的。开销主要来自三块:
Mask 构造:每步要算"下一步合法 token 集合"。对正则和 JSON 这种规则化的,预编译表后查询是 O(1) 或近 O(1)。对复杂 CFG 可能是 O(states × vocab),每步几毫秒。这部分如果实现不好会成为瓶颈。
Logits mask 应用:把 mask 应用到 logits 向量上。一次 element-wise 操作,开销很小。
Beam Search 兼容:纯贪心或 sampling 解码下 mask 简单。Beam Search 时每条 beam 的 grammar 状态独立要分别维护,复杂度上升。
实测上良好实现的 constrained decoding 增加的端到端延迟在 5%-15% 之间,可以接受。早期的实现(比如 Outlines 0.x 版本)开销大得多,能让吞吐量减半,所以工程上要用维护好的库。
隐藏的副作用
输出质量可能下降
强制 mask 改变了模型的概率分布。如果模型本来想生成的 token 不在 allowed 集合里,被强制走第二选择,可能引入次优 token——表现为模型"硬挤出来"的结果不如自由生成的自然。
最典型的是 JSON Mode 下模型可能强行编造 schema 要求但模型不知道的字段(schema 要求 email 字段但用户没提到 email,模型会编一个)。这是 hard constraint 带来的副作用——生成内容必须符合 schema,但内容的真实性 constrained decoding 管不了。
Token 边界错位问题
LLM 的 token 不一定按字符边界对齐——有些 token 是 "}\n " 这种含换行和缩进的混合 token。constrained decoding 要在 token 级别决定合法性,但约束(比如正则)是字符级的。token 级 mask 可能把"看起来正常"的合法字符序列禁掉,因为它要走 char-by-char 的某些 token 不可达。这个问题各家库的解决方式不同,叫 token healing 的技巧是一种主流方案。
应用场景
Tool Calling / Function Calling:参数结构必须严格符合 schema,constrained decoding 能 100% 保证 tool_call 参数合法。OpenAI 的 strict mode 工具调用就是这么实现的。
数据抽取:从非结构化文本里抽出结构化信息(人名、日期、关系),输出必须符合 schema。
代码生成:约束输出符合编程语言语法(Python AST、SQL 语法),降低生成代码的语法错误率。
Agent 决策:Agent 输出动作必须符合预定义的动作 schema,避免 Agent 输出无效动作。
面试时可以这样答
Constrained Decoding 的核心机制是在 LLM 每步采样前实时计算"哪些 token 是合法的下一个 token",把非法 token 的 logits 设成 -inf,softmax 后概率变成 0 自然采样不到。这样最终输出严格符合约束,是硬保证不是 prompt 那种软约束。
不同约束类型实现不同。正则编译成 DFA 跟踪状态。JSON Schema 用解析栈跟踪嵌套结构。通用语法用 CFG 加 Earley 或 LL 解析器。每种都是"约束 → 自动机 → 每步查询合法 token 集"的套路。
主流实现里 OpenAI Structured Outputs 是 API 级方案,开源生态里 Outlines、Guidance、xgrammar 用得最多。xgrammar 是性能优化的代表,集成进 vLLM 作为 grammar 后端。
性能开销主要在 mask 构造——预编译表查询基本是 O(1),复杂 CFG 是几毫秒级。良好实现的端到端延迟开销 5-15%。早期实现开销可能大得多。
副作用要注意。一是输出质量可能下降——模型本来想生成的 token 被禁掉就走第二选择,硬挤出来的内容不一定最优。最典型是 JSON Mode 下 schema 要求字段但模型不知道,会编造内容。二是 token 边界错位——token 不按字符对齐,token 级 mask 可能禁掉合法字符序列,要靠 token healing 之类的技巧处理。
适用场景包括 Tool Calling 参数严格 schema、数据抽取、代码生成语法约束、Agent 动作 schema。Tool Calling 的 strict mode 就是这么实现的。
常见追问
- Token healing 具体是什么?解决什么 case?
- Beam Search 配合 constrained decoding 有什么特殊处理?
- 流式输出(streaming)能用 constrained decoding 吗?怎么和 SSE 配合?