Python & AI Tutorials Logo
LangChain & LangGraph

7. 使用 Pydantic 实现结构化输出

在前面的章节中,我们一直将 LLM 输出作为原始文本字符串处理。这对于人类阅读响应的聊天机器人来说效果很好,但在构建需要程序解析和解释 LLM 输出的 AI 代理(agent)时,我们需要可预测的结构化数据。在本章中,你将学习如何使用 Pydantic 模式(schema)使 LLM 返回结构化的 Python 对象。

7.1) 为什么需要结构化输出?

自由文本 LLM 输出的问题

让我们首先了解为什么原始文本响应在实际应用中会产生问题。考虑这个常见场景:

python
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 应用程序中使用这些数据。你如何将价格提取为数字?如何以编程方式检查库存状态?你可能会尝试这样的字符串解析:

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)  # 脆弱 - 如果格式改变怎么办?

这种解析似乎有效。但实际上并非如此。原因如下:

为什么这种方法会失败:

  1. 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 响应改变时,你需要完全不同的解析逻辑。这使得构建可靠的应用程序变得困难。

  1. LLM 响应不可预测: 同一提示每次都可能产生不同的格式
  2. 字符串解析比看起来更难: 你需要处理 $、空格、换行符、逗号等
  3. 完全没有类型安全: 你无法确定 price 是浮点数、字符串还是 None
  4. 错误处理困难: 如果 LLM 说"价格不可用",你的 float() 调用会崩溃
  5. 难以维护: 稍微改变提示,你就要重写所有解析代码

核心思想: Python 需要契约,而非散文

想想当你在 Python 中与 API 服务器通信时。当你调用特定的 REST API 时,你期望它返回定义好的 JSON 响应:

python
# 你期望这种结构
{
    "product_name": "UltraWidget Pro",
    "price": 299.99,
    "in_stock": true
}

在构建 AI 应用程序时,你需要同样的原则。LLM 输出应该作为具有定义结构的数据返回,而不是每次都返回自由格式的文本。

散文 vs 契约:

  • 散文: 自由格式的自然文本。适合人类阅读,但程序难以处理。
  • 契约: 具有定义结构和类型的数据。一个承诺:"这些字段将以这些类型存在。"

结构化输出意味着定义契约: "LLM,我需要恰好这些字段,恰好这些类型,恰好这种格式。"

这就是 Pydantic 的用武之地。Pydantic 是 Python 最流行的数据验证库,LangChain 使用它以结构化形式接收 LLM 输出。

思维模式转变:

  • 之前: "LLM,告诉我这个产品的信息" → 解析不可预测的文本
  • 之后: "LLM,以定义的格式响应" → 接收结构化的 Python 对象

这种从散文到契约的转变是构建可靠 AI 代理的基础。当代理需要根据 LLM 响应决定下一步行动时(例如,如果有货则购买,如果没货则注册通知),它必须以定义的格式接收响应。

手动解析

结构化输出

经常出错

类型安全

LLM 文本输出

脆弱的字符串逻辑

类型化的 Python 对象

运行时错误

可靠的代码

7.2) 你的第一个结构化输出

在 7.1 中,我们了解了为什么 LLM 应该以定义的结构而非自由格式文本响应。现在让我们看看如何实际实现这一点。

关键思想: 仅仅要求 LLM "请以这种格式响应" 是不够的。你需要在 Python 代码中定义确切的数据结构,并让 LangChain 将其传递给 LLM。这个定义的数据结构称为模式(schema)

什么是模式?

模式(schema) 是定义数据结构的蓝图。它指定:

  • 必须存在哪些字段
  • 每个字段应该是什么类型(字符串、数字、布尔值等)
  • 应用哪些约束(可选 vs 必需、有效范围等)

在 Python 中,我们使用 Pydantic 的 BaseModel 类定义模式。这是最简单的示例:

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

这个模式说:"ProductInfo 对象必须恰好有三个字段:product_name(字符串)、price(浮点数)和 in_stock(布尔值)。"

三步模式: 定义、绑定、调用

使用结构化输出很简单。只需记住三个步骤:

  1. 定义: 使用 Pydantic 类创建模式
  2. 绑定: 使用 .with_structured_output() 将模式连接到 LLM
  3. 调用: 调用 .invoke() 获取类型化对象

这是你将用于大多数结构化提取任务的标准模板:

python
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

刚才发生了什么?

  1. 模式定义: 我们定义了想要的字段和类型
  2. 绑定: .with_structured_output(ProductInfo) 配置 LLM 使用结构化输出
  3. 调用和响应: 调用 .invoke() 时,LangChain 将 JSON Schema 传递给 LLM,LLM 以匹配该结构的 JSON 响应
  4. 自动转换: LangChain 将 JSON 转换为 ProductInfo 对象 - 不需要解析代码

无需解析。无需类型转换。无需错误。

将这个三步模式作为你的模板。 每当你需要结构化输出时,都遵循它。

字段描述: 引导 LLM 的关键

在上面的模式定义示例中,我们使用了 Field(description="...")。这个描述不仅仅是文档。它是 LLM 读取并遵循的指令

在典型的 Pydantic 使用中,Field 描述是可选的:

