用 Pydantic AI 构建类型安全的 LLM Agent:结构化输出、工具与依赖注入实战

2026-05-12 32 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

大模型返回的 JSON 字段名飘忽不定、类型随机、偶尔还塞一段自然语言当值——这是每个在生产环境调用 LLM 的开发者都踩过的坑。Pydantic AI 的核心思路很简单:用 Pydantic 模型把"LLM 应该返回什么"钉死在类型系统里,再配合工具声明和依赖注入,让 Agent 从"能跑的脚本"升级为"可维护的系统"。

下面拆解它的三个关键机制,最后用一个可运行的完整示例把它们串起来。

结构化输出:让 LLM 说话有规矩

裸调用 LLM,你拿到的是一段字符串,接下来自己写正则、写 JSON 解析、写 fallback——本质上是在用脆弱的字符串匹配对抗一个概率模型。Pydantic AI 的做法是:把期望的输出结构声明为 Pydantic 模型,框架在调用 LLM 时把模型 schema 嵌进 prompt,并在收到响应后做 Pydantic 校验。校验失败会触发重试(可配置次数),而不是把半成品丢给下游。

from pydantic import BaseModel, Field
from pydantic_ai import Agent

class Diagnosis(BaseModel):
    disease: str = Field(description="最可能的疾病名称")
    confidence: float = Field(description="置信度,0-1之间")
    symptoms_matched: list[str] = Field(description="匹配到的症状列表")

doctor_agent = Agent(
    model="openai:gpt-4o",
    result_type=Diagnosis,          # 关键:声明输出类型
    system_prompt="你是一个医学诊断助手,根据症状给出结构化诊断。",
)

result = doctor_agent.run_sync("患者发热3天,咳嗽,乏力")
# result.data 是一个 Diagnosis 实例,字段类型已校验
print(result.data.disease)        # str
print(result.data.confidence)     # float,保证在 0-1
print(result.data.symptoms_matched)  # list[str],不会出现 None 或字符串

几点值得注意:

  • Field(description=...) 不是给开发者看的注释,而是会被注入 prompt 的约束说明,LLM 会据此调整输出格式。
  • 如果 LLM 返回的 confidence"高",Pydantic 校验会失败,框架自动重试,不需要你手写异常处理。
  • result_type 支持嵌套模型和 Union,复杂业务场景也能覆盖。

工具声明:给 Agent 一双可调用的手

Agent 只有"嘴"没有"手"时,回答永远停留在"建议你查一下数据库"。Pydantic AI 的工具机制让 Agent 在推理过程中主动调用你注册的函数,拿到真实数据再继续推理。

from pydantic_ai import Agent, RunContext

agent = Agent("openai:gpt-4o", system_prompt="你是一个客服助手,可以查询订单状态。")

@agent.tool
async def order_status(ctx: RunContext[None], order_id: str) -> str:
    """查询订单状态。order_id 为订单编号。"""
    # 实际场景中这里会查数据库或调用内部 API
    mock_db = {
        "ORD-001": "已发货,预计明天送达",
        "ORD-002": "仓库备货中",
    }
    return mock_db.get(order_id, "未找到该订单")

result = agent.run_sync("我的订单 ORD-001 到哪了?")
# Agent 会自动调用 order_status("ORD-001"),然后把结果融入回答
print(result.data)

工具函数的几个设计要点:

  1. 类型签名即契约——参数 order_id: str 和返回 -> str 会被框架提取成工具描述,LLM 据此决定何时调用、传什么值。
  2. docstring 是自然语言接口——"""查询订单状态。order_id 为订单编号。""" 会作为工具的 description 发给 LLM,写清楚比写短更重要。
  3. RunContext 是依赖注入的入口——第一个参数 ctx 携带运行时依赖,下一节展开。

依赖注入:把状态和连接从工具参数里剥离出来

工具函数如果直接接收数据库连接、API key 等参数,签名会变得臃肿,而且测试时你得构造真实依赖。Pydantic AI 的依赖注入把这类"环境依赖"放进 RunContext,工具函数只声明需要什么类型,框架在运行时注入。

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    db_conn: str          # 模拟数据库连接
    api_key: str          # 外部服务认证

agent = Agent(
    "openai:gpt-4o",
    deps_type=Deps,
    system_prompt="你是一个财务助手,可以查询账户余额。",
)

