Pydantic AI 源码深度剖析与应用

2 阅读33分钟

本文基于 pydantic/pydantic-aimain 分支源码(本地 clone 于 d:\opencode\q\pydantic-ai)。所有代码引用均使用 相对路径 的形式,可直接在编辑器中跳转。

目标读者:已经用过几款 Agent 框架(LangChain / LlamaIndex / AutoGen…),想知道 pydantic-ai 为什么"另起炉灶"、它的代码里到底藏着什么巧思,以及如何把它用出深度。

阅读路径建议:先看第 1 章建立全局地图,再按兴趣跳到第 2–5 章的四个核心模块,最后用第 6 章的落地案例把所有概念串起来。


目录

  1. 项目定位与整体架构
  2. Agent 核心与运行循环
  3. Model 抽象与 Provider 层
  4. 工具系统与结构化输出
  5. pydantic-graph 工作流引擎
  6. 贯穿式应用案例
  7. 总结: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**:

FastAPIpydantic-ai
路由函数的签名 = 请求参数 schema工具函数的签名 = tool JSON Schema
BaseModel 响应类型 = OpenAPI schemaoutput_type=MyModel = 强制 LLM 结构化输出
Depends(...) 依赖注入RunContext[Deps] 依赖注入
Starlette 中间件AbstractCapability / hooks
uvicorn ASGI 事件循环pydantic_graph.GraphRun 异步驱动

一旦看懂这个类比,后面所有模块都能对上号。

1.2 仓库骨架

pydantic-ai 是一个 monorepopyproject.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.pyagent/abstract.py_agent_graph.pyrun.py_run_context.py

2.1 Agent 的构造:一份声明式契约

agent/init.py#L282-L310 上的 Agent.__init__ 接受一大串参数,但它们本质上只有四类:

  1. 做什么modeloutput_typeinstructions / system_prompt
  2. 用什么工具toolstoolsetsbuiltin_tools
  3. 怎么约束deps_typeretriesend_strategymodel_settings(可为 callable)
  4. 怎么观察capabilities(hooks 容器)、instrumentmetadata

关键属性都是"以后再用"的储存: 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-L1055AsyncIterator[AgentRun](async CM)最底层,暴露每个 Node
run()agent/abstract.py#L214-L342AgentRunResult[T]自动把图跑完
run_sync()agent/abstract.py#L388-L471同上,同步版本内部 loop.run_until_complete(run())
run_stream()agent/abstract.py#L517-L750StreamedRunResult(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-L1865build_agent_graph(),返回一个形如:

Graph[
    GraphAgentState,               # 运行过程中的可变状态
    GraphAgentDeps[DepsT, OutputT],# 运行过程中的配置/依赖
    UserPromptNode[DepsT, OutputT],# 入口节点
    result.FinalResult[OutputT],   # 结束类型
]

的图。图里有四个节点:

2.4 UserPromptNode:一次运行的第一块拼图

UserPromptNode.run() 做四件事:

  1. 接管消息历史 _agent_graph.py#L208-L211:通过 capture_run_messages() 得到一个可被外部观察的 list 引用,直接赋给 ctx.state.message_history。后续所有消息修改都发生在这个引用上——这意味着在 with capture_run_messages() as messages: 语句外,你仍然能看到运行期间产生的所有消息。

  2. 重算动态系统提示 _agent_graph.py#L352-L377:对历史中每个 SystemPromptPart(dynamic_ref=...),用 dynamic_ref_system_prompt_dynamic_functions 查表,重跑对应函数。这让"你是X角色"这种动态提示在多轮 resume 时能够根据新的 RunContext 更新。

  3. 处理延迟工具 (deferred tool results) 和 resume 场景:如果上一轮留下了未完成的工具调用(典型场景:人机审批后回来),直接跳到 CallToolsNode

  4. 构造首个 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() 按顺序做:

  1. 遍历 model_response.parts,区分 TextPart / ToolCallPart / ThinkingPart / BuiltinToolCallPart
  2. 先处理 output 工具kind='output')——如果匹配成功且合法,拿到 FinalResult
  3. 运行 output_validators(见 _agent_graph.py#L1310-L1318)。
  4. 并发执行剩余的 function 工具(ToolManager 中默认 parallel)。
  5. 依据 end_strategy'early' | 'graceful' | 'exhaustive')决定是否提前终止。
  6. 返回下一个节点: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

