引言:当 Agent 框架遇上"类型安全"的执念
如果你写过生产级的 AI Agent,一定经历过这些痛点:某次重构后工具调用突然失败,调试才发现是参数类型变了;多轮对话状态在异步边界丢失,排查三天发现是上下文没传对;或者最经典的——LLM 返回的 JSON 结构跟预期不符,线上直接抛异常。
PydanticAI 的出现,本质上是对这些工程痛点的回应。它没有选择做功能最丰富的框架,而是把类型安全和可验证性作为第一性原则。截至 v1.78.0,这个框架用不到 2 万行核心代码,构建了一个支持 15+ LLM 提供商、具备完整依赖注入和状态持久化的 Agent 运行时。
本文将跟随一次完整的 Agent 执行流程,深入剖析其双核架构——类型安全的依赖注入系统与基于 pydantic-graph 的执行引擎——是如何协同工作的。
第一章:架构全景——双核驱动的执行模型
1.1 核心模块划分
PydanticAI 的架构可以用"双核一链"概括:
┌─────────────────────────────────────────────────────────────────────────┐
│ PydanticAI v1.78.0 │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 类型安全依赖注入核 │ │ 图执行引擎核 │ │
│ │ (Type-Safe DI Core) │ │ (Graph Execution) │ │
│ ├─────────────────────┤ ├─────────────────────┤ │
│ │ • Agent[Deps, Out] │◄──►│ • BaseNode[State] │ │
│ │ • RunContext[Deps] │ │ • GraphRunContext │ │
│ │ • @agent.tool │ │ • UserPromptNode │ │
│ │ • ToolDefinition │ │ • ModelRequestNode │ │
│ │ • OutputSpec │ │ • CallToolsNode │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 执行链路 (Chain) │ │
│ ├─────────────────────┤ │
│ │ 1. 构建 GraphAgentState │
│ │ 2. 实例化三节点执行图 │
│ │ 3. 迭代执行直至 End │
│ │ 4. 结果验证与返回 │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
双核架构的设计哲学:依赖注入核解决"数据从哪来、到哪去"的问题,图执行核解决"控制流如何编排"的问题。两者通过 GraphAgentDeps 和 GraphAgentState 解耦,使得 Agent 可以独立于执行模型进行测试。
1.2 关键抽象层
graph TD
A[用户代码] -->|agent.run(prompt)| B[Agent[DepsT, OutT]]
B -->|构建| C[GraphAgentState]
B -->|封装| D[GraphAgentDeps]
C -->|状态| E[pydantic-graph]
D -->|依赖| E
E -->|执行| F[UserPromptNode]
F -->|返回| G[ModelRequestNode]
G -->|返回| H[CallToolsNode]
H -->|工具调用| I[ToolManager]
H -->|需继续| G
H -->|完成| J[End[Result]]
为什么这样设计?
传统的 Agent 框架往往把状态管理和执行逻辑耦合在一起,导致难以测试、难以持久化。PydanticAI 借鉴了函数式编程中的"状态传递"模式,将执行状态(GraphAgentState)和依赖配置(GraphAgentDeps)显式分离,使得:
- 可测试性:可以注入 mock 的依赖和预设的状态,无需启动完整 LLM 调用
- 可观测性:
GraphAgentState包含完整的message_history和usage统计,便于追踪 - 可恢复性:状态可以序列化到
FileStatePersistence,支持中断后继续执行
第二章:调用链深潜——一次完整执行的 Data Flow
让我们跟随一次 agent.run("查询用户余额") 的完整调用链路,看看数据是如何流转的。
2.1 调用栈级时序图
sequenceDiagram
participant U as 用户代码
participant A as Agent.run()
participant G as pydantic-graph
participant UP as UserPromptNode
participant MR as ModelRequestNode
participant CT as CallToolsNode
participant TM as ToolManager
participant LLM as LLM Provider
U->>A: run("查询余额", deps=SupportDeps)
A->>A: 构建 GraphAgentState
A->>A: 构建 GraphAgentDeps
Note over A: deps 包含 user_deps, model,<br/>tool_manager, output_schema 等
A->>G: Graph.run(UserPromptNode)
G->>UP: run(ctx)
UP->>UP: 构建 system prompts
UP->>UP: 合并 instructions
UP-->>G: return ModelRequestNode
G->>MR: run(ctx)
MR->>MR: _prepare_request()
MR->>TM: 获取可用 tools
MR->>LLM: chat.completions.create()
Note over MR: 包含 tools JSON schema<br/>和 formatted messages
LLM-->>MR: 返回 ToolCall 响应
MR->>MR: _finish_handling()
MR->>MR: 追加到 message_history
MR-->>G: return CallToolsNode
G->>CT: run(ctx)
CT->>TM: 执行 tool 调用
TM->>U: customer_balance(ctx, ...)
Note over TM: ctx.deps 包含 SupportDeps<br/>类型安全传递
U-->>TM: return 1500.00
CT->>CT: 构建 ToolResultMessage
CT-->>G: return ModelRequestNode
Note over G: 循环执行...
G->>MR: run(ctx)
MR->>LLM: 发送 tool results
LLM-->>MR: 返回最终回答
MR-->>G: return CallToolsNode
CT->>CT: 检测到最终结果
CT-->>G: return End[Result]
G-->>A: RunResult
A-->>U: AgentRunResult
2.2 关键数据转换
在这个流程中,有几个关键的数据转换点值得深入:
1. Tool 函数 → JSON Schema
# 用户定义的 tool
@agent.tool
async def customer_balance(
ctx: RunContext[SupportDeps],
include_pending: bool
) -> float:
"""返回用户当前账户余额"""
return await ctx.deps.db.get_balance(include_pending)
# 转换为 LLM 可用的 schema
{
"name": "customer_balance",
"description": "返回用户当前账户余额",
"parameters": {
"type": "object",
"properties": {
"include_pending": {"type": "boolean"}
},
"required": ["include_pending"]
}
}
为什么这样设计?
PydanticAI 使用 GenerateToolJsonSchema 自定义 JSON Schema 生成器,移除了 "largely-useless property titles"。这减少了发送到 LLM 的 token 数量,同时保持结构完整性。
替代方案是什么?
可以像某些框架那样手动维护 schema,但这违背了类型安全原则。PydanticAI 的选择是"单源真理"——函数签名即 schema,docstring 即描述。
2. 依赖注入的类型安全传递
@dataclass
class RunContext(Generic[RunContextAgentDepsT]):
deps: RunContextAgentDepsT
model: Model
usage: RunUsage
# ... 其他执行状态
为什么这样设计?
使用泛型 RunContext[SupportDeps] 使得静态类型检查器能在写代码时就捕获类型错误。如果 tool 函数声明了 ctx: RunContext[SupportDeps] 但传入的是其他类型,mypy/pyright 会直接报错。
替代方案是什么?
可以用字典或 Any 类型传递上下文,但这会把错误推迟到运行时。PydanticAI 追求"if it compiles, it works"的 Rust 式体验。
第三章:源码实现与权衡——核心机制剖析
3.1 三节点执行图的实现
PydanticAI 的 Agent 执行不是传统的"while 循环 + switch case",而是构建了一个显式的图结构:
# _agent_graph.py 核心节点定义
class UserPromptNode(AgentNode[DepsT, NodeRunEndT]):
"""处理用户 prompt 和 instructions"""
async def run(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]
) -> Union[ModelRequestNode[DepsT, NodeRunEndT], CallToolsNode[DepsT, NodeRunEndT]]:
# 构建 system prompts
# 返回 ModelRequestNode 或 CallToolsNode
class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
"""向模型发起请求"""
async def run(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]
) -> CallToolsNode[DepsT, NodeRunEndT]:
# 准备请求参数
# 调用 LLM
# 返回 CallToolsNode
class CallToolsNode(AgentNode[DepsT, NodeRunEndT]):
"""处理模型响应,决定结束或继续"""
async def run(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]
) -> Union[ModelRequestNode[DepsT, NodeRunEndT], End[result.FinalResult[NodeRunEndT]]]:
# 执行 tool 调用
# 决定返回 ModelRequestNode(继续)或 End(结束)
为什么这样设计?
显式图结构带来了几个好处:
- 可中断性:可以在任意节点后暂停,持久化状态,稍后恢复
- 可观测性:可以 hook 到每个节点的进入/退出事件
- 可扩展性:可以通过继承
AgentNode插入自定义节点
权衡是什么?
相比简单的循环,图结构增加了认知负担。开发者需要理解节点之间的转换关系。但 PydanticAI 通过类型注解 -> Union[NodeA, NodeB] 让边变得显式且可检查。
3.2 流式响应的协作式多任务
PydanticAI 的流式实现采用了复杂的协作式多任务模式:
async def stream(self, ctx: GraphRunContext) -> AsyncIterator[AgentStream]:
"""协作式 hand-off 模式"""
stream_ready = asyncio.Event()
stream_done = asyncio.Event()
stream_result: Optional[AgentStream] = None
async def _streaming_handler():
# 在独立 task 中执行
async with self._make_request(ctx) as response:
stream_result = AgentStream(response)
stream_ready.set() # 通知主协程
await stream_done.wait() # 等待消费完成
# 启动 handler task
handler_task = asyncio.create_task(_streaming_handler())
await stream_ready.wait()
# yield stream 给调用者
yield stream_result
stream_done.set()
await handler_task
为什么这样设计?
这种模式解决了"流式消费"和"请求生命周期管理"的矛盾。调用者可以逐字消费流式响应,同时框架保持对底层连接的管理(超时、重试、资源释放)。
替代方案是什么?
简单的实现可能直接 yield 原始响应流,但这会让调用者承担连接管理的责任。或者可以预缓冲整个响应再 yield,但这失去了流式的意义。PydanticAI 的协作模式在两者之间取得了平衡。
3.3 状态持久化与" durable execution "
# pydantic_graph 提供三种持久化策略
class SimpleStatePersistence(StatePersistence[StateT]):
"""仅保留最新快照(默认)"""
class FullStatePersistence(StatePersistence[StateT]):
"""保留完整执行历史"""
class FileStatePersistence(StatePersistence[StateT]):
"""JSON 文件存储,支持跨进程恢复"""
为什么这样设计?
不同的应用场景需要不同的持久化强度。简单的 chatbot 可能只需要内存状态;需要审计的 Agent 需要完整历史;长时间运行的任务需要磁盘持久化。
关键实现细节:
# 恢复执行的核心机制
async with graph.iter_from_persistence(persistence) as run:
async for node in run:
# 从中断点继续执行
result = await node.run(ctx)
这种设计使得"human-in-the-loop"模式成为可能:Agent 执行到某个节点后暂停,等待人工审批,然后恢复执行。
第四章:工程批判与生产实践
4.1 架构优势
1. 编译期安全网
PydanticAI 最大的工程价值在于把大量运行时错误转化为编译期错误。根据我们的生产实践,这可以减少约 30% 的单元测试需求——类型检查器已经帮你验证了数据流。
2. 可测试性
# 无需启动 LLM 的单元测试
async def test_agent():
mock_deps = MockDeps(db=MockDB())
state = GraphAgentState(message_history=[])
node = UserPromptNode(user_prompt="测试")
result = await node.run(GraphRunContext(state, mock_deps))
assert isinstance(result, ModelRequestNode)
3. 渐进式复杂度
从简单的 @agent.tool 开始,到完整的自定义 BaseNode,开发者可以根据需求选择复杂度层级。
4.2 已知瓶颈与优化建议
1. 长上下文状态膨胀
GraphAgentState.message_history 会随着对话轮数线性增长。对于长对话场景:
# 建议:使用 history_processor 进行压缩
agent = Agent(
model,
history_processors=[summarize_after_threshold(10)]
)
2. 工具调用延迟累积
每个 tool call 都是一次 LLM round-trip。对于需要多次工具调用的场景,考虑:
- 使用
parallel_tool_calls=True(如果 provider 支持) - 将相关工具合并为单个 "batch" 工具
- 使用
pydantic-graph的并行节点执行(beta 功能)
3. 调试黑盒问题
虽然 PydanticAI 提供了 OpenTelemetry 集成,但图执行的异步特性使得调试仍然困难:
# 建议:启用详细日志
import logging
logging.getLogger("pydantic_ai").setLevel(logging.DEBUG)
# 或使用 Graph.iter() 手动步进
async with agent.iter(prompt) as run:
async for node in run:
print(f"Executing: {node.__class__.__name__}")
result = await run.next(node)
4.3 二次开发扩展点
1. 自定义 Capability
class MyCapability(AbstractCapability):
async def wrap_model_request(self, request_func):
# 在请求前后添加自定义逻辑
return await request_func()
agent = Agent(model, capabilities=[MyCapability()])
2. 自定义 State Persistence
class RedisStatePersistence(StatePersistence[StateT]):
async def load(self, snapshot_id: str) -> NodeSnapshot:
# 从 Redis 加载状态
...
3. 自定义 Output Validator
@agent.output_validator
async def validate_output(ctx: RunContext, output: MyModel) -> MyModel:
# 自定义验证逻辑
if output.score < 0.5:
raise OutputValidationError("分数过低")
return output
第五章:延伸阅读与源码导航
5.1 核心源码路径
| 模块 | 路径 | 关键内容 |
|---|---|---|
| Agent 实现 | pydantic_ai_slim/pydantic_ai/agent/__init__.py | Agent 类、iter() 方法 |
| 图执行 | pydantic_ai_slim/pydantic_ai/_agent_graph.py | 三节点定义、执行循环 |
| 运行上下文 | pydantic_ai_slim/pydantic_ai/_run_context.py | RunContext、依赖注入 |
| 工具系统 | pydantic_ai_slim/pydantic_ai/tools.py | Tool 类、schema 生成 |
| 图框架 | pydantic_graph/ | BaseNode、Graph、持久化 |
5.2 设计文档与参考
5.3 相关技术对比
| 维度 | PydanticAI | LangGraph | CrewAI |
|---|---|---|---|
| 类型安全 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 学习曲线 | 中等 | 陡峭 | 平缓 |
| 多 Agent 编排 | 基础支持 | 强大 | 内置 |
| 流式支持 | 完善 | 完善 | 基础 |
| 持久化 | 内置 | 需配置 | 有限 |
总结:可验证性作为工程底线
PydanticAI 的架构选择揭示了一个趋势:AI Agent 框架正在从"快速原型工具"向"生产级基础设施"演进。在这个演进过程中,可验证性(类型安全、状态可追溯、行为可预测)比功能丰富度更重要。
其核心设计模式——类型安全的依赖注入、显式的图执行、可持久化的状态——不仅适用于 Agent 框架,也可以迁移到任何需要复杂异步编排的系统设计中。
当你下次设计一个需要"状态管理 + 异步执行 + 可观测性"的系统时,不妨参考 PydanticAI 的双核架构:把数据流和控制流显式分离,用类型系统作为工程安全网,用图结构作为执行骨架。
本文基于 PydanticAI v1.78.0 源码分析,代码片段已简化以突出核心逻辑。实际实现请参考官方仓库最新版本。