把不确定的文本变成确定的对象
为什么需要结构化输出
第 03 篇里我们用 Prompt 技巧让模型尽量返回 JSON,但只要把这种调用接到任何稍正式一点的系统里你会发现:再怎么调 Prompt,模型偶尔还是会返回非法 JSON、字段缺失、类型错配、多塞一段解释文字。线上千分之一概率的格式错误对 demo 没影响,对一个跑批处理的脚本来说意味着每天都会触发兜底逻辑,下游对账永远对不上。
所以工业界采用的方式,不是"祈祷 Prompt 写得够好",而是把约束从自然语言层面下沉到 API 层面 + 类型系统层面:让模型服务端在生成时就强制约束输出结构(response_format),并在客户端用 Pydantic 做强校验和类型化,从两端把不确定性挤掉。
Python 在这件事上比其他语言有优势——pydantic 几乎是 Python AI 生态的事实标准,openai SDK 已经把 Pydantic 模型当成一等公民支持。本篇会把这套链路打通。
Pydantic 极速入门
如果你没用过 pydantic,先花两分钟看明白它的核心思想就够本篇用。
pip install pydanticfrom 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
要点只有三条:
- 继承
BaseModel,类属性写类型注解,就是一份 schema - 用
Field给字段加约束(最大最小值、字符串长度、正则等) - 实例化即校验,校验失败抛
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 instructorimport 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 用你的数据"的能力的底座。
参考资料
- Pydantic 官方文档
- OpenAI Structured Outputs 指南
- instructor 库文档 — Python 结构化输出生态最活跃的项目
- JSON Schema 规范
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:结构化输出:用 Pydantic 把 LLM 变成靠谱的函数
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/04-结构化输出/
本文最后一次更新为 天前,文章中的某些内容可能已过时!