让 LLM 生成一个 JSON 对象,它最终一定会输出一个末尾逗号、字符串里未转义的换行符,或者在一个本该有引号键的位置输出 bare word。这个错误不是模型的 bug。它是自回归采样工作方式的必然结果:在每一步,模型都会把自己的词表里每个 token 都当作候选,包括那些会让部分输出在语法上 invalid 的 token。
你可以通过约束 token 词表本身来修复这个问题。与其让模型从三万多个 token 中选择,不如只给它那个能把部分解析保持为合法状态的子集。这项技术叫做 grammar-constrained decoding,它把 LLM 从概率文本生成器变成语法感知的代码合成器。
为什么提示工程无法解决语法正确性
大多数开发者都会从显而易见的办法入手:在系统提示里加上 “Output valid JSON only”,在用户消息里重复一遍 schema,然后祈祷。这有一定帮助,但不是保证。
模型在生成过程中并不会解析自己的输出。它基于以提示和已生成内容为条件的、整个词表上的概率分布来预测下一个 token。像 , 这样的 token 在右花括号之后可能有很高的概率,因为训练数据里逗号经常出现在这个位置。但这个逗号在这个特定 JSON 对象的这个精确位置上是否语法合法,并不在模型的决策过程之中。
即便是经过 RLHF 微调过的模型,也只是把概率质量向通常正确的模式偏移。它们并没有消除非法路径。如果你要的是保证,而不是高概率,那就必须改变采样机制本身。
语法约束解码如何工作
核心思路很简单:在 LLM 生成的同时维护一个解析器状态,在每个 token 位置,把会把解析器带入错误状态的 token 全部屏蔽掉。
在步骤 t,模型输出一个覆盖整个词表的 logits 向量。通常你会施加 temperature scaling 然后采样。使用约束解码时,你先问语法引擎:给定目前已生成的 token,接下来哪些 token 是合法的?引擎返回一个词表上的 bitmask。你把非法 token 的 logits 设为负无穷,在剩余部分上做 softmax,然后从过滤后的分布中采样。
语法引擎不需要在生成结束后重新解析完整输出。它是增量解析的。每接受一个 token,就更新内部的状态机。当解析器到达接受状态时,生成可以停止;当处于非接受状态时,生成继续。
这意味着模型仍然可以在所有语法合法的续写中自由选择。它在语法内部保留创造力,只是不能越界。
实现长什么样
下面是一个简化的 Python 草图,使用现代解析库 lark 来构建语法掩码。在生产环境中你会用更快的引擎,比如 outlines、guidance 或 llama.cpp 内置的 GBNF 支持,但原理完全相同。
from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# Define a tiny grammar for a simple config DSL.
grammar = r"""
start: pair+
pair: KEY "=" VALUE
KEY: /[a-z_]+/
VALUE: /"[^"]*"/
"""
parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
def legal_next_tokens(partial_text: str) -> set[int]:
"""Return token IDs that do not cause a parse error."""
legal = set()
for token_id in range(tokenizer.vocab_size):
candidate = tokenizer.decode([token_id], skip_special_tokens=True)
try:
# Try parsing partial_text + candidate as a prefix.
parser.parse(partial_text + candidate)
legal.add(token_id)
except Exception:
# Some exceptions are expected mid-parse.
# A production engine tracks parser state, not exceptions.
pass
return legal
# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()
for _ in range(50):
outputs = model(generated)
logits = outputs.logits[:, -1, :]
partial = tokenizer.decode(generated[0], skip_special_tokens=True)
legal_ids = legal_next_tokens(partial)
mask = torch.full_like(logits, float("-inf"))
for tid in legal_ids:
mask[0, tid] = logits[0, tid]
next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
generated = torch.cat([generated, next_token], dim=-1)
# Stop if the parser is in an accepting state.
if parser.parse(partial):
break
print(tokenizer.decode(generated[0], skip_special_tokens=True))
这是一个朴素实现。真实系统不会在每一步都遍历整个词表。它会提前从语法构建有限状态自动机,并预先计算从每个自动机状态出发哪些词表 token 是合法的。生成时,掩码就是一次查表。
约束解码在有界上下文中的位置
领域驱动设计中的有界上下文由它的通用语言定义。这种语言通常被形式化为一种小型 DSL:查询语法、规则语法、配置格式或表达式语言。当你要求 LLM 在该上下文内生成或编辑工件时,你希望它正确地讲这门语言。
语法约束强制执行边界。LLM 不能发明 DSL 中不存在的字段,不能输出不匹配的括号,也不能产生允许枚举集合之外的值。语法变成编译期保证,而不是运行时祈祷。
这一点在 LLM 输出直接喂给解析器或解释器时尤其有价值。如果你的 DSL 由严格的递归下降解析器来解析,一个 unexpected token 就会让整个管道崩溃。约束解码消除了这种失败模式。
你应该了解的权衡
约束解码不是免费的。开销取决于你如何实现语法引擎。
吞吐量。 每次前向传播都构建掩码会增加延迟。outlines 或支持 GBNF 的 llama.cpp 等快速引擎会预先计算 token 到状态的映射,把每步开销降到大约 5-10%。每一步都对每个候选 token 重新解析的朴素实现会让生成速度慢上几个数量级。
语法表达力。 并不是每种语法都能轻松表达为能干净映射到 token 掩码的语法。上下文敏感规则,比如”这个标识符必须在作用域中更早声明”,标准上下文无关文法无法捕捉。你可以用文法约束语法,但没有额外机制就约束不了语义。
模型兼容性。 某些推理 API,尤其是托管云 API,不暴露 logits,也不允许自定义掩码。OpenAI 的 API 提供 JSON mode,这是专门针对 JSON 的硬编码语法约束,但你不能自带文法。对于自定义 DSL,通常需要本地推理,或者使用暴露 logits 的框架。
部分 token 问题。 token 边界并不总是与语法边界对齐。语法可能期望一个引号字符串,但下一个 token 可能是 "hel 后跟 lo"。语法引擎必须能对部分 token 匹配进行推理,而不仅仅是完整 token。生产库通过把 token 映射到字符前缀并检查前缀对语法的合法性来处理这个问题。
今天如何真正落地实现
你不需要从零写语法引擎。好几个库已经处理了难点。
outlines 对 Python 用户最友好。你定义一个 Pydantic 模型或正则表达式,它就把约束编译成高效的有限状态自动机,与 Hugging Face transformers 和 vLLM 集成。
from outlines import models, generate
model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")
guidance 来自微软,提供更丰富的模板系统。你把语法约束和提示模板交错在一起,它内部处理掩码。
llama.cpp 支持 GBNF(GGML BNF),一种类 BNF 的语法格式。你在推理时传入一个 .gbnf 文件,引擎在 C++ 层面强制执行。这是本地推理的最快选项。
jsonformer 和 instructor 是专门针对 JSON 的轻量级替代方案。它们使用类语法约束,但仅限于 JSON schema。如果你的有界上下文 DSL 恰好是 JSON 形状,它们是最简单的起点。
约束解码修不了什么
受语法约束的 LLM 仍然可能生成语义错误的输出。它可以 emit 一条引用了不存在的表的有效 SQL 查询,或者一条设置了无效端口号的有效配置。语法是必要护栏,但不是充分护栏。
你仍然需要在 LLM 之下保留验证层。解析输出,针对领域模型做类型检查,如果语义错误就拒绝或重试。约束解码把失败模式从”语法错误”收窄到”逻辑错误”。这是很大进步,但不是免死金牌。
而且,语法约束并不会让模型更聪明。如果语法太宽松,模型仍然可能游荡到无意义但语法合法的领域。如果语法太严格,模型没有合法路径来表达正确答案,你就会得到低概率垃圾或重复循环。设计语法是工程工作的一部分。
从输出格式开始,而不是从模型开始
在你微调提示词或者换更大的模型之前,先问清楚问题到底是推理还是语法。如果 LLM 理解你想要什么,只是偶尔格式不对,语法约束就是正确的修复方案。它比微调便宜,比提示工程可靠,而且能给出纯采样给不了的保证。
定义好你的有界上下文 DSL 的语法,把它接入约束解码器,让模型在框框里生成。输出偶尔还是会让你意外,但永远不会是语法错误。
FAQ
什么是语法约束解码?
语法约束解码是一种在每一步生成中使用形式文法过滤 LLM 词表的技术。只考虑保持部分输出语法合法的 token,从而保证最终输出符合文法。
约束解码会降低输出质量吗?
对于受语法约束的任务不会。模型仍然可以在所有语法合法的续写中自由选择。对于开放式创意写作,约束会损害质量。对于代码、配置或 DSL 生成,约束同时提高正确性和可靠性。
我能在 OpenAI 或 Claude API 上使用这个吗?
OpenAI 提供 JSON mode,这是专门针对 JSON 的内置语法约束,不能自带文法。Anthropic 目前不暴露语法约束。对于自定义 DSL,通常需要本地推理,使用 vLLM、llama.cpp 或类似引擎。
支持哪些语法格式?
常见格式包括 EBNF、BNF、PEG 和 GBNF。outlines 等库接受正则表达式和 Pydantic 模型。llama.cpp 使用 GBNF。选择你的推理引擎支持的格式。