把不确定的文本变成确定的对象

为什么需要结构化输出

第 03 篇里我们用 Prompt 技巧让模型尽量返回 JSON,但只要把这种调用接到任何稍正式一点的系统里你会发现:再怎么调 Prompt,模型偶尔还是会返回非法 JSON、字段缺失、类型错配、多塞一段解释文字。线上千分之一概率的格式错误对 demo 没影响,对一个跑批处理的脚本来说意味着每天都会触发兜底逻辑,下游对账永远对不上。

所以工业界采用的方式,不是"祈祷 Prompt 写得够好",而是把约束从自然语言层面下沉到 API 层面 + 类型系统层面:让模型服务端在生成时就强制约束输出结构(response_format),并在客户端用 Pydantic 做强校验和类型化,从两端把不确定性挤掉。

Python 在这件事上比其他语言有优势——pydantic 几乎是 Python AI 生态的事实标准,openai SDK 已经把 Pydantic 模型当成一等公民支持。本篇会把这套链路打通。

Pydantic 极速入门

如果你没用过 pydantic,先花两分钟看明白它的核心思想就够本篇用。

pip install pydantic
from pydantic import BaseModel, Field
from typing import Literal

class User(BaseModel):
    name: str
    age: int = Field(ge=0, le=150)
    role: Literal["admin", "user", "guest"] = "user"

# 从字典构造,自动校验
u = User(name="alice", age=30)
print(u.name, u.age, u.role)

# 校验失败会抛 ValidationError
User(name="bob", age=200)  # ValidationError: age 必须 <= 150

要点只有三条:

  1. 继承 BaseModel,类属性写类型注解,就是一份 schema
  2. Field 给字段加约束(最大最小值、字符串长度、正则等)
  3. 实例化即校验,校验失败抛 ValidationError

后面所有 LLM 输出的"目标结构",我们都用 BaseModel 来定义。

第一种方式:response_format=json_object

openai SDK 的最朴素结构化方式是把 response_format 设成 {"type": "json_object"}。这会强制模型返回一个语法合法的 JSON(不是 markdown 包裹、不是散文)。但它不约束 JSON 的具体 schema,字段叫什么、类型是什么,仍然要靠 Prompt 描述。

from ai import _client  # 沿用 02 篇的封装

response = _client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {"role": "system", "content": "你是一个邮件分类器,输出 JSON。"},
        {"role": "user", "content": "邮件内容:恭喜您中奖了,请点击链接领取..."},
    ],
    response_format={"type": "json_object"},
    temperature=0,
)

import json
data = json.loads(response.choices[0].message.content)
print(data)

注意一个反直觉的点:用 json_object 模式时,Prompt 里必须出现 "JSON" 这个词,否则 OpenAI 协议会报错,这是协议层的硬约束。

这种方式适合简单场景,但它有两个缺点:合法 JSON 不等于合法 schema(字段错了它也"合法"),客户端仍然要做一遍校验;而且仍然依赖 Prompt 描述结构,模型理解不一致就会出错。下面看更好的方式。

第二种方式:response_format=json_schema

新版协议支持你直接传一份 JSON Schema,服务端会保证输出严格符合 schema——字段名、类型、嵌套结构都在生成阶段就被约束。这是目前最可靠的结构化输出方式

openai SDK 1.40+ 提供了一个 client.beta.chat.completions.parse 方法,可以直接传 Pydantic 模型当 schema,返回值已经是解析好的对象:

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
import os

client = OpenAI(
    api_key=os.getenv("OPENAI_API_KEY"),
    # 这个特性目前 OpenAI 官方 + 部分国产模型支持
    # DeepSeek 等如果不支持,回落到 json_object 模式
)

class EmailClassification(BaseModel):
    label: Literal["spam", "promotion", "transaction", "personal"]
    confidence: float = Field(ge=0, le=1, description="0~1 之间的置信度")
    reason: str = Field(max_length=200)

response = client.beta.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "你是一个邮件分类器。"},
        {"role": "user", "content": "邮件内容:恭喜您中奖..."},
    ],
    response_format=EmailClassification,
    temperature=0,
)

result: EmailClassification = response.choices[0].message.parsed
print(result.label, result.confidence)

注意几个关键点:

  • 返回值的 message.parsed 已经是 EmailClassification 实例,不再需要 json.loads
  • 服务端会把 Pydantic 模型转成 JSON Schema 后塞进协议,模型生成时就遵循
  • 字段的 description 会被作为提示传给模型,等于"在 schema 内部写 Prompt",比额外说一遍更稳
  • 这个能力目前是 OpenAI 官方 + 部分国产模型支持。DeepSeek 当前主要走 json_object 模式,下面给一个跨服务的统一方案

跨服务统一方案:用 instructor

instructor 是一个轻量库,把"传 Pydantic 模型 / 拿 Pydantic 实例"的体验包到几乎所有 OpenAI 兼容服务上。它在内部根据你的 Schema 自动注入 Prompt + 校验 + 失败重试,对调用者完全透明。

pip install instructor
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
import os

# 用 patch 增强 OpenAI 客户端
client = instructor.from_openai(
    OpenAI(
        api_key=os.getenv("DEEPSEEK_API_KEY"),
        base_url="https://api.deepseek.com/v1",
    )
)

class EmailClassification(BaseModel):
    label: Literal["spam", "promotion", "transaction", "personal"]
    confidence: float = Field(ge=0, le=1)
    reason: str

result = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "邮件内容:恭喜您中奖..."}],
    response_model=EmailClassification,
    max_retries=3,  # 校验失败时自动带着报错重试
)

print(result.label, result.confidence, result.reason)