GraphAgentStateGraphAgentDeps
性质可变不可变(几乎)
内容message_historyusageretriesrun_steprun_idmetadatauser_depsmodeltool_manageroutput_schemaoutput_validatorsroot_capabilitytracer
语义这一轮运行产生了什么这一轮运行应该用什么

这种切分让图节点非常纯粹——只有 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-L1283build_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_promptinstructions
目的角色/性格定义当前任务指导
存放位置ModelRequest.parts 里的 SystemPromptPartModelRequest.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 提供了三种粒度的"流":

  1. iter() + node.stream():最底层,直接拿 AgentStream 迭代原始事件(PartStartEvent / PartDeltaEvent / PartEndEvent / FunctionToolCallEvent / FunctionToolResultEvent / FinalResultEvent)。

  2. run_stream() + StreamedRunResult:中层,框架自动驱动到第一个 FinalResultEvent 就 yield 一个 StreamedRunResult,用户再 async for chunk in result 消费输出。end_strategy 决定了背景是否继续跑剩余工具:

    • 'early'(默认):见到最终输出就停。
    • 'graceful':把剩余的 function tools 跑完,跳过 output tools。
    • 'exhaustive':全部跑完。
  3. 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_validatorsstate.retries(全局)UnexpectedModelBehavior
工具调用重试ToolManager.execute_tool_callctx.retries[tool_name]同上
模型重试capability hooks 抛 ModelRetry同 output追加历史后再请求

一个设计细节:ModelRetry 被抛时,ModelRequestNode保留已经产生的 ModelResponse_agent_graph.py#L750-L765),然后追一条 RetryPromptPart,让模型看到"你之前说了啥、为什么不对、再来一次"——这是让 LLM 学会纠正的关键上下文。

2.12 本章值得写进"面试题"的三个设计点

  1. 为什么用图而不是状态机?——图让节点可以独立测试,capability 钩子能在"进入任意节点前/后"集中注入,新增节点不改老代码;流式 API 可以在任何节点插入,无需回调地狱。
  2. GraphAgentState + RunContext 的单一事实源——消息历史是一个 list 引用贯穿所有节点和用户函数,改一处全可见;但 RunContext 是 dataclass,字段级别的替换(replace)避免了共享可变数据的混乱。
  3. 动态系统提示的延迟评估 + 每步重算——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,它集中做了五件事

  1. 合并默认 settings 与运行时 settings。
  2. 根据 profile.supports_thinking 把统一的 thinking 字段翻译为各厂商认的格式。
  3. 解析 'auto' 输出模式为 'native''tool'(参考 profile)。
  4. 校验这个模型是否真的支持所请求的功能(否则早失败)。
  5. 过滤 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[]}]
Anthropicsystem 独立字段 + messages[{role, content_blocks[]}]
Googlecontents[{role, parts[{text or function_call or ...}]}]

三家对"一条回复"的切分粒度完全不同。Pydantic AI 选择了最细的粒度——每个语义单位都是一个独立的 Part。于是:

  • OpenAI 的 tool_calls[0]ToolCallPart(name, args, id)
  • Anthropic 的 BetaThinkingBlockThinkingPart(content)
  • Google 的 function_call.name + args → 同样的 ToolCallPart

映射的实现各占一文件,以 OpenAI 和 Anthropic 举例:

  • models/openai.py#L800-L890_completions_create + _process_response,其中 _map_messages 负责请求侧映射。注意 OpenAI 的 tool_calls[].function.argumentsJSON 字符串,要解析;而 Anthropic 的 BetaToolUseBlock.input 已经是 dict。
  • models/anthropic.py#L559-L710_messages_create + _process_response,Anthropic 的系统提示被独立出消息数组,缓存点通过 CacheControlEphemeralParam 标记在消息/工具定义上。

3.5 Provider:单纯的鉴权+客户端

providers/init.py#L22-L98 抽象:

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 分开?两个原因:

  1. 一个 Provider 可以喂给多个 ModelOpenAIProvider 既能驱动 OpenAIChatModel,也能驱动 OpenAIResponsesModel(Responses API 是另一套)。
  2. 客户端复用AsyncOpenAI 的连接池、重试、超时是全局的资源,应该在 Provider 层统一管理。

