一篇读懂结构化输出:JSON Mode 与约束解码

它解决什么问题

把大模型接进程序,最先崩掉的不是能力,是格式。你让模型输出 JSON 给下游解析,它「基本会」,但总会偶发坏 JSON:多一个尾逗号、用单引号、字段名从 userName 漂成 user_name、兴致上来再给你包一层 ```json 代码围栏。json.loads 一抛异常,整条链路告警。

这类故障有多常见?所有把 LLM 用在生产数据链路的团队都踩过:抽取结果要入库、函数调用参数要路由到真 API、Agent 间靠 JSON 消息协作——每一环都假设上游结构可信,而上游恰恰是按概率吐 token 的模型。本专栏之前写过 Function Calling,讲的是协议层;协议定义了参数该长什么样,但「参数真的长这样」,属于输出结构约束的地盘,也就是本文的主角。

工程师的第一反应是祈祷式编程:「请输出合法 JSON」。这在提示词工程里属于许愿,不属于约束——模型按概率采样下一个 token,没有任何机制保证第 37 个 token 一定是引号。加上「解析失败就把报错贴回去重试」的补偿逻辑,系统看着能转,但每次重试都烧双倍 token 和延迟,失败率上限并没有被消除:1% 的失败率放到每天一百万次调用,就是一万次线上告警,而且你永远不知道下次坏在哪个字段。

结构化输出(structured output)要解决的就是这件事:把「输出格式」从概率问题变成确定性约束。这不是调参能解决的(采样参数的事,本专栏之前聊过 Temperature 与 Top-p),而是要在解码过程本身动手脚。

顺带看一眼真实的失败样本——同一个「输出用户信息」的请求,模型可能给出:

好的,以下是用户的 JSON:
{"name": "张三", "age": 28,}          <- 尾逗号,JSON 不允许
{'name': '张三', 'age': 28}           <- 单引号,JSON 只认双引号
{"user_name": "张三", "userAge": 28}  <- 字段名自作主张

还有一种最气人的:输出本身合法,外面却包了一层 ```json 代码围栏,模型觉得它是在聊天窗口里「展示」代码。每一种都能让 json.loads 当场去世,而且出现得随机——这正是「基本会」的可怕之处。

它怎么工作

三层方案光谱

按约束强度从弱到强,业界方案分三层:

第一层:提示词 + 重试。 在提示词里贴 schema、给 few-shot 示例,解析失败就把报错喂回去重试。便宜、任何模型都能用,但本质是 best effort:失败率随 schema 复杂度上升,嵌套一深、字段一多,漏字段、串类型的概率肉眼可见地涨。适合原型期,不适合当生产保障。

第二层:JSON Mode。 API 级开关,比如 OpenAI 的 response_format: {"type": "json_object"}。它保证输出一定可解析——不会再有尾逗号和代码围栏——但不保证符合你的 schema:字段可能缺失、类型可能不对。OpenAI 官方文档的对比很直白:两者都输出合法 JSON,但只有 structured outputs「adheres to schema」,并称它是 JSON mode 的演进,能用就用后者(截至 2026-10-07 的官方文档表述)。JSON Mode 解决的是「解析器不炸」,字段对不对还得自己兜。

第三层:结构化输出 / 约束解码。 OpenAI 2024 年 8 月 6 日发布 Structured Outputs:把 JSON Schema 和 strict: true 一起传给 API,输出保证符合 schema。官方博客的评测数字很有说服力:gpt-4o-2024-08-06 在他们的复杂 JSON schema 遵从测试中拿到 100%,而没做约束解码的旧模型 gpt-4-0613 不到 40%。更值得注意的是过程:他们先试过仅靠模型训练把成绩拉到 93%,仍然不够——93% 意味着每 14 次调用就有 1 次失败——最后是工程化的约束解码补上了最后一格。

logit 掩码:从概率上抹掉非法选项

约束解码的核心动作只有一步:每生成一个 token 之前,查一下当前状态下词表里哪些 token 是合法后继,把非法 token 的 logit 置为 −∞。softmax 时每一项取指数,−∞ 的指数精确为 0,于是非法 token 的概率被抹成零,采样只能在合法集合里进行。模型再「想」输出单引号,也输出不出来——不是被拦下重写,而是从源头就没有这个选项。

那么「当前状态下什么是合法的」由谁说了算?由把你的 JSON Schema 编译出的文法状态机:

flowchart LR
    A[JSON Schema] --> B[编译为文法状态机]
    L[模型 logits] --> M[每步解码]
    B --> M
    M --> N[非法 token 置负无穷]
    N --> P[概率归零 只剩合法候选]
    P --> Q[输出与 schema 完全一致]

这里有个容易想浅了的细节:FSM 还是 CFG? 正则、枚举这类简单结构可编译成有限状态机(FSM);但完整 JSON Schema 允许递归引用——对象里嵌数组、数组里再嵌对象,深度无上限。OpenAI 博客明确说明他们把 schema 编译成上下文无关文法(CFG),因为 FSM 表达不了递归类型。通行做法是「编译一次、解码全程查表」:请求开始时编译成状态机,之后每步只做查表,所以 OpenAI 文档提示首次使用某个 schema 有额外延迟,同一 schema 的后续请求不再有。

