应用与 Agent / 结构化输出 实践指南实践 5 分钟阅读 查看源文件 ↗

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 方案没有屏蔽,只靠概率,所以不可靠。