3.6 infer_model("openai:gpt-4o") 的解析过程

models/init.py#L1238-L1330infer_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:

profiles/init.py#L22-L121

@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]] = ...

厂商可以继承(OpenAIModelProfileAnthropicModelProfileGoogleModelProfile),每家加自己的字段。例如 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
InstrumentedModelOTel / Logfire 埋点models/instrumented.py#L66-L75
TestModel单测,自动调所有工具models/test.py#L59-L100
FunctionModel用本地函数当 LLMmodels/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-L1100StreamedResponse 是"流的 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 本章的三个设计亮点

  1. Part-based 消息模型是连接十几家厂商的粘合剂。新厂商接入时,只要能把原生回复 map 成 Part 列表,就自动获得了流式、工具、结构化输出、思考、多模态所有能力。
  2. ModelProfile 把"能力矩阵"变成配置。版本迭代带来的大量 if 语句被压缩到一张表里,新模型上线几乎只改数据不改代码。
  3. WrapperModel 让组合优于继承FallbackModel(InstrumentedModel(real_model)) 这种堆叠写法就是装饰器模式的教科书实现,每一层关注点都独立、可单测。

4. 工具系统与结构化输出

本章是 pydantic-ai 最能体现"FastAPI 迁到 GenAI"的地方。核心文件:tools.pytool_manager.py_function_schema.py_griffe.py_json_schema.pyoutput.py_output.py

4.1 Tool vs ToolDefinition:两张脸的工具

tools.py#L356-L497Tool运行时对象:带实际的 Python 函数、prepare 钩子、args_validatorstrictsequentialrequires_approvaltimeout

tools.py#L590-L725ToolDefinition发给 LLM 的对象:只含 namedescriptionparameters_json_schemareturn_schemakind'function' | 'output' | 'external' | 'unapproved')。

转换在 tools.py#L554-L568tool_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-L80doc_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-L134ToolManager 做了四件事:

  1. 按 step 刷新工具集for_run_step(ctx)tool_manager.py#L108-L133):每个模型请求前调用一次,允许 toolset 根据当前 ctx 动态决定"这一步给 LLM 看哪些工具"。
  2. 积累失败计数(同上代码段):把上一步标记为 failed 的工具的 ctx.retries[name] 递增,便于工具函数在下次执行时通过 ctx.retry 感知"我这是第几次失败了"。
  3. 分离验证与执行
  4. 并发策略tool_manager.py#L85-L156parallel_execution_mode ContextVar 控制三种模式:'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 outputOpenAI 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 函数
例子WebSearchToolCodeExecutionToolXSearchToolFileSearchTooltavily_searchduckduckgo_searchweb_fetch、图像生成
抽象基类builtin_tools/init.pyAbstractBuiltinTool普通函数

选型建议:模型支持 builtin 时优先用 builtin(延迟低、计费清晰),否则用 common。

4.8 prepare 钩子:给工具"两幅面孔"

两个层面的 prepare:

  1. Tool 级 preparetools.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)
    
  2. Agent 级 prepare_tools(全局钩子):对所有工具统一加工,例如给 OpenAI 模式强制开 strict=True

每个 ModelRequest 之前都会触发 prepare,所以工具集合是每步可变的。这为两个场景提供了基础:

  • 权限控制:根据用户身份动态隐藏危险工具。
  • 工具搜索defer_loading=True 的工具在 get_tools 时不返回,直到某个 search tool 被模型调用后再注册到 toolset——这是 pydantic-ai 应对"千工具场景"的解法。

4.9 本章的三个设计亮点

  1. docstring → JSON Schema 自动化——griffe + inspect.signature + Pydantic 三件套合力,让写工具几乎没有样板代码。
  2. Tool vs ToolDefinition 的人格分裂——运行时对象和协议对象分开,prepare 钩子让二者可以动态解耦。
  3. 结构化输出的多策略 + 自动降级——'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:运行时传给节点的上下文,只有 statedeps 两个字段。

  • 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-L136BaseNode.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-L257Graph.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-L226BaseStatePersistence 定义了六个关键方法:

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

三种后端:

位置特点
SimpleStatePersistencepersistence/in_mem.py#L31-L83仅留最后一个快照,一次性运行
FullStatePersistencepersistence/in_mem.py#L85-L173内存全量列表 + dump_json/load_json
FileStatePersistencepersistence/file.py#L29-L173JSON 文件追加,_lock() 并发安全

