7. 使用 Pydantic 实现结构化输出
在前面的章节中,我们一直将 LLM 输出作为原始文本字符串处理。这对于人类阅读响应的聊天机器人来说效果很好,但在构建需要程序解析和解释 LLM 输出的 AI 代理(agent)时,我们需要可预测的结构化数据。在本章中,你将学习如何使用 Pydantic 模式(schema)使 LLM 返回结构化的 Python 对象。
7.1) 为什么需要结构化输出?
自由文本 LLM 输出的问题
让我们首先了解为什么原始文本响应在实际应用中会产生问题。考虑这个常见场景:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# 向 LLM 询问产品信息
message = HumanMessage(content="""
从以下文本中提取产品信息:
"UltraWidget Pro 售价 $299.99,目前有货。"
""")
response = llm.invoke([message])
print(response.content)输出:
产品名称: UltraWidget Pro
价格: $299.99
库存状态: 有货输出看起来不错。但现在假设你需要在 Python 应用程序中使用这些数据。你如何将价格提取为数字?如何以编程方式检查库存状态?你可能会尝试这样的字符串解析:
# 脆弱的解析方法
text = response.content
price_line = [line for line in text.split('\n') if '价格:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # 脆弱 - 如果格式改变怎么办?这种解析似乎有效。但实际上并非如此。原因如下:
为什么这种方法会失败:
- LLM 下次可能会以不同方式格式化响应("价格: 299.99 美元" 或 "零售价: $299.99")
以下是同一提示可能出现的不同输出示例:
# 示例 1
"该产品是 UltraWidget Pro,售价 $299.99,有货。"
# 示例 2
"产品: UltraWidget Pro
成本: 299.99 美元
状态: 有货"
# 示例 3
"UltraWidget Pro - $299.99 (有货)"
# 示例 4
"我找到了 UltraWidget Pro。它售价 $299.99,目前可以购买。"当 LLM 响应改变时,你需要完全不同的解析逻辑。这使得构建可靠的应用程序变得困难。
- LLM 响应不可预测: 同一提示每次都可能产生不同的格式
- 字符串解析比看起来更难: 你需要处理
$、空格、换行符、逗号等 - 完全没有类型安全: 你无法确定
price是浮点数、字符串还是 None - 错误处理困难: 如果 LLM 说"价格不可用",你的
float()调用会崩溃 - 难以维护: 稍微改变提示,你就要重写所有解析代码
核心思想: Python 需要契约,而非散文
想想当你在 Python 中与 API 服务器通信时。当你调用特定的 REST API 时,你期望它返回定义好的 JSON 响应:
# 你期望这种结构
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}在构建 AI 应用程序时,你需要同样的原则。LLM 输出应该作为具有定义结构的数据返回,而不是每次都返回自由格式的文本。
散文 vs 契约:
- 散文: 自由格式的自然文本。适合人类阅读,但程序难以处理。
- 契约: 具有定义结构和类型的数据。一个承诺:"这些字段将以这些类型存在。"
结构化输出意味着定义契约: "LLM,我需要恰好这些字段,恰好这些类型,恰好这种格式。"
这就是 Pydantic 的用武之地。Pydantic 是 Python 最流行的数据验证库,LangChain 使用它以结构化形式接收 LLM 输出。
思维模式转变:
- 之前: "LLM,告诉我这个产品的信息" → 解析不可预测的文本
- 之后: "LLM,以定义的格式响应" → 接收结构化的 Python 对象
这种从散文到契约的转变是构建可靠 AI 代理的基础。当代理需要根据 LLM 响应决定下一步行动时(例如,如果有货则购买,如果没货则注册通知),它必须以定义的格式接收响应。
7.2) 你的第一个结构化输出
在 7.1 中,我们了解了为什么 LLM 应该以定义的结构而非自由格式文本响应。现在让我们看看如何实际实现这一点。
关键思想: 仅仅要求 LLM "请以这种格式响应" 是不够的。你需要在 Python 代码中定义确切的数据结构,并让 LangChain 将其传递给 LLM。这个定义的数据结构称为模式(schema)。
什么是模式?
模式(schema) 是定义数据结构的蓝图。它指定:
- 必须存在哪些字段
- 每个字段应该是什么类型(字符串、数字、布尔值等)
- 应用哪些约束(可选 vs 必需、有效范围等)
在 Python 中,我们使用 Pydantic 的 BaseModel 类定义模式。这是最简单的示例:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool这个模式说:"ProductInfo 对象必须恰好有三个字段:product_name(字符串)、price(浮点数)和 in_stock(布尔值)。"
三步模式: 定义、绑定、调用
使用结构化输出很简单。只需记住三个步骤:
- 定义: 使用 Pydantic 类创建模式
- 绑定: 使用
.with_structured_output()将模式连接到 LLM - 调用: 调用
.invoke()获取类型化对象
这是你将用于大多数结构化提取任务的标准模板:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# 步骤 1: 定义模式
class ProductInfo(BaseModel):
product_name: str = Field(description="完整的产品名称")
price: float = Field(description="以美元计的价格")
in_stock: bool = Field(description="产品是否有货")
# 步骤 2: 将模式绑定到 LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# 步骤 3: 调用并获取类型化对象
message = HumanMessage(content="""
从以下文本中提取产品信息:
"UltraWidget Pro 售价 $299.99,目前有货。"
""")
result = structured_llm.invoke([message])
# result 现在是 ProductInfo 对象,而不是字符串
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True刚才发生了什么?
- 模式定义: 我们定义了想要的字段和类型
- 绑定:
.with_structured_output(ProductInfo)配置 LLM 使用结构化输出 - 调用和响应: 调用
.invoke()时,LangChain 将 JSON Schema 传递给 LLM,LLM 以匹配该结构的 JSON 响应 - 自动转换: LangChain 将 JSON 转换为
ProductInfo对象 - 不需要解析代码
无需解析。无需类型转换。无需错误。
将这个三步模式作为你的模板。 每当你需要结构化输出时,都遵循它。
字段描述: 引导 LLM 的关键
在上面的模式定义示例中,我们使用了 Field(description="...")。这个描述不仅仅是文档。它是 LLM 读取并遵循的指令。
在典型的 Pydantic 使用中,Field 描述是可选的:
# 常规 Pydantic - 描述是给人类看的文档
class User(BaseModel):
name: str = Field(description="用户名称") # 没有它也能正常工作但在使用 LLM 时,它们是必不可少的:
# 使用 LLM - 描述决定 LLM 行为
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="整体情感: 'positive'、'negative' 或 'neutral'"
)LLM 读取这个描述并用它来决定如何响应。
让我们看看实际效果:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="整体情感: 'positive'、'negative' 或 'neutral'"
)
main_issue: str = Field(
description="主要投诉或关注点(如果有)。如果没有提到问题,使用 'none'。"
)
urgency: str = Field(
description="问题的紧急程度: 'low'、'medium' 或 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
分析这条客户反馈:
"产品运行良好,但配送花了 3 周时间。我已经错过了项目截止日期。请立即回复。"
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # Slow shipping
print(result.urgency) # high描述如何塑造 LLM 决策:
sentiment描述 → LLM 了解有效值是 'positive'、'negative'、'neutral' → 配送问题导致错过截止日期,所以选择 'negative'main_issue描述 → LLM 被指示"找到主要投诉" → 识别"配送缓慢"为问题urgency描述 → LLM 了解紧急程度必须是 'low'、'medium' 或 'high' → 看到"请立即回复"并选择 'high'
没有描述会发生什么?
sentiment: str # 没有描述LLM 可能以不可预测的格式返回 "negative"、"bad"、"unsatisfied"、"2/5"、"disappointed",使你的代码难以处理这些值。
关键点: 字段描述是控制 LLM 行为的代码的一部分。清晰具体地编写它们。
分类字段: 指定允许的值
在上面的示例中,sentiment 字段只能有三个值:'positive'、'negative' 或 'neutral'。必须是特定值集合之一的字段称为分类字段。
对于分类字段,在描述中列出所有可能的值:
sentiment: str = Field(
description="情感: 恰好是 'positive'、'negative' 或 'neutral'(小写)"
)通过指定"恰好"和"(小写)",我们强调 LLM 应该以恰好这三个值之一响应。
但是,不能保证 LLM 总是以指定值之一响应。这就是为什么你必须编写防御性代码。
LLM 返回意外值的情况:
result.sentiment = "Positive" # 首字母大写
result.sentiment = "NEGATIVE" # 全大写
result.sentiment = "good" # 完全不同的词编写防御性代码:
allowed = {"positive", "negative", "neutral"}
# 转换为小写并检查
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # 对意外值使用默认值
# 现在 sentiment 保证是允许值之一关键要点:
- 在描述中指定允许的值 → LLM 更可能正确响应
- 在代码中验证 → 安全处理意外值
注意: 第 18 章展示了使用 Python 枚举进行强制执行的更强模式。
比较: 手动解析 vs 结构化输出
让我们比较使用和不使用结构化输出完成相同任务,看看区别:
手动解析:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
从以下内容中提取产品名称、价格和库存状态:
"UltraWidget Pro 售价 $299.99,目前有货。"
格式: 名称 | 价格 | 库存状态
""")
response = llm.invoke([message])
text = response.content
# 手动解析
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'in stock' in availability or 'available' in availability
print(f"名称: {product_name}")
print(f"价格: ${price}")
print(f"有货: {in_stock}")结构化输出:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="完整的产品名称")
price: float = Field(description="以美元计的价格")
in_stock: bool = Field(description="产品是否有货")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
从以下内容中提取产品信息:
"UltraWidget Pro 售价 $299.99,目前有货。"
""")
result = structured_llm.invoke([message])
print(f"名称: {result.product_name}")
print(f"价格: ${result.price}")
print(f"有货: {result.in_stock}")关键区别:
- 无解析逻辑: 结构化版本没有解析代码
- 类型安全:
result.price保证是浮点数 - 更简单的代码: 无正则表达式、无字符串分割、无手动类型转换
- 验证: Pydantic 确保所有必需字段都存在
- 可维护性: 更改模式比更新解析逻辑更容易
7.3) 模式设计考虑因素
现在你知道如何使用结构化输出,让我们学习如何设计好的模式。本节涵盖区分必需字段和可选字段的实用设计原则。
必需字段
默认情况下,Pydantic 模型中的所有字段都是必需的。这意味着 LLM 必须从用户的提示中提取或推断每个必需字段的值,并在响应中提供它。
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool当你使用这个模式时,LLM 将尝试在输入文本中找到所有三个字段(product_name、price、in_stock)的值。
但是当提示缺少必需字段的信息时会发生什么?
我们可能期望以下行为:
- LLM 在提示中找不到信息
- LLM 在响应中省略该字段
- LangChain 无法创建有效的
ProductInfo实例 - 引发
ValidationError
然而,这并不总是发生。
原因是不同的 LLM 可能以不同方式处理缺失信息。
一些 LLM(如 OpenAI 模型)倾向于即使提示中不存在所需信息也生成值。在这种情况下,不会发生 ValidationError,但这可能导致更大的问题,因为你的 Python 应用程序可能会将虚构的信息当作真实信息处理。
我们将在 7.4 节: 当出现问题时 解决如何解决这个问题。
现在,只需注意并非所有 LLM 都以相同方式处理缺失信息。
可选字段
你可能需要即使在正常情况下也可以合法存在或不存在的字段。例如,配送备注(delivery_note)可能由客户提供,也可能不提供,即使对于有效订单也是如此。
何时使用 Optional:
- 数据本身可能不存在(例如,当允许匿名评论时,匿名评论没有评论者姓名)
- 你希望 LLM 明确指示缺失信息,而不是虚构值
要使字段可选,使用 typing 模块中的 Python Optional 类型:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # 匿名评论没有评论者姓名注意: Python 3.10+ 用户可以使用
str | None代替Optional[str]。
当字段是 Optional 时:
- 如果在提示中找不到信息,LLM 可以在响应中省略它
- 省略的字段设置为默认值(
None)
这是一个完整的示例:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
提取产品信息: "UltraWidget Pro 售价 $299.99,有货。"
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (未提及)
print(result.warranty_years) # None (未提及)模式设计检查清单
在最终确定模式之前,问自己:
字段选择:
- 必需字段真的是必不可少的吗?(如果提示中缺少此字段会发生什么?)
- 可选字段即使在正常情况下也可以合法缺失吗?
字段规范:
- 每个字段都有清晰的描述吗?
- 分类字段是否明确约束?(例如,"必须恰好是 'A'、'B' 或 'C'")
7.4) 当出现问题时
使用结构化输出时可能出现两个问题:
- LLM 省略必需字段值 → 发生 ValidationError
- LLM 虚构缺失信息 → 不发生 ValidationError,但你的代码处理不正确的数据
本节介绍如何处理每种情况。
理解验证错误
当用户提示缺少模式中定义的必需字段的信息时,LLM 无法为这些字段提供值。然后 Python 程序引发 ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# 输入缺少必需信息
message = HumanMessage(content="""
从以下内容中提取产品信息: "这个小部件很棒!强烈推荐。"
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("发生 ValidationError")注意: 当提示缺少必需字段的信息时,一些 LLM 可能会虚构值并在响应中提供它们。在这种情况下,不会发生
ValidationError,但会出现更大的问题。我们将在下一节介绍这一点。
当提示缺少必需字段的信息并发生 ValidationError 时,这实际上对你的 Python 应用程序有帮助。应用程序可以检测到发生了问题,并以受控方式处理错误。错误恢复策略在 第 14 章(代理级错误恢复)和 第 17 章(使用状态(state)管理的重试逻辑)中介绍。
更大的问题: LLM 虚构缺失信息
正如我们在 7.3 节 中讨论的,一些 LLM 表现出更危险的行为:当提示中缺少信息时,它们虚构值并在响应中提供它们。
问题如何发生:
- 提示缺少必需信息
- LLM 仍然生成看似合理的值
- 不发生
ValidationError - 你的 Python 应用程序将虚构数据当作真实数据处理
示例:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
从以下内容中提取产品信息: "这个小部件很棒!"
""")
# 当一些 LLM 为 product_name、price 和 in_stock 虚构值时
result = structured_llm.invoke([message])
# 没有引发错误!
print(result.product_name) # "widget" (从文本中提取)
print(result.price) # 0.0 (虚构的!)
print(result.in_stock) # False (虚构的!)
# 问题: 你无法分辨哪些值是真实的 vs 虚构的这比 ValidationError 更糟糕,因为:
- 你的 Python 应用程序继续使用错误数据执行
- 你不知道哪些字段是真实的 vs 虚构的
- 下游逻辑可能基于虚假数据做出错误决策
解决方案: 使用带验证的可选字段
解决方案是将所有必需字段定义为 Optional,然后使用验证器检查所有必需字段是否有值。
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# 这些实际上是必需的,但声明为 Optional
# 真正的验证发生在下面的验证器中
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""验证所有必要字段都存在"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("必须提供所有字段 (product_name, price, in_stock)")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# 使用不完整数据测试
message = HumanMessage(content="""
从以下内容中提取产品信息: "这个小部件很棒!"
""")
try:
result = structured_llm.invoke([message])
# 如果到达这里,所有字段都保证存在
print(f"产品: {result.product_name}")
print(f"价格: ${result.price}")
except ValidationError as e:
# 必需字段缺失 - 提取失败
print(f"提取不完整: {e}")这里发生了什么:
@model_validator是 Pydantic 的装饰器,添加自定义验证逻辑mode='after'意味着验证在所有字段解析后运行- 如果任何字段是
None,我们引发ValueError来表示数据不完整 - Pydantic 自动将此
ValueError包装在ValidationError中
为什么这有效:
当提示缺少字段的信息时:
- LLM 不虚构值,而是在响应中省略这些字段
- 在这种情况下,这些字段变为
None - 如果这些字段实际上是必需的,Pydantic 验证器引发
ValueError - Pydantic 将其包装为
ValidationError
这样,你的 Python 应用程序接收到明确的错误来处理,而不是虚构的数据。
关键要点: 当提示缺少必需字段的信息时,获得 ValidationError 是完全正常和预期的。真正的危险是虚构的数据。使用带验证器的 Optional 字段来防止 LLM 虚构缺失信息,同时明确检测必需字段何时缺失。
章节总结:
在本章中,你学习了如何将 LLM 输出转换为可靠的 Python 对象:
- 为什么重要: 自由文本解析很脆弱;基于模式的输出提供类型安全
- 如何使用: 使用 Pydantic
BaseModel定义模式 → 使用.with_structured_output()绑定 - 设计原则: 选择必需 vs 可选字段,使用字段描述引导 LLM
- 处理问题: ValidationError 是正常的;真正的危险是虚构的数据