摘要
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. 类型校验的位置
类型校验至少发生在三个位置:
- API 层校验用户请求。
- 工具层校验模型传入的参数。
- 结果层校验模型最终输出。
类型系统提高了可维护性,但不能替代业务权限、数据库约束和安全检查。
四、实战示例
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 负责理解和决策,业务服务负责校验和执行,数据库负责保存最终事实。
参考资料
- PydanticAI 官方文档:https://ai.pydantic.dev/
- PydanticAI GitHub:https://github.com/pydantic/pydantic-ai
- Pydantic 官方文档:https://docs.pydantic.dev/
- FastAPI 官方文档:https://fastapi.tiangolo.com/