instructor 内部做的事情:把 EmailClassification 转成 JSON Schema 注入 Prompt → 调用模型 → 用 Pydantic 校验返回 → 校验失败时把 ValidationError 信息追加进 messages 让模型自己修正 → 重试。整个过程对你完全透明。

这是 Python 做结构化输出最推荐的方式instructor + Pydantic,几乎所有兼容 OpenAI 协议的服务都能用。后面 RAG、Agent 等环节如果需要结构化中间结果,我们都默认用这套。

实战:从简历文本提取结构化字段

把上面所有东西串起来,做一个有真实价值的小工具——把一段非结构化的简历文本变成结构化字段。

# resume_extractor.py
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
import os
from dotenv import load_dotenv

load_dotenv()

client = instructor.from_openai(
    OpenAI(
        api_key=os.getenv("DEEPSEEK_API_KEY"),
        base_url="https://api.deepseek.com/v1",
    )
)


class WorkExperience(BaseModel):
    company: str
    title: str
    start: str = Field(description="开始时间,格式 YYYY-MM,未知填 unknown")
    end: str = Field(description="结束时间,格式 YYYY-MM 或 present")
    highlights: list[str] = Field(max_length=5, description="最多 5 条核心成就")


class Resume(BaseModel):
    name: str
    email: str | None = None
    phone: str | None = None
    summary: str = Field(max_length=300, description="对候选人的一句话总结")
    skills: list[str] = Field(max_length=15)
    experience: list[WorkExperience]
    years_of_experience: int = Field(ge=0)


def extract(resume_text: str) -> Resume:
    return client.chat.completions.create(
        model="deepseek-chat",
        messages=[
            {"role": "system", "content": "你是一个简历信息抽取器,从给定文本提取结构化字段。"},
            {"role": "user", "content": resume_text},
        ],
        response_model=Resume,
        max_retries=3,
    )


if __name__ == "__main__":
    sample = """
    张三,5 年 Python 后端经验,邮箱 [email protected]
    2021.03 - 至今 字节跳动 后端工程师
      - 主导广告投放系统重构,QPS 从 5k 提升到 20k
      - 设计并落地基于 Kafka 的事件总线
    2019.07 - 2021.02 美团 高级开发工程师
      - 负责订单履约系统的高可用改造
    技能:Python、Django、FastAPI、PostgreSQL、Kafka、Redis、Docker
    """
    resume = extract(sample)
    print(resume.model_dump_json(indent=2, ensure_ascii=False))

跑起来你会得到一个完全干净的 JSON,所有字段都过了 Pydantic 校验。重要的是,这个 extract() 函数已经可以直接当作普通 Python 函数接进任何业务系统,输入字符串、输出强类型对象,调用者不需要关心底下用了 LLM。

这就是结构化输出的终极意义——让 LLM 从"会聊天的玩具"变成系统里可以信赖的一个组件

失败时怎么办

即便加了 max_retries=3,仍然可能失败。对于关键业务流程,建议在外层再做一层:

from pydantic import ValidationError
import logging

def extract_safely(resume_text: str) -> Resume | None:
    try:
        return extract(resume_text)
    except ValidationError as e:
        logging.warning(f"结构化失败:{e}")
        # 兜底策略选其一:
        # 1. 返回 None 让上层处理
        # 2. 降级到一个最简模型再试一次
        # 3. 把这条任务塞回人工审核队列
        return None

关键经验:永远假设 LLM 调用会失败,永远给关键链路准备兜底。生产环境里 99.9% 的成功率意味着每千次就有一次需要你优雅处理。

字段描述的隐藏威力

Field(description="...") 不只是给程序员看的注释——它会被序列化进 JSON Schema 一起送给模型。这意味着你可以在 schema 内部直接给字段下指令,相当于把"Prompt"分散到了类型定义里:

class TweetGenerator(BaseModel):
    text: str = Field(
        max_length=280,
        description="正文必须 280 字符以内,结尾不要加 hashtag",
    )
    hashtags: list[str] = Field(
        max_length=3,
        description="3 个以内的 hashtag,每个不超过 20 字符,不带 # 号",
    )
    mentioned_users: list[str] = Field(
        default_factory=list,
        description="正文中 @ 提到的用户名,没有则为空数组",
    )

这种"约束写在结构里"的风格让 Prompt 极其简洁——你只需要说"生成一条推文",结构和约束都自动通过 schema 传递给模型。

本篇要点

  • 纯 Prompt 让 LLM 输出 JSON 在生产环境不够稳,必须把约束下沉到协议 + 类型系统
  • response_format={"type": "json_object"} 保证语法合法但不约束 schema,需要客户端自校验
  • response_format=PydanticModel(OpenAI 等支持)保证严格遵循 schema,是最强方案
  • 跨服务推荐 instructor 库:Pydantic schema + 自动重试 + 校验失败时让模型自修正
  • Field(description=...) 会进 schema 一起喂模型,把约束分散写进类型定义比写大段 Prompt 更优雅
  • 关键链路必须有兜底,永远假设结构化会失败

下一篇

到第 04 篇为止,我们能稳定地让 LLM "对着输入按约束输出",但模型本身只能依赖训练数据回答问题——它不知道你的私有文档、你的产品规则、你的最新业务数据。第 05 篇进入 Embedding 与向量:把任意文本编码成一组数字,用相似度比较来检索。这是 RAG、语义搜索、推荐等所有"让 AI 用你的数据"的能力的底座。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:结构化输出:用 Pydantic 把 LLM 变成靠谱的函数

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/04-结构化输出/

本文最后一次更新为 天前,文章中的某些内容可能已过时!