python
# 常规 Pydantic - 描述是给人类看的文档
class User(BaseModel):
    name: str = Field(description="用户名称")  # 没有它也能正常工作

但在使用 LLM 时,它们是必不可少的:

python
# 使用 LLM - 描述决定 LLM 行为
class CustomerFeedback(BaseModel):
    sentiment: str = Field(
        description="整体情感: 'positive'、'negative' 或 'neutral'"
    )

LLM 读取这个描述并用它来决定如何响应。

让我们看看实际效果:

python
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'

没有描述会发生什么?

python
sentiment: str  # 没有描述

LLM 可能以不可预测的格式返回 "negative"、"bad"、"unsatisfied"、"2/5"、"disappointed",使你的代码难以处理这些值。

关键点: 字段描述是控制 LLM 行为的代码的一部分。清晰具体地编写它们。

分类字段: 指定允许的值

在上面的示例中,sentiment 字段只能有三个值:'positive'、'negative' 或 'neutral'。必须是特定值集合之一的字段称为分类字段

对于分类字段,在描述中列出所有可能的值:

python
sentiment: str = Field(
    description="情感: 恰好是 'positive'、'negative' 或 'neutral'(小写)"
)

通过指定"恰好"和"(小写)",我们强调 LLM 应该以恰好这三个值之一响应。

但是,不能保证 LLM 总是以指定值之一响应。这就是为什么你必须编写防御性代码。

LLM 返回意外值的情况:

python
result.sentiment = "Positive"    # 首字母大写
result.sentiment = "NEGATIVE"    # 全大写
result.sentiment = "good"        # 完全不同的词

编写防御性代码:

python
allowed = {"positive", "negative", "neutral"}
 
# 转换为小写并检查
sentiment = result.sentiment.lower()
 
if sentiment not in allowed:
    sentiment = "neutral"  # 对意外值使用默认值
 
# 现在 sentiment 保证是允许值之一

关键要点:

  1. 在描述中指定允许的值 → LLM 更可能正确响应
  2. 在代码中验证 → 安全处理意外值

注意: 第 18 章展示了使用 Python 枚举进行强制执行的更强模式。

比较: 手动解析 vs 结构化输出

让我们比较使用和不使用结构化输出完成相同任务,看看区别:

手动解析:

python
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}")

结构化输出:

python
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}")

关键区别:

  1. 无解析逻辑: 结构化版本没有解析代码
  2. 类型安全: result.price 保证是浮点数
  3. 更简单的代码: 无正则表达式、无字符串分割、无手动类型转换
  4. 验证: Pydantic 确保所有必需字段都存在
  5. 可维护性: 更改模式比更新解析逻辑更容易

7.3) 模式设计考虑因素

现在你知道如何使用结构化输出,让我们学习如何设计好的模式。本节涵盖区分必需字段和可选字段的实用设计原则。

必需字段

默认情况下,Pydantic 模型中的所有字段都是必需的。这意味着 LLM 必须从用户的提示中提取或推断每个必需字段的值,并在响应中提供它。

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

当你使用这个模式时,LLM 将尝试在输入文本中找到所有三个字段(product_namepricein_stock)的值。

但是当提示缺少必需字段的信息时会发生什么?

我们可能期望以下行为:

  1. LLM 在提示中找不到信息
  2. LLM 在响应中省略该字段
  3. LangChain 无法创建有效的 ProductInfo 实例
  4. 引发 ValidationError

然而,这并不总是发生。

原因是不同的 LLM 可能以不同方式处理缺失信息。

一些 LLM(如 OpenAI 模型)倾向于即使提示中不存在所需信息也生成值。在这种情况下,不会发生 ValidationError,但这可能导致更大的问题,因为你的 Python 应用程序可能会将虚构的信息当作真实信息处理。

我们将在 7.4 节: 当出现问题时 解决如何解决这个问题。

现在,只需注意并非所有 LLM 都以相同方式处理缺失信息

可选字段

你可能需要即使在正常情况下也可以合法存在或不存在的字段。例如,配送备注(delivery_note)可能由客户提供,也可能不提供,即使对于有效订单也是如此。

何时使用 Optional:

  • 数据本身可能不存在(例如,当允许匿名评论时,匿名评论没有评论者姓名)
  • 你希望 LLM 明确指示缺失信息,而不是虚构值

要使字段可选,使用 typing 模块中的 Python Optional 类型:

python
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)

这是一个完整的示例:

python
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) 当出现问题时

使用结构化输出时可能出现两个问题:

  1. LLM 省略必需字段值 → 发生 ValidationError
  2. LLM 虚构缺失信息 → 不发生 ValidationError,但你的代码处理不正确的数据

本节介绍如何处理每种情况。

理解验证错误

当用户提示缺少模式中定义的必需字段的信息时,LLM 无法为这些字段提供值。然后 Python 程序引发 ValidationError:

python
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 表现出更危险的行为:当提示中缺少信息时,它们虚构值并在响应中提供它们

问题如何发生:

  1. 提示缺少必需信息
  2. LLM 仍然生成看似合理的值
  3. 发生 ValidationError
  4. 你的 Python 应用程序将虚构数据当作真实数据处理

示例:

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,然后使用验证器检查所有必需字段是否有值。

python
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 是正常的;真正的危险是虚构的数据