LLM 结构化输出与约束解码深度实战:从 JSON Mode 到语法级生成控制
一、从一次线上事故说起
一个周五晚上,我们的 Agent 平台开始批量报错:json.JSONDecodeError: Expecting ',' delimiter。排查后发现问题不在服务端,而在模型输出——某次工具调用返回的 JSON 里,模型在字符串字段中写了一句 He said "OK", then left,双引号没有转义,整个结构直接崩掉。
更隐蔽的是"半结构化"失败:模型输出的 JSON 语法完全合法,但字段名拼成了 user_name 而不是约定的 userName,下游 Pydantic 校验照样抛异常。这类问题在 Agent 场景下会被放大——一次解析失败意味着一轮工具调用作废,重试既烧钱又增加延迟。
这就是结构化输出(Structured Output)要解决的问题:让模型的输出空间从"任意 token 序列"收缩到"符合某个模式的 token 序列"。而实现这件事最彻底的手段,是约束解码(Constrained Decoding)。
二、四个层次,可靠性与代价的权衡
工程上常见的方案可以分为四层,越往下越可靠,也越依赖底层能力:
| 层次 | 手段 | 语法保证 | 典型失败率 | 代价 |
|---|---|---|---|---|
| L1 | Prompt 约束 + 重试 | 无 | 5%~15% | 延迟波动大、成本翻倍 |
| L2 | 函数调用 / Tool Calling | 弱(依赖模型对齐) | 1%~5% | 受限于厂商支持 |
| L3 | JSON Mode / Response Schema | 语法有保证,语义部分保证 | 0.1%~1% | 需厂商支持 |
| L4 | 语法级约束解码(GBNF/XGrammar) | 语法 100% 保证 | ~0 | 需自持推理栈 |
很多团队的直觉是"写好 prompt 就行",但概率模型的本质是长尾不可控:哪怕单 token 出错率只有 0.1%,一段 500 token 的 JSON 里出现至少一次语法错误也是大概率事件。随着输出变长、结构变复杂(嵌套数组、枚举、正则字段),L1/L2 的失败率会快速上升。
我的判断是:只要输出需要被程序解析,就应该上 L3;只要你在自持推理栈(vLLM / SGLang / llama.cpp),就应该上 L4。
三、约束解码的核心机制
约束解码的思想其实很朴素:在每一步解码时,把不符合目标语法的 token 的 logits 置为 -inf,让它们在 softmax 之后概率归零。
# 最朴素的实现(示意,未考虑 token 边界问题)
def constrained_step(logits, vocab, accepted_token_ids):
mask = torch.full_like(logits, float("-inf"))
mask[accepted_token_ids] = 0.0
return logits + mask
真正的难点在于怎么算出 accepted_token_ids,而且要在几十毫秒内算完,否则吞吐会崩。
3.1 天真的做法:字符串前缀过滤
最直觉的实现是:维护一个已生成的部分输出,筛选出所有"接上去之后仍是目标语法合法前缀"的 token。
def naive_filter(partial: str, vocab: list[str]) -> list[int]:
allowed = []
for tid, tok in enumerate(vocab):
if is_valid_prefix(partial + tok): # 每次都要跑一遍解析器
allowed.append(tid)
return allowed
这个方案有两个致命问题:
- 复杂度爆炸:词表通常 32k~150k,每步都要对每个候选跑一次语法校验,单步耗时从毫秒级涨到秒级。
- 语义错误:
is_valid_prefix用字符级解析器判断,但 tokenizer 的 token 是多字节片段。一个 token 可能横跨{"na这样的边界,甚至包含半个 UTF-8 字符(Llama 3 的 byte-fallback tokenizer 尤其明显)。字符级判断在 token 边界上会给出错误结论——要么误杀合法 token,要么放过非法 token。
3.2 XGrammar 的做法:下推自动机 + 持久化栈
XGrammar(SGLang、vLLM 目前的主力后端)的解法是先把上下文无关文法编译成下推自动机(PDA),再做两件事:
其一,token 表预扫描。 离线阶段对词表中每个 token 做一次"这个 token 在当前 PDA 状态下能否被完整接受"的判定,把结果缓存成位图。运行时只需做位图交集,把 O(|V|) 的语法校验摊平成 O(1) 的内存操作。
其二,持久化栈 + 前缀合并。 多个候选状态共享同一个 PDA 栈前缀时,用不可变数据结构(persistent stack)复用,避免深拷贝;对一批请求做并行掩码生成时,把相同前缀的状态合并处理。官方基准显示,这一切下来结构化生成的额外开销可以压到 1% 以内,而朴素实现往往是 10x 级别的吞吐损失。
理解这一点对工程决策很关键:约束解码不是免费的,但它是"用确定的 CPU 开销换掉不确定的重试开销",而后者在长尾上要贵得多。
四、实战:三种落地方式
4.1 用 Response Schema(托管服务,最快见效)
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
class ToolCall(BaseModel):
name: Literal["search", "calculator", "none"]
query: str = Field(description="搜索关键词,calculator 时为空串")
confidence: float = Field(ge=0.0, le=1.0)
client = OpenAI()
resp = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[{"role": "user", "content": "北京明天适合穿什么?"}],
text_format=ToolCall,
)
call = resp.output_parsed # 已是 ToolCall 实例,无需自己 parse
注意 ge/le 这类约束属于语义约束,多数厂商只保证 JSON 语法和字段类型,不保证取值范围。取值范围仍需你在业务侧兜底,或自己写 grammar。
4.2 llama.cpp 的 GBNF(本地部署 / 边缘场景)
GBNF 是 llama.cpp 使用的 EBNF 变体,用起来就是手写一段文法:
root ::= object
object ::= "{" ws "\"name\"" ws ":" ws string "," ws "\"query\"" ws ":" ws string "," ws "\"confidence\"" ws ":" ws number "}" ws
string ::= "\"" char* "\""
char ::= [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4})
number ::= "-"? ([0-9] | [1-9][0-9]*) ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
ws ::= ([ \t\n] ws)?
启动时挂载即可:
llama-server -m model.gguf --grammar-file tool_call.gbnf
GBNF 的优点是确定性强、完全离线可控;缺点是要手写文法,正则字段(比如邮箱、日期)得自己展开成产生式。实践中我一般写个脚本从 JSON Schema 自动生成 GBNF,避免手误。
4.3 自持推理栈:vLLM + XGrammar
from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams
llm = LLM(model="Qwen/Qwen2.5-7B-Instruct", guided_decoding_backend="xgrammar")
schema = {
"type": "object",
"properties": {
"name": {"enum": ["search", "calculator", "none"]},
"query": {"type": "string"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["name", "query", "confidence"],
}
sp = SamplingParams(
temperature=0,
max_tokens=256,
structured_outputs=StructuredOutputsParams(json=schema),
)
out = llm.chat([{"role": "user", "content": "北京明天适合穿什么?"}], sp)
print(out[0].outputs[0].text) # 一定是合法 JSON,无需重试
这里有个容易踩的坑:guided_decoding_backend 在部分版本默认不是 XGrammar,而是 Outlines 的 FSM 后端。后者对小 schema 够用,但遇到复杂嵌套或大规模并发时吞吐差很多。上线前务必显式指定并压测。
五、五个工程陷阱
1. tokenizer 边界与多字节字符。 中文场景下尤其明显:一个汉字可能被切成多个 byte token,若你的掩码逻辑按字符判断,会误杀。务必使用基于 token 预扫描的后端,而非自己写的字符级过滤器。
2. 文法越严格,退化风险越高。 约束过强时,模型可能在某个位置"卡住"——所有高概率 token 都被屏蔽,只能选择一个极低频 continuation,表现为输出重复或语义崩坏。建议:枚举字段用 enum 而不是自由文本 + 正则;给字符串字段留出足够自由度。
3. JSON 转义仍要按字符串处理。 约束解码保证的是"外层结构合法",但字符串内容里的换行、引号由文法中的 char 产生式兜住。如果你自定义 grammar 时把 char 写得太松(比如 [^"]*),仍可能产出非法 JSON。
4. 别把 temperature 设成 0.8 再抱怨不稳定。 结构化输出场景下我默认用 temperature=0 或 0.2。约束解码解决的是语法,不是随机性——字段取值仍然会抖。
5. 不是所有场景都该上约束。 写文章、做摘要、创意生成这类自由文本任务,约束解码只会限制表达。判断标准很简单:输出要不要被 json.loads() 吃进去? 要,就上;不要,就别上。
六、一点判断
结构化输出在过去两年完成了从"prompt 技巧"到"基础设施能力"的转变。我观察到的趋势是:它正在成为 Agent 框架的默认底座——工具调用、多步规划、状态机编排,全都依赖"输出一定可解析"这个前提。
对自持推理栈的团队来说,约束解码已经是必选项而非加分项:它把一次 3 秒的重试变成 30 毫秒的掩码计算,在批量 Agent 场景下这是数量级的成本差异。而对使用托管 API 的团队,至少要把 Response Schema 用起来,并在业务侧保留 schema 校验和有限次重试——不要信任任何未经校验的模型输出,包括被约束过的。
真正的健壮系统,从来不是单点保证,而是约束解码 + 校验 + 重试的三层防御。

发表评论 取消回复