@agent.tool
async def balance(ctx: RunContext[Deps], account: str) -> str:
    """查询账户余额。account 为账户名。"""
    # ctx.deps 携带注入的依赖
    print(f"使用 db_conn={ctx.deps.db_conn} 查询")
    return f"账户 {account} 余额:¥12,350"

# 运行时传入依赖实例
deps = Deps(db_conn="postgres://prod-db:5432", api_key="sk-live-xxx")
result = agent.run_sync("查一下张三的余额", deps=deps)
print(result.data)

这样做的好处:

  • 工具签名干净——balance(ctx, account) 只保留业务参数,不混入基础设施细节。
  • 测试友好——跑单测时传入 Deps(db_conn="mock", api_key="test-key"),不需要启动真实数据库。
  • 多环境切换——开发、预发、生产只需换 deps 实例,Agent 代码零修改。

把三件事串起来:一个完整的类型安全 Agent

下面是一个可直接运行(需安装 pydantic-ai 和配置 OpenAI API key)的示例,把结构化输出、工具、依赖注入组合在一起:

import asyncio
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext

# ---- 1. 结构化输出模型 ----
class TravelPlan(BaseModel):
    destination: str = Field(description="推荐目的地城市")
    budget_estimate: float = Field(description="预估人均费用,单位元")
    activities: list[str] = Field(description="推荐活动列表,至少3项")
    risk_note: str = Field(description="需要注意的风险或限制")

# ---- 2. 依赖声明 ----
@dataclass
class Deps:
    weather_api: str   # 天气服务端点(模拟)
    user_locale: str   # 用户所在城市

# ---- 3. Agent + 工具 ----
travel_agent = Agent(
    model="openai:gpt-4o",
    deps_type=Deps,
    result_type=TravelPlan,
    system_prompt=(
        "你是一个旅行规划助手。根据用户偏好和实时天气,"
        "给出结构化的旅行方案。务必调用天气工具确认目的地天气。"
    ),
)

@travel_agent.tool
async def get_weather(ctx: RunContext[Deps], city: str) -> str:
    """查询指定城市的当前天气。city 为城市名。"""
    # 模拟调用——实际替换为真实天气 API
    mock = {
        "三亚": "晴天,32°C,适合海滩活动",
        "成都": "多云,22°C,适合室内和美食",
        "哈尔滨": "小雪,-8°C,注意保暖",
    }
    return mock.get(city, "暂无该城市天气数据")

# ---- 4. 运行 ----
async def main():
    deps = Deps(weather_api="https://api.weather.example", user_locale="北京")
    result = await travel_agent.run("想去暖和的地方度假,3天左右", deps=deps)

    plan = result.data  # 类型:TravelPlan,字段已校验
    print(f"目的地:{plan.destination}")
    print(f"预算:¥{plan.budget_estimate:.0f}")
    print(f"活动:{', '.join(plan.activities)}")
    print(f"风险提示:{plan.risk_note}")

asyncio.run(main())

运行前需要:

pip install pydantic-ai
export OPENAI_API_KEY=sk-your-key

这个示例里,LLM 会先调用 get_weather 查询候选城市的天气,再把结果融入推理,最终返回一个经过 Pydantic 校验的 TravelPlan 实例。如果 LLM 返回的 budget_estimate 是字符串 "约3000",校验失败后框架会自动重试,直到拿到合法的 float

上手前的几条务实建议

决策点 建议
什么时候引入 result_type 只要下游代码要消费 LLM 输出的具体字段,就该用。纯对话场景(聊天机器人)可以不加。
工具数量上限 单个 Agent 注册 5-8 个工具效果最好;超过 15 个 LLM 容易选错工具,考虑拆成多个子 Agent。
依赖注入的粒度 把"可替换的"放 deps(数据库、API key),把"不可替换的"写进工具逻辑(业务规则)。
重试策略 默认重试 1 次,关键场景调到 2-3 次,但要在日志里监控重试率——高重试率说明 prompt 或 schema 需要优化。
模型选择 结构化输出对模型能力有要求;gpt-4o / claude-3.5 表现稳定,小模型容易在 schema 约束下"偷懒"输出空值。

Pydantic AI 不是唯一选择——OpenAI 的 Structured Outputs、Instructor、Marvin 都在做类似的事。它的差异化在于:把类型安全从"输出校验"延伸到了"工具签名"和"依赖管理"三个层面,让 Agent 的输入、输出、执行环境都有类型约束。如果你的 Agent 正在从 demo 走向生产,这套约束值得认真评估。


相关推荐