价值:

  1. 中断恢复graph.iter_from_persistence(persistence) 重启后继续跑。
  2. 审计日志:每个节点的 start_ts / duration / status 都在那。
  3. 人机协作:节点可标记为 'pending' 等审批,外部改状态后继续。
  4. 分布式:多 Worker 通过 FileStatePersistence 协作,load_next() 自动选下一个 'created' 快照。

5.5 Mermaid 自动生成

mermaid.py#L41-L114generate_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 对比:

经典 BaseNodeGraphBuilder
节点定义class N(BaseNode)async def step(ctx) -> ...
分支返回值的 Union.decision(predicate)
并行不支持fork() + join(reducer)
类型安全mypy 友好动态但有类型提示
学习曲线较陡较平

对于复杂多 Agent 协作流,GraphBuilder 的 Fork/Join 是必需的——比如"让三个 Agent 并行分析同一份文档,然后用 reducer 聚合答案"。

5.8 本章的三个设计亮点

  1. 类型注解即 DSL:节点的 run() -> A | B | End[T] 既是类型声明,又是图的边声明,还会被 Mermaid 自动渲染。一份信息三处用。
  2. 持久化友好的节点语义:每个节点前后都能快照,幂等操作 + 状态机(created → pending → running → success)让分布式协作成为自然能力。
  3. 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_streamagent/abstract.py#L517 实现。它手动驱动 graph,在 ModelRequestNode.stream() 拿到 AgentStream,遇到第一个 FinalResultEvent 就 yield StreamedRunResult
  • 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, ...)

源码关键点

  • FallbackModelmodels/fallback.py 中遍历 models,命中任何 fallback_on 条件就进下一个;全部失败抛 FallbackExceptionGroup(Python 的原生 ExceptionGroup)。
  • instrument_model 装饰器把 OTel span / metrics 绑到请求上,响应里的 model_nameprovider_nameusage.* 都会自动进 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#L1364 process_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 的设计哲学归纳成五句话:

  1. 类型即契约。工具 schema、图结构、依赖注入、输出验证全都走类型注解这条管道,写得对 → 编译期就对。
  2. 声明式构造,懒执行。Agent、Graph 实例化时几乎不做事,执行推迟到运行时——这让对象可复用、可序列化、可跨线程共享。
  3. 抽象要能加新东西,不要能加新分支ModelProfile 把厂商差异变成配置、Toolset 把工具源变成一种接口、Capability 把钩子集中到一个扩展点——新增功能只在数据/组合层,不改核心流程。
  4. 图是第一公民。Agent 不是"主函数调用 LLM + 工具"的糖纸,而是图的一次运行。Workflow 和 Agent 使用同一套引擎,随时可以相互嵌套。
  5. 观测、重试、恢复都是默认的,不是附加项RunContext 里的 tracer、UsageLimits 的预检、FileStatePersistence 的快照、FallbackModel 的多机容错,这些不是"锦上添花的第三方插件",而是核心抽象的一部分。

扩展阅读地图

想深挖的话按这个顺序读最不迷路:

  1. agent/abstract.py — 看看 run_streamrun 的差异对流式有什么影响。
  2. _agent_graph.py — 整个 Agent 的灵魂文件,三个节点全在这。
  3. models/openai.py — 最成熟的 Model 适配器,对比 models/anthropic.py 看差异处理。
  4. _function_schema.py + _griffe.py — 工具 schema 生成全流程。
  5. _output.py — 结构化输出四种策略的实现。
  6. pydantic_graph/nodes.py + pydantic_graph/graph.py — 先别看 beta,先把经典 API 读透。
  7. pydantic_graph/persistence/file.py — 看持久化的典型实现,作为写自定义后端的起点。
  8. pydantic_graph/beta/graph_builder.py — 了解 Fork/Join 对应的图语义。
  9. examples/pydantic_ai_examples/question_graph.pybank_support.pyweather_agent.py — 三个规模从小到大的实战样例。

本分析基于 2026 年 4 月抓取的 main 分支。pydantic-ai 迭代速度很快,新版本可能会在 beta 模块、capabilities 体系、MCP 集成等方向上有较大变动。遇到具体细节和代码不符时以最新源码为准。