2.3 与 minagent 对照:代码量与可测性对比
前两节用 PydanticAI 重写了问答路径、加上了结构化输出,但一直没回答一个最实际的问题:换成框架,到底省了什么、又多了什么? 「框架能少写代码」是一句流行的假设,但它经不起细看——本节的实测结果是:就这个任务而言,框架版并没有更短。真正省下的不是这一节的几十行,而是卷一那套引擎的上百行由谁来维护。这一节把同一任务写成两版并排跑,用行数和断言入口把这笔交易摆到台面上。
本节把第 2 章的对照推进到「可量化」:用卷一契约写 minagent 版、用 PydanticAI 写框架版,两版真跑得到等价轨迹,再给出代码量、可测性、失败信号、扩展成本四张对照表,并指出「框架赢在哪、输在哪」。
2.3.1 对照口径:同一任务、同一契约
先钉死对照口径,否则行数对比没有意义。
- 同一任务:输入「用的哪个 Postgres?」,调一次
search,输出带引用的回答。 - 同一契约:沿用卷一的引擎契约——
Message/ToolCall/ModelResponse/RunResult的形状不变(见卷一 4.1 工具即函数 )。框架版只是换了个运行时,业务语义一字不差。 - 同一夹具策略:两版都用本地假模型,零网络、零费用、逐字节可复现。
行数口径也统一:非空、非注释的逻辑行(不含 from __future__)。这样比总行数更能反映「真实工作量」,也不会被空行和注释带偏。
2.3.2 minagent 版
沿用卷一契约,检索器用闭包注入(这是卷一工具注册的典型写法):
from __future__ import annotations
import json
from typing import Any
from minagent.types import Message, ModelResponse, ToolCall
from minagent.tools import Tool, ToolRegistry
from minagent.fake import FakeModel
from minagent.agent import Agent
CORPUS = {"pg": "ResearchCopilot 使用 PostgreSQL 17.11。",
"cache": "语义缓存用 Redis 7.4。"}
def search(query: str, k: int = 2) -> list[str]:
"""按关键词检索本地语料。"""
q = query.lower()
hits = [f"[{key}] {text}" for key, text in CORPUS.items() if q in key or q in text.lower()]
return hits[:k]
SEARCH_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"query": {"type": "string"}, "k": {"type": "integer"}},
"required": ["query"],
}
def build() -> tuple[Agent, FakeModel]:
reg = ToolRegistry()
reg.add(Tool(name="search", description="按关键词检索语料",
parameters=SEARCH_SCHEMA, func=search))
script = [
[ToolCall(id="c1", name="search", arguments={"query": "pg"})],
"依据 [pg]:ResearchCopilot 使用 PostgreSQL 17.11。",
]
model = FakeModel(script)
return Agent(model, reg, system="先检索再回答,保留引用。"), model
agent, model = build()
result = agent.run("用的哪个 Postgres?")
print("final:", result.final)
print("steps:", [(s.kind, s.detail) for s in result.steps])
print("模型第 1 次看到的工具:", [t["name"] for t in model.calls[0][1]])
本机实测(PYTHONPATH=/tmp/agentbook/chk 指向卷一引擎包):
final: 依据 [pg]:ResearchCopilot 使用 PostgreSQL 17.11。
steps: [('tool_call', 'search -> ["[pg] ResearchCopilot 使用 PostgreSQL 17.11。"]'), ('final', '依据 [pg]:ResearchCopilot 使用 PostgreSQL 17.11。')]
模型第 1 次看到的工具: ['search']
注意 model.calls[0][1]——这是卷一约定的二元组 (消息列表, 工具 schema 列表),[1] 就是模型当时看到的工具名列表。这个口径三卷统一,本节断言也用它。
2.3.3 PydanticAI 版
同一个任务,换成 Agent + deps_type 依赖注入 + FunctionModel:
from __future__ import annotations
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。",
"cache": "语义缓存用 Redis 7.4。"}
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 key or q in text.lower()]
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": "pg"})])
return ModelResponse(parts=[TextPart(content=f"依据检索:{returns[0].content}")])
retriever = FakeRetriever(CORPUS)
res = agent.run_sync("用的哪个 Postgres?", deps=Deps(retriever), model=FunctionModel(scripted))
print("output:", res.output)
print("检索器调用:", retriever.calls)
print("工具调用记录:", [p.tool_name for m in res.all_messages()
for p in m.parts if p.part_kind == "tool-call"])
本机实测:
output: 依据检索:['[pg] ResearchCopilot 使用 PostgreSQL 17.11。']
检索器调用: ['pg']
工具调用记录: ['search']
两版行为等价:都是「一次检索 → 带引用回答 → 收敛」,工具都被调了一次,终值都含同一条片段。区别在接线方式:minagent 版把检索器写进 Tool 的 func,PydanticAI 版把它放进 Deps 由 ctx.deps 取。
2.3.4 真跑:两版轨迹等价
把两版的终值与「模型看到的工具」并排看:
| 观测点 | minagent 版 | PydanticAI 版 |
|---|---|---|
| 终值 | 依据 [pg]:ResearchCopilot 使用 PostgreSQL 17.11。 | 依据检索:['[pg] ResearchCopilot 使用 PostgreSQL 17.11。'] |
| 工具被调次数 | 1 | 1 |
| 模型看到的工具 | ['search'] | ['search'](+ 无输出工具,因 output_type 默认 str) |
| 检索器收到的 query | pg(脚本写死) | pg(脚本写死) |
终值文字不同是假模型脚本不同导致的(一个直接写死文本、一个回显检索结果),不是行为差异——两版走的都是「检索一次后收敛」。真正可比的是「工具调用次数」与「模型看到的工具」,两者完全一致。
2.3.5 代码量对照表
这是本节最反直觉的一张表。实测(非空非注释逻辑行):
| 部分 | minagent 版 | PydanticAI 版 |
|---|---|---|
| 本次任务脚本 | 34 | 36 |
| 其中引擎本体(循环 / 分发 / schema / 消息) | 122(types+tools+agent+fake,自研,自己维护) | 0(库提供) |
| 其中业务依赖(检索器) | 8 | 11 |
| 其中模型夹具 | 6(脚本列表) | 8(scripted 函数) |
结论很直接:就这一个任务,框架版反而多 2 行。原因是框架的「仪式」有成本——Deps 数据类、FakeRetriever 的 self.calls 记账、scripted 函数都比 minagent 的「闭包 + 脚本列表」略长。
但如果把「必须存在的代码总量」算进去,画风就变了:
| 口径 | minagent 版 | PydanticAI 版 |
|---|---|---|
| 你写的代码 | 34 + 122 = 156 | 36 |
| 你维护的引擎代码 | 122 | 0 |
| 引擎代码由谁升级 / 修 bug | 你 | 库作者 |
框架赢在「你不再拥有的代码」:那 122 行引擎(消息协议、工具分发、循环、schema 生成)不是你少写了,而是不再由你维护。它换来的还有前两节展示的两样东西——2.2 节的校验重试、以及多供应商切换——这些在 minagent 里都要从零写。
反过来,框架输在「表达自由度」与「版本绑定」:output_type 这个参数名就是绑定证据(旧教程的 result_type 已失效),引擎内部行为你要改就得读源码(第 5.2 节)。所以「框架省代码」这个说法要修正为:框架省的是「长期维护成本」,不是「首次编写的行数」。
2.3.6 可测性对照:断言入口
行数只是一半,另一半是可测性。同一个断言——「模型被调时看到哪些工具、最终回答了啥」——两版的入口不同:
minagent 版(断言入口来自卷一契约):
assert [t["name"] for t in model.calls[0][1]] == ["search"] # 模型看到的工具
assert result.steps[-1].kind == "final" # 收敛,不是撞 max_steps
assert result.final == "依据 [pg]:ResearchCopilot 使用 PostgreSQL 17.11。"
PydanticAI 版(断言入口来自框架的消息对象):
calls = [p.tool_name for m in res.all_messages()
for p in m.parts if p.part_kind == "tool-call"]
assert calls == ["search"] # 工具调用记录
assert retriever.calls == ["pg"] # 业务侧探针
assert "PostgreSQL 17.11" in res.output # 终值
两套断言本机实测全部通过。可测性对照:
| 维度 | minagent 版 | PydanticAI 版 |
|---|---|---|
| 模型入参断言 | model.calls[i] 二元组 | res.all_messages() 遍历 part |
| 工具 schema 断言 | model.calls[0][1] 直接可读 | 需从 info.function_tools 或消息里提 |
| 步骤断言 | result.steps[i].kind | 按 part_kind 过滤消息 |
| 终值断言 | result.final(str) | res.output(str 或模型实例) |
| 业务依赖断言 | 检索器自带 calls 记账 | 依赖自带 calls 记账(同) |
| 失败信号 | 脚本耗尽抛 RuntimeError | 重试耗尽抛 UnexpectedModelBehavior |
minagent 版的断言更「扁平」:calls 是二元组、steps 是列表、final 是字符串,三个入口一眼可见。PydanticAI 版更「结构化」:消息是带 part_kind 的对象树,好处是能表达更细的语义(工具调用 / 工具返回 / 重试反馈各有类型),代价是断言要多写一层遍历。
一个实际差别值得点出:minagent 的 model.calls[0][1] 直接就是工具 schema 列表,断言「模型看到哪些工具」是一行;PydanticAI 里同样的信息要先从消息里筛 tool-call part 的 tool_name,或从 AgentInfo 取。框架的消息对象更强,但读取成本更高。
2.3.7 框架替我做了什么、我让渡了什么
| 维度 | minagent(自研) | PydanticAI |
|---|---|---|
| 首次编写行数 | 略少(本任务 34 vs 36) | 略多 |
| 长期维护 | 引擎 122 行自担 | 引擎 0 行 |
| 依赖注入 | 闭包 / 全局变量 | deps_type + RunContext |
| 校验重试 | 无 | 内置(2.2 节) |
| 结构化输出 | 无 | output_type(2.2 节) |
| 多供应商 | 自己写适配器 | 内置 |
| 断言入口 | 扁平(calls/steps/final) | 结构化(消息 part 树) |
| 失败信号 | 脚本耗尽 RuntimeError | 重试耗尽 UnexpectedModelBehavior |
| 版本绑定 | 无(自己就是版本) | 有(output_type 即证据) |
一句话总结这笔交易:框架用「首次多写几行 + 让渡内部实现控制权 + 接受版本绑定」,换来「引擎不归你维护 + 校验重试与结构化输出白送 + 多供应商」。这不是「哪个更好」的问题,而是「你的团队更缺编写时间还是更缺维护精力」。第 1 章的选型打分表(1.3 选型方法:按可观测/持久化/生态/锁定度打分 )就是把这笔交易拆成可打分的维度。
小结
- 同一任务、同一契约下,本次任务脚本两版行数基本持平(minagent 34 逻辑行 vs PydanticAI 36 逻辑行),框架版并不更短。
- 真正的差别在引擎本体:minagent 的 122 行(
types+tools+agent+fake)由你自己维护,PydanticAI 版为 0。 - 两版行为等价:都「检索一次 → 带引用回答 → 收敛」,工具调用次数与模型看到的工具一致。
- 可测性上,minagent 的断言入口更扁平(
calls/steps/final),PydanticAI 的入口更结构化(消息 part 树),读取成本更高但语义更细。 - 框架省的是长期维护成本(外加校验重试、结构化输出、多供应商),让渡的是表达自由度与版本绑定。
第 2 章到此收尾:PydanticAI 的 Agent / RunContext / output_type 三板斧都跑通了,对照账也算了。下一节进入第 3 章,看另一个路线的框架——Claude Agent SDK,它把「工具、权限、会话」都做进了运行时,代价是更深的平台绑定。想回看卷一引擎契约的全貌,翻 4.1 工具即函数:schema 生成、注册表与分发
。
阅读导航:上一节:2.2 结构化输出与工具:输出模型 + 校验重试 · 下一节:3.1 核心概念:subagent/hooks/权限/session 搭研究任务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。