← 学习笔记

结构化输出是怎么做到的

LLM 的输入不过是一段提示词。凭什么它吐出来的 JSON 一定是合法的?
答案有点反直觉:根本不靠提示词。

一、你以为的地方,和真正起作用的地方

大多数人的心智模型是这样的:提示词进去 → 文字出来。既然只有提示词能控制它,那「请返回 JSON」就是你唯一的手段。

但生成过程里还有第二个你能插手的地方——采样器。模型每一步吐出来的不是一个词,而是整个词表的分数,选哪个是采样器决定的。

环节你能做什么能保证格式吗
提示词请求、示例、威胁、加感叹号不能,只是在祈祷
采样器把会导致非法的 token 分数改成 −∞能,物理上生不出错

被改成 −∞ 的 token,softmax 之后概率恒等于 0

所以不是「模型很听话」,是模型想不听话也没有那个选项。这个技术叫约束解码(constrained decoding)

二、亲眼看一遍

下面这个演示,左边是模型每一步真实的倾向,右边是最终输出。把开关拨到「约束关」,你会看到它第一步就想输出 markdown 围栏——这正是「请返回 JSON」失败的真相。

逐 token 看掩码

目标 Schema:{"name": string, "age": integer}

模型此刻的候选(按分数排)
语法状态
等待开始
已生成
第 0 步

注意第 1 步。约束关的时候,模型最想输出的是 ```json(分数 6.2),远高于 { 的 2.0。

约束开了之后,```json 的概率是 0%——不是「概率很低」,是取不到

三、三个层次,差别在哪

「让模型输出 JSON」有三种做法,保证力度天差地别:

做法怎么实现保证什么不保证什么
提示词里写「请返回 JSON」 纯文本请求 什么都不保证 围栏、前言、字段漂移、截断
JSON Mode 约束解码,文法 = 「合法 JSON」 一定能 JSON.parse 字段可能缺、可能多、类型可能错
Structured Outputs 约束解码,文法 = 你给的 Schema 严格符合 Schema 内容对不对(那是模型能力问题)

一句话记住:JSON Mode 保证「能 parse」,Structured Outputs 保证「能用」。

那 JSON Schema 算哪一层?

Schema 不是一种做法,是一份规格说明。它同时被三个地方消费:

四、Schema 是怎么变成「文法」的

约束解码需要知道「此刻哪些 token 合法」。这个判断来自把 Schema 编译成状态机。拿上面那个 Schema 举例,编译结果是一串「段」:

// {"name": string, "age": integer}  编译后
[0] LIT  '{"name":"'      ← 固定字面量,模型一个字的自由都没有
[1] VAR  字符串正文        ← 这里才有自由
[2] LIT  '","age":'       ← 又是固定
[3] VAR  数字
[4] LIT  '}'

状态 = (第几段, 段内第几个字符)。「下一个 token 允许是什么」由状态唯一决定——和模型想输出什么毫无关系,是纯粹的确定性计算。

看出来了吗——整段输出里,大部分位置根本轮不到模型决定。它只在 VAR 段有选择权。

因为这个判断是确定性的,转移表可以预先算好,所以约束解码在推理时几乎零额外开销

这也解释了那两条奇怪的限制

用过 OpenAI Structured Outputs 的都被这两条卡过:

原因就在这儿:字段可选 = 每个位置都要分支,字段可多 = 状态无穷。文法会爆炸,编不出高效的状态机。
想表达「这个字段可能没有」,正确做法是允许它为 null,而不是让它可缺失。

五、真实代码

OpenAI · Structured Outputs

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "order",
            "strict": True,          # ← 关键。不写就退化成 JSON Mode
            "schema": {
                "type": "object",
                "properties": {
                    "order_id": {"type": "string"},
                    "amount":   {"type": "number"},
                    "status":   {"type": "string",
                                  "enum": ["paid", "refunded"]},
                    "note":     {"type": ["string", "null"]}  # 用 null 表达「可能没有」
                },
                "required": ["order_id", "amount", "status", "note"],
                "additionalProperties": False
            }
        }
    })

本地模型 · 自己写掩码

用 HuggingFace 的话,约束就是一个 LogitsProcessor——整个机制其实就是那一行 -inf

class GrammarMask(LogitsProcessor):
    def __call__(self, input_ids, scores):
        allowed = self.fsm.allowed_tokens(input_ids[0])   # 文法说了算
        mask = torch.full_like(scores, float("-inf"))
        mask[0, allowed] = scores[0, allowed]             # 只留合法的
        return mask                                      # ← 全部秘密就在这

model.generate(**inputs, logits_processor=[GrammarMask(fsm)])

现成的轮子:OutlinesXGrammarllama.cpp 的 GBNFvLLM 的 guided_json。原理都是上面这套。

六、五个坑

说明
Schema 越复杂,内容质量越差 深嵌套、大量 oneOf 会让模型困惑。格式对了不等于内容对。能扁平就扁平。
被截断不是格式问题 撞上 max_tokens 会得到半个 JSON。先看 finish_reason——是 length 就该加长度,重试多少次都一样断。
流式输出拿不到完整 JSON 边流边收的是不完整的 JSON 片段,要么等收完再 parse,要么用增量 JSON 解析器。
模型可以拒答 触发安全策略时返回的是 refusal 字段,不是你的 Schema。这条分支必须处理。
拿不到 token 级控制就做不了 只给你最终文本的 API 没法做约束解码。自己跑模型才能自定义文法。

最后一条铁律:Schema 保证的是「形状」,不是「真假」。

{"order_id": "12345", "amount": 999} 完全符合 Schema——但这个订单号可能是编的
结构化输出替你省掉了解析和格式校验,业务校验和权限检查一步都不能少