结构化输出实战:让大模型输出可编程的 JSON
AI 导读
结构化输出实战:让大模型输出可编程的 JSON 作者:Maurice | 灵阙学院 前言 大模型最令人头疼的生产问题不是"输出错了",而是"输出了一个几乎正确的 JSON"。 漏了一个逗号,多了个 markdown 代码块围栏,把数字输出成字符串——这类问题不会在开发阶段暴露,专挑凌晨三点生产告警的时候出现。 本文系统梳理结构化输出的三种实现路径、各主流厂商 API 的落地方法、Pydantic...
结构化输出实战:让大模型输出可编程的 JSON
作者:Maurice | 灵阙学院
前言
大模型最令人头疼的生产问题不是"输出错了",而是"输出了一个几乎正确的 JSON"。
漏了一个逗号,多了个 markdown 代码块围栏,把数字输出成字符串——这类问题不会在开发阶段暴露,专挑凌晨三点生产告警的时候出现。
本文系统梳理结构化输出的三种实现路径、各主流厂商 API 的落地方法、Pydantic + Instructor 的 2025 最佳实践,以及高阶的"两步推理法"。目标只有一个:让大模型输出真正可编程的 JSON,而不是"看起来像 JSON 的字符串"。
一、为什么需要结构化输出
1.1 工程系统需要可解析的数据
大模型的默认输出是自然语言,设计上就是为人类阅读优化的。但当 LLM 嵌入工程系统时,下游往往是另一个程序:
- 数据提取管道:从合同文本提取甲方、金额、日期
- API 响应生成:LLM 直接充当微服务,输出符合 OpenAPI 规范的响应
- 多步骤 Agent:每一步的输出作为下一步的输入,必须机器可读
- 评测系统:模型打分结果需要写入数据库
这些场景对输出格式的要求是 100% 确定性,而不是"大概率正确"。
1.2 "几乎正确的 JSON"是最危险的 bug
与其说 LLM 会输出乱码,不如说它会输出"高度相似但不合规"的结构。常见的生产问题包括:
# 模型可能输出的各种"坏 JSON"
# 问题1:Markdown 代码块包裹
```json
{"name": "张三", "age": 28}
问题2:字段名不一致(age vs Age vs user_age)
{"Name": "张三", "Age": "28"}
问题3:数字变字符串
{"name": "张三", "age": "二十八"}
问题4:多余的解释文字
这是结果:{"name": "张三", "age": 28},希望符合您的需求。
问题5:截断(尤其在 streaming 场景)
{"name": "张三", "age": 28, "address": "北京市朝
每一种都会导致 `json.loads()` 抛出异常,或者字段值类型不匹配的 bug。
### 1.3 问题的本质
LLM 的解码过程是逐 token 的概率采样。在没有约束的情况下,模型会用自然语言的直觉来"猜测"什么是好的输出——而自然语言的直觉和 JSON RFC 规范并不完全一致。结构化输出的本质,是在解码层面施加约束,让模型只能生成符合规范的 token 序列。
---
## 二、三种实现方式对比
### 2.1 方式一:Prompt 约束(Prompt-Only)
最简单也最脆弱的方式。在 System Prompt 或 User Prompt 中明确要求模型输出 JSON:
```python
system_prompt = """
你是一个信息提取助手。
请严格按照以下 JSON 格式输出,不要包含任何其他文字:
{"name": "string", "age": "integer", "city": "string"}
"""
问题:模型会"努力遵守",但遵守率受模型能力、提示词质量、输出长度影响,在复杂 Schema 下可靠性显著下降。这是一种"软约束",没有任何技术保证。
2.2 方式二:JSON Mode
OpenAI、Anthropic(早期)、Google 都提供 JSON Mode。在 API 层面开启后,模型保证输出合法的 JSON(json.loads() 不报错),但不保证字段名、字段类型、Schema 结构。
# OpenAI JSON Mode 示例
response = client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"}, # 开启 JSON Mode
messages=[{"role": "user", "content": "提取姓名和年龄,以 JSON 输出"}]
)
局限:你可能得到 {"姓名": "张三", "年龄": 28},也可能得到 {"name": "张三", "年": 28}。格式合法,但 Schema 不确定。
注意:使用 JSON Mode 时,System Prompt 中必须提及 "JSON",否则 OpenAI API 会报错。
2.3 方式三:Structured Output(Schema 约束)
2024-2025 年各主流厂商相继推出的最高强度保证。在 JSON Mode 的基础上,额外传入 JSON Schema,模型通过受约束的解码(constrained decoding)保证输出完全符合 Schema。
# OpenAI Structured Output 示例
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
response_format={
"type": "json_schema",
"json_schema": {
"name": "person_info",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"],
"additionalProperties": False
}
}
},
messages=[{"role": "user", "content": "从文本提取人员信息:张三今年28岁"}]
)
2.4 三种方式对比表
| 维度 | Prompt 约束 | JSON Mode | Structured Output |
|---|---|---|---|
| 合法 JSON 保证 | 无 | 有 | 有 |
| Schema 一致性保证 | 无 | 无 | 有 |
| 可靠性 | 低(70-90%) | 中(99%+合法JSON) | 高(接近100%) |
| 灵活性 | 最高 | 高 | 中(Schema有限制) |
| 支持厂商 | 全部 | OpenAI、Gemini、Mistral等 | OpenAI、Anthropic(Beta)、Google |
| 延迟影响 | 无 | 轻微 | 轻微(+5-15ms) |
| Schema 深度限制 | 无 | 无 | OpenAI 最多 5 层嵌套、100 属性 |
| 推荐场景 | 原型验证 | 简单内容生成 | 生产数据管道 |
结论:生产环境优先使用 Structured Output;不支持时退回 JSON Mode;仅在快速原型阶段使用 Prompt 约束。
三、各厂商 API 实战
3.1 OpenAI:原生 JSON Schema 支持
OpenAI 从 gpt-4o-2024-08-06 开始支持 Structured Output,Python SDK 支持直接传入 Pydantic 模型。
from openai import OpenAI
from pydantic import BaseModel
from typing import List, Optional
client = OpenAI()
class Address(BaseModel):
street: str
city: str
province: str
class PersonInfo(BaseModel):
name: str
age: int
occupation: Optional[str] = None
address: Address
# 方式A:直接传 Pydantic 模型(推荐,SDK 自动转换为 JSON Schema)
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "从用户提供的文本中提取人员信息。"},
{"role": "user", "content": "张三是北京朝阳区的一名软件工程师,今年32岁。"}
],
response_format=PersonInfo,
)
person = completion.choices[0].message.parsed
print(person.name) # 张三
print(person.address.city) # 北京
3.2 Anthropic Claude:Structured Output Beta(2025.11 发布)
Claude 于 2025 年 11 月正式发布 Structured Output(公测),支持 output_format 参数配合 JSON Schema,需要传 beta header。在此之前,生产环境通常用 tool_use 模拟结构化输出。
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
class ExtractedEntity(BaseModel):
entity_type: str # PERSON / ORG / LOCATION
entity_name: str
context: str
class ExtractionResult(BaseModel):
entities: list[ExtractedEntity]
confidence: float
# 方式A:Structured Output Beta(Claude Sonnet 4.5 / Opus 4.1)
response = client.beta.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "从以下文本提取实体:阿里巴巴创始人马云在杭州创建了公司。"}],
betas=["structured-outputs-2025-11-13"],
output_format={
"type": "json_schema",
"json_schema": ExtractionResult.model_json_schema()
}
)
# 方式B:tool_use 模拟(兼容旧版 Claude,生产中仍广泛使用)
tools = [
{
"name": "extract_entities",
"description": "提取文本中的实体信息",
"input_schema": ExtractionResult.model_json_schema()
}
]
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "extract_entities"},
messages=[{"role": "user", "content": "从以下文本提取实体:阿里巴巴创始人马云在杭州创建了公司。"}]
)
# 从 tool_use 响应中解析结果
tool_result = next(b for b in response.content if b.type == "tool_use")
result = ExtractionResult(**tool_result.input)
注意:tool_use 模拟的核心原理是把 Schema 放进工具的 input_schema,然后强制模型调用该工具(tool_choice: force)。由于工具调用本身有 Schema 验证,输出可靠性接近原生 Structured Output。
3.3 Google Gemini:response_schema 参数
Gemini 2.0/2.5 全系列支持 response_schema,可直接传 Pydantic 模型。
import google.generativeai as genai
from pydantic import BaseModel
from typing import List
import enum
class Sentiment(str, enum.Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
class ReviewAnalysis(BaseModel):
sentiment: Sentiment
key_points: List[str]
score: float # 0.0 - 10.0
summary: str
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel(
"gemini-2.0-flash",
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
response_schema=ReviewAnalysis,
)
)
response = model.generate_content(
"分析这条用户评论:'产品质量很好,但发货太慢了,等了两周。'"
)
import json
result = ReviewAnalysis(**json.loads(response.text))
print(result.sentiment) # Sentiment.NEGATIVE or NEUTRAL
print(result.key_points) # ['产品质量好', '发货速度慢']
四、Pydantic + Instructor:2025 生产最佳实践
4.1 为什么需要 Instructor
原生 API 的 Structured Output 解决了"格式保证"问题,但没有解决:
- 验证失败后的自动重试:Schema 合规了,但业务逻辑不合法(如 score > 10)怎么办?
- 多 Provider 统一接口:同一套代码切换 OpenAI / Anthropic / Gemini
- Streaming 场景的增量解析:实时输出部分结果
Instructor 库(3M+ 月下载量)专门解决这些问题。
4.2 基础用法
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator
from typing import List
# 用 instructor 包装标准 client
client = instructor.from_openai(OpenAI())
class ContractParty(BaseModel):
name: str = Field(description="合同主体名称")
role: str = Field(description="甲方或乙方")
credit_code: str = Field(description="统一社会信用代码,18位")
@field_validator("credit_code")
@classmethod
def validate_credit_code(cls, v: str) -> str:
if len(v) != 18:
raise ValueError(f"信用代码必须18位,当前为{len(v)}位")
return v
class ContractInfo(BaseModel):
parties: List[ContractParty]
contract_amount: float = Field(description="合同金额,单位元")
signing_date: str = Field(description="签署日期,格式 YYYY-MM-DD")
contract_type: str
# Instructor 自动处理验证失败的重试(默认最多3次)
contract = client.chat.completions.create(
model="gpt-4o",
response_model=ContractInfo,
messages=[
{"role": "system", "content": "你是一个合同信息提取助手。"},
{"role": "user", "content": CONTRACT_TEXT} # 合同原文
],
max_retries=3 # 验证失败自动重试,并把错误信息反馈给模型
)
print(contract.contract_amount) # 直接访问属性,无需 json.loads
4.3 多 Provider 统一接口
import instructor
import anthropic
import google.generativeai as genai
# 切换 Provider,只需改这一行
# OpenAI
client = instructor.from_openai(OpenAI())
# Anthropic
client = instructor.from_anthropic(anthropic.Anthropic())
# Google Gemini
client = instructor.from_gemini(
client=genai.GenerativeModel("gemini-2.0-flash"),
mode=instructor.Mode.GEMINI_JSON
)
# 调用代码完全一致
result = client.chat.completions.create(
model="...",
response_model=ContractInfo,
messages=[...]
)
4.4 Instructor 重试机制详解
当 Pydantic 验证失败时,Instructor 会把验证错误作为额外的 user message 追加进对话,让模型"看到自己的错误并修正":
[第1次调用]
User: 提取合同信息...
Assistant: {"credit_code": "91310000MA1FL"} <- 只有14位,验证失败
[自动重试:Instructor 追加错误信息]
User: 提取合同信息...
Assistant: {"credit_code": "91310000MA1FL"}
User: 验证失败:信用代码必须18位,当前为14位。请修正后重新输出。
Assistant: {"credit_code": "91310000MA1FLXXXX07"} <- 修正,验证通过
这个机制的优雅之处在于:它把"格式错误"变成了对话上下文,让模型有机会自我纠正,而不是直接抛出异常。
五、两步推理法(Advanced Pattern)
5.1 为什么直接要求 JSON 会降低推理质量
研究发现,当模型被强制以 JSON 格式输出时,推理质量会下降。原因在于:
- Token 生成顺序影响注意力:JSON 中
"answer"key 出现在"reasoning"之前时,模型实际上是"先说答案再补理由",违背了思维链的本质 - 格式约束占用模型容量:模型要同时思考"内容是什么"和"格式是否合规",两者竞争注意力
- 嵌套 Schema 增加负担:复杂 Schema 要求模型在生成每个 token 时都维护当前所处的 Schema 路径
在 GSM8k 数学推理任务上,加入 reasoning 字段并让它先于 answer 输出,准确率提升 60%。
5.2 两步推理法实现
import instructor
from openai import OpenAI
from pydantic import BaseModel
client = instructor.from_openai(OpenAI())
# Step 1:自由推理,无格式约束
def step1_reason(question: str) -> str:
"""让模型用自然语言自由思考,不施加 JSON 约束"""
response = OpenAI().chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": "你是一个专业分析师,请用自然语言详细分析以下问题。"
},
{"role": "user", "content": question}
]
)
return response.choices[0].message.content
# Step 2:格式化,将推理结果转换为 JSON
class AnalysisResult(BaseModel):
conclusion: str
confidence_level: str # HIGH / MEDIUM / LOW
key_evidence: list[str]
recommended_actions: list[str]
risk_factors: list[str]
def step2_format(reasoning_text: str, schema_class) -> AnalysisResult:
"""将自然语言推理结果格式化为 JSON"""
return client.chat.completions.create(
model="gpt-4o",
response_model=schema_class,
messages=[
{
"role": "system",
"content": "将以下分析内容整理为结构化 JSON,不要改变分析的核心结论。"
},
{"role": "user", "content": f"待整理的分析内容:\n\n{reasoning_text}"}
]
)
# 完整流程
def analyze_with_two_steps(question: str) -> AnalysisResult:
reasoning = step1_reason(question)
result = step2_format(reasoning, AnalysisResult)
return result
result = analyze_with_two_steps("分析某电商平台 Q3 用户留存率下降 12% 的可能原因")
print(result.conclusion)
print(result.recommended_actions)
5.3 Schema 设计中的 reasoning 字段技巧
如果不想用两步法(有额外 API 调用成本),可以在 Schema 中设计 reasoning 字段,并保证它排在实质性答案字段之前。大多数 LLM 按字段定义顺序生成:
class EvaluationResult(BaseModel):
# reasoning 排在最前面,强制模型先"思考"再"打分"
reasoning: str = Field(description="评分依据,详细说明打分理由")
score: int = Field(ge=1, le=10, description="综合评分 1-10")
strengths: list[str] = Field(description="优势列表")
weaknesses: list[str] = Field(description="不足列表")
verdict: str = Field(description="总体判断:APPROVE / REJECT / REVIEW")
六、复杂 Schema 设计原则
6.1 嵌套对象与数组
from pydantic import BaseModel
from typing import List, Optional
from enum import Enum
class InvoiceStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
tax_rate: float = 0.13 # 默认13%增值税
amount: float
class Invoice(BaseModel):
invoice_number: str
invoice_date: str
seller_name: str
buyer_name: str
line_items: List[LineItem]
total_amount: float
status: InvoiceStatus = InvoiceStatus.PENDING
notes: Optional[str] = None
6.2 Schema 设计原则
宁简勿繁,避免深嵌套:
| 反模式 | 推荐做法 |
|---|---|
| 超过 4 层嵌套 | 拆分为多个独立 Schema,分步提取 |
| 全部字段 required | Optional 字段给默认值,减少模型失败概率 |
| 字段名含歧义 | 用 Field(description=...) 明确语义 |
| 单个 Schema 超过 30 个字段 | 拆分子 Schema,分批提取后合并 |
| 递归 Schema(如树状结构) | OpenAI Structured Output 不支持,用 JSON Mode + Prompt |
枚举优于字符串:能用枚举的地方不用自由字符串,大幅降低模型随意发挥的空间。
# 不推荐
class Result(BaseModel):
status: str # 模型可能输出 "ok" / "success" / "done" / "completed"
# 推荐
class Status(str, Enum):
SUCCESS = "success"
FAILED = "failed"
PENDING = "pending"
class Result(BaseModel):
status: Status # 只能是三个值之一
七、错误处理与容错
7.1 重试策略
import json
import re
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
def extract_with_retry(text: str) -> dict:
"""带指数退避的重试"""
response = call_llm(text)
return json.loads(response)
def extract_with_fallback(text: str) -> dict | None:
"""完整的容错链"""
# 尝试1:Structured Output
try:
return call_structured_output(text)
except Exception:
pass
# 尝试2:JSON Mode + json.loads
try:
raw = call_json_mode(text)
return json.loads(raw)
except json.JSONDecodeError:
pass
# 尝试3:正则提取 JSON 块
try:
raw = call_plain_llm(text)
pattern = r'```json\s*([\s\S]*?)\s*```|(\{[\s\S]*\})'
match = re.search(pattern, raw)
if match:
json_str = match.group(1) or match.group(2)
return json.loads(json_str)
except Exception:
pass
# 降级:返回 None,上游决定如何处理
return None
7.2 Streaming 场景的增量 JSON 解析
import instructor
from openai import OpenAI
client = instructor.from_openai(OpenAI(), mode=instructor.Mode.TOOLS)
# Instructor 支持 streaming partial 解析
for partial_result in client.chat.completions.create_partial(
model="gpt-4o",
response_model=ContractInfo,
messages=[{"role": "user", "content": CONTRACT_TEXT}],
stream=True
):
# partial_result 是 Partial[ContractInfo],字段可能为 None(尚未生成)
if partial_result.parties:
# 只要有数据就开始处理,不等全部生成完
print(f"已提取 {len(partial_result.parties)} 个合同主体")
八、性能与成本考量
8.1 结构化约束对速度的影响
Structured Output 基于受约束解码(constrained decoding),在每个 token 生成前需要对词表进行 Schema 过滤。实测影响:
- 首 token 延迟(TTFT):+10-30ms(Schema 编译一次后缓存)
- 后续 token 速度:几乎无影响(约束检查 < 1ms/token)
- 总体影响:对于典型 200-500 token 的输出,延迟增加通常 < 5%
8.2 JSON 的 Token 成本
JSON 的键名会消耗大量 Token,这在高并发场景下不可忽视:
# 同样的数据,不同格式的 Token 消耗差异
JSON(含字段名):
{"name": "张三", "age": 28, "city": "北京", "occupation": "工程师"}
约 25 tokens
纯值(压缩格式,需要客户端解包):
张三|28|北京|工程师
约 8 tokens(节省 68%)
成本优化建议:
- 高频调用场景:考虑压缩字段名(
a代替address),用文档记录映射关系 - 使用
additionalProperties: false避免模型生成多余字段 - 合理设置
maxTokens,过大的预算会让模型"话痨"
8.3 Schema 缓存
OpenAI 对 Structured Output 的 Schema 编译结果有服务端缓存。首次使用某个 Schema 时有轻微的额外延迟,之后缓存命中几乎无额外开销。同一个 Schema 的多次调用会复用缓存。
九、实战场景速查
9.1 数据提取
class EmailMetadata(BaseModel):
sender: str
recipients: list[str]
subject: str
intent: str # 询价 / 投诉 / 咨询 / 合作
urgency: str # HIGH / MEDIUM / LOW
action_required: bool
key_info: list[str] # 需要关注的关键信息点
# 适用:CRM 邮件自动分类、合同信息提取、发票 OCR 后处理
9.2 API 响应生成
class APIResponse(BaseModel):
success: bool
code: int
message: str
data: dict | None = None
errors: list[str] = []
# 适用:LLM 直接生成符合 OpenAPI 规范的响应体
9.3 多步骤 Agent 的步骤输出
class AgentStep(BaseModel):
step_id: int
action: str # tool_call / reasoning / final_answer
tool_name: str | None
tool_args: dict | None
reasoning: str # 先推理,后行动
confidence: float # 0.0 - 1.0
# 适用:ReAct 框架、工具调用 Agent、代码执行 Agent
9.4 评测打分
class EvalResult(BaseModel):
reasoning: str # 必须放在最前,强制先思考
dimension_scores: dict[str, float] # 各维度得分
overall_score: float
pass_fail: bool
feedback: str
# 适用:LLM-as-Judge 评测框架、内容审核、代码质量评分
十、小结:选型决策树
生产环境中需要解析 LLM 输出?
│
├── 是否需要 Schema 保证?
│ ├── 否 → JSON Mode(OpenAI / Gemini)
│ └── 是 → Structured Output
│ ├── OpenAI → response_format: json_schema + strict: true
│ ├── Anthropic → beta Structured Output 或 tool_use 模拟
│ └── Google → response_schema 参数
│
├── 有复杂业务验证逻辑?
│ └── 是 → Instructor + Pydantic(自动重试+多 Provider)
│
├── 推理质量要求高?
│ └── 是 → 两步推理法(Step1 自由推理,Step2 格式化)
│
└── 有 Streaming 需求?
└── 是 → Instructor create_partial 增量解析
结构化输出本质上是"把工程契约写进 AI 调用"——不是靠提示词祈祷,而是用 Schema 约束确保每次调用的输出都是程序可消费的数据。2025 年的主流 LLM 已经具备了足够成熟的技术支撑,开发者不再需要在"灵活性"和"可靠性"之间二选一。
生产环境中,Pydantic + Instructor + Structured Output 三件套,是目前最经过验证的组合。
Maurice | maurice_wen@proton.me