LLM 的输入不过是一段提示词。凭什么它吐出来的 JSON 一定是合法的?
答案有点反直觉:根本不靠提示词。
大多数人的心智模型是这样的:提示词进去 → 文字出来。既然只有提示词能控制它,那「请返回 JSON」就是你唯一的手段。
但生成过程里还有第二个你能插手的地方——采样器。模型每一步吐出来的不是一个词,而是整个词表的分数,选哪个是采样器决定的。
| 环节 | 你能做什么 | 能保证格式吗 |
|---|---|---|
| 提示词 | 请求、示例、威胁、加感叹号 | 不能,只是在祈祷 |
| 采样器 | 把会导致非法的 token 分数改成 −∞ | 能,物理上生不出错 |
被改成 −∞ 的 token,softmax 之后概率恒等于 0。
所以不是「模型很听话」,是模型想不听话也没有那个选项。这个技术叫约束解码(constrained decoding)。
下面这个演示,左边是模型每一步真实的倾向,右边是最终输出。把开关拨到「约束关」,你会看到它第一步就想输出 markdown 围栏——这正是「请返回 JSON」失败的真相。
目标 Schema:{"name": string, "age": integer}
注意第 1 步。约束关的时候,模型最想输出的是 ```json(分数 6.2),远高于 { 的 2.0。
约束开了之后,```json 的概率是 0%——不是「概率很低」,是取不到。
「让模型输出 JSON」有三种做法,保证力度天差地别:
| 做法 | 怎么实现 | 保证什么 | 不保证什么 |
|---|---|---|---|
| 提示词里写「请返回 JSON」 | 纯文本请求 | 什么都不保证 | 围栏、前言、字段漂移、截断 |
| JSON Mode | 约束解码,文法 = 「合法 JSON」 | 一定能 JSON.parse |
字段可能缺、可能多、类型可能错 |
| Structured Outputs | 约束解码,文法 = 你给的 Schema | 严格符合 Schema | 内容对不对(那是模型能力问题) |
一句话记住:JSON Mode 保证「能 parse」,Structured Outputs 保证「能用」。
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 的都被这两条卡过:
requiredadditionalProperties: false原因就在这儿:字段可选 = 每个位置都要分支,字段可多 = 状态无穷。文法会爆炸,编不出高效的状态机。
想表达「这个字段可能没有」,正确做法是允许它为 null,而不是让它可缺失。
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)])
现成的轮子:Outlines、XGrammar、llama.cpp 的 GBNF、vLLM 的 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——但这个订单号可能是编的。
结构化输出替你省掉了解析和格式校验,业务校验和权限检查一步都不能少。