结构化生成的三条路:JSON Mode、Function Calling 与约束解码
LLM 要输出合法的 JSON 不是「写对就行」,而是一个从概率分布中硬挤出结构化数据的工程问题。三种主流方案背后的物理原理和取舍。
你用 LLM 做一个 Agent,第一步就是让模型输出结构化数据——JSON、函数参数、或者特定的 schema。
然后你遇到的第一个坑就是:模型输出的 JSON 不合法。少了个引号,多了个逗号,null 写成了 None。
你试了 JSON Mode,好像好了一点,但还是会翻车。你试了 Function Calling,准确率高了很多,但灵活性变差了。你听说了约束解码,但不知道它到底好在哪。
这三种方案不是「选个最好的」,而是不同的技术路径,各有各的物理局限。
先理解问题的本质
LLM 生成文本的基本操作是:在当前上下文中,从词汇表中选一个 token 作为下一个输出。每一步都产生一个概率分布,采样或取 argmax。
问题就是从这里开始的——词汇表里大部分 token 都不是合法的 JSON 字符。
假设词汇表大小是 32k(GPT 系的标准),其中和 JSON 格式相关的 token 只有几百个。括号、引号、冒号、数字、字母、true/false/null。绝大多数 token 在 JSON 上下文里都是「非法字符」。
让 LLM 输出合法 JSON,本质上是一个把生成空间限制到合法子集的问题。
方案一:JSON Mode(最轻但最不可靠)
你发指令说「请以 JSON 格式输出」,然后在拿到回复后用 json.loads() 做校验,解析失败就重试。
这是最简单的方案。它的物理原理是什么?用模型的自然语言理解能力来近似结构约束。 模型在预训练中见过大量 JSON 格式的文本,知道 JSON 大概长什么样,会本能地生成合法的格式。
但「本能」不是「保证」。
问题出在 token 级别的概率竞争上。假设模型正在生成一个字符串值,比如 "status": ",下一个 token 的候选可能是 active、"active"(带引号)、或者 active"(漏了引号)。在概率分布中,几个候选的差距可能只有零点几个百分点。任何微小的上下文偏移——比如前面有个奇怪的特殊字符——都可能让错误的 token 胜出。
JSON Mode 的致命缺陷:它依赖模型的「习惯」而非强制约束。
方案二:Function Calling(Grammar-Guided Generation)
你定义一个 JSON Schema,模型收到的不只是自然语言指令,还有一个结构化的 function definition。
Function Calling 的底层实现因模型而异,但核心原理是把 schema 信息注入到生成过程中。模型内部在计算注意力时,function 的 schema token 参与了每一层的计算,影响了每一步的 token 选择。
这和 JSON Mode 的区别是什么?信息密度。 JSON Mode 只用一句话描述格式要求,信息量可能只有几十个 token。而 Function Calling 把完整的 schema 送进了上下文,模型在生成时能看到「这里需要一个 array of objects,每个 object 要有 name 和 age 字段」。
所以 Function Calling 的错误率远低于 JSON Mode——不是因为它在底层做了什么特殊的事情,而是因为它给了模型更多的上下文信息来辅助决策。
但它不是万能的。如果你定义了一个复杂的嵌套 schema,模型仍然可能翻车。比如要求输出一个多层嵌套的 JSON,里面字段类型是联合类型(union type),模型在处理深层嵌套时会在 token 级出现概率混淆。
另外,Function Calling 有一个隐藏限制——它只适合「调用函数」这个场景。很多 Agent 需要输出的结构化数据不是函数参数,而是自由格式的 JSON。强行用 Function Calling 来做,不仅绕,还会丢失灵活性。
方案三:约束解码(Constrained Decoding)
这是最彻底的方案。
不做 logits 采样后做校验,而是在生成前就 mask 掉不合法的 token。
具体来说:假设当前已经生成了 {"name": ",约束解码器维护一个 JSON schema 的有限状态机。在这个状态下,合法的 token 只有:
- 任意非引号字符([a-zA-Z0-9 ] 等)
- 或者引号(")来结束字符串
所有的标点符号 token、控制字符 token、数字 token(在字符串值语境中并不非法,但如果是数值类型则数字合法)——约束解码器会根据当前状态动态计算合法的 token 集合,然后把 logits 中所有非法 token 的概率设为零。
这是从物理层面解决问题:在 token 概率分布的「出生」时刻就做了限制。
效果立竿见影——只要约束解码器实现正确,输出的 JSON 100% 合法。
但代价呢?
- 速度。 每一步都要运行一个 FSM,检查当前 token 序列的状态,计算合法 token 集。对于简单 schema,这一步很快(几微秒),但对于复杂的嵌套 schema,FSM 的状态空间会膨胀。带有嵌套 object、union、optional fields 的 schema,每一步都要解析当前的 JSON 路径。
- token 覆盖率。 LLM 的 tokenizer 是 BPE,同一个语义可能对应多个 token 表示。比如
"hello"可能是一个 token,也可能是"hell+o"两个 token。约束解码器需要知道 tokenizer 的词汇表,知道每个 token 的字符序列会不会破坏 JSON 结构。对于 32k 的词汇表,每步遍历一次开销不小。优化的实现会用前缀树(trie)加速。 - 语义损失。 当模型想说
"result": "not available",但约束解码器强制要求输出一个数字时,模型被迫从概率分布中选一个数字 token。这个 token 可能和「not available」完全不相关,纯属噪声。
第三种代价最隐蔽——约束解码保证了格式正确,但不能保证语义正确。
三条路的核心取舍
| JSON Mode | Function Calling | 约束解码 | |
|---|---|---|---|
| 格式正确率 | 70-90% (取决于模型) | 90-99% | ~100% |
| 语义损失 | 无 | 低 | 中等 |
| 速度影响 | 无 (后校验) | 无 | 每步 FSM 开销 |
| 灵活性 | 高 | 低 (限函数调用) | 中 (需 schema) |
| 实现复杂度 | 低 | 中 | 高 |
没有一条路通吃所有场景。
JSON Mode + 重试适合对延迟敏感、错误可容忍的场景。Function Calling 适合工具调用这种 schema 相对固定的场景。约束解码适合输出格式必须 100% 正确的场景,比如支付接口的请求体生成——一个格式错误可能导致整个请求被拒绝。
尾
结构化生成在 Agent 系统中无处不在——工具调用参数、Agent 内部状态序列化、多 Agent 通信协议。理解这三条路背后的物理原理,能帮你在不同场景下选对工具。
下次你的 Agent 输出奇怪的 JSON 时,别怪模型——想想你选的是哪条路,它有什么局限。然后你就知道该不该换一条路,还是在这个方向上继续优化。
评论
发表评论