LLM 结构化输出#
生产中经常需要 LLM 输出严格符合某个 schema 的 JSON,而不是自由文本。按"机制"分有 5 种主流方案,三大厂商(OpenAI / Anthropic / Google)和本地开源模型各有侧重:
| 方案 | 保证程度 | 原理 | 主要支持方 |
|---|---|---|---|
| 1. API JSON Mode | 合法 JSON,不保证 schema | logit masking(token 级别) | OpenAI response_format / Gemini response_mime_type |
| 2. API Schema 绑定 | 100% 符合 schema | schema 编译为 logit mask | OpenAI Structured Outputs / Gemini response_schema |
| 3. Tool Use 路由法 | 实测 >99%(软约束) | 训练对齐 + 强制 tool_choice |
Claude 主推 / OpenAI / Gemini 通用 |
| 4. Constrained Decoding | 100% 符合 schema | FSM + logit masking | 本地模型: Outlines / vLLM / jsonformer |
| 5. Prompt + 后处理 | 不保证,靠重试兜底 | 无 | 最通用,任何模型 |
核心二分:硬约束 vs 软约束。方案 1/2/4 是底层 token 采样层硬拦非法 token(logit masking + FSM),数学上保证;方案 3 是训练时强对齐 + 路由,靠模型"非常听话",实测准确率极高但不是底层硬约束;方案 5 完全靠 prompt 引导,没有任何约束。
方案一:API JSON Mode
最简单,加一个参数告诉 API "我要 JSON":
response = client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"}, # 加这一行
messages=[{"role": "user", "content": "返回包含 name 和 age 的 JSON"}]
)
原理:采样每个 token 时,只允许"当前位置符合 JSON 语法"的 token 被选中,非法 token 概率强制设为 -∞。比如已经生成了 {"name":,下一个 token 只能是字符串开头的 ",不可能是普通文字或 [。
局限:只保证输出是合法 JSON,不保证字段符合你要求的格式。你要 {"name": str, "age": int},它可能给你 {"foo": "bar"},也是合法 JSON 但字段对不上。
方案二:API Schema 绑定(方案一的升级版)
直接绑定你定义的数据结构,不只是"合法 JSON",而是"符合这个具体 schema 的 JSON"。OpenAI Structured Outputs:
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
response = client.beta.chat.completions.parse(
model="gpt-4o",
response_format=User, # 绑定 Pydantic 模型
messages=[...]
)
result = response.choices[0].message.parsed # 直接拿到 User 对象,100% 有 name(str) 和 age(int)
Gemini 等价做法:在 generation_config 里设 response_schema=User,机制相同。
原理:把你的 schema 编译成更精细的 logit mask——不只是"合法 JSON",而是"符合这个具体 schema 的 JSON"。生成到 age 字段的 value 位置时,只有 0-9 开头的 token 合法,字符串 token 概率清零。
局限:仅限对应厂商 API,换本地模型就没有了;Anthropic Claude 不支持这种直接 schema 绑定(用方案三代替)。
方案三:Tool Use 路由法(Claude 主推,三大厂商通用)
核心 trick:定义一个"假工具",它的 input_schema 就是你想要的输出 schema,然后强制模型调用这个工具。模型返回 tool_use 块时,arguments 字段就是结构化数据。这是 Claude 的官方推荐做法(Claude 没有 response_format 参数)。
import anthropic
client = anthropic.Anthropic()
extract_user_tool = {
"name": "extract_user",
"description": "从文本中抽取用户信息",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
},
}
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
tools=[extract_user_tool],
tool_choice={"type": "tool", "name": "extract_user"}, # 强制调用
messages=[{"role": "user", "content": "张三今年 25 岁"}],
)
result = response.content[0].input # {"name": "张三", "age": 25}, 已经是 dict
OpenAI/Gemini 同样可用:OpenAI 的 tools=[...] + tool_choice={"type":"function","function":{"name":"..."}};Gemini 的 function calling 同模式。这是 vendor-agnostic 的结构化输出模式。
原理:不依赖 logit masking,而是训练时大量 tool use 样本对齐,让模型"非常擅长按 schema 输出",再通过 tool_choice 强制路由到目标工具。是软约束 + 强对齐的组合。实测 Claude/GPT-4 类大模型字段准确率 >99%。
Claude 简化技巧 — Prefill:简单场景不必造工具,可以直接预填 assistant turn:
messages = [
{"role": "user", "content": "返回包含 name 和 age 的 JSON"},
{"role": "assistant", "content": "{"}, # 关键:预填 {
]
response = client.messages.create(model="claude-opus-4-7",
messages=messages, stop_sequences=["}"])
output = "{" + response.content[0].text + "}"
Claude 会从 { 开始续写,被迫输出 JSON。配合 stop_sequences 控制结束。Prefill 也是开源模型常用技巧(如 LLaMA/Mistral 用 Transformers 库时直接 append "{" 到 prompt)。
方案四:Constrained Decoding(本地模型,原理最重要)
上面两个是 API 层面的功能。用本地开源模型(Mistral、LLaMA)时没有这些,Outlines 这类库自己实现了同样的机制:
import outlines
model = outlines.models.transformers("mistral-7b")
generator = outlines.generate.json(model, User) # 传入 schema
result = generator("生成一个用户") # 100% 符合 schema
原理——有限状态机(FSM):JSON 解析器在任意时刻都处于某个"状态",每个状态决定下一个合法字符是什么:
状态0:期待 {
状态1:期待 key(字符串,必须以 " 开头)
状态2:期待 :
状态3a:期待 string value(必须以 " 开头)
状态3b:期待 integer value(必须以 0-9 开头)
状态4:期待 , 或 }
Outlines 把这个状态机和词表对应:
当前状态 = "age 字段,期待 integer"
→ 只有 0-9 开头的 token 合法
→ 其他 token 概率 → -∞,softmax 后为 0,永远不会被采样
每步采样前先问 FSM:"当前允许哪些 token?"
→ 不在允许列表的 token 直接屏蔽
这是数学上的保证,不依赖模型"听话",直接在采样层面拦截非法 token。
方案五:Prompt Engineering + 后处理(最通用,不保证)
system = """只能输出合法 JSON,不要输出任何额外文字。
格式:{"name": "string", "age": integer}
示例:{"name": "张三", "age": 25}"""
import json, re
def parse_llm_json(text, client, messages, max_retry=3):
for _ in range(max_retry):
# 提取 ```json ... ``` 代码块
match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
text = match.group(1) if match else text
try:
return json.loads(text)
except json.JSONDecodeError as e:
# 把错误附在 prompt 里让模型自己修正
messages.append({"role": "assistant", "content": text})
messages.append({"role": "user", "content": f"JSON 格式错误:{e},请重新输出"})
text = client.chat.completions.create(...).choices[0].message.content
return None
缺点:LLM 的生成是概率采样,指令只影响概率分布,无法硬性约束。模型能力越弱越容易多输出 markdown 代码块或多余文字,需要后处理 + 重试兜底。
三个技术方案的本质是同一件事:在采样时屏蔽非法 token。区别只是谁来实现、精细程度如何:API JSON Mode 屏蔽"非法 JSON token",Structured Outputs 屏蔽"不符合具体 schema 的 token",Constrained Decoding 自己用 FSM 在本地实现同样的屏蔽。Prompt 方案没有屏蔽,只靠概率,所以不可靠。