开源栈把这条路走得更彻底。Outlines(Willard & Louf 2023)是早期奠基:把输出文法编译成 FSM,逐 token 按状态掩码。XGrammar(MLSys 2025 论文)重点解决性能——每步对全词表跑文法分析开销太大,于是按文法上下文把词表分成两类:「上下文无关 token」提前批量预计算掩码,少数「上下文相关 token」运行时用持久栈判定,并让文法计算与 GPU 执行重叠。论文报告相对已有方案最高 100 倍加速,JSON 生成近零开销。如今它是 vLLM、SGLang、TensorRT-LLM、MLC-LLM 等主流引擎的共享后端——约束解码已从论文话题变成推理引擎的标配零件。

值得强调掩码与「事后校验 + 重试」的本质差异:校验只能发现问题,约束可以杜绝问题——前者是报警器,后者是物理防线。另一个误解是「掩码会破坏采样」:不会。温度、Top-p 照常工作,只是候选集先被文法筛过一遍,在合法集合内部模型仍按自己的分布做主。约束解码不改变模型「想说什么」,只改变它「能写出来什么」。

约 50 行玩具:亲手做一次字符级约束解码

原理清楚了,我们可以纯 Python 把整条链路模拟一遍。假设「模型」每步对词表输出一组随机 logits(真实模型只是把这组数换成神经网络的实际输出),我们负责 FSM 和掩码:

import json, math, random

VOCAB = list('{}":, ' + 'abcdefghijklmnopqrstuvwxyz0123456789') + ['<EOS>']

def allowed(state, buf):
    # FSM 转移表:{允许字符: 下一状态},只放行 {"name": <字母串>, "age": <数字>}
    if state == 'START':  return {'{': 'Q1'}
    if state == 'Q1':     return {'"': 'K1'}
    if state == 'K1':     # 逐字符匹配键名 "name"
        return {'name'[len(buf)]: 'K1'} if len(buf) < 4 else {'"': 'Q2'}
    if state == 'Q2':     return {':': 'Q3'}
    if state == 'Q3':     return {'"': 'V1'}
    if state == 'V1':     # 姓名值:1~8 个小写字母
        out = {c: 'V1' for c in 'abcdefghijklmnopqrstuvwxyz' if len(buf) < 8}
        if buf:
            out['"'] = 'Q4'
        return out
    if state == 'Q4':     return {',': 'Q5'}
    if state == 'Q5':     return {'"': 'K2'}
    if state == 'K2':     # 逐字符匹配键名 "age"
        return {'age'[len(buf)]: 'K2'} if len(buf) < 3 else {'"': 'Q6'}
    if state == 'Q6':     return {':': 'Q7'}
    if state == 'Q7':     # 年龄:1~3 位数字;首位为 0 则立即收尾(JSON 禁前导零)
        out = {d: 'Q7' for d in '0123456789' if len(buf) < 3}
        if buf == '0':
            out = {'}': 'END'}
        elif buf:
            out['}'] = 'END'
        return out
    if state == 'END':    return {'<EOS>': 'END'}
    raise ValueError(state)

def softmax(logits):
    m = max(x for x in logits if x != -math.inf)
    exps = [math.exp(x - m) for x in logits]
    s = sum(exps)
    return [e / s for e in exps]

def generate(seed):
    rng, state, buf, out = random.Random(seed), 'START', '', ''
    while True:
        legal = allowed(state, buf)
        logits = [rng.uniform(-1, 1) for _ in VOCAB]   # 假装这是模型给出的 logits
        for i, tok in enumerate(VOCAB):                # 约束解码核心:非法 token 掩码
            if tok not in legal:
                logits[i] = -math.inf                  # softmax 后概率精确为 0
        tok = VOCAB[rng.choices(range(len(VOCAB)), weights=softmax(logits))[0]]
        if tok == '<EOS>':
            return out
        out += tok
        prev, state = state, legal[tok]
        buf = buf + tok if state == prev and state in ('K1', 'K2', 'V1', 'Q7') else ''

for seed in range(3):
    s = generate(seed)
    print(s)
    assert set(json.loads(s)) == {'name', 'age'}

读代码时留意两处。其一,allowed 返回的字典就是「当前状态的合法 token 表」,generate 里除掩码循环外没有正则、没有事后校验,非法输出在概率层已经不存在。其二,buf 缓冲负责变长片段(键名逐字符匹配、长度上限),说明约束不仅能定形状,还能表达值域规则。本地运行输出(我跑了 5000 组采样,全部合法且符合 schema):

{"name":"vozgjwps","age":660}
{"name":"xwdxtxgt","age":559}
{"name":"rhdwlggx","age":223}

