《AI Agent 高级编程》2.3 与 minagent 对照:代码量与可测性对比

同一任务写两版:卷一契约的 minagent 版与 PydanticAI 版,都真跑并断言。实测本次任务脚本两版行数基本持平(minagent 34 逻辑行 vs PydanticAI 36 逻辑行),真正差别在引擎本体的 122 行由谁维护。给出代码量、断言入口、失败信号、扩展成本的对照表,并说明框架赢在「你不再拥有的代码」而非「你少写的代码」。

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。']
工具被调次数11
模型看到的工具['search']['search'](+ 无输出工具,因 output_type 默认 str)
检索器收到的 querypg(脚本写死)pg(脚本写死)

终值文字不同是假模型脚本不同导致的(一个直接写死文本、一个回显检索结果),不是行为差异——两版走的都是「检索一次后收敛」。真正可比的是「工具调用次数」与「模型看到的工具」,两者完全一致。

2.3.5 代码量对照表

这是本节最反直觉的一张表。实测(非空非注释逻辑行):

部分minagent 版PydanticAI 版
本次任务脚本3436
其中引擎本体(循环 / 分发 / schema / 消息)122(types+tools+agent+fake,自研,自己维护)0(库提供)
其中业务依赖(检索器)811
其中模型夹具6(脚本列表)8(scripted 函数)

结论很直接:就这一个任务,框架版反而多 2 行。原因是框架的「仪式」有成本——Deps 数据类、FakeRetriever 的 self.calls 记账、scripted 函数都比 minagent 的「闭包 + 脚本列表」略长。

但如果把「必须存在的代码总量」算进去,画风就变了:

口径minagent 版PydanticAI 版
你写的代码34 + 122 = 15636
你维护的引擎代码1220
引擎代码由谁升级 / 修 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 搭研究任务 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距