本文基于 pydantic/pydantic-ai 的
main分支源码(本地 clone 于d:\opencode\q\pydantic-ai)。所有代码引用均使用 相对路径 的形式,可直接在编辑器中跳转。目标读者:已经用过几款 Agent 框架(LangChain / LlamaIndex / AutoGen…),想知道 pydantic-ai 为什么"另起炉灶"、它的代码里到底藏着什么巧思,以及如何把它用出深度。
阅读路径建议:先看第 1 章建立全局地图,再按兴趣跳到第 2–5 章的四个核心模块,最后用第 6 章的落地案例把所有概念串起来。
目录
- 项目定位与整体架构
- Agent 核心与运行循环
- Model 抽象与 Provider 层
- 工具系统与结构化输出
- pydantic-graph 工作流引擎
- 贯穿式应用案例
- 总结:pydantic-ai 的设计哲学
1. 项目定位与整体架构
1.1 官方定位与设计动机
README 开头的一句话很能说明问题:
Pydantic AI is a Python agent framework designed to help you quickly, confidently, and painlessly build production grade applications and workflows with Generative AI.
We built Pydantic AI with one simple aim: to bring that FastAPI feeling to GenAI app and agent development.
作者是 Pydantic 团队,所以整套框架的骨架就是**"FastAPI 对待 HTTP 请求的方式"被平移到 LLM**:
| FastAPI | pydantic-ai |
|---|---|
| 路由函数的签名 = 请求参数 schema | 工具函数的签名 = tool JSON Schema |
BaseModel 响应类型 = OpenAPI schema | output_type=MyModel = 强制 LLM 结构化输出 |
Depends(...) 依赖注入 | RunContext[Deps] 依赖注入 |
| Starlette 中间件 | AbstractCapability / hooks |
| uvicorn ASGI 事件循环 | pydantic_graph.GraphRun 异步驱动 |
一旦看懂这个类比,后面所有模块都能对上号。
1.2 仓库骨架
pydantic-ai 是一个 monorepo,pyproject.toml 里本体依赖 pydantic-ai-slim[openai,anthropic,...],真正的核心都在 slim 包里:
pydantic-ai/
├── pydantic_ai_slim/pydantic_ai/ ← 核心代码(slim 包)
│ ├── agent/ Agent 公开 API
│ │ ├── abstract.py AbstractAgent + run / run_sync / run_stream
│ │ ├── __init__.py 具体 Agent 类
│ │ └── wrapper.py Wrapper / Instrumented 装饰器
│ ├── _agent_graph.py ⭐ Agent 运行循环的图节点实现
│ ├── _run_context.py RunContext:用户函数的运行时上下文
│ ├── _system_prompt.py 静态/动态系统提示
│ ├── _instructions.py instructions 机制
│ ├── _function_schema.py ⭐ 函数 → JSON Schema 管线核心
│ ├── _griffe.py docstring → 参数描述
│ ├── _json_schema.py Schema 规范化/降级
│ ├── _output.py / output.py ⭐ 结构化输出策略
│ ├── tools.py Tool 数据模型
│ ├── tool_manager.py ToolManager 运行时管理
│ ├── toolsets/ Toolset 抽象与组合
│ ├── builtin_tools/ 厂商内置工具(WebSearch 等)
│ ├── common_tools/ 框架自带工具(web_fetch、tavily 等)
│ ├── models/ ⭐ 十几家 LLM 的 Model 适配
│ │ ├── __init__.py Model 抽象基类
│ │ ├── openai.py / anthropic.py / google.py ...
│ │ ├── fallback.py / wrapper.py / instrumented.py
│ │ └── test.py / function.py 测试/伪造
│ ├── providers/ 身份认证 + 客户端
│ ├── profiles/ 模型能力描述(ModelProfile)
│ ├── capabilities/ 能力系统(hooks 的载体)
│ ├── messages.py ⭐ 统一消息/Part 模型
│ ├── run.py AgentRun(图运行对象)
│ ├── result.py AgentStream / StreamedRunResult
│ ├── settings.py / usage.py ModelSettings / UsageLimits
│ └── exceptions.py ModelRetry / UnexpectedModelBehavior / ...
├── pydantic_graph/pydantic_graph/ ← ⭐ 通用图引擎(独立包)
│ ├── nodes.py BaseNode / End / GraphRunContext
│ ├── graph.py Graph / GraphRun
│ ├── persistence/ 内存 / 全量内存 / 文件持久化
│ ├── mermaid.py 类型推断 → Mermaid 流程图
│ └── beta/ 新一代 GraphBuilder(支持 Fork/Join)
├── pydantic_evals/ ← 评估框架
├── examples/pydantic_ai_examples/ ← 官方示例
└── docs/ ← 文档(与 ai.pydantic.dev 同步)
1.3 一张全景图
用户代码
────────
│
│ agent.run_sync("...") / agent.iter(...)
▼
┌───────────────────────────────────────────────┐
│ AbstractAgent.run / run_stream / iter │ ← 入口层 (agent/abstract.py)
└──────────────────────────┬────────────────────┘
▼
┌─────────────────────────────────────┐
│ build_agent_graph() │ ← _agent_graph.py
│ pydantic_graph.Graph 实例 │
└────────────────┬────────────────────┘
▼
┌───────────────────────────────────────────────────────┐
│ UserPromptNode │
│ │ │
│ ▼ │
│ ModelRequestNode ◀───┐ │
│ │ │ │
│ ▼ │ 工具结果回喂 │
│ CallToolsNode ──────┘ │
│ │ │
│ ▼ │
│ End[FinalResult[OutputT]] │
└───────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Model │ │ Tool │ │ Output │
│ 抽象层 │ │ Manager │ │ Schema │
└────┬─────┘ └────┬─────┘ └────┬─────┘
▼ ▼ ▼
Provider 客户端 Toolset 组合 结构化输出
(鉴权/HTTP) (filter/prefix) (tool/native/prompted)
这张图是全文的坐标系,后面四个核心模块各占一个角落。
2. Agent 核心与运行循环
本章的代码几乎全部集中在 agent/init.py、agent/abstract.py、_agent_graph.py、run.py 与 _run_context.py。
2.1 Agent 的构造:一份声明式契约
agent/init.py#L282-L310 上的 Agent.__init__ 接受一大串参数,但它们本质上只有四类:
- 做什么 —
model、output_type、instructions/system_prompt - 用什么工具 —
tools、toolsets、builtin_tools - 怎么约束 —
deps_type、retries、end_strategy、model_settings(可为 callable) - 怎么观察 —
capabilities(hooks 容器)、instrument、metadata
关键属性都是"以后再用"的储存: agent/init.py#L161-L217
_model: Model | KnownModelName | str | None
_instructions: list[str | SystemPromptFunc[AgentDepsT]]
_system_prompts: tuple[str, ...]
_system_prompt_functions: list[SystemPromptRunner[AgentDepsT]]
_system_prompt_dynamic_functions: dict[str, SystemPromptRunner[AgentDepsT]]
_function_toolset: FunctionToolset[AgentDepsT]
_output_toolset: OutputToolset[AgentDepsT] | None
_user_toolsets: list[AbstractToolset[AgentDepsT]]
_max_result_retries: int
_max_tool_retries: int
_validation_context: Any | Callable[[RunContext[AgentDepsT]], Any]
💡 洞察:Agent 在构造时几乎什么都不做,不会验证模型可达、不会生成工具 schema(除了
Tool在自己的构造里生成)、不会发任何网络请求。它是纯粹的声明。真正的工作都留给run()时去做——这让 Agent 对象可以在进程中长期存在、线程安全地共享。
2.2 四个入口:一条主路四条小径
AbstractAgent 对外暴露四个入口,它们实际上是同一条路径的不同切面:
| 入口 | 位置 | 返回 | 特点 |
|---|---|---|---|
iter() | agent/init.py#L951-L1055 | AsyncIterator[AgentRun](async CM) | 最底层,暴露每个 Node |
run() | agent/abstract.py#L214-L342 | AgentRunResult[T] | 自动把图跑完 |
run_sync() | agent/abstract.py#L388-L471 | 同上,同步版本 | 内部 loop.run_until_complete(run()) |
run_stream() | agent/abstract.py#L517-L750 | StreamedRunResult(async CM) | 边流边消费,第一个 FinalResultEvent 到达就 yield |
调用关系:
iter() ← 真正的实现
┌───┼───┐
run() │ run_stream()
│ │
run_sync()│
(都只是 iter() 的消费者)
这张图意味着——要深入理解 Agent,只需要搞懂 iter() 是怎么建图、怎么驱动图的。
2.3 建图:把一次 Agent 执行变成一个 pydantic_graph.Graph
AbstractAgent.iter() 内部最终会调 _agent_graph.py#L1836-L1865 的 build_agent_graph(),返回一个形如:
Graph[
GraphAgentState, # 运行过程中的可变状态
GraphAgentDeps[DepsT, OutputT],# 运行过程中的配置/依赖
UserPromptNode[DepsT, OutputT],# 入口节点
result.FinalResult[OutputT], # 结束类型
]
的图。图里有四个节点:
UserPromptNode_agent_graph.py#L172-L395ModelRequestNode_agent_graph.py#L516-L949CallToolsNode_agent_graph.py#L952-L1258SetFinalResult(出口)— 把FinalResult转成End[FinalResult]
2.4 UserPromptNode:一次运行的第一块拼图
UserPromptNode.run() 做四件事:
-
接管消息历史 _agent_graph.py#L208-L211:通过
capture_run_messages()得到一个可被外部观察的 list 引用,直接赋给ctx.state.message_history。后续所有消息修改都发生在这个引用上——这意味着在with capture_run_messages() as messages:语句外,你仍然能看到运行期间产生的所有消息。 -
重算动态系统提示 _agent_graph.py#L352-L377:对历史中每个
SystemPromptPart(dynamic_ref=...),用dynamic_ref去_system_prompt_dynamic_functions查表,重跑对应函数。这让"你是X角色"这种动态提示在多轮 resume 时能够根据新的RunContext更新。 -
处理延迟工具 (deferred tool results) 和 resume 场景:如果上一轮留下了未完成的工具调用(典型场景:人机审批后回来),直接跳到
CallToolsNode。 -
构造首个
ModelRequest:装入系统提示 + 用户 prompt,返回ModelRequestNode。
2.5 ModelRequestNode:真正和 LLM 打交道的节点
这是最复杂的一个节点,核心三个方法:
run()_agent_graph.py#L528-L539:简单包装,把活儿转给_make_request()。stream()_agent_graph.py#L541-L689:@asynccontextmanager,对外暴露AgentStream。_prepare_request()_agent_graph.py#L770-L868:请求前装备工作——解析 instructions、调用tool_manager.for_run_step(ctx)重新配工具集、触发before_model_request钩子、返回(model, settings, params, messages, run_context)五元组。
其中一个很有意思的设计是 "short-circuit by exception":stream() 允许上游 capability 在 wrap_model_request hook 里抛 SkipModelRequest(response=...)。这会让整个 HTTP 请求被跳过,直接拿 hook 给定的响应继续走。应用场景:本地缓存、单元测试、代理转发。
2.6 CallToolsNode:从响应到下一轮的分叉口
CallToolsNode._run_stream() 按顺序做:
- 遍历
model_response.parts,区分TextPart/ToolCallPart/ThinkingPart/BuiltinToolCallPart。 - 先处理 output 工具(
kind='output')——如果匹配成功且合法,拿到FinalResult。 - 运行
output_validators(见 _agent_graph.py#L1310-L1318)。 - 并发执行剩余的 function 工具(ToolManager 中默认 parallel)。
- 依据
end_strategy('early' | 'graceful' | 'exhaustive')决定是否提前终止。 - 返回下一个节点:
End[FinalResult]、ModelRequestNode(把工具结果塞回去给 LLM)或继续CallToolsNode。
节点状态迁移的文字示意:
┌─────────────────────────┐
│ UserPromptNode │
└──────┬────────┬─────────┘
(正常) │ │ (恢复/延迟工具)
▼ ▼
┌────────────────┐ ┌───────────────┐
│ ModelRequestNode│ │ CallToolsNode │
└────┬────────────┘ └──────┬────────┘
(正常) │ │
┌─────┘ ┌───── ModelRetry ─┘
▼ ▼
┌───────────────────────┐
│ CallToolsNode │
│ - 输出工具 -> End │
│ - 函数工具 -> Req │
│ - 空响应 -> Req/End │
└────────┬──────────────┘
▼
End[FinalResult]
2.7 GraphAgentState 与 GraphAgentDeps:状态与配置的分离
_agent_graph.py#L73-L114 和 _agent_graph.py#L116-L146:
GraphAgentState | GraphAgentDeps | |
|---|---|---|
| 性质 | 可变 | 不可变(几乎) |
| 内容 | message_history、usage、retries、run_step、run_id、metadata | user_deps、model、tool_manager、output_schema、output_validators、root_capability、tracer |
| 语义 | 这一轮运行产生了什么 | 这一轮运行应该用什么 |
这种切分让图节点非常纯粹——只有 ctx.state 是可以写的东西,其他全是读。图引擎自己不会乱写 state,也不会乱动 deps。
唯一的例外是 tool_manager 会在 ModelRequestNode._prepare_request() 里被 替换 为 await tool_manager.for_run_step(run_context) 的新实例——因为它需要按当前步动态解析工具集。
2.8 RunContext:面向用户函数的单一事实源
_run_context.py#L32-L112 定义的 RunContext 是所有用户回调(tool、system_prompt、output_validator、prepare、capability hooks…)看到的上下文。
它的字段看似杂,归类后其实清晰:
身份: run_id、run_step、agent、model、metadata
输入: deps(用户注入的)、prompt、messages
当前执行: tool_name、tool_call_id、retry、max_retries、tool_call_approved
工具与验证: tool_manager、validation_context、partial_output
观测: tracer、trace_include_content、instrumentation_version
用量: usage
每次要给用户函数传 context 时,框架都调用 _agent_graph.py#L1260-L1283 的 build_run_context(ctx) 按需重建。这里有个小巧思:
run_context = RunContext(messages=ctx.state.message_history, ...)
validation_context = build_validation_context(..., run_context) # 需要 run_context 本身
run_context = replace(run_context, validation_context=validation_context)
先建一个基础的 RunContext,再把它作为输入去计算 validation_context,最后 dataclasses.replace 回去——避免了鸡生蛋蛋生鸡,又保留了不可变感。
messages 字段不是拷贝而是同一个引用,所以工具函数里 ctx.messages 看到的永远是最新的历史。
2.9 System Prompt vs Instructions:两套看起来像、实则互补的机制
两者的差异在源码里分得很清:
| 维度 | system_prompt | instructions |
|---|---|---|
| 目的 | 角色/性格定义 | 当前任务指导 |
| 存放位置 | ModelRequest.parts 里的 SystemPromptPart | ModelRequest.instructions(独立字段) |
| 何时生成 | UserPromptNode 初始化时 | 每次 ModelRequestNode._prepare_request() 时 |
| 动态更新 | 带 dynamic_ref 的 part 会在新 step 重算 | 每步都重算 |
| 源码 | _system_prompt.py | _instructions.py |
配合使用时,system_prompt="You are a math tutor"(固定人设)加上 instructions=TemplateStr("Solve: {problem}")(动态任务)这种组合最常见。
2.10 流式:三层事件从模型冒泡到用户
pydantic-ai 提供了三种粒度的"流":
-
iter()+node.stream():最底层,直接拿AgentStream迭代原始事件(PartStartEvent/PartDeltaEvent/PartEndEvent/FunctionToolCallEvent/FunctionToolResultEvent/FinalResultEvent)。 -
run_stream()+StreamedRunResult:中层,框架自动驱动到第一个FinalResultEvent就 yield 一个StreamedRunResult,用户再async for chunk in result消费输出。end_strategy决定了背景是否继续跑剩余工具:'early'(默认):见到最终输出就停。'graceful':把剩余的 function tools 跑完,跳过 output tools。'exhaustive':全部跑完。
-
run()+event_stream_handler:把一个async def handler(ctx, stream)塞进run(),框架会在每次ModelRequestNode/CallToolsNode执行前把事件流喂给你。
三者的事件冒泡路径汇于一条:
Model client (httpx/openai/anthropic SDK)
│ 产生 chunk
▼
StreamedResponse._get_event_iterator() ← 厂商侧实现
│ 把 chunk 映射成 ModelResponseStreamEvent
▼
iterator_with_final_event() ← 在事件流上再扫描一遍
│ 出现 FinalResultEvent
▼
iterator_with_part_end() ← 补发 PartEndEvent
│
▼
AgentStream (result.py) ← 驱动工具执行、再发工具事件
│
▼
node.stream() 上下文管理器
│
▼
用户代码 (async for event in stream)
2.11 重试与失败:三类重试各有归处
pydantic-ai 的重试语义分得很清(exceptions 定义在 exceptions.py):
| 来源 | 触发位置 | 计数器 | 超限后 |
|---|---|---|---|
| 输出验证重试 | _run_output_validators | state.retries(全局) | UnexpectedModelBehavior |
| 工具调用重试 | ToolManager.execute_tool_call | ctx.retries[tool_name] | 同上 |
| 模型重试 | capability hooks 抛 ModelRetry | 同 output | 追加历史后再请求 |
一个设计细节:ModelRetry 被抛时,ModelRequestNode 会保留已经产生的 ModelResponse(_agent_graph.py#L750-L765),然后追一条 RetryPromptPart,让模型看到"你之前说了啥、为什么不对、再来一次"——这是让 LLM 学会纠正的关键上下文。
2.12 本章值得写进"面试题"的三个设计点
- 为什么用图而不是状态机?——图让节点可以独立测试,capability 钩子能在"进入任意节点前/后"集中注入,新增节点不改老代码;流式 API 可以在任何节点插入,无需回调地狱。
GraphAgentState+RunContext的单一事实源——消息历史是一个 list 引用贯穿所有节点和用户函数,改一处全可见;但RunContext是 dataclass,字段级别的替换(replace)避免了共享可变数据的混乱。- 动态系统提示的延迟评估 + 每步重算——
dynamic_ref是个隐式的"函数名快照",保证消息历史跨进程 resume 后,动态提示依然能被正确地重新驱动。
3. Model 抽象与 Provider 层
pydantic-ai 目前接入了 20+ 家模型/网关。但代码里只有一个 Model 抽象基类、一个 Provider 抽象基类,加上一份 ModelProfile。这一章拆解这三层是怎么分工的。
3.1 三层分工
┌──────────────────────────────────────────────┐
│ Provider ← 鉴权 / HTTP 客户端 / 基本 URL │
│ 例:OPENAI_API_KEY 读取、AsyncOpenAI 实例 │
├──────────────────────────────────────────────┤
│ Model ← 请求编排 / 消息格式转换 / 响应解析│
│ 例:_map_messages、_process_response│
├──────────────────────────────────────────────┤
│ ModelProfile ← 能力描述(不做事,只描述) │
│ 例:supports_json_schema_output、 │
│ anthropic_supports_adaptive_thinking │
└──────────────────────────────────────────────┘
3.2 Model 基类契约
models/init.py#L570 定义了抽象类 Model(ABC, Generic[InterfaceClient])。对子类的硬要求是两个方法:
async def request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse: ...
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext[Any] | None = None,
) -> AsyncIterator[StreamedResponse]: ...
可选重写:async def count_tokens(...)(不支持提前计数的模型返回 NotImplementedError)。
所有子类在 request() 最前面都会调父类的 prepare_request() models/init.py#L691-L770,它集中做了五件事:
- 合并默认 settings 与运行时 settings。
- 根据
profile.supports_thinking把统一的thinking字段翻译为各厂商认的格式。 - 解析
'auto'输出模式为'native'或'tool'(参考 profile)。 - 校验这个模型是否真的支持所请求的功能(否则早失败)。
- 过滤 builtin tools —— 不支持的就剔掉。
3.3 ModelRequestParameters:一个厂商无关的请求描述
models/init.py#L500-L551 的核心 dataclass:
function_tools: list[ToolDefinition]
builtin_tools: list[AbstractBuiltinTool]
output_mode: OutputMode = 'text'
output_object: OutputObjectDefinition | None
output_tools: list[ToolDefinition]
prompted_output_template: str | None
allow_text_output: bool = True
allow_image_output: bool = False
instruction_parts: list[InstructionPart]
thinking: ThinkingLevel | None
每个 Model 子类都接收这个对象,不是厂商原生的 body。所有"OpenAI 用 response_format、Anthropic 用 response_schema、Google 用 response_mime_type"这种差异都在 _completions_create() / _messages_create() 这种内部函数里被抹平。
3.4 统一消息模型:Part-based 抽象
messages.py 定义的消息模型是 pydantic-ai 最大胆的设计之一:
ModelMessage = ModelRequest | ModelResponse
ModelRequest.parts ⊇ { SystemPromptPart, UserPromptPart,
ToolReturnPart, RetryPromptPart }
ModelResponse.parts ⊇ { TextPart, ToolCallPart, ThinkingPart,
FilePart, CompactionPart, BuiltinToolCallPart }
为什么要这么抽象?看对比:
| 厂商 | 原生结构 |
|---|---|
| OpenAI | [{role, content, tool_calls[]}] |
| Anthropic | system 独立字段 + messages[{role, content_blocks[]}] |
contents[{role, parts[{text or function_call or ...}]}] |
三家对"一条回复"的切分粒度完全不同。Pydantic AI 选择了最细的粒度——每个语义单位都是一个独立的 Part。于是:
- OpenAI 的
tool_calls[0]→ToolCallPart(name, args, id) - Anthropic 的
BetaThinkingBlock→ThinkingPart(content) - Google 的
function_call.name + args→ 同样的ToolCallPart
映射的实现各占一文件,以 OpenAI 和 Anthropic 举例:
- models/openai.py#L800-L890:
_completions_create+_process_response,其中_map_messages负责请求侧映射。注意 OpenAI 的tool_calls[].function.arguments是JSON 字符串,要解析;而 Anthropic 的BetaToolUseBlock.input已经是 dict。 - models/anthropic.py#L559-L710:
_messages_create+_process_response,Anthropic 的系统提示被独立出消息数组,缓存点通过CacheControlEphemeralParam标记在消息/工具定义上。
3.5 Provider:单纯的鉴权+客户端
class Provider(ABC, Generic[InterfaceClient]):
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def base_url(self) -> str: ...
@property
@abstractmethod
def client(self) -> InterfaceClient: ...
@staticmethod
def model_profile(model_name: str) -> ModelProfile | None:
return None
实例看 providers/openai.py:读 OPENAI_API_KEY、建立 AsyncOpenAI(api_key, base_url, ...) 实例。
为什么要和 Model 分开?两个原因:
- 一个 Provider 可以喂给多个 Model:
OpenAIProvider既能驱动OpenAIChatModel,也能驱动OpenAIResponsesModel(Responses API 是另一套)。 - 客户端复用:
AsyncOpenAI的连接池、重试、超时是全局的资源,应该在 Provider 层统一管理。
3.6 infer_model("openai:gpt-4o") 的解析过程
models/init.py#L1238-L1330 的 infer_model():
"openai:gpt-4o"
│
▼ parse_model_id
("openai", "gpt-4o")
│
▼ infer_provider("openai") → 默认 provider factory
OpenAIProvider() # 读 env 构建 AsyncOpenAI
│
▼ 根据 provider_kind 分发
OpenAIChatModel("gpt-4o", provider=provider)
│
▼ Model.__init__ 里拿 profile
profile = OpenAIProvider.model_profile("gpt-4o")
→ 返回一个 OpenAIModelProfile 实例
特殊兼容通路(OpenRouter / Cerebras / Ollama 用的是 OpenAI 协议但要走自家 Model 类)在 models/init.py#L1276-L1288 优先匹配。
3.7 ModelProfile:用配置代替 if-else
随着模型越来越多、版本差异越来越大(Opus 4.5 → 4.6 → 4.7 每个小版本都有新能力),代码里塞 if model_name.startswith(...) 很快会失控。pydantic-ai 把所有差异集中成 profile:
@dataclass(kw_only=True)
class ModelProfile:
supports_tools: bool = True
supports_json_schema_output: bool = False
supports_json_object_output: bool = False
supports_thinking: bool = False
thinking_always_enabled: bool = False
thinking_tags: tuple[str, str] = ('<think>', '</think>')
supported_builtin_tools: frozenset[type[AbstractBuiltinTool]] = ...
厂商可以继承(OpenAIModelProfile、AnthropicModelProfile、GoogleModelProfile),每家加自己的字段。例如 profiles/anthropic.py#L9-L98:
anthropic_supports_adaptive_thinking: bool = False # Sonnet 4.6+ / Opus 4.6+
anthropic_supports_effort: bool = False # Opus 4.5+ 的 effort 参数
anthropic_disallows_budget_thinking: bool = False # Opus 4.7+ 拒绝预算式
anthropic_disallows_sampling_settings: bool = False # Opus 4.7+ 不接 temperature
动态生成 profile 的函数 profiles/anthropic.py#L61-L98:
def anthropic_model_profile(model_name: str) -> ModelProfile | None:
supports_json_schema_output = model_name.startswith((
'claude-haiku-4-5', 'claude-sonnet-4-5', ...
))
supports_adaptive = model_name.startswith((
'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-7',
))
return AnthropicModelProfile(
supports_json_schema_output=supports_json_schema_output,
anthropic_supports_adaptive_thinking=supports_adaptive,
...
)
发新模型时,只需更新这一处,不需要改 AnthropicModel 本身。
3.8 Wrapper 家族:Model 也能被 Model 包
models/wrapper.py 定义了 WrapperModel,本身不做事,只为子类提供基础设施。它有四个有趣的子孙:
| 类 | 用途 | 关键代码 |
|---|---|---|
FallbackModel | 故障转移 | models/fallback.py#L68-L80 |
InstrumentedModel | OTel / Logfire 埋点 | models/instrumented.py#L66-L75 |
TestModel | 单测,自动调所有工具 | models/test.py#L59-L100 |
FunctionModel | 用本地函数当 LLM | models/function.py#L44-L100 |
FallbackModel 的故障谓词设计值得一提——fallback_on 既接受异常类,也接受 callable:
model = FallbackModel(
models=[primary, backup1, backup2],
fallback_on=[
APIConnectionError,
lambda response: response.finish_reason == 'content_filter',
lambda exc: isinstance(exc, RateLimitError) and exc.retry_after > 30,
],
)
所有模型都挂了会抛 FallbackExceptionGroup(Python 3.11+ 的原生 ExceptionGroup)。
3.9 流式统一:StreamedResponse 的三层迭代器
models/init.py#L982-L1100 的 StreamedResponse 是"流的 base class"。它的 __aiter__ 是三层包装:
# 最内层:厂商 chunk → ModelResponseStreamEvent
async for event in self._get_event_iterator():
# 中层:监测 schema 匹配,产生 FinalResultEvent
async for event in iterator_with_final_event(...):
# 外层:补发 PartEndEvent
async for event in iterator_with_part_end(...):
yield event
一个细节特别好使——StreamedResponse.get() 不要求流被完全消费就能调用:
def get(self) -> ModelResponse:
return ModelResponse(
parts=self._parts_manager.get_parts(), # 目前为止累积到的
usage=self._usage,
...
)
这意味着你可以中途拿到部分结果、做决策(比如检测到敏感词就中止),而不必等模型说完。
3.10 本章的三个设计亮点
- Part-based 消息模型是连接十几家厂商的粘合剂。新厂商接入时,只要能把原生回复 map 成
Part列表,就自动获得了流式、工具、结构化输出、思考、多模态所有能力。 ModelProfile把"能力矩阵"变成配置。版本迭代带来的大量if语句被压缩到一张表里,新模型上线几乎只改数据不改代码。- WrapperModel 让组合优于继承。
FallbackModel(InstrumentedModel(real_model))这种堆叠写法就是装饰器模式的教科书实现,每一层关注点都独立、可单测。
4. 工具系统与结构化输出
本章是 pydantic-ai 最能体现"FastAPI 迁到 GenAI"的地方。核心文件:tools.py、tool_manager.py、_function_schema.py、_griffe.py、_json_schema.py、output.py、_output.py。
4.1 Tool vs ToolDefinition:两张脸的工具
tools.py#L356-L497 的 Tool 是运行时对象:带实际的 Python 函数、prepare 钩子、args_validator、strict、sequential、requires_approval、timeout…
tools.py#L590-L725 的 ToolDefinition 是发给 LLM 的对象:只含 name、description、parameters_json_schema、return_schema、kind('function' | 'output' | 'external' | 'unapproved')。
转换在 tools.py#L554-L568 的 tool_def 属性里延迟完成。这种切分的价值:
- LLM 只看到它该看的(schema、描述),看不到
timeout/sequential这些运行时细节。 prepare钩子可以动态修改ToolDefinition,每步返回不同版本。- 同一个
Tool实例可以被多个 agent 共享。
4.2 从函数到 JSON Schema 的四步管线
这是整个工具系统最巧的部分。写工具只要:
@agent.tool
async def get_weather(ctx: RunContext[Deps], city: str, unit: str = 'C') -> str:
"""Get current weather.
Args:
city: The city name, e.g. 'Shanghai'.
unit: Temperature unit, 'C' or 'F'.
Returns:
A human-readable weather report.
"""
...
框架会自动生成一个 OpenAI/Anthropic/Google 都认的 JSON Schema。四步管线:
Step 1 — inspect.signature 读签名
function_signature.py 负责把参数、默认值、返回类型读出来。
Step 2 — griffe 读 docstring
_griffe.py#L18-L80 的 doc_descriptions(func, sig, docstring_format) 返回 (main_desc, param_descriptions):
- 自动识别 docstring 风格(Google/NumPy/Sphinx),_griffe.py#L83-L92 用正则匹配关键词。
- 返回值用 XML
<returns>…</returns>包起来注入主描述,方便 LLM 识别。 - 关闭 griffe 的日志噪声。
Step 3 — _function_schema.function_schema() 组装
_function_schema.py#L84-L256 的主入口:
for index, (name, p) in enumerate(sig.parameters.items()):
if index == 0 and takes_ctx is None:
takes_ctx = p.annotation is not sig.empty and _is_call_ctx(type_hints[name])
if index == 0 and takes_ctx:
continue # RunContext 参数不进 schema
# 其余参数 → FieldInfo,并把 docstring 描述塞进 description
field_info = FieldInfo.from_annotation(annotation) if required else \
FieldInfo.from_annotated_attribute(annotation, p.default)
field_info.description = field_descriptions.get(field_name)
产出:一个 TypedDict 风格的 core schema + Pydantic SchemaValidator + 一份 json_schema。特殊处理:如果函数只有一个参数且它是 BaseModel / TypeAdapter,就直接用它的 schema,不套 wrapper——这避免了 {"args": {...}} 这种多余的外层。
返回值 schema 也会被生成(mode='serialization'),作为 return_schema 可选地发送给模型,让 LLM 知道工具返回什么形状的数据。
Step 4 — _json_schema.JsonSchemaTransformer 规范化
_json_schema.py#L14-L150 是各厂商 schema 适配器的基类:
prefer_inlined_defs:内联$defs(部分模型不认$ref)。strict模式:移除minLength/pattern/format等 OpenAI strict 模式不支持的字段。- 处理递归
$ref(保留在$defs)。
各厂商模型通过 profile.json_schema_transformer 指定用哪个子类。
4.3 RunContext 的自动识别与注入
_function_schema.py#L131-L146:
def _is_call_ctx(annotation: Any) -> bool:
return annotation is RunContext or get_origin(annotation) is RunContext
- 构造 FunctionSchema 时检查第一个参数类型是否为
RunContext[...],是 →takes_ctx=True,并从 JSON Schema 中剔掉该参数。 - 运行时 _function_schema.py#L61-L81 的
_call_args()决定参数组装:takes_ctx=True时把ctx塞到*args最前面;否则只用**kwargs。
好处:工具函数既可以要 context 也可以不要,行为完全一致,LLM 看到的 schema 一样。
4.4 ToolManager:一轮运行的工具中枢
tool_manager.py#L42-L134 的 ToolManager 做了四件事:
- 按 step 刷新工具集(
for_run_step(ctx),tool_manager.py#L108-L133):每个模型请求前调用一次,允许 toolset 根据当前 ctx 动态决定"这一步给 LLM 看哪些工具"。 - 积累失败计数(同上代码段):把上一步标记为 failed 的工具的
ctx.retries[name]递增,便于工具函数在下次执行时通过ctx.retry感知"我这是第几次失败了"。 - 分离验证与执行:
validate_tool_calltool_manager.py#L340-L425 只做参数校验。execute_tool_calltool_manager.py#L427-L458 才真正调函数。- 分开是为了并发调度:先对所有工具并发 validate,再决定并发 / 串行执行。
- 并发策略:tool_manager.py#L85-L156 的
parallel_execution_modeContextVar 控制三种模式:'parallel' | 'sequential' | 'parallel_ordered_events'。如果某工具sequential=True,会被强制串行。
4.5 Toolset:工具的集合抽象
toolsets/abstract.py#L74-L191 定义 AbstractToolset。关键方法 get_tools(ctx) 返回 dict[str, ToolsetTool]。
Toolset 是可组合的,这一点和 Unix 管道很像:
toolset = (
FunctionToolset([weather_tool, news_tool])
.filtered(lambda ctx, tool: ctx.deps.user_role == 'admin' or tool.name != 'delete_data')
.prefixed('backend_')
)
内置 toolset 实现(都在 toolsets/ 下):
| 类 | 作用 |
|---|---|
FunctionToolset | 包装 Python 函数 |
CombinedToolset | 合并多个 toolset |
FilteredToolset | 条件过滤 |
PrefixedToolset | 工具名统一加前缀(避免冲突) |
PreparedToolset | 应用全局 prepare_tools 钩子 |
ApprovalRequiredToolset | 人机审批流 |
_dynamic.DynamicToolset | 运行时注册 |
fastmcp.* | 连 MCP 服务器 |
这种"工具源抽象层"的价值在于:MCP 服务器暴露的工具、Python 函数、lambda、外部 API,都走同一条 ToolManager 链路。
4.6 结构化输出:多策略可插拔
output.py#L75-L263 定义了四个标记类,对应四种"让 LLM 给出结构化数据"的策略:
| 标记类 | 策略 | 原理 |
|---|---|---|
ToolOutput(T) | 假 final_result 工具 | 把输出类型定义成一个名为 final_result 的 "工具",LLM 要返回结构化数据,必须"调用"这个工具;tool_choice 被强制为 required |
NativeOutput(T) | 厂商原生 structured output | OpenAI response_format={"type":"json_schema"} / Anthropic response_schema / Google response_mime_type |
PromptedOutput(T) | prompt 注入 schema | 在 instructions 里插入 "请返回符合此 JSON Schema 的 JSON:\n<schema>" |
TextOutput(fn) | 拿文本再处理 | LLM 自由发挥文本,fn(text) 解析/校验 |
调度选择:如果用户写 output_type=MyModel,框架默认使用 'auto' 模式,然后在 Model.prepare_request() 里根据 profile 决定落到 'tool' 还是 'native'(models/init.py#L725-L741)。
**"假 final_result 工具"**的源码体现在 _agent_graph.py#L1364-L1453 process_tool_calls():先把所有 kind='output' 的 tool_call 挑出来处理,第一个校验通过的就成为 FinalResult 并返回 End。
校验失败时走 _agent_graph.py#L1413-L1417:生成 RetryPromptPart("Validation error: ..."),连同 pydantic 的 error 列表一起喂回 LLM,让它自己纠正。
输出验证器(@agent.output_validator)接入在 _agent_graph.py#L1310-L1318。它接受四种签名(参考 _output.py#L57-L62):
(ctx, output) → output
(ctx, output) → Awaitable[output]
(output,) → output
(output,) → Awaitable[output]
抛 ModelRetry("reason") 触发重试,重试失败累计超过 retries 就抛 UnexpectedModelBehavior。
4.7 Builtin Tools vs Common Tools
很多人会把这两个混淆,源码层面的区别很清楚:
builtin_tools/ | common_tools/ | |
|---|---|---|
| 性质 | 厂商原生工具的抽象 | 框架自己实现的通用工具 |
| 传递 | 通过 ModelRequestParameters.builtin_tools 传给模型 | 通过普通 tools=[...] / Toolset |
| 执行 | 模型侧(OpenAI/Anthropic server)执行 | 本地 Python 函数 |
| 例子 | WebSearchTool、CodeExecutionTool、XSearchTool、FileSearchTool | tavily_search、duckduckgo_search、web_fetch、图像生成 |
| 抽象基类 | builtin_tools/init.py 的 AbstractBuiltinTool | 普通函数 |
选型建议:模型支持 builtin 时优先用 builtin(延迟低、计费清晰),否则用 common。
4.8 prepare 钩子:给工具"两幅面孔"
两个层面的 prepare:
-
Tool 级
prepare(tools.py#L93-L121):def admin_only(ctx: RunContext[UserDeps], tool_def: ToolDefinition) -> ToolDefinition | None: if ctx.deps.user.is_admin: return tool_def # 包含此工具 return None # 隐藏此工具 tool = Tool(delete_database, prepare=admin_only) -
Agent 级
prepare_tools(全局钩子):对所有工具统一加工,例如给 OpenAI 模式强制开strict=True。
每个 ModelRequest 之前都会触发 prepare,所以工具集合是每步可变的。这为两个场景提供了基础:
- 权限控制:根据用户身份动态隐藏危险工具。
- 工具搜索:
defer_loading=True的工具在get_tools时不返回,直到某个 search tool 被模型调用后再注册到 toolset——这是 pydantic-ai 应对"千工具场景"的解法。
4.9 本章的三个设计亮点
- docstring → JSON Schema 自动化——
griffe+inspect.signature+ Pydantic 三件套合力,让写工具几乎没有样板代码。 ToolvsToolDefinition的人格分裂——运行时对象和协议对象分开,prepare 钩子让二者可以动态解耦。- 结构化输出的多策略 + 自动降级——
'auto'模式 +ModelProfile.default_structured_output_mode组合,让用户写output_type=MyModel就好了,框架替你选最佳策略。
5. pydantic-graph 工作流引擎
pydantic_graph 是单独发布的包,但它是 pydantic-ai 的"操作系统内核"——Agent 的运行循环就是一个 Graph 实例。本章细节来自 pydantic_graph/pydantic_graph/。
5.1 四个核心类
-
GraphRunContext[StateT, DepsT]nodes.py#L27-L34:运行时传给节点的上下文,只有state和deps两个字段。 -
BaseNode[StateT, DepsT, NodeRunEndT]nodes.py#L37-L136:抽象基类,唯一抽象方法:@abstractmethod async def run(self, ctx: GraphRunContext[StateT, DepsT]) \ -> BaseNode[StateT, DepsT, Any] | End[NodeRunEndT]: ...节点的
run可以返回下一个节点实例或End(result)终止——这两种返回值也是图的静态结构来源(见 5.2)。 -
End[T]nodes.py#L143-L168:终止信号 + 结果携带。 -
Graph[StateT, DepsT, StartNode, RunEndT]graph.py#L24-L105:容器,实例化时扫描所有节点的返回类型注解,建起静态边表。
5.2 类型驱动的边推断
这是 pydantic-graph 的招牌:图的边由节点的返回类型注解自动推断,不需要手动连边。
实现在 nodes.py#L105-L136 的 BaseNode.get_node_def():
type_hints = get_type_hints(cls.run, localns=local_ns, include_extras=True)
return_hint = type_hints['return'] # 例如 NextNode | End[int]
for return_type in get_union_args(return_hint):
if get_origin(return_type) is End:
end_edge = edge
elif issubclass(get_origin_or_self(return_type), BaseNode):
next_node_edges[return_type.get_node_id()] = edge
Graph 构造时通过 graph.py#L508-L525 的 _validate_edges() 确保所有引用的节点都在 node_defs 里——错连节点会在构造时就抛 GraphSetupError,不用等到运行时才炸。
三种情形:
-> SpecificNode:单一确定边。-> End[T]:终止边。-> BaseNode(裸泛型):returns_base_node=True,表示可跳到任意节点(慎用)。
5.3 运行时:Graph.iter() / GraphRun.next()
graph.py#L192-L257 的 Graph.iter() 建 GraphRun,它的 next() 方法 graph.py#L663-L752:
async def next(self, node=None) -> BaseNode | End:
await self.persistence.snapshot_node_if_new(node.get_snapshot_id(), self.state, node)
with logfire_span(...):
async with self.persistence.record_run(snapshot_id):
ctx = GraphRunContext(state=self.state, deps=self.deps)
self._next_node = await node.run(ctx) # ← 真正执行节点
if isinstance(self._next_node, End):
await self.persistence.snapshot_end(self.state, self._next_node)
elif isinstance(self._next_node, BaseNode):
await self.persistence.snapshot_node(self.state, self._next_node)
return self._next_node
每次 next() 做三件事:快照→执行→再快照。record_run 是上下文管理器,自动记录 start_ts / duration / status。
Graph.run() 就是一个 async for _ in graph_run: pass 的便捷包装。
5.4 持久化:pydantic-graph 的杀手特性
persistence/init.py#L106-L226 的 BaseStatePersistence 定义了六个关键方法:
async def snapshot_node(state, next_node)
async def snapshot_node_if_new(snapshot_id, state, node) # 幂等
async def snapshot_end(state, end)
@asynccontextmanager
async def record_run(snapshot_id) # 记录耗时/状态
async def load_next() → NodeSnapshot | None # 恢复时取下一步
async def load_all() → list[Snapshot] # 审计
快照结构 persistence/init.py#L44-L103:
NodeSnapshot: state, node, status (created|pending|running|success|error),
start_ts, duration
EndSnapshot: state, result, ts
Snapshot = NodeSnapshot | EndSnapshot
三种后端:
| 类 | 位置 | 特点 |
|---|---|---|
SimpleStatePersistence | persistence/in_mem.py#L31-L83 | 仅留最后一个快照,一次性运行 |
FullStatePersistence | persistence/in_mem.py#L85-L173 | 内存全量列表 + dump_json/load_json |
FileStatePersistence | persistence/file.py#L29-L173 | JSON 文件追加,_lock() 并发安全 |
价值:
- 中断恢复:
graph.iter_from_persistence(persistence)重启后继续跑。 - 审计日志:每个节点的
start_ts / duration / status都在那。 - 人机协作:节点可标记为
'pending'等审批,外部改状态后继续。 - 分布式:多 Worker 通过
FileStatePersistence协作,load_next()自动选下一个'created'快照。
5.5 Mermaid 自动生成
mermaid.py#L41-L114 的 generate_code() 直接遍历 graph.node_defs 产出 stateDiagram-v2:
---
title: never_42_graph
---
stateDiagram-v2
[*] --> Increment
Increment --> Check42
Check42 --> Increment
Check42 --> [*]
支持 highlighted_nodes=... 标出当前节点位置,适合做实时可视化调试。
5.6 Agent 如何复用 pydantic-graph
_agent_graph.py#L26-L29 的顶层 import:
from pydantic_graph import BaseNode, GraphRunContext
from pydantic_graph.beta import Graph, GraphBuilder
用到的能力:
- ✅
BaseNode/GraphRunContext— 三个节点的基类、上下文。 - ✅ 持久化基础设施 — 允许 Agent 在长流程中保存进度。
- ✅
iter()— Agent 的agent.iter()就是graph.iter()的薄包装。 - ✅ 类型推断 — 把 Agent 节点连边的正确性推到构造期。
- ⏳
beta.GraphBuilder— 用上了 Fork/Join 的基础设施(为将来的并行工具/子 agent 预留)。
5.7 beta 模块:下一代 GraphBuilder
beta/ 引入了流畅的 builder API:
builder = GraphBuilder[State, Deps, Input, Output](
name='my_graph', state_type=State, deps_type=Deps,
input_type=Input, output_type=Output,
)
node_a = builder.start_node(step_a)
node_b = node_a.step(step_b)
node_c = node_b.decision(condition_fn)
graph = builder.build()
新旧 API 对比:
经典 BaseNode | GraphBuilder | |
|---|---|---|
| 节点定义 | class N(BaseNode) | async def step(ctx) -> ... |
| 分支 | 返回值的 Union | .decision(predicate) |
| 并行 | 不支持 | fork() + join(reducer) |
| 类型安全 | mypy 友好 | 动态但有类型提示 |
| 学习曲线 | 较陡 | 较平 |
对于复杂多 Agent 协作流,GraphBuilder 的 Fork/Join 是必需的——比如"让三个 Agent 并行分析同一份文档,然后用 reducer 聚合答案"。
5.8 本章的三个设计亮点
- 类型注解即 DSL:节点的
run() -> A | B | End[T]既是类型声明,又是图的边声明,还会被 Mermaid 自动渲染。一份信息三处用。 - 持久化友好的节点语义:每个节点前后都能快照,幂等操作 + 状态机(
created → pending → running → success)让分布式协作成为自然能力。 - Agent 与 Workflow 同构:同一个图引擎同时跑"LLM 轮询"(Agent)和"业务流程"(比如审批流),Workflow 里嵌套 Agent、Agent 里调用 Workflow 都是一等公民。
6. 贯穿式应用案例
前五章把零件拆明白了。最后一章用几个典型场景把零件组装起来。所有示例都基于官方 examples 的代码风格改写,便于对照。
6.1 最小可用:结构化输出 + 依赖注入
from dataclasses import dataclass
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
db: "Database" # 你自己的数据库连接
class Invoice(BaseModel):
customer_id: int
amount_cents: int = Field(gt=0)
currency: str = Field(pattern=r'^[A-Z]{3}$')
agent = Agent[Deps, Invoice](
'anthropic:claude-sonnet-4-6',
deps_type=Deps,
output_type=Invoice,
instructions='Extract invoice data as JSON.',
)
@agent.tool
async def lookup_customer(ctx: RunContext[Deps], email: str) -> int:
"""Look up a customer ID by email.
Args:
email: The customer's email address.
"""
return await ctx.deps.db.customer_id_by_email(email)
# 使用
result = await agent.run(
'Bill user alice@example.com for 99.50 USD',
deps=Deps(db=my_db),
)
assert isinstance(result.output, Invoice)
对应源码关键点:
output_type=Invoice会被 _output.py 编译成一个kind='output'的假final_result工具,LLM 通过工具调用方式返回结构化数据。@agent.tool触发 _function_schema.py#L84 生成 schema,docstring 里的email: The customer's ...会进 _griffe.py 解析成parameters.properties.email.description。ctx.deps.db在工具函数里拿到的是RunContext.deps,由 _run_context.py#L32 注入。
6.2 多工具 + 流式 + 用量限制
from pydantic_ai.usage import UsageLimits
limits = UsageLimits(request_limit=8, total_tokens_limit=20_000)
async with agent.run_stream(
'Summarize the last 3 invoices for user bob@example.com',
deps=Deps(db=my_db),
usage_limits=limits,
) as result:
async for chunk in result:
print(chunk, end='', flush=True)
final = await result.get_output()
源码关键点:
run_stream在 agent/abstract.py#L517 实现。它手动驱动 graph,在ModelRequestNode.stream()拿到AgentStream,遇到第一个FinalResultEvent就 yieldStreamedRunResult。UsageLimits的校验发生在 _agent_graph.py#L856-L866——请求前校验 token 估算、请求数上限。
6.3 Fallback + 埋点:生产环境模型组合
from pydantic_ai.models import FallbackModel, instrument_model
from pydantic_ai.models.instrumented import InstrumentationSettings
from anthropic import APIConnectionError, RateLimitError
primary = 'anthropic:claude-sonnet-4-6'
secondary = 'openai:gpt-4o'
model = FallbackModel(
models=[primary, secondary],
fallback_on=[
APIConnectionError,
lambda exc: isinstance(exc, RateLimitError) and (exc.retry_after or 0) > 30,
lambda response: response.finish_reason == 'content_filter',
],
)
model = instrument_model(model, InstrumentationSettings(include_content=False))
agent = Agent(model, output_type=Invoice, deps_type=Deps, ...)
源码关键点:
FallbackModel在 models/fallback.py 中遍历models,命中任何fallback_on条件就进下一个;全部失败抛FallbackExceptionGroup(Python 的原生 ExceptionGroup)。instrument_model装饰器把 OTel span / metrics 绑到请求上,响应里的model_name、provider_name、usage.*都会自动进 span 属性。
6.4 Tool 过滤 + 人工审批
from pydantic_ai.toolsets import FunctionToolset
from pydantic_ai.tools import Tool, ToolDefinition
def only_for_admin(ctx: RunContext[Deps], td: ToolDefinition) -> ToolDefinition | None:
return td if ctx.deps.user.is_admin else None
tools = FunctionToolset([
Tool(read_orders, prepare=None),
Tool(delete_order, prepare=only_for_admin, requires_approval=True),
]).filtered(lambda ctx, td: 'internal' not in td.metadata)
requires_approval=True会让该工具的ToolDefinition.kind='unapproved',在 _agent_graph.py#L1364process_tool_calls()里被识别为"延迟工具"——当前运行直接End,外部系统拿到 deferred tool 的参数展示给人审批,然后通过新的agent.run(..., resume_with=...)继续。- 要想让这个"停-审批-续"流程跨进程可靠,把
persistence=FileStatePersistence('run-123.json')传进agent.iter(),就能在另一个进程恢复。
6.5 用 pydantic-graph 编排多 Agent 工作流
from dataclasses import dataclass
from pydantic_graph import BaseNode, End, Graph, GraphRunContext
@dataclass
class ResearchState:
topic: str
outline: str | None = None
draft: str | None = None
feedback: str | None = None
# 三个 agent
outline_agent = Agent('openai:gpt-4o', output_type=str, instructions='Generate a concise outline.')
drafting_agent = Agent('anthropic:claude-sonnet-4-6', output_type=str, instructions='Write a draft from outline.')
review_agent = Agent('anthropic:claude-sonnet-4-6', output_type=str, instructions='Review and give feedback.')
@dataclass
class OutlineNode(BaseNode[ResearchState]):
async def run(self, ctx: GraphRunContext[ResearchState]) -> "DraftNode":
result = await outline_agent.run(ctx.state.topic)
ctx.state.outline = result.output
return DraftNode()
@dataclass
class DraftNode(BaseNode[ResearchState]):
async def run(self, ctx: GraphRunContext[ResearchState]) -> "ReviewNode":
result = await drafting_agent.run(f'Outline:\n{ctx.state.outline}')
ctx.state.draft = result.output
return ReviewNode()
@dataclass
class ReviewNode(BaseNode[ResearchState]):
async def run(self, ctx: GraphRunContext[ResearchState]) -> "DraftNode | End[str]":
result = await review_agent.run(f'Draft:\n{ctx.state.draft}')
fb = result.output
if 'LGTM' in fb.upper():
return End(ctx.state.draft)
ctx.state.feedback = fb
return DraftNode() # 反馈不通过 → 回去重写
graph = Graph(nodes=(OutlineNode, DraftNode, ReviewNode))
state = ResearchState(topic='Agent frameworks in 2026')
final = await graph.run(OutlineNode(), state=state)
print(final.output) # 最终稿
print(graph.mermaid_code()) # 顺手出一张流程图
- 节点之间只靠类型注解连边:
ReviewNode.run -> DraftNode | End[str]自动成为"要么回到 Draft,要么终止"。 - 结合
FileStatePersistence,可以做到"审稿人走开两小时回来继续"——整个 state 在磁盘上。 - Mermaid 图按需生成。
6.6 调试与可观测:把事件流喂给 handler
async def log_events(ctx: RunContext, stream):
async for event in stream:
print(f'[step={ctx.run_step}] {type(event).__name__}: {event}')
result = await agent.run(
'Find the top 3 unpaid invoices',
deps=Deps(db=my_db),
event_stream_handler=log_events,
)
在 agent/abstract.py#L308-L329 你会看到,框架在每个 ModelRequestNode / CallToolsNode 执行前都会把事件流喂给 log_events。再配上 capabilities.wrap_run_event_stream 钩子,可以拦截、过滤、改写事件——这是写自研 Logfire / Langfuse 适配层的入口点。
7. 总结:pydantic-ai 的设计哲学
通读全码之后,可以把 pydantic-ai 的设计哲学归纳成五句话:
- 类型即契约。工具 schema、图结构、依赖注入、输出验证全都走类型注解这条管道,写得对 → 编译期就对。
- 声明式构造,懒执行。Agent、Graph 实例化时几乎不做事,执行推迟到运行时——这让对象可复用、可序列化、可跨线程共享。
- 抽象要能加新东西,不要能加新分支。
ModelProfile把厂商差异变成配置、Toolset把工具源变成一种接口、Capability把钩子集中到一个扩展点——新增功能只在数据/组合层,不改核心流程。 - 图是第一公民。Agent 不是"主函数调用 LLM + 工具"的糖纸,而是图的一次运行。Workflow 和 Agent 使用同一套引擎,随时可以相互嵌套。
- 观测、重试、恢复都是默认的,不是附加项。
RunContext里的 tracer、UsageLimits的预检、FileStatePersistence的快照、FallbackModel的多机容错,这些不是"锦上添花的第三方插件",而是核心抽象的一部分。
扩展阅读地图
想深挖的话按这个顺序读最不迷路:
- agent/abstract.py — 看看
run_stream和run的差异对流式有什么影响。 - _agent_graph.py — 整个 Agent 的灵魂文件,三个节点全在这。
- models/openai.py — 最成熟的 Model 适配器,对比 models/anthropic.py 看差异处理。
- _function_schema.py + _griffe.py — 工具 schema 生成全流程。
- _output.py — 结构化输出四种策略的实现。
- pydantic_graph/nodes.py + pydantic_graph/graph.py — 先别看 beta,先把经典 API 读透。
- pydantic_graph/persistence/file.py — 看持久化的典型实现,作为写自定义后端的起点。
- pydantic_graph/beta/graph_builder.py — 了解 Fork/Join 对应的图语义。
examples/pydantic_ai_examples/question_graph.py、bank_support.py、weather_agent.py— 三个规模从小到大的实战样例。
本分析基于 2026 年 4 月抓取的 main 分支。pydantic-ai 迭代速度很快,新版本可能会在 beta 模块、capabilities 体系、MCP 集成等方向上有较大变动。遇到具体细节和代码不符时以最新源码为准。