LLM 结构化输出实战:Instructor、原生 JSON Mode、手动解析三种方案横评
上个月接了个需求:把用户反馈邮件自动分类,提取情绪、问题类型、优先级,塞进工单系统。听起来简单,扔给 GPT 一句 prompt 就完事了?
试了一下午,发现 LLM 返回的 JSON 大约有 15% 的概率会出问题------多个逗号、少个引号、字段名拼错、枚举值不在预定义范围里。线上跑了两天,Sentry 报了 47 个 JSON 解析错误。
这篇文章把我试过的三种结构化输出方案摆在一起,跑同一批测试数据,记录成功率、延迟、代码复杂度。不讲理论,只看实测数据。
测试环境
- 模型:GPT-4o(2025-05-13 版本)、Claude 3.5 Sonnet
- 测试数据:200 条真实用户反馈邮件(英文 120 条,中文 80 条)
- 目标 schema:情绪(positive/negative/neutral)、问题类型(bug/feature/question/other)、优先级(1-5)、摘要(string, max 100 chars)
- 每条跑 3 次取平均
目标 Pydantic 模型长这样:
python
from pydantic import BaseModel, Field
from enum import Enum
from typing import Literal
class Sentiment(str, Enum):
positive = "positive"
negative = "negative"
neutral = "neutral"
class TicketType(str, Enum):
bug = "bug"
feature = "feature"
question = "question"
other = "other"
class FeedbackTicket(BaseModel):
sentiment: Sentiment
ticket_type: TicketType
priority: int = Field(ge=1, le=5)
summary: str = Field(max_length=100)
方案一:手动 prompt + json.loads
最原始的做法。在 prompt 里写清楚 JSON 格式要求,拿到回复后 json.loads 解析。
python
import openai
import json
def extract_manual(text: str) -> dict:
prompt = f"""分析以下用户反馈,返回 JSON 格式:
{{
"sentiment": "positive" | "negative" | "neutral",
"ticket_type": "bug" | "feature" | "question" | "other",
"priority": 1-5 的整数,
"summary": "不超过100字的摘要"
}}
只返回 JSON,不要其他内容。
用户反馈:{text}"""
client = openai.OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
raw = resp.choices[0].message.content.strip()
# 常见的坑:模型会在 JSON 外面包一层 ```json ```
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(raw)
实测数据
| 指标 | GPT-4o | Claude 3.5 Sonnet |
|---|---|---|
| JSON 解析成功率 | 94.5% | 91.2% |
| Schema 合规率 | 87.3% | 83.6% |
| 平均延迟 | 1.2s | 1.8s |
"Schema 合规率"指的是解析成功后,字段值也符合约束(枚举值正确、priority 在 1-5 范围内、summary 不超 100 字)。
踩过的坑:
- markdown 代码块包裹 。大约 30% 的情况下模型会返回
json ...,得手动剥。 - 枚举值大小写不一致。prompt 里写的 "positive",模型偶尔返回 "Positive" 或 "POSITIVE"。
- priority 返回字符串 。写明了"整数",但模型有时返回
"priority": "3"而不是"priority": 3。 - 中文输入时 summary 超长。中文场景下模型对"100字"的理解不稳定,经常返回 150+ 字的摘要。
要让这套方案在生产环境跑起来,得加一层验证 + 重试:
python
import json
from pydantic import ValidationError
def extract_with_retry(text: str, max_retries: int = 3) -> FeedbackTicket:
for attempt in range(max_retries):
try:
raw = extract_manual(text)
# 手动修复常见问题
if isinstance(raw.get("priority"), str):
raw["priority"] = int(raw["priority"])
if raw.get("sentiment"):
raw["sentiment"] = raw["sentiment"].lower()
return FeedbackTicket(**raw)
except (json.JSONDecodeError, ValidationError, ValueError) as e:
if attempt == max_retries - 1:
raise
raise RuntimeError("不应该走到这里")
加上重试和手动修复后,成功率能到 98% 左右。但代码已经开始变丑了。
方案二:OpenAI 原生 JSON Mode / Structured Output
OpenAI 从 GPT-4o 开始支持 response_format 参数,可以传入 JSON Schema,让模型保证输出格式。
python
import openai
def extract_native(text: str) -> dict:
client = openai.OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是一个用户反馈分析助手。"},
{"role": "user", "content": f"分析以下反馈:{text}"}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "feedback_ticket",
"strict": True,
"schema": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"]
},
"ticket_type": {
"type": "string",
"enum": ["bug", "feature", "question", "other"]
},
"priority": {
"type": "integer"
},
"summary": {
"type": "string"
}
},
"required": ["sentiment", "ticket_type", "priority", "summary"],
"additionalProperties": False
}
}
},
temperature=0
)
return json.loads(resp.choices[0].message.content)
实测数据
| 指标 | GPT-4o (strict mode) |
|---|---|
| JSON 解析成功率 | 100% |
| Schema 合规率 | 99.7% |
| 平均延迟 | 1.4s |
JSON 格式问题彻底解决了。那 0.3% 的 schema 不合规是什么?priority 字段------JSON Schema 里 integer 类型没法直接约束范围(1-5),模型偶尔会返回 0 或 6。
踩过的坑:
-
strict 模式的 schema 限制 。不支持
$ref、不支持oneOf/anyOf的复杂嵌套。如果你的数据结构有多态(比如"结果可能是成功也可能是失败,两种结构不同"),得想办法绕过去。 -
additionalProperties 必须设为 false。strict 模式下如果不写这个会报错。这意味着模型不能返回任何你没定义的字段。
-
不支持所有 JSON Schema 关键字 。
minimum/maximum(数值范围)、minLength/maxLength(字符串长度)都不支持。这些约束得靠 system prompt 补充说明。 -
只有 OpenAI 支持。Anthropic 的 Claude 没有等价功能(Claude 有 tool use 可以变相实现,但不是一回事)。你的代码跟 OpenAI 绑死了。
-
首次调用有冷启动。OpenAI 会对 schema 做预编译缓存,第一次调用同一个 schema 会慢 200-500ms。
方案三:Instructor 库
Instructor 的思路是用 Pydantic 模型定义 schema,库自动处理 prompt 注入、响应解析、验证失败重试。
python
import instructor
from pydantic import BaseModel, Field
from enum import Enum
class Sentiment(str, Enum):
positive = "positive"
negative = "negative"
neutral = "neutral"
class TicketType(str, Enum):
bug = "bug"
feature = "feature"
question = "question"
other = "other"
class FeedbackTicket(BaseModel):
sentiment: Sentiment
ticket_type: TicketType
priority: int = Field(ge=1, le=5, description="1最低5最高")
summary: str = Field(max_length=100, description="不超过100字的摘要")
# 一行搞定
client = instructor.from_provider("openai/gpt-4o")
def extract_instructor(text: str) -> FeedbackTicket:
return client.create(
response_model=FeedbackTicket,
messages=[
{"role": "user", "content": f"分析以下用户反馈:{text}"}
],
max_retries=3
)
这段代码做了什么?
- 把
FeedbackTicket的 Pydantic schema 转成 JSON Schema,注入到 API 调用里 - 拿到响应后用 Pydantic 验证
- 如果验证失败(比如 priority=7),把错误信息喂回模型让它修正
- 最多重试 3 次
实测数据
| 指标 | GPT-4o + Instructor | Claude 3.5 + Instructor |
|---|---|---|
| 最终成功率 | 100% | 99.8% |
| Schema 合规率 | 100% | 99.8% |
| 平均延迟(含重试) | 1.5s | 2.1s |
| 重试触发率 | 3.2% | 5.1% |
重试触发率 3.2% 意味着每 100 次调用有约 3 次首次验证失败,但通过自动重试全部修正了。
Instructor 比原生 JSON Mode 多做了什么?
python
# 自定义验证器也能用
from pydantic import field_validator
class FeedbackTicket(BaseModel):
sentiment: Sentiment
ticket_type: TicketType
priority: int = Field(ge=1, le=5)
summary: str = Field(max_length=100)
@field_validator("summary")
@classmethod
def summary_not_empty(cls, v):
if len(v.strip()) < 5:
raise ValueError("摘要太短,至少5个字")
return v.strip()
@field_validator("priority")
@classmethod
def priority_matches_sentiment(cls, v, info):
# 业务规则:negative 情绪的工单 priority 不能低于 3
sentiment = info.data.get("sentiment")
if sentiment == Sentiment.negative and v < 3:
raise ValueError("负面情绪的工单优先级不能低于3")
return v
这才是 Instructor 的杀手锏------Pydantic 的 field_validator 可以写业务逻辑约束,验证失败后自动把错误原因反馈给模型重试。原生 JSON Mode 做不到这个。
Instructor 踩坑记录
-
from_provider vs patch 。旧版 Instructor 用
instructor.patch(client)的方式,现在推荐from_provider。但有些教程和 StackOverflow 答案还是旧写法,照抄会报 deprecation warning。 -
streaming 模式下验证时机 。如果用
instructor.Partial做流式输出,验证是在最终完整响应上做的,中间的 partial 结果不会触发重试。这点文档里没明说。 -
token 开销。Instructor 会把 JSON Schema 塞进 system prompt,复杂 schema(嵌套 5+ 层)会占掉不少 context 长度。我碰到过一个 schema 本身就占了 2000 tokens 的情况。
-
多 provider 切换的坑 。
from_provider("anthropic/claude-3-5-sonnet")底层走的是 Anthropic 的 tool use,跟 OpenAI 的 structured output 实现路径不同。有些 Pydantic schema 在 OpenAI 上好用,换到 Claude 就会出问题------Claude 对Literal类型嵌套的支持不太好。 -
重试成本。每次重试是一次完整的 API 调用。如果你的 schema 很复杂导致重试率高,成本会翻倍。我在一个有 15 个字段的 schema 上测到 12% 的重试率,意味着平均每次调用花的钱是预期的 1.12 倍。
三种方案对比
跑完全部测试,汇总一下:
| 维度 | 手动 prompt | 原生 JSON Mode | Instructor |
|---|---|---|---|
| JSON 格式正确率 | 94.5% | 100% | 100% |
| Schema 合规率 | 87.3% | 99.7% | 100% |
| 平均延迟 | 1.2s | 1.4s | 1.5s |
| 代码量 | 40 行(含修复逻辑) | 35 行 | 15 行 |
| 多 provider 支持 | 是 | 仅 OpenAI | 是(15+ provider) |
| 自定义验证 | 手写 | 不支持 | Pydantic validator |
| 依赖 | 无 | openai SDK | instructor + pydantic |
什么时候用哪个
手动 prompt:原型阶段随便试试可以,生产环境别用。你会花大量时间在修复 edge case 上。
原生 JSON Mode:如果你只用 OpenAI、schema 不复杂(没有数值范围、字符串长度等约束)、不需要业务逻辑验证,这个方案够用。格式保证 100% 正确,零额外依赖。
Instructor:生产环境推荐。特别是这些场景------
- schema 有复杂约束(数值范围、字段间关联校验)
- 需要在 OpenAI、Claude、Gemini 之间切换
- 团队已经在用 Pydantic 做数据校验
一个实际的生产配置
最后贴一个我在生产环境用的配置,处理客服邮件分类:
python
import instructor
from pydantic import BaseModel, Field, field_validator
from enum import Enum
from tenacity import retry, stop_after_attempt, wait_exponential
import logging
logger = logging.getLogger(__name__)
class EmailCategory(str, Enum):
refund = "refund"
shipping = "shipping"
product_issue = "product_issue"
account = "account"
praise = "praise"
other = "other"
class EmailAnalysis(BaseModel):
category: EmailCategory
urgency: int = Field(ge=1, le=5, description="1最低5最高")
customer_emotion: str = Field(
max_length=20,
description="一个词描述情绪:愤怒/焦虑/满意/困惑/平静"
)
action_needed: str = Field(
max_length=200,
description="客服需要做什么,具体行动"
)
auto_reply_ok: bool = Field(
description="这封邮件能否用自动回复处理"
)
@field_validator("action_needed")
@classmethod
def action_must_be_actionable(cls, v):
vague_words = ["处理", "解决", "跟进", "关注"]
if v in vague_words:
raise ValueError(f"行动描述太笼统:{v},需要写明具体步骤")
return v
client = instructor.from_provider("openai/gpt-4o")
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def analyze_email(email_body: str) -> EmailAnalysis:
try:
result = client.create(
response_model=EmailAnalysis,
messages=[
{"role": "system", "content": "你是客服邮件分析助手。根据邮件内容提取分类信息。"},
{"role": "user", "content": email_body}
],
max_retries=2 # Instructor 内部重试
)
return result
except Exception as e:
logger.error(f"分析失败: {e}")
raise
# 用法
result = analyze_email("你们的快递到了第五天还没到,我已经催了三次了!")
print(result.model_dump_json(indent=2))
# {
# "category": "shipping",
# "urgency": 4,
# "customer_emotion": "愤怒",
# "action_needed": "查询物流单号,联系快递公司确认配送状态,回复客户预计到达时间",
# "auto_reply_ok": false
# }
这套配置跑了三个月,日均处理 800 封邮件,Instructor 内部重试率稳定在 2-4%,没有一次最终解析失败。外层的 tenacity 重试(处理网络超时)大概一周触发 2-3 次。
关于 Instructor 的版本,目前用的 1.7.x。from_provider 是新 API,老项目如果还在用 instructor.patch(openai.OpenAI()),建议尽快迁移,patch 方式在某些 provider 上已经有兼容问题了。
一句话总结:如果你的项目需要从 LLM 拿结构化数据,Instructor + Pydantic 是目前最省心的方案。格式问题它帮你兜底,你只管写业务逻辑。