还有个彩蛋藏在 Q7 状态里:JSON 规定数字不能有前导零,"age": 052 是非法 JSON——这种语法细节连「生成完再正则校验」的粗方案都常常漏掉,而文法用一行特判就精确表达了。这正是「按 schema 约束」和「事后校验」的差距所在。

从玩具到生产,差的只是工程量,不是原理:把手写的 allowed 换成「JSON Schema 自动编译出的状态机」,把字符级词表换成真实 tokenizer 的十几万 token 词表,把随机 logits 换成模型前向输出的真实 logits,掩码与采样逻辑一模一样。Outlines 和 XGrammar 做的就是前两步;而它们被 vLLM、SGLang 内置后,你连这两步都不用碰——传个 schema 进请求就行。

工程实践

  • schema 设计:结构扁平优先,少用自由嵌套,深度和字段数都会放大掩码对生成质量的干扰;能枚举就别给自由字符串,enum 等于把开放题变成选择题;字段名自解释,重要的 key 加 description——模型也是「读着 schema 写 JSON」的。用 OpenAI strict 模式时,additionalProperties: false 且全字段列入 required 是标准写法;部分高级 JSON Schema 关键字不被支持,传上去前先对一遍官方支持清单。
  • 流式场景:等整份 JSON 流完再解析,等于把流式的延迟优势全吐回去。通行做法是部分 JSON 解析(partial JSON parsing):边流边把已闭合的字段交给前端渲染。自建解析器要小心字符串转义与数字截断的中间态,拿不准就用经过考验的现成实现。
  • 监控与评估:上了约束解码,解析失败率应当归零——若仍监控到解析报错,先怀疑 schema 传错或后端方言问题,而不是模型。反过来要新建「值正确率」指标:schema 合法但值错的样本,只能靠抽检评估集和业务断言发现。
  • provider 支持现状(截至 2026-10-07):OpenAI 的 structured outputs 覆盖 Responses、Chat Completions、Assistants、Batch、Fine-tuning 各 API,gpt-4o-2024-08-06 及之后的模型快照支持,但只支持 JSON Schema 的一个子集,安全拒答通过单独的 refusal 字段暴露以便程序识别;Gemini 走 responseMimeType: application/json 加 responseSchema(JSON Schema 子集),官方文档提醒「输出是语法正确的 JSON,但应用侧仍要校验值」;开源侧 vLLM 支持 JSON Schema / regex / EBNF / choice 约束,xgrammar 与 guidance 双后端(OpenAI 兼容接口旧 guided_* 参数自 v0.12.0 起移除,改用 structured_outputs 字段),SGLang 支持 json_schema / regex / ebnf 且默认 xgrammar 后端,llama.cpp 用自研 GBNF 文法并附带 json_schema_to_grammar.py 转换器。注意同一段约束在不同引擎上的方言不完全兼容,尤其 regex。

边界在哪

只保形状,不保内容。 100% 合规指 100% 符合 schema 的形状,不是 100% 正确的值。OpenAI 博客自己也挑明:不可能阻止所有模型错误,比如逻辑错误或错误的值。"age": -5 可以被 schema 挡住(加 minimum: 0),但该提取「张三」提取成「李四」、该有值的地方给了空串——这些约束解码无能为力,因为按 schema 它们可能都是「合法的」。内容正确性仍要靠提示词、评估集和业务校验来兜。

过度约束会伤质量。 掩码等于拿着鞭子抽模型走指定路线:模型可能被迫吐出本不会选的低概率 token,语义连贯性在长输出上悄悄劣化,深嵌套、长枚举、苛刻的字段顺序都会放大变形。编译延迟也随复杂度增长——OpenAI 说复杂 schema 首次编译可能到 1 分钟(一般 10 秒内),Gemini 文档则警告过大或过深的 schema 可能直接被拒。把 schema 砍到业务需要的最小形状,通常两头受益。

tokenizer 对齐是个真坑。 玩具按字符走 FSM,界限清楚;真实模型一次吐一个 token,可能横跨多个字符——「"na」合不合法,取决于键名匹配到哪一步,同一个 FSM 状态在不同 tokenizer 下的合法 token 集完全不同。XGrammar 的核心贡献之一就是这种 tokenizer 感知的文法变换与预计算。此外各后端 regex 方言不统一(vLLM 文档明示 xgrammar/guidance 用 Rust 风格 regex,lm-format-enforcer 用 Python 的 re),跨引擎复用约束要先查文档。

一句话收束:约束解码把「格式」这最后一公里铺成了水泥路,但路上装什么货,仍然是你和模型共同的功课。

参考资料

  1. Introducing Structured Outputs in the API — OpenAI
  2. Structured Outputs — OpenAI 官方文档
  3. XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models — arXiv:2411.15100
  4. XGrammar — GitHub
  5. Structured Outputs — vLLM 文档
  6. Efficient Guided Generation for Large Language Models — arXiv:2307.09702
  7. Structured output — Gemini API 官方文档
  8. llama.cpp grammars/json.gbnf — GitHub
← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?