2.1 核心模型:Agent/RunContext/依赖注入重写问答路径
卷二把卷一的手写引擎 minagent 包装成了 ResearchCopilot 服务,第一个验收用例 UC1 是「单跳事实问答」:用户提问 → 检索本地语料 → 生成带引用的回答。那条路径在卷二里完全是我们自己搭的——ToolRegistry 注册 search、Agent.run() 转循环、工具结果以 tool 角色回灌。这一节不改需求、不改引擎契约,只把同一条路径用 PydanticAI 的 Agent 重写一遍,把「框架究竟替我们做了什么」看得清清楚楚。
本节把 PydanticAI 推进到「能跑 UC1」:用
Agent+RunContext依赖注入 + 本地假模型,把卷二的问答路径重写并真跑,拿到与自研版等价的轨迹;同时钉死本卷后续反复引用的三个构造参数——output_type、deps_type、system_prompt。
2.1.1 要重写的那条路径
先把要重写的东西摆出来。UC1 的路径只有三步,卷二里它落在「服务层调用引擎」这条线上(见卷一 1.3 可编程假模型 FakeModel 与卷二用例定义):
| 步 | 动作 | 卷二里的实现 |
|---|---|---|
| 1 | 用户问题进入引擎 | Agent.run(user_input) |
| 2 | 模型要求调用 search | ToolRegistry.call(ToolCall) 分发 |
| 3 | 拿到片段后生成带引用回答 | 模型再次调用,返回 final |
三个环节里,只有第 2 步的「检索器」是业务依赖:它可能连本地 Postgres、可能连向量库、测试时要换成假实现。自研版把检索器塞进工具函数的闭包里;PydanticAI 提供了一套更正式的机制——依赖注入(deps_type + RunContext),本节重点就在这。
2.1.2 Agent 的构造:三个参数定形状
PydanticAI 的入口是 Agent。它的构造函数参数很多,但对 UC1 只需要三个:
from pydantic_ai import Agent
agent = Agent(
"test", # 模型名;本节用假模型占位
deps_type=Deps, # 声明「运行时要注入的依赖」类型
system_prompt="先检索再回答,回答里保留引用。", # 系统提示词
)
三个参数各管一件事:
deps_type:声明依赖的类型。它本身不携带值,只是一份契约,让工具函数能用RunContext[Deps]拿到强类型的ctx.deps。不声明它,工具就只能靠全局变量或闭包取依赖。system_prompt:字符串或可调用对象,等价于卷一的system参数。- 第一个位置参数是模型。本节统一传
"test",真正跑的时候用run_sync(..., model=...)覆盖成假模型。
这里有一个必须实测确认的坑:输出类型参数叫 output_type,不叫 result_type。旧版教程里的 result_type= 在本机安装的 pydantic-ai 2.55.0 上已经不存在。核对方法:
import inspect
from pydantic_ai import Agent
print([p for p in inspect.signature(Agent.__init__).parameters][:6])
# ['self', 'model', 'output_type', 'instructions', 'system_prompt', 'deps_type']
output_type 的默认值是 str——也就是「不指定时,回答就是一段纯文本」。2.2 节会把它换成 Pydantic 模型,本节先保持默认。
2.1.3 RunContext:依赖注入注入了什么
RunContext 是 PydanticAI 传给每个工具的运行时上下文。它是一层薄封装,把「本次 run 的依赖、用量、模型、消息历史」打包递给工具。我们只关心 ctx.deps 这一个字段——它就是 run_sync(..., deps=...) 传进来的那个对象。
写个最小探针,验证注入的确实是同一个实例、而不是副本:
import os
os.environ["PYDANTIC_AI_NO_BANNER"] = "1"
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
@dataclass
class Deps:
tenant: str
retriever: str
agent = Agent("test", deps_type=Deps)
@agent.tool
def whoami(ctx: RunContext[Deps]) -> str:
"""返回本次 run 注入的依赖摘要。"""
return f"tenant={ctx.deps.tenant} retriever={ctx.deps.retriever}"
print(agent.run_sync("who", deps=Deps(tenant="acme", retriever="fake-retriever"),
model=TestModel()).output)
本机实测输出:
{"whoami":"tenant=acme retriever=fake-retriever"}
两点值得记住:
ctx.deps就是传入的那个Deps实例。deps_type=Deps只是类型声明,真正的值由每次run_sync(deps=...)提供——这意味着同一个Agent对象可以服务多个租户,每个 run 注入不同依赖,互不干扰。- 工具用
@agent.tool装饰,第一个参数必须是RunContext(类型标注RunContext[Deps])。框架据此知道「这个工具需要上下文」,并在调用时把当前 run 的上下文塞进去。其余参数才是模型要填的。
第一点正是依赖注入的价值:卷二里检索器写在闭包里,一个 Agent 只能配一个检索器;现在检索器是参数,测试注入假实现、线上注入真实现,Agent 定义一行不用改。
2.1.4 用假检索器重写问答路径
现在把 2.1.1 的三步路径写成 PydanticAI 版本。假检索器是一个普通类,用 FunctionModel 精确控制模型行为(卷一 1.3
的假模型思路,这里换成框架自带夹具):
import os
os.environ["PYDANTIC_AI_NO_BANNER"] = "1"
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.function import FunctionModel, AgentInfo
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart
CORPUS = {
"pg": "ResearchCopilot 使用 PostgreSQL 17.11(容器 ab-pg,端口 15435)。",
"cache": "语义缓存用 Redis 7.4(容器 ab-redis,端口 16380)。",
"loop": "引擎停止条件只看 tool_calls 是否为空。",
}
class FakeRetriever:
"""假检索器:把「注入什么依赖」和「依赖怎么用」解耦。"""
def __init__(self, corpus: dict[str, str]) -> None:
self.corpus = corpus
self.calls: list[str] = []
def search(self, query: str, k: int = 2) -> list[str]:
self.calls.append(query)
q = query.lower()
hits = [f"[{key}] {text}" for key, text in self.corpus.items()
if q in text.lower() or any(tok in text.lower() for tok in q.split())]
return hits[:k]
@dataclass
class Deps:
retriever: FakeRetriever
agent = Agent("test", deps_type=Deps, system_prompt="先检索再回答,回答里保留引用。")
@agent.tool
def search(ctx: RunContext[Deps], query: str) -> list[str]:
"""按关键词检索本地语料,返回带来源标记的片段。"""
return ctx.deps.retriever.search(query)
def scripted(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
returns = [p for p in messages[-1].parts if p.part_kind == "tool-return"]
if not returns:
return ModelResponse(parts=[ToolCallPart(tool_name="search", args={"query": "postgres"})])
return ModelResponse(parts=[TextPart(content=f"依据检索:{returns[0].content}")])
retriever = FakeRetriever(CORPUS)
result = agent.run_sync("ResearchCopilot 用的 Postgres 是哪个版本?",
deps=Deps(retriever), model=FunctionModel(scripted))
print("output:", result.output)
print("检索器收到:", retriever.calls)
print("消息数:", len(result.all_messages()))
print("工具调用:", [p.tool_name for m in result.all_messages()
for p in m.parts if p.part_kind == "tool-call"])
本机实测(pydantic-ai 2.55.0,PYDANTIC_AI_NO_BANNER=1):
output: 依据检索:['[pg] ResearchCopilot 使用 PostgreSQL 17.11(容器 ab-pg,端口 15435)。']
检索器收到: ['postgres']
消息数: 4
工具调用: ['search']
逐行读这段输出:
检索器收到: ['postgres']是依赖注入生效的铁证:scripted这个假模型只知道要调search、参数是{"query": "postgres"},它根本不知道检索器是谁;真正被调用的那个FakeRetriever是从ctx.deps里取的。消息数: 4与卷一自研循环的轨迹结构一致:system、user、assistant(带 tool_call)、tool(回灌) 四条。框架没有发明新的消息形状,只是把卷一手写的Message换成了它自己的ModelMessage体系。工具调用: ['search']是从result.all_messages()里按part_kind == "tool-call"筛出来的——这正是 2.3 节要对比的可测性入口。
对照卷一自研循环,这里省掉的东西很具体:没有循环(run_sync 内部就是那个 for)、没有 ToolRegistry(@agent.tool 即注册)、没有 JSON Schema(从函数签名自动推导)、没有工具结果回灌(框架自动把 search 的返回值包成 tool 消息)。我们只写了「检索器长什么样」和「模型该说什么」——前者是业务,后者是测试夹具。
2.1.5 TestModel:零配置冒烟
FunctionModel 用来精确控制每一轮输出,适合断言;但日常「这段代码还能不能跑」的冒烟,不需要写脚本。PydanticAI 自带 TestModel,它会自动合成一个合法响应:调一次所有工具、再吐一段文本。
from pydantic_ai.models.test import TestModel
smoke = agent.run_sync("用的哪个 Postgres?", deps=Deps(FakeRetriever(CORPUS)),
model=TestModel())
print("smoke output:", smoke.output[:60])
本机实测(TestModel 会把工具返回值塞进它的合成文本):
smoke output: {"search":["[pg] ResearchCopilot 使用 PostgreSQL 17.11(容器 ab-p
TestModel 的价值不在内容——它合成的那段 JSON 没有语义——而在于它保证「构造 → 注入 → 调工具 → 出结果」这条链路是通的。CI 里跑几十个冒烟用例,零成本、零网络、零随机。要断言具体内容时才换 FunctionModel。
2.1.6 框架替我做了什么、我让渡了什么
把这一节钉在一张表上:
| 维度 | 自研 minagent | PydanticAI |
|---|---|---|
| 循环 | 自己写 for + 停止条件 | run_sync 内置 |
| 工具注册 | ToolRegistry.add(Tool(...)) | @agent.tool 装饰器 |
| 工具 schema | 手写或自己推导 JSON Schema | 从函数签名自动生成 |
| 依赖注入 | 闭包 / 全局变量 | deps_type + RunContext |
| 消息类型 | 自己的 Message dataclass | 框架的 ModelMessage 体系 |
| 轨迹访问 | RunResult.messages | result.all_messages() |
| 假模型 | 自写 FakeModel 脚本 | TestModel / FunctionModel |
| 输出形状 | 自己约定(默认文本) | output_type 声明(2.2 节) |
框架替你做的,是把「循环 + 注册 + schema + 回灌」这四件事从「你维护」变成「库维护」;你让渡的,是对这条链路内部实现的控制权——消息结构、停止条件、工具分发顺序都由库定义,你要改就得读它的源码(第 5.2 节会做这件事)。此外还多了一层版本绑定:output_type 这个参数名就是绑定证据,旧教程的 result_type 已经失效。
这不是「框架一定更好」,而是「用控制权换维护量」。2.3 节会拿同一任务把两版并排跑,用行数与断言入口把这笔交易量化。
小结
- PydanticAI 的入口是
Agent;本节用到deps_type、system_prompt、output_type三个参数,其中输出参数是output_type(实测2.55.0),不是旧版result_type。 RunContext是框架传给每个工具的运行时上下文,ctx.deps就是本次 run 注入的依赖实例;@agent.tool装饰的工具第一个参数必须是它。- 依赖注入把检索器从「闭包里的固定实现」变成「每次 run 传入的参数」,一个
Agent可服务多租户。 - 用
FunctionModel精确脚本化模型输出、TestModel做零配置冒烟,两者都不连真实 API、完全确定性。 - 框架替你做「循环 / 注册 / schema / 回灌」,你让渡「内部实现控制权」与「版本绑定」。
问答路径重写完了,但它现在还只会输出纯文本——卷二的 UC1 要求「回答必须带可追溯引用」。下一节 2.2 结构化输出与工具:输出模型 + 校验重试
把 output_type 换成一个 Pydantic 模型,并演示校验失败时框架如何自动重试。想回看卷二为什么把「引用可回溯」列成独立验收标准,翻 1.1 需求与用例:3 个核心用例与验收标准
。
阅读导航:上一节:1.3 选型方法:按可观测/持久化/生态/锁定度打分 · 下一节:2.2 结构化输出与工具:输出模型 + 校验重试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。