Structured Output - 让 LLM 输出可控的结构化数据 / Structured Output for Controllable, Machine-Readable LLM Responses
📅 创建时间:2026-07-28 🏷️ 标签:#StructuredOutput #ConstrainedGeneration #JSONSchema #Agent基础 📚 前置知识:[[01-function-calling]]
📋 本章目标
- 理解 Structured Output 在 HTTP 层面的真正含义
- 区分 JSON Mode 与 JSON Schema 的本质差异
- 掌握 API 级约束生成 vs 后处理重试两种方案
- 理解 Zod/Pydantic → JSON Schema → LLM 的完整流水线
- 能够用 OpenAI 和 Anthropic SDK 实现结构化输出
- 掌握实体抽取、分类、结构化数据生成等实战模式
- 理解 Structured Output 的局限性与适用边界
第0部分:先搞清数据到底是怎么流动的
在理解 Structured Output 的设计意图之前,有一个更底层的问题必须先回答:数据在网络上到底长什么样? 很多人卡在这里,因为他们只见过 SDK 封装后的 response.choices[0].message.content,不知道这行代码背后 HTTP 请求体和响应体里到底是什么。
0.1 普通聊天:输出是自由文本
你写的代码:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "列出三个中国城市"}]
)
print(response.choices[0].message.content)实际发出的 HTTP 请求体:
POST /v1/chat/completions
{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "列出三个中国城市"}
]
}LLM 返回的响应体——content 是自由文本:
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "以下是三个中国城市:\n1. 北京\n2. 上海\n3. 广州"
},
"finish_reason": "stop"
}
]
}你的程序拿到的是一个字符串:"以下是三个中国城市:\n1. 北京\n2. 上海\n3. 广州"。如果你想把这个字符串变成 ["北京", "上海", "广州"],你必须自己写代码去解析它——正则、字符串切割、或者祈祷 LLM 每次输出格式一致。
0.2 Structured Output:输出是符合 Schema 的 JSON
现在加上 Structured Output。你写的代码:
from pydantic import BaseModel
class CityList(BaseModel):
cities: list[str]
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": "列出三个中国城市"}],
response_format=CityList # 告诉 API:输出必须符合这个结构
)
# response.choices[0].message.parsed 已经是 CityList 对象
print(response.choices[0].message.parsed.cities)
# 输出:['北京', '上海', '广州']实际发出的 HTTP 请求体——多了一个 response_format 字段:
POST /v1/chat/completions
{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "列出三个中国城市"}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "CityList",
"strict": true,
"schema": {
"type": "object",
"properties": {
"cities": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["cities"],
"additionalProperties": false
}
}
}
}LLM 返回的响应体——content 是保证符合 Schema 的 JSON 字符串:
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"cities\":[\"北京\",\"上海\",\"广州\"]}"
},
"finish_reason": "stop"
}
]
}┌─────────────────────────────────────────────────────────────┐
│ 普通输出 vs 结构化输出:数据流对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 普通聊天: │
│ 你的代码 → HTTP POST → LLM → content="自由文本" │
│ (无 schema) → 你需要手工解析 │
│ │
│ 结构化输出: │
│ 你的代码 → HTTP POST → LLM → content='{"cities": │
│ (带 json_schema) ["北京","上海","广州"]}' │
│ → 保证 json.loads() 成功 │
│ → 保证字段类型正确 │
│ → 保证无多余字段 │
│ │
│ 区别不在"输出看起来像 JSON"——普通模式下你也可以让 LLM │
│ 输出 JSON。区别在于 API 层面做了强制约束:不符合 Schema │
│ 的 token 根本不会被生成。 │
│ │
└─────────────────────────────────────────────────────────────┘0.3 所以 "response_format" 到底做了什么?
LLM 的本质是 token 生成器:给定前文,预测下一个 token。正常情况下,模型在每个位置会选择概率最高的 token,没有任何外部约束。
当你在 API 请求中加了 response_format,API 服务端(不是 LLM 本身)会做一件事:
┌─────────────────────────────────────────────────────────────┐
│ response_format 的工作机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第1步:Schema → 语法约束 │
│ API 服务端把你的 JSON Schema 转换成一个有限状态机(FSM) │
│ 或上下文无关文法(CFG),定义了每一步"允许哪些 token"。 │
│ │
│ 第2步:逐 token 约束 │
│ LLM 在每一步生成下一个 token 时,API 服务端会: │
│ ① 让 LLM 给出所有 token 的概率分布 │
│ ② 把 Schema 不允许的 token 的概率设为 0(或 -inf) │
│ ③ 从允许的 token 中采样 │
│ │
│ 第3步:保证输出 │
│ 因为每一步都被约束,最终生成的 token 序列在语法上必然 │
│ 符合给定的 JSON Schema。整个过程对 LLM 本身是透明的—— │
│ LLM 不知道自己被"限制"了,它只是在受限的候选集中选词。 │
│ │
│ 关键认知:这不是"先输出再校验",而是"每一步都限制"。 │
│ 这就是 constrained generation(约束生成)的含义。 │
│ │
└─────────────────────────────────────────────────────────────┘第1部分:为什么需要 Structured Output?
1.1 自由文本输出的困境
当你让 LLM 输出 JSON 但没有用 Structured Output 时,你实际上是在赌博:
# 方案A:不用 Structured Output —— 脆弱
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "system",
"content": "请只输出 JSON,格式:{\"name\": \"...\", \"age\": ...}"
}, {
"role": "user",
"content": "张三今年25岁"
}]
)
text = response.choices[0].message.content
# text 可能是:
# ✅ '{"name": "张三", "age": 25}'
# ❌ '好的,这是 JSON:\n```json\n{"name": "张三", "age": 25}\n```'
# ❌ '{"name": "张三", "age": 25, "note": "这是一个年轻人"}'
# ❌ '{"姓名": "张三", "age": "二十五"}'
# 你需要写一堆防御代码:
import json, re
try:
# 尝试去掉 markdown code block
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
if match:
text = match.group(1)
data = json.loads(text)
name = data.get("name") or data.get("姓名") # key 名不确定
age = int(data["age"]) # 可能类型不对
except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e:
# 解析失败,你可能需要重试——但重试也不保证成功
...┌─────────────────────────────────────────────────────────────┐
│ 自由文本 + 正则解析:脆弱性的根源 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的代码假设 LLM 会按你的格式输出,但 LLM 可能: │
│ │
│ ❌ 包裹 markdown code block (```json ... ```) │
│ ❌ 在前面加解释文字:"好的,这是结果:..." │
│ ❌ 修改 key 名称:name → 姓名,age → 年龄 │
│ ❌ 类型错误:age 输出 "二十五" 而不是 25 │
│ ❌ 多余字段:自动加上 "confidence": 0.95 │
│ ❌ 缺失字段:忘了输出某个 required 字段 │
│ ❌ JSON 语法错误:少了引号、逗号、括号 │
│ │
│ 每多一种可能,你的解析代码就多一个 if/else 分支。 │
│ 这不可维护,也不可靠。 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 Structured Output 的保证
┌─────────────────────────────────────────────────────────────┐
│ Structured Output 给你的保证 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ 输出一定是合法的 JSON(json.loads 不会抛异常) │
│ ✅ 输出一定包含所有 required 字段 │
│ ✅ 输出一定不包含 additionalProperties 之外的字段 │
│ ✅ 每个字段的类型一定正确(string 就是 string,不会变 │
│ number) │
│ ✅ 如果定义了 enum,输出一定在枚举值范围内 │
│ ✅ 没有 markdown 包裹,没有解释文字,纯 JSON │
│ │
│ 这些保证不是"大概率"或"通常"——是 API 层面的强制约束。 │
│ 当 strict: true 时,任何不符合 Schema 的 token 序列 │
│ 在生成阶段就被禁止了。 │
│ │
└─────────────────────────────────────────────────────────────┘1.3 为什么这对 Agent 至关重要?
Agent 不是和人对话——Agent 的输出要喂给下一个程序模块。想想这些场景:
┌─────────────────────────────────────────────────────────────┐
│ Agent 必须用 Structured Output 的场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景1:工具参数生成 │
│ Agent 需要调用 send_email(to, subject, body)。如果 LLM │
│ 输出的 JSON 里有 "recipient" 而不是 "to",程序就挂了。 │
│ Function Calling 本质就是 Structured Output 的特例—— │
│ tools 定义里的 parameters 就是一个 JSON Schema。 │
│ │
│ 场景2:实体抽取 → 数据库写入 │
│ Agent 从文档中抽取 {name, date, amount},直接写入数据库。 │
│ 字段类型不匹配 = 数据库报错。必须保证类型正确。 │
│ │
│ 场景3:Agent 间通信 │
│ Agent A 调用 Agent B,B 的返回值被 A 的代码解析。 │
│ 结构不稳定 = A 的解析逻辑崩溃 = 级联失败。 │
│ │
│ 场景4:前端渲染 │
│ LLM 输出 UI 组件的 props JSON,不符合类型 → 白屏。 │
│ │
│ 一句话:当 LLM 的输出要进程序逻辑而不是给人看时, │
│ Structured Output 不是"nice to have",而是"必须"。 │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:JSON Mode vs JSON Schema —— 两种保证级别
很多初学者把这两个概念混为一谈,但它们能保证的东西完全不同。
2.1 JSON Mode:只保证"是合法 JSON"
JSON Mode(OpenAI 的 response_format: {"type": "json_object"})只做一件事:确保 LLM 的输出是一个合法的 JSON 对象。
# JSON Mode:type: "json_object"
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "输出 JSON"},
{"role": "user", "content": "张三,25岁,工程师"}
],
response_format={"type": "json_object"} # 只保证是合法 JSON
)
# 输出一定是合法 JSON,但结构完全不保证:
# 可能1: {"name": "张三", "age": 25, "job": "工程师"}
# 可能2: {"姓名": "张三", "年龄": 25, "职业": "工程师"}
# 可能3: {"person": {"name": "张三"}, "details": {"age": 25}}
# 可能4: {"result": "张三,25岁,工程师", "confidence": 0.9}2.2 JSON Schema:保证"字段和类型都按你说的来"
# JSON Schema:type: "json_schema"
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "张三,25岁,工程师"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"job": {"type": "string"}
},
"required": ["name", "age", "job"],
"additionalProperties": False
}
}
}
)
# 输出保证:
# ✅ 一定是 {"name": "张三", "age": 25, "job": "工程师"}
# ✅ name 一定是 string
# ✅ age 一定是 integer(不会是 "25")
# ✅ 不会多出 confidence、note 等额外字段
# ✅ 不会少 name、age、job 中的任何一个┌─────────────────────────────────────────────────────────────┐
│ JSON Mode vs JSON Schema:一张表说清 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ JSON Mode │ JSON Schema │
│ ─────────────────┼───────────────────┼─────────────────── │
│ API 参数 │ type:json_object │ type:json_schema │
│ 保证合法 JSON │ ✅ │ ✅ │
│ 保证字段名 │ ❌ │ ✅ │
│ 保证字段类型 │ ❌ │ ✅ │
│ 保证必填字段 │ ❌ │ ✅ │
│ 禁止多余字段 │ ❌ │ ✅ (additional │
│ │ │ Properties:false) │
│ 支持 enum 约束 │ ❌ │ ✅ │
│ 支持嵌套对象 │ 不保证 │ ✅ 保证 │
│ 支持数组元素类型 │ 不保证 │ ✅ 保证 │
│ │
│ 一句话:JSON Mode 是"输出是 JSON"; │
│ JSON Schema 是"输出是我指定的那个 JSON"。 │
│ │
└─────────────────────────────────────────────────────────────┘2.3 strict: true 的含义
OpenAI 的 strict: true 是 Structured Output 的一个关键开关。不开 strict 时,模型尽力遵循 Schema,但不保证。开了 strict 后:
- 底层使用 constrained decoding(约束解码),从 token 采样阶段就限制
- 100% 保证输出符合 Schema,不会出现"字段类型不对"的情况
- 代价:Schema 必须满足一些限制条件(见第6部分)
# strict: true —— 硬约束
# strict: false / 不设置 —— 软约束(尽力而为)
response_format={
"type": "json_schema",
"json_schema": {
"name": "...",
"strict": True, # 开!
"schema": {...}
}
}第3部分:两种技术路线 —— 约束生成 vs 后处理重试
实现 Structured Output 有两条技术路线:一条是在 API 层面做约束,一条是在应用层面做重试。理解它们的本质区别很重要。
3.1 路线A:API 级约束生成(推荐)
┌─────────────────────────────────────────────────────────────┐
│ 路线A:API 级约束生成(Constrained Decoding) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ Schema ┌──────────┐ 约束后token │
│ │ 你的代码 │──────────────→│ API 网关 │─────────────────→│
│ │ │ │ │ │
│ │ Pydantic │ json_schema │ 构建 FSM │ 只采样合法token │
│ │ 模型 │ │ 约束采样 │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ ↓ LLM 原始 token 流 │
│ ┌──────────┐ │
│ │ LLM │ │
│ │ (模型) │ │
│ └──────────┘ │
│ │
│ 特点: │
│ • 一次调用即成功,不需要重试 │
│ • 从 token 生成阶段就约束,不是"先生成再检查" │
│ • 需要模型/API 支持(OpenAI、Anthropic、vLLM、llama.cpp) │
│ • 可靠性最高 │
│ │
│ 代表实现: │
│ • OpenAI: response_format + strict:true │
│ • Anthropic: tool_choice + tools (利用 tool use 机制) │
│ • llama.cpp: GBNF grammar │
│ • vLLM: guided_decoding_backend │
│ • Outlines, Guidance, LMQL 等库 │
│ │
└─────────────────────────────────────────────────────────────┘OpenAI 示例:
from openai import OpenAI
client = OpenAI()
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": "张三,25岁,工程师"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"job": {"type": "string"}
},
"required": ["name", "age", "job"],
"additionalProperties": False
}
}
}
)
# parsed 属性直接返回 Pydantic 模型(SDK 内部做了 json.loads + 校验)
person = response.choices[0].message.parsed
print(person.name, person.age, person.job) # 张三 25 工程师Anthropic 示例(通过 tool_use 机制实现结构化输出):
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": "张三,25岁,工程师"}],
tools=[{
"name": "extract_person",
"description": "提取人员信息",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"job": {"type": "string"}
},
"required": ["name", "age", "job"]
}
}],
tool_choice={"type": "tool", "name": "extract_person"}
)
# Claude 一定会返回一个 tool_use,参数符合 input_schema
tool_use = response.content[1] # content[0] 可能是 thinking,content[1] 是 tool_use
print(tool_use.input) # {'name': '张三', 'age': 25, 'job': '工程师'}3.2 路线B:后处理重试(兜底方案)
当你用的 API/模型不支持原生 Structured Output 时,只能用这种方法:
┌─────────────────────────────────────────────────────────────┐
│ 路线B:生成 → 校验 → 重试 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ 提示词 ┌──────────┐ 原始文本 │
│ │ 你的代码 │──────────────→│ LLM │─────────────────→│
│ │ │ │ │ │
│ │ prompt里 │ │ 自由生成 │ "这是结果: │
│ │ 写了 │ │ │ {"name":"张 │
│ │ "输出 │ │ │ 三"...}" │
│ │ JSON" │ │ │ │
│ └──────────┘ └──────────┘ │
│ ↑ │ │
│ │ ↓ 原始文本 │
│ │ ┌──────────────┐ │
│ │ │ 解析 + 校验 │ │
│ │ │ │ │
│ │ │ ① 去markdown │ │
│ │ │ ② json.loads │ │
│ │ │ ③ 字段校验 │ │
│ │ │ ④ 类型校验 │ │
│ │ └──────┬───────┘ │
│ │ │ │
│ │ ┌────────────┴────────────┐ │
│ │ ↓ ↓ │
│ │ 校验通过 校验失败 │
│ │ → 返回结果 → 重试(最多N次)│
│ │ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 特点: │
│ • 不需要 API 支持,任何 LLM 都能用 │
│ • 需要写解析 + 重试逻辑 │
│ • 不可靠——重试 N 次后仍可能失败 │
│ • 浪费 token(每次重试都是额外的 API 调用) │
│ • Prompt 工程量大——要靠 prompt 让 LLM 输出稳定格式 │
│ │
└─────────────────────────────────────────────────────────────┘import json
import re
from openai import OpenAI
client = OpenAI()
MAX_RETRIES = 3
def extract_json_with_retry(prompt: str, schema: dict) -> dict:
"""路线B:生成 → 解析 → 失败则重试"""
messages = [
{"role": "system", "content": f"只输出 JSON,严格遵循此 Schema:\n{json.dumps(schema, ensure_ascii=False)}"},
{"role": "user", "content": prompt}
]
for attempt in range(MAX_RETRIES):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages
)
text = response.choices[0].message.content
try:
# 尝试去除 markdown code block
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
if match:
text = match.group(1)
data = json.loads(text)
# 手动校验字段
for field, props in schema.get("properties", {}).items():
if field in schema.get("required", []) and field not in data:
raise ValueError(f"缺少必填字段: {field}")
if field in data and props.get("type") == "integer":
data[field] = int(data[field])
return data
except (json.JSONDecodeError, ValueError, KeyError) as e:
if attempt == MAX_RETRIES - 1:
raise RuntimeError(f"重试 {MAX_RETRIES} 次后仍失败: {e}")
# 把错误信息反馈给 LLM
messages.append({"role": "assistant", "content": text})
messages.append({"role": "user", "content": f"输出格式有误:{e}。请修正后重新输出纯 JSON。"})
raise RuntimeError("不可达")3.3 两条路线的可靠性对比
┌─────────────────────────────────────────────────────────────┐
│ 约束生成 vs 后处理重试:可靠性对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ 约束生成 │ 后处理重试 │
│ ─────────────────┼─────────────────┼─────────────────── │
│ 一次成功率 │ 100%(硬保证) │ 70-95%(看 prompt) │
│ Token 效率 │ 高(一次调用) │ 低(可多N次调用) │
│ 复杂 Schema 适用 │ 好 │ 差(越复杂越易出错) │
│ Prompt 工程负担 │ 低 │ 高 │
│ 跨模型通用性 │ 需模型支持 │ 所有模型都可用 │
│ 延迟稳定性 │ 稳定 │ 不稳定(重试增加延迟)│
│ │
│ 结论:如果你的模型/API 支持约束生成,用它。 │
│ 后处理重试只在"没办法"的时候作为兜底。 │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:Zod/Pydantic → JSON Schema → LLM 的核心流水线
这是现代 Agent 开发中最核心的模式。你不需要手写 JSON Schema——你用代码定义类型,工具链帮你做转换和校验。
4.1 完整流水线
┌─────────────────────────────────────────────────────────────┐
│ Zod/Pydantic → JSON Schema → LLM 完整流水线 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第1步:用代码定义类型(Type Definition) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Python (Pydantic): │ │
│ │ class Person(BaseModel): │ │
│ │ name: str │ │
│ │ age: int │ │
│ │ email: EmailStr │ │
│ │ │ │
│ │ TypeScript (Zod): │ │
│ │ const Person = z.object({ │ │
│ │ name: z.string(), │ │
│ │ age: z.number().int(), │ │
│ │ email: z.string().email() │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第2步:转换为 JSON Schema(.model_json_schema() / .toJSONSchema())│
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "type": "object", │ │
│ │ "properties": { │ │
│ │ "name": {"type": "string"}, │ │
│ │ "age": {"type": "integer"}, │ │
│ │ "email": {"type": "string", "format": "email"} │ │
│ │ }, │ │
│ │ "required": ["name", "age", "email"] │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第3步:传给 LLM(作为 response_format 或 tool schema) │
│ ↓ │
│ 第4步:LLM 输出符合 Schema 的 JSON │
│ ↓ │
│ 第5步:用原始类型校验(Pydantic .model_validate() / Zod .parse())│
│ ┌─────────────────────────────────────────────────────┐ │
│ │ raw_json = response.choices[0].message.content │ │
│ │ person = Person.model_validate_json(raw_json) │ │
│ │ # 如果到这里没抛异常,数据一定是对的 │ │
│ │ # person.name 是 str,person.age 是 int │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键洞察:同一个 Schema 用了两次—— │
│ • 生成时:约束 LLM 输出 │
│ • 解析时:校验 LLM 输出 │
│ 双重保证 = 类型安全 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 Python 完整示例:Pydantic + OpenAI
from pydantic import BaseModel, Field
from typing import Literal
from openai import OpenAI
client = OpenAI()
# ── 第1步:定义类型 ──
class Address(BaseModel):
"""地址信息"""
province: str = Field(description="省份")
city: str = Field(description="城市")
district: str | None = Field(default=None, description="区县,可选")
class Person(BaseModel):
"""人员信息提取结果"""
name: str = Field(description="姓名")
age: int = Field(description="年龄", ge=0, le=150)
gender: Literal["男", "女"] = Field(description="性别")
occupation: str = Field(description="职业")
address: Address | None = Field(default=None, description="地址信息,可选")
# ── 第2步:自动转换 → 第3步:传给 LLM ──
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{
"role": "user",
"content": "我叫李四,28岁,男,软件工程师,住在广东省深圳市南山区"
}],
response_format=Person # Pydantic 模型直接传入!
)
# ── 第4、5步:SDK 自动做了 json.loads + model_validate ──
person = response.choices[0].message.parsed
# person 是 Person 实例,类型完全确定
print(person.name) # "李四" (str)
print(person.age) # 28 (int)
print(person.gender) # "男" (Literal["男", "女"])
print(person.occupation) # "软件工程师" (str)
print(person.address.city) # "深圳市" (str)
print(person.address.province) # "广东省" (str)
# 如果有 refusals(LLM 因安全策略拒绝回答)
if response.choices[0].message.refusal:
print(f"LLM 拒绝了请求: {response.choices[0].message.refusal}")4.3 TypeScript 完整示例:Zod + OpenAI
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const openai = new OpenAI();
// ── 第1步:定义类型 ──
const AddressSchema = z.object({
province: z.string().describe("省份"),
city: z.string().describe("城市"),
district: z.string().nullable().optional().describe("区县,可选"),
});
const PersonSchema = z.object({
name: z.string().describe("姓名"),
age: z.number().int().min(0).max(150).describe("年龄"),
gender: z.enum(["男", "女"]).describe("性别"),
occupation: z.string().describe("职业"),
address: AddressSchema.nullable().optional().describe("地址信息,可选"),
});
// ── 第2步:Zod → JSON Schema(zodResponseFormat 内部处理)→ 第3步:传给 LLM ──
const response = await openai.beta.chat.completions.parse({
model: "gpt-4o",
messages: [
{
role: "user",
content: "我叫李四,28岁,男,软件工程师,住在广东省深圳市南山区",
},
],
response_format: zodResponseFormat(PersonSchema, "person_extract"),
});
// ── 第4、5步:SDK 自动校验 ──
const person = response.choices[0].message.parsed;
// person 的类型是 z.infer<typeof PersonSchema>
console.log(person.name); // TypeScript 知道这是 string
console.log(person.age); // TypeScript 知道这是 number4.4 这个流水线为什么强大?
┌─────────────────────────────────────────────────────────────┐
│ 类型驱动开发:定义一次,处处安全 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 传统方式: │
│ 1. 手写 prompt 描述输出格式 │
│ 2. 手写 json.loads() 解析逻辑 │
│ 3. 手写字段校验(if "name" in data: ...) │
│ 4. 字段类型不确定(data["age"] 是 int 还是 str?) │
│ → prompt、解析、校验三处不同步,改一处忘两处 │
│ │
│ 流水线方式: │
│ 1. 定义 Pydantic/Zod 模型(唯一的真相来源) │
│ 2. 自动生成 JSON Schema → 传给 LLM │
│ 3. 自动校验 LLM 输出 → 类型安全的对象 │
│ → prompt、解析、校验全部由同一个类型定义驱动 │
│ → 改类型定义 → 自动同步到 LLM约束和运行时校验 │
│ │
│ 这就是"Single Source of Truth"原则在 LLM 应用中的体现。 │
│ │
└─────────────────────────────────────────────────────────────┘第5部分:实战模式
5.1 模式1:实体抽取(Entity Extraction)
从非结构化文本中提取结构化信息,是最常见的用例。
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
class Transaction(BaseModel):
"""金融交易记录"""
date: date = Field(description="交易日期")
amount: float = Field(description="交易金额,正数为收入,负数为支出")
counterparty: str = Field(description="交易对手方")
category: str = Field(description="交易类别,如:餐饮、交通、购物、工资")
note: Optional[str] = Field(default=None, description="备注")
class TransactionList(BaseModel):
transactions: List[Transaction]
# 输入是一段自然语言
text = """
2024年3月15日,工资收入15000元。
3月16日,在海底捞吃饭花了320元。
3月17日,坐地铁花了8元,在淘宝买了一件衬衫花了199元。
"""
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{
"role": "system",
"content": "从文本中提取所有交易记录。金额正数为收入,负数为支出。"
}, {
"role": "user",
"content": text
}],
response_format=TransactionList,
)
result = response.choices[0].message.parsed
for t in result.transactions:
print(f"{t.date} | {t.category:4s} | {t.counterparty:6s} | {t.amount:>8.2f}")
# 输出:
# 2024-03-15 | 工资 | (收入) | 15000.00
# 2024-03-16 | 餐饮 | 海底捞 | -320.00
# 2024-03-17 | 交通 | 地铁 | -8.00
# 2024-03-17 | 购物 | 淘宝 | -199.005.2 模式2:分类与路由(Classification & Routing)
根据用户输入判断意图,路由到不同的处理分支。这是多 Agent 系统的核心组件。
from pydantic import BaseModel, Field
from typing import Literal
class IntentClassification(BaseModel):
"""用户意图分类"""
intent: Literal[
"查询天气",
"预订机票",
"股票查询",
"闲聊",
"投诉"
] = Field(description="用户意图类别")
confidence: float = Field(description="置信度,0-1之间", ge=0, le=1)
reason: str = Field(description="分类理由")
key_entities: list[str] = Field(description="关键实体列表")
def classify_user_intent(user_message: str) -> IntentClassification:
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{
"role": "system",
"content": "你是一个意图分类器。根据用户消息判断意图类别。"
}, {
"role": "user",
"content": user_message
}],
response_format=IntentClassification,
max_tokens=200,
)
return response.choices[0].message.parsed
# 使用示例
result = classify_user_intent("我买的股票茅台今天涨了多少?")
print(f"意图: {result.intent}") # 股票查询
print(f"置信度: {result.confidence}") # 0.95
print(f"理由: {result.reason}")
print(f"实体: {result.key_entities}") # ['茅台']
# 根据分类结果路由
if result.intent == "股票查询":
# 调股票 Agent
...
elif result.intent == "查询天气":
# 调天气 Agent
...5.3 模式3:结构化合成(Structured Synthesis)
给定多个输入,合成一个结构化的摘要或报告。
from pydantic import BaseModel
from typing import List
class ProductComparison(BaseModel):
"""产品对比结果"""
product_name: str
price_range: str
pros: List[str]
cons: List[str]
best_for: str
overall_rating: float
class ComparisonReport(BaseModel):
"""完整对比报告"""
products: List[ProductComparison]
recommendation: str
summary: str
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{
"role": "user",
"content": """
对比以下三款手机:iPhone 16 Pro、Samsung Galaxy S25、Xiaomi 15 Pro。
从价格、优缺点、适用人群等方面分析。
"""
}],
response_format=ComparisonReport,
)
report = response.choices[0].message.parsed
for p in report.products:
print(f"\n## {p.product_name} ({p.price_range})")
print(f"优点: {', '.join(p.pros)}")
print(f"缺点: {', '.join(p.cons)}")
print(f"评分: {p.overall_rating}/10")
print(f"\n推荐: {report.recommendation}")5.4 模式4:层级分类(Hierarchical Classification)
先用粗粒度分类,再在子类别中细分。相比一次性做细粒度分类,层级分类的准确率更高。
# 第一级:粗粒度
class CoarseCategory(BaseModel):
category: Literal["技术问题", "账户问题", "账单问题", "其他"]
# 第二级:细粒度(每个大类有自己的子分类)
class TechSubCategory(BaseModel):
sub_category: Literal["API", "SDK", "部署", "性能", "Bug"]
class AccountSubCategory(BaseModel):
sub_category: Literal["注册", "登录", "密码重置", "权限"]
# 分类流程
def hierarchical_classify(user_message: str):
# 第一级
coarse = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": user_message}],
response_format=CoarseCategory,
).choices[0].message.parsed
# 第二级:根据第一级结果选择不同的 Schema
if coarse.category == "技术问题":
sub_response_format = TechSubCategory
elif coarse.category == "账户问题":
sub_response_format = AccountSubCategory
else:
return coarse.category, None
sub = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": user_message}],
response_format=sub_response_format,
).choices[0].message.parsed
return coarse.category, sub.sub_category5.5 模式5:Optional 字段与条件输出
用 Optional 和 | None 标记可选字段,让 Schema 更灵活。
from pydantic import BaseModel
from typing import Optional, Literal
class Event(BaseModel):
"""日历事件"""
title: str
date: str # ISO date
time: Optional[str] = None # 有些事件没有具体时间
location: Optional[str] = None
is_all_day: bool = False
recurrence: Optional[Literal["每天", "每周", "每月", "每年"]] = None
class CalendarExtract(BaseModel):
events: list[Event]
text = """
下周一上午10点开会,地点在3楼会议室。
周五是妈妈生日(全天)。
每周三下午2点有瑜伽课。
"""
result = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": text}],
response_format=CalendarExtract,
).choices[0].message.parsed
for e in result.events:
flags = []
if e.is_all_day: flags.append("全天")
if e.recurrence: flags.append(e.recurrence)
print(f"{e.title} | {e.date} {e.time or ''} | {', '.join(flags)}")第6部分:局限性与注意事项
6.1 复杂嵌套 Schema 的挑战
┌─────────────────────────────────────────────────────────────┐
│ 复杂嵌套 Schema 可能带来的问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题1:模型"迷路" │
│ 嵌套层级超过 3-4 层时,模型可能在深层嵌套中丢失上下文 │
│ → 把深层嵌套拆分为多次调用,每次处理一层 │
│ │
│ 问题2:描述冲突 │
│ 父级和子级的 description 如果语义不一致,模型困惑 │
│ → 确保 description 层级一致,不矛盾 │
│ │
│ 问题3:枚举爆炸 │
│ Literal 的类型太多时(如 50+ 个枚举值),模型选择困难 │
│ → 用层级分类代替大枚举 │
│ │
│ 问题4:数组元素数量不确定 │
│ Schema 可以约束数组中每个元素的类型,但不能精确控制数量 │
│ → 在 prompt 中说明期望数量,在代码中校验长度 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 Token 开销
每个 Schema 都是一个大的 prompt 前缀。一个中等复杂的 Schema 可能有 500-2000 tokens。这直接计入每次调用的成本。
# 小 Schema:约 50 tokens
class Simple(BaseModel):
name: str
age: int
# 大 Schema:约 500+ tokens
class Complex(BaseModel):
"""... 长描述 ..."""
field1: str = Field(description="很长的描述,解释这个字段的用途和格式要求...")
field2: list[NestedObject] = Field(description="嵌套对象列表...")
field3: Literal["a", "b", "c", "d", ..., "z"] # 26个枚举值
# 权衡:Schema 越精确 → token 开销越大 → 成本越高
# Schema 越简单 → token 开销越小 → 约束越弱6.3 strict: true 的限制(OpenAI)
开了 strict 后,你的 JSON Schema 必须满足一些约束:
- 所有对象必须设置
additionalProperties: false - 所有字段必须在
required中声明(或者全部 optional) - 不支持某些 JSON Schema 高级特性(如
oneOf/anyOf/allOf的部分形式) - 嵌套深度限制
# OpenAI strict 模式支持的 schema 子集:
# ✅ type: object, string, number, integer, boolean, array, null
# ✅ required, properties, additionalProperties: false
# ✅ items (数组元素类型)
# ✅ enum
# ✅ description
# ✅ $ref, $defs (有限支持)
#
# ❌ oneOf, anyOf, allOf, not (部分受限)
# ❌ pattern (正则约束)
# ❌ minLength, maxLength (字符串长度限制)
# ❌ minimum, maximum (数字范围——可以用但可能被忽略)6.4 Anthropic 的差异
Anthropic 不直接提供 response_format 参数,而是通过 tool_use 机制实现类似效果。差异点:
- Claude 的 tool_use 是为"调用工具"设计的,不是为"提取结构"设计的——但你可以强制触发 tool_use 来获得结构化输出
- Claude 的
input_schema不保证 100% 约束——它更多是"软引导" - Claude 可能在 tool_use 之前输出 text 块(thinking),你需要跳过
- 复杂 Schema 下,Claude 可能把内容放在 text 块里而不是 tool_use 里
6.5 模型支持现状
┌─────────────────────────────────────────────────────────────┐
│ 各模型/API 的 Structured Output 支持 │
├─────────────────────────────────────────────────────────────┤
│ │
│ OpenAI GPT-4o / GPT-4o-mini │
│ ✅ response_format (json_schema + strict) │
│ ✅ client.beta.chat.completions.parse (Pydantic/Zod) │
│ │
│ Anthropic Claude (Sonnet 4, Opus 4) │
│ ⚠️ 无原生 response_format │
│ ⚠️ 通过 tool_use + tool_choice 间接实现 │
│ ⚠️ 不保证 100% 类型约束 │
│ │
│ Google Gemini │
│ ✅ response_schema (类似 OpenAI) │
│ ✅ Google GenAI SDK 支持 Pydantic │
│ │
│ 开源模型 (Llama, Qwen, DeepSeek) + 推理框架 │
│ ✅ llama.cpp: GBNF grammar (强制约束) │
│ ✅ vLLM: guided_decoding_backend │
│ ✅ Outlines: 基于 FSM 的约束生成 │
│ ✅ Guidance: 类似模板引擎的约束 │
│ │
│ 关键:不是模型本身支持,而是推理框架/API网关支持。 │
│ 同一个 Llama 模型,用 llama.cpp 可以强制约束, │
│ 用 Ollama 默认配置则不行——不是模型的问题,是框架的问题。 │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结1:Structured Output 的本质
Structured Output = 让 LLM 的输出从"自由文本"变成"类型安全的 JSON"
• 不是在 prompt 里说"请输出 JSON"——那只是建议
• 而是在 API/推理层面限制 token 生成——这是强制
• 结果:100% 保证输出的 JSON 符合你定义的 Schema总结2:JSON Mode vs JSON Schema
| 模式 | 保证内容 | 适用场景 |
|---|---|---|
| JSON Mode | 输出是合法 JSON | 结构简单,只需保证可解析 |
| JSON Schema | 字段名、类型、必填都保证 | 生产环境,输出进程序逻辑 |
总结3:两条技术路线
路线A(推荐):API 级约束生成
一次调用 → 100% 符合 Schema → 零解析代码
路线B(兜底):后处理重试
生成 → json.loads → 失败 → 重试 → 再失败 → ...
只在不支持约束生成的模型/API 上使用总结4:核心流水线
定义 Pydantic/Zod 类型
→ 自动生成 JSON Schema
→ 传给 LLM(约束生成)
→ LLM 输出符合 Schema 的 JSON
→ 自动校验(Pydantic/Zod)
→ 得到类型安全的对象
同一个类型定义既是"约束规则"又是"校验规则",双重保险。总结5:适用场景矩阵
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 实体抽取 | json_schema | 字段固定,类型确定 |
| 意图分类 | json_schema + enum | 枚举约束提高准确率 |
| 复杂嵌套结构 | 拆分为多步调用 | 避免模型在深层迷路 |
| 简单 KV 提取 | JSON Mode 即可 | 开销小,够用 |
| 本地模型 | GBNF/Outlines/Guidance | 在推理层加约束 |
| 不支持约束的 API | 后处理重试 | 没得选 |
章节测试
测试1:概念理解
Structured Output 和"在 prompt 里让 LLM 输出 JSON"的根本区别是什么?
测试2:HTTP 层面
在 OpenAI API 中,使用 Structured Output 时,请求体比普通请求多了哪个字段?它包含什么内容?
测试3:JSON Mode vs JSON Schema
以下场景应该用 JSON Mode 还是 JSON Schema?
- A. 需要保证
age字段一定是整数 - B. 只需要输出是合法的 JSON,结构无所谓
- C. 需要保证输出只有
name和email两个字段,不能多也不能少
测试4:技术路线
API 级约束生成(如 OpenAI strict)和"生成后 json.loads 再重试"的本质区别是什么?
测试5:Pydantic 流水线
在 Pydantic → JSON Schema → LLM → 校验 这个流水线中,同一个 Schema 被用了两次,分别起什么作用?
测试6:Anthropic Claude
Anthropic Claude 如何实现类似 Structured Output 的效果?与 OpenAI 的方案有什么本质区别?
测试7:局限性
你正在做一个法律合同解析系统,合同条款有深度嵌套的结构(合同 → 条款 → 子条款 → 条件 → 例外)。用 Structured Output 可能会遇到什么问题?如何解决?
参考答案
测试1答案
答案:
- "在 prompt 里让 LLM 输出 JSON"只是软约束——LLM 可能不听话,可能包裹 markdown,可能写错字段名
- Structured Output 是硬约束——在 token 生成阶段就限制,确保输出 100% 符合 Schema,不是"先输出再看对不对"
测试2答案
答案:多了一个 response_format 字段,包含:
type:"json_schema"json_schema.name: Schema 的名称json_schema.strict: 是否开启严格模式json_schema.schema: JSON Schema 定义(type, properties, required, additionalProperties 等)
这个字段告诉 API 服务端:在生成 token 时,每一步都只允许符合 Schema 的 token。
测试3答案
答案:
- A. JSON Schema——需要保证类型(integer)
- B. JSON Mode——只要合法 JSON 就行
- C. JSON Schema——需要禁止多余字段(additionalProperties: false)和强制必填(required)
测试4答案
答案:
API 级约束生成(strict):
每一步 token 生成都被 Schema 限制 → 不合法 token 不会被生成
→ 一次调用必定成功,不需要重试
→ 类型安全在生成阶段就已保证
后处理重试:
先生成,再检查 → 可能失败 → 重试
→ 每次重试都不保证成功,浪费 token
→ 类型安全依赖你自己的校验代码本质区别:前者是"生成阶段约束"(prevention),后者是"生成后校验"(detection + correction)。在安全领域,prevention 总是优于 detection。
测试5答案
答案:
第一次:作为 LLM 的约束规则(response_format),告诉 LLM "你只能输出符合这个结构的数据"。作用在生成阶段。
第二次:作为程序的数据校验规则(model_validate_json()),验证 LLM 的输出确实符合预期。作用在解析阶段。
两次使用确保:即使 API 层面的约束出了问题(虽然理论上不会),程序的校验层也能兜底。这就是 defense in depth(纵深防御)。
测试6答案
答案:
Anthropic Claude 不提供原生的 response_format 参数。要实现结构化输出,需要:
- 定义一个 tool,把
input_schema当作目标 Schema - 设置
tool_choice: {"type": "tool", "name": "..."}强制 Claude 调用该 tool - Claude 返回的
tool_use.input就是你想要的结构化数据
与 OpenAI 的本质区别:
- OpenAI 的方案是真正的约束生成(constrained decoding),100% 保证
- Anthropic 的方案是利用 tool_use 机制间接实现,更多是"引导"而非"硬约束"
- Claude 可能在 tool_use 之前输出 text 块(thinking),需要额外处理跳过
测试7答案
答案:
可能遇到的问题:
- 嵌套过深(>3-4层)时模型丢失上下文,在深层产生不符合 Schema 的输出
- 子结构之间存在微妙的语义依赖,Schema 无法表达(如"如果 type='A' 则需要字段 X,否则需要字段 Y")
- 大 Schema 的 token 开销(500-2000 tokens)在高频调用时显著增加成本
解决方案:
- 拆分为多次调用:先提取第一层结构,再逐步深入
- 对条件依赖使用
anyOf(如果 API 支持)或者分步处理 - 权衡 Schema 的精确性和 token 开销,字段描述尽量精简
相关笔记
- [[01-function-calling]] - Function Calling 本质也是 Structured Output
- [[03-rag-basics]] - RAG 中使用 Structured Output 做证据提取
- [[04-memory-management]] - 记忆管理中的结构化存储
- [[05-agent-workflow]] - 工作流中 Structured Output 作为节点间契约
下一步学习
- [ ] 阅读 03 - RAG 基础
- [ ] 实践:用 Pydantic + OpenAI Structured Output 做一个简历解析器
- [ ] 实践:用 Zod + OpenAI SDK 做一个多意图分类器
学习状态:🟡 开始学习