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)。

二、四个层次,可靠性与代价的权衡

工程上常见的方案可以分为四层,越往下越可靠,也越依赖底层能力:

层次手段语法保证典型失败率代价
L1Prompt 约束 + 重试无5%~15%延迟波动大、成本翻倍
L2函数调用 / Tool Calling弱(依赖模型对齐)1%~5%受限于厂商支持
L3JSON 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

这个方案有两个致命问题:

  1. 复杂度爆炸:词表通常 32k~150k,每步都要对每个候选跑一次语法校验,单步耗时从毫秒级涨到秒级。
  2. 语义错误: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 校验和有限次重试——不要信任任何未经校验的模型输出,包括被约束过的。

真正的健壮系统,从来不是单点保证,而是约束解码 + 校验 + 重试的三层防御。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部