PydanticAI 实战:用类型安全构建可靠的 Python Agent

摘要

Python Agent 项目经常从几行模型调用代码开始,随后逐渐增加工具、依赖、结构化输出、重试、流式响应和测试。代码量增长后,如果输入输出完全依赖字符串约定,错误通常只能在运行时暴露。

PydanticAI 是一个面向 Python 的 Agent 开发框架,使用 Pydantic 的数据模型和类型系统描述依赖、工具参数与结构化结果。本文通过一个客服工单 Agent 示例,介绍 Agent、工具、依赖、结构化输出、测试替身和生产边界。

一、背景与问题

一个最简单的 Agent 可能只有以下逻辑:

python 复制代码
response = model.generate(prompt)
return response.text

实际项目很快会增加:

  • 查询订单、工单和知识库的工具。
  • 用户和租户身份。
  • 数据库、HTTP 客户端等运行时依赖。
  • 结构化 JSON 输出。
  • 模型调用失败重试。
  • 工具参数校验和权限检查。
  • Agent 行为测试与成本统计。

如果这些内容通过字符串拼接和全局变量组织,常见问题包括:

问题 表现
输出格式不稳定 下游解析 JSON 失败
依赖来源不清晰 测试无法替换真实数据库
工具参数不完整 运行到模型调用阶段才报错
权限校验遗漏 Agent 调用超出用户范围
测试成本高 每次测试都真实调用模型

PydanticAI 的核心思路是把类型、依赖和 Agent 运行逻辑显式表达出来,让错误尽早暴露。

二、核心概念

1. Agent

Agent 是模型、系统指令、工具、依赖和结果类型的组合。一个 Agent 通常可以声明:

  • 使用哪个模型。
  • 接受什么类型的依赖。
  • 返回什么类型的结果。
  • 可以调用哪些工具。
  • 系统指令如何生成。

2. 依赖注入

依赖是 Agent 执行时需要的外部对象,例如:

  • 当前用户和租户。
  • 数据库仓库。
  • 工单 API 客户端。
  • 当前请求的 Trace ID。
  • 配置和权限服务。

将依赖显式传入,比在工具函数中直接读取全局变量更容易测试和审计。

3. 工具

工具是模型可以调用的函数。工具的参数应有明确类型,执行逻辑仍由服务端控制。模型只能决定"请求调用哪个工具以及传递什么参数",不能绕过工具内部的权限校验。

4. 结构化输出

结构化输出把模型结果约束为一个 Pydantic 模型:

text 复制代码
模型文本
  → 结构化结果解析
  → Pydantic 校验
  → 业务服务继续处理

校验失败时,可以重试生成或返回明确错误,而不是把不完整字符串传给下游系统。

三、工作原理

1. Agent 执行流程

text 复制代码
用户输入
  → 构造 RunContext
  → 组装系统指令
  → 模型选择工具或返回结果
  → 工具参数校验
  → 执行工具
  → 返回工具结果
  → 模型继续推理
  → 结果类型校验

系统应该在执行层设置最大步骤数、总耗时和 Token 预算,不能让模型无限循环。

2. 依赖的作用域

依赖通常只在一次 Agent 运行中有效:

text 复制代码
HTTP 请求
  └─ Agent Run
       └─ RunContext
            ├─ 用户身份
            ├─ 租户权限
            ├─ 数据库连接
            └─ Trace ID

跨请求共享的配置、连接池和客户端可以由应用管理;用户身份和权限必须随请求重新确定。

3. 类型校验的位置

类型校验至少发生在三个位置:

  1. API 层校验用户请求。
  2. 工具层校验模型传入的参数。
  3. 结果层校验模型最终输出。

类型系统提高了可维护性,但不能替代业务权限、数据库约束和安全检查。

四、实战示例

1. 安装依赖

bash 复制代码
pip install pydantic-ai pydantic-settings

生产环境应固定版本,并通过项目依赖文件管理升级。模型供应商的适配包和版本要求,应以当前项目使用的 PydanticAI 文档为准。

2. 定义依赖和结果模型

python 复制代码
from dataclasses import dataclass

from pydantic import BaseModel, Field


@dataclass
class SupportDependencies:
    user_id: str
    tenant_id: str
    ticket_repository: "TicketRepository"


class SupportAnswer(BaseModel):
    answer: str = Field(min_length=1)
    ticket_ids: list[str] = []
    needs_human: bool = False

SupportDependencies 保存本次运行需要的服务对象,SupportAnswer 描述 Agent 输出的业务结构。

3. 定义仓库接口

python 复制代码
from typing import Protocol


class TicketRepository(Protocol):
    async def find_visible(
        self,
        ticket_id: str,
        user_id: str,
        tenant_id: str,
    ) -> dict | None:
        ...

权限过滤应由仓库或业务服务执行,而不是让模型根据返回的数据自行判断哪些字段可以展示。

4. 创建 Agent

python 复制代码
from pydantic_ai import Agent, RunContext


support_agent = Agent(
    "openai:gpt-4o-mini",
    deps_type=SupportDependencies,
    output_type=SupportAnswer,
    system_prompt=(
        "你是客服工单助手。"
        "只能回答当前用户有权限访问的工单。"
        "无法确认的信息必须明确说明。"
        "涉及修改工单时,需要请求人工确认。"
    ),
)

模型名称和供应商配置应通过环境变量或配置中心管理,不要把生产密钥写在源代码中。

5. 添加工具

python 复制代码
@support_agent.tool
async def query_ticket(
    ctx: RunContext[SupportDependencies],
    ticket_id: str,
) -> dict:
    ticket = await ctx.deps.ticket_repository.find_visible(
        ticket_id=ticket_id,
        user_id=ctx.deps.user_id,
        tenant_id=ctx.deps.tenant_id,
    )
    if ticket is None:
        return {"found": False, "ticket_id": ticket_id}
    return {
        "found": True,
        "ticket_id": ticket["id"],
        "status": ticket["status"],
        "summary": ticket["summary"],
    }

工具返回的数据应尽量满足当前任务需要,不要把数据库整行记录直接交给模型。

6. 运行 Agent

python 复制代码
async def answer_question(
    question: str,
    deps: SupportDependencies,
) -> SupportAnswer:
    result = await support_agent.run(
        question,
        deps=deps,
    )
    return result.output

在 HTTP 服务中,应为每次请求创建新的依赖对象,并把租户、用户和 Trace ID 从已经完成鉴权的请求上下文中传入。

7. 测试替身

python 复制代码
class FakeTicketRepository:
    async def find_visible(
        self,
        ticket_id: str,
        user_id: str,
        tenant_id: str,
    ) -> dict | None:
        if ticket_id != "INC-1001":
            return None
        return {
            "id": "INC-1001",
            "status": "处理中",
            "summary": "登录接口响应缓慢",
        }

模型测试也可以使用测试模型或预设响应,避免每次测试都消耗真实 Token。测试重点应包括工具参数、权限范围、结构化输出和异常处理。

8. 增加 API 层

python 复制代码
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class ChatRequest(BaseModel):
    question: str


@app.post("/api/support")
async def support(request: ChatRequest) -> SupportAnswer:
    deps = SupportDependencies(
        user_id="user-001",
        tenant_id="tenant-a",
        ticket_repository=FakeTicketRepository(),
    )
    return await answer_question(request.question, deps)

示例中的用户信息仅用于演示,真实服务必须从认证结果中读取,不允许使用客户端提交的任意用户 ID。

五、常见问题与实践建议

1. 类型安全能保证模型一定正确吗?

不能。类型安全主要保证程序对输入和输出的解析边界更清晰。模型仍可能生成事实错误、越权建议或不完整答案,需要业务校验、检索引用和人工反馈。

2. 工具函数是否可以直接操作数据库?

不建议。工具应调用封装好的业务服务或仓库,业务服务负责权限、事务、参数范围和审计。这样既方便测试,也能避免每个工具重复实现安全逻辑。

3. 如何限制工具调用次数?

设置单次运行最大步骤、单个工具频率和总耗时。对写操作使用幂等键,必要时增加人工确认,不要只在系统提示中写"谨慎调用"。

4. 模型调用异常如何处理?

区分参数错误、认证失败、限流、超时、内容拦截和服务端错误。只对可安全重试的错误执行指数退避,工具产生副作用后不能无条件重放。

5. 依赖注入会不会让代码变复杂?

初期会增加一些类型定义,但可以降低全局状态和隐式依赖带来的调试成本。依赖对象应该保持小而明确,不要把整个应用容器传给 Agent。

六、进阶思考

1. 将 Agent 放在业务服务之后

推荐的安全边界是:

text 复制代码
API 层:身份认证与请求校验
业务层:权限、事务、状态变更
Agent 层:理解意图和选择工具
数据层:最终事实

Agent 不应直接成为数据库管理员,也不应绕过业务层修改状态。

2. 可观测性

一次 Agent 运行至少记录:

  • run ID 和 trace ID。
  • 模型和版本。
  • 输入、输出 Token。
  • 工具调用名称和耗时。
  • 重试次数和错误分类。
  • 结果校验是否成功。
  • 费用估算。

默认不要记录完整 Prompt、敏感工具参数和完整业务数据。需要调试时使用脱敏采样。

3. 结构化输出与领域模型

SupportAnswer 只是接口输出模型,不一定等同于数据库实体。可以在 Agent 输出后增加一层领域转换,避免模型字段变化直接影响持久化结构。

4. 从单 Agent 到工作流

当任务步骤固定时,使用显式工作流通常比让一个 Agent 自由规划更容易测试:

text 复制代码
识别问题
  → 查询工单
  → 判断升级条件
  → 请求人工确认
  → 写入升级结果

Agent 适合处理自然语言理解和局部决策,工作流负责关键业务边界。

结论

PydanticAI 的价值不只是提供一个模型调用封装,而是把 Agent 的依赖、工具参数和结果结构显式化,让 Python Agent 更容易测试、维护和接入业务系统。

实际落地时,应把类型安全与权限、幂等、超时、成本和观测结合起来。Agent 负责理解和决策,业务服务负责校验和执行,数据库负责保存最终事实。

参考资料

  1. PydanticAI 官方文档:https://ai.pydantic.dev/
  2. PydanticAI GitHub:https://github.com/pydantic/pydantic-ai
  3. Pydantic 官方文档:https://docs.pydantic.dev/
  4. FastAPI 官方文档:https://fastapi.tiangolo.com/
相关推荐
落魄实习生1 小时前
Agent Scope Java 2.x 系列【10】Middleware
java·开发语言·ai
梦想画家2 小时前
SQLMesh Python 模型入门(三):前后置语句、蓝图建模与避坑指南
大数据·python·sqlmesh
Ramble_Naylor2 小时前
async/await:让一个线程同时等很多件事
开发语言·rust
林伽一2 小时前
100 万输出词元与窄开放,前沿模型发布范式正在改写|2026年10月02日
人工智能·科技·安全·ai
迅猛龙办公室2 小时前
Python实现绘制同切圆
开发语言·python
弹简特2 小时前
【Java项目-企悦抽】16-抽奖模块01-获取活动完整信息接口实现
java·开发语言·状态模式·springboot
言乐63 小时前
Python语音检索
开发语言·python·django·virtualenv·pygame
BD_Marathon3 小时前
多轮对话聊天机器人
开发语言·机器人·c#
泡茶喝茶写代码3 小时前
A股量化数据工程:从 REST 接口到策略信号(第 1 篇):指数列表与实时行情接入
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据接口