LLM 结构化输出实战:Instructor、原生 JSON Mode、手动解析三种方案横评

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 字)。

踩过的坑:

  1. markdown 代码块包裹 。大约 30% 的情况下模型会返回 json ... ,得手动剥。
  2. 枚举值大小写不一致。prompt 里写的 "positive",模型偶尔返回 "Positive" 或 "POSITIVE"。
  3. priority 返回字符串 。写明了"整数",但模型有时返回 "priority": "3" 而不是 "priority": 3
  4. 中文输入时 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。

踩过的坑:

  1. strict 模式的 schema 限制 。不支持 $ref、不支持 oneOf/anyOf 的复杂嵌套。如果你的数据结构有多态(比如"结果可能是成功也可能是失败,两种结构不同"),得想办法绕过去。

  2. additionalProperties 必须设为 false。strict 模式下如果不写这个会报错。这意味着模型不能返回任何你没定义的字段。

  3. 不支持所有 JSON Schema 关键字minimum/maximum(数值范围)、minLength/maxLength(字符串长度)都不支持。这些约束得靠 system prompt 补充说明。

  4. 只有 OpenAI 支持。Anthropic 的 Claude 没有等价功能(Claude 有 tool use 可以变相实现,但不是一回事)。你的代码跟 OpenAI 绑死了。

  5. 首次调用有冷启动。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
    )

这段代码做了什么?

  1. FeedbackTicket 的 Pydantic schema 转成 JSON Schema,注入到 API 调用里
  2. 拿到响应后用 Pydantic 验证
  3. 如果验证失败(比如 priority=7),把错误信息喂回模型让它修正
  4. 最多重试 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 踩坑记录

  1. from_provider vs patch 。旧版 Instructor 用 instructor.patch(client) 的方式,现在推荐 from_provider。但有些教程和 StackOverflow 答案还是旧写法,照抄会报 deprecation warning。

  2. streaming 模式下验证时机 。如果用 instructor.Partial 做流式输出,验证是在最终完整响应上做的,中间的 partial 结果不会触发重试。这点文档里没明说。

  3. token 开销。Instructor 会把 JSON Schema 塞进 system prompt,复杂 schema(嵌套 5+ 层)会占掉不少 context 长度。我碰到过一个 schema 本身就占了 2000 tokens 的情况。

  4. 多 provider 切换的坑from_provider("anthropic/claude-3-5-sonnet") 底层走的是 Anthropic 的 tool use,跟 OpenAI 的 structured output 实现路径不同。有些 Pydantic schema 在 OpenAI 上好用,换到 Claude 就会出问题------Claude 对 Literal 类型嵌套的支持不太好。

  5. 重试成本。每次重试是一次完整的 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 是目前最省心的方案。格式问题它帮你兜底,你只管写业务逻辑。

相关推荐
SEO_juper9 小时前
AI 内容安全写法:AIGC 初稿 + 人工 E-E-A-T 润色 + 实拍验证
人工智能·aigc·seo·跨境电商·独立站·谷歌优化·外贸电商
码农阿强9 小时前
OpenCode 快速配置指南:三步完成部署与接口对接
人工智能·ai·aigc·ai编程·gpu算力
imbackneverdie12 小时前
论文/课题/组会PPT技术路线图绘制完整教程
人工智能·信息可视化·aigc·科研·论文写作·科研绘图·ai工具
星纬智联技术13 小时前
深度测评:AI搜索引擎引用内容的共同特征与GEO优化的核心判断标准
人工智能·aigc·geo
视觉&物联智能14 小时前
【杂谈】-筑牢AI安全防线:解锁运行时保护新密钥
人工智能·安全·chatgpt·aigc·agi·deepseek
ServBay1 天前
2026 Mac 本地大模型部署深度解析与混合架构指南
后端·macos·aigc
DisonTangor1 天前
【字节拥抱开源】Lance: 多任务协同的统一多模态建模
人工智能·ai作画·开源·aigc
小虎AI生活1 天前
WorkBuddy 直接把 ima 知识库内置了,这件事比你想的大
aigc·ai编程
墨风如雪1 天前
找不到高颜值视频素材?我用Codex与Claude Code跑通了HyperFrames,效果惊艳!
aigc