《AI Agent 高级编程》4.3 三框架同题对照:同评测集比分数与成本

同一道检索任务、同一份确定性假模型,把 PydanticAI、smolagents 与卷一 minagent 拉到一起真跑横向对照:量出步数、工具调用次数、代码行数与可测性,并给出 token 与延迟的成本估算口径,最后落成一张可复用的选型决策表。

4.3 三框架同题对照:同评测集比分数与成本

前面三节各自讲透了一个框架,但「哪个更省、哪个更好测」这种问题,光看文档答不出来。这一节做一件工程上更硬的事:同一道题、同一份确定性假模型,把三家拉到一起真跑,量出可复现的数字。这也是本卷「每节都要有量化对照数据」的兑现。

本节把「框架选型」推进到可测量:为 PydanticAI、smolagents 与卷一 minagent 各写一个适配器,跑同一道检索题,量出步数、工具调用次数、消息数与代码行数,并给出 token 与延迟的成本估算口径。全程用本地假模型,零网络、零凭据。

4.3.1 对照方法:同题、同模型、同脚本

要让对照有意义,三个变量必须锁死:

  • 同一道题:用 search 工具查 smolagents,再给出结论。
  • 同一份假模型:都是「按脚本吐响应」的确定性模型,第 1 步调 search,第 2 步给最终答复。
  • 同一个工具:search(query) -> "[hit] {query} => 本地知识库命中 3 条",返回值逐字节一致。

三家框架对「假模型」的表达方式不同,但语义等价:minagent 是 FakeModel.complete() 返回 ModelResponse,PydanticAI 是 FunctionModel 接收 (messages, info) 返回 ModelResponse,smolagents 是继承 Model 覆盖 generate() 返回 ChatMessage。只要假模型的脚本一致,三家的「步数」就可以横向比。

一个口径说明:本节的「步数」定义为模型被调用的次数(不是循环迭代数),「工具调用次数」定义为工具函数被真实执行的次数。这两者分开统计,才能看出「一次模型调用触发几个工具」的差别。

4.3.2 三个适配器

minagent 侧(卷一自研引擎,含完整消息契约):

# adapt_minagent.py
import json
from dataclasses import dataclass, field
from typing import Any, Literal

Role = Literal["system", "user", "assistant", "tool"]


@dataclass
class ToolCall:
    id: str
    name: str
    arguments: dict[str, Any]


@dataclass
class Message:
    role: Role
    content: str = ""
    tool_calls: list[ToolCall] = field(default_factory=list)
    tool_call_id: str | None = None


@dataclass
class ModelResponse:
    message: Message
    stop_reason: str = "end_turn"
    usage: dict[str, int] = field(default_factory=dict)


class FakeModel:
    def __init__(self, script):
        self.script = list(script)
        self.calls = []
        self.n = 0

    def complete(self, messages, tools):
        self.calls.append((list(messages), list(tools)))
        step = self.script[min(self.n, len(self.script) - 1)]
        self.n += 1
        if isinstance(step, str):
            return ModelResponse(Message("assistant", step))
        return ModelResponse(Message("assistant", tool_calls=step), stop_reason="tool_use")


class ToolRegistry:
    def __init__(self):
        self._tools = {}
        self.n_calls = 0

    def add(self, name, func):
        self._tools[name] = func

    def schemas(self):
        return [{"name": n} for n in self._tools]

    def call(self, tc):
        self.n_calls += 1
        try:
            return str(self._tools[tc.name](**tc.arguments))
        except Exception as e:
            return json.dumps({"error": f"{type(e).__name__}: {e}"}, ensure_ascii=False)


def run_task():
    reg = ToolRegistry()
    reg.add("search", lambda query: f"[hit] {query} => 本地知识库命中 3 条")
    model = FakeModel([
        [ToolCall("c1", "search", {"query": "smolagents"})],
        "结论:smolagents 用 CodeAgent 把动作写成代码。",
    ])
    messages = [Message("user", "查 smolagents 并给结论")]
    steps = 0
    for i in range(8):
        steps += 1
        resp = model.complete(messages, reg.schemas())
        messages.append(resp.message)
        if not resp.message.tool_calls:
            return {"output": resp.message.content, "steps": steps,
                    "tool_calls": reg.n_calls, "msgs": len(messages)}
        for tc in resp.message.tool_calls:
            messages.append(Message("tool", reg.call(tc), tool_call_id=tc.id))
    raise RuntimeError("未收敛")

PydanticAI 侧(FunctionModel 驱动):

# adapt_pydanticai.py
from pydantic_ai import Agent
from pydantic_ai.models.function import FunctionModel, AgentInfo
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart

TOOL_CALLS = {"n": 0}


def script_fn(messages, info: AgentInfo):
    done = sum(1 for m in messages for p in getattr(m, "parts", [])
               if type(p).__name__ == "ToolReturnPart")
    if done == 0:
        return ModelResponse(parts=[ToolCallPart("search", {"query": "smolagents"})])
    return ModelResponse(parts=[TextPart("结论:smolagents 用 CodeAgent 把动作写成代码。")])


def run_task():
    agent = Agent(FunctionModel(script_fn), instructions="查资料再给结论")

    @agent.tool_plain
    def search(query: str) -> str:
        """检索本地知识库。"""
        TOOL_CALLS["n"] += 1
        return f"[hit] {query} => 本地知识库命中 3 条"

    r = agent.run_sync("查 smolagents 并给结论")
    msgs = r.all_messages()
    n_model_calls = sum(1 for m in msgs if type(m).__name__ == "ModelResponse")
    return {"output": r.output, "steps": n_model_calls,
            "tool_calls": TOOL_CALLS["n"], "msgs": len(msgs)}

smolagents 侧(自定义 Model 驱动 CodeAgent):

# adapt_smolagents.py
from smolagents.models import Model, ChatMessage
from smolagents import CodeAgent, tool

SCRIPT = [
    'res = search("smolagents")\nprint(res)',
    'final_answer("结论:smolagents 用 CodeAgent 把动作写成代码。")',
]


class ScriptedModel(Model):
    def __init__(self, script):
        super().__init__(model_id="scripted-local")
        self.script = list(script)
        self.calls = []
        self.n = 0

    def generate(self, messages, stop_sequences=None, response_format=None,
                 tools_to_call_from=None, **kwargs):
        self.calls.append((len(messages), [t.name for t in (tools_to_call_from or [])]))
        code = self.script[min(self.n, len(self.script) - 1)]
        self.n += 1
        return ChatMessage(role="assistant", content=f"Thought: 第 {self.n} 步\n<code>\n{code}\n</code>")


def run_task():
    model = ScriptedModel(SCRIPT)
    calls = {"n": 0}

    @tool
    def search(query: str) -> str:
        """检索本地知识库。
        Args:
            query: 查询词。
        """
        calls["n"] += 1
        return f"[hit] {query} => 本地知识库命中 3 条"

    agent = CodeAgent(tools=[search], model=model, max_steps=5)
    out = agent.run("查 smolagents 并给结论")
    return {"output": out, "steps": len(model.calls),
            "tool_calls": calls["n"], "msgs": model.calls[-1][0]}

4.3.3 真跑对照表

一个跑分脚本 import 三个适配器,各自 run_task(),再用 tokenize 去掉空行、注释与文档字符串统计「有效代码行」。真跑结果:

框架             步数    工具调用    消息数    有效行    总行
minagent        2       1      4     64    85
pydantic-ai     2       1      4     20    30
smolagents      2       1      5     28    42

三个数字结论直接读出来:

维度minagentPydanticAIsmolagents
步数(模型调用)222
工具调用次数111
消息条数445
有效代码行642028
总行数853042

第一,行为完全一致:三家都是 2 步、1 次工具调用、得到同一结论。这说明「同一道题」确实被三家用等价的方式解掉了,对照有效。第二,代码量差三倍:minagent 要 64 行有效代码(消息契约 + 假模型 + 注册表 + 循环全得自己写),PydanticAI 只要 20 行,smolagents 28 行。这正是「框架替你做了什么」的量化答案。第三,smolagents 消息数多一条:因为 CodeAgent 在代码执行后多回灌了一轮观察(Observation),这是「代码即行动」范式的固有开销。

4.3.4 可测性对照:断言写在哪

行数只说明「写起来多累」,可测性说明「维护起来多痛」。三家在「怎么断言一次运行」上差别很大:

# minagent:calls 是二元组,断言工具 schema 直达
assert len(model.calls[0][0]) == 2
assert [t["name"] for t in model.calls[0][1]] == ["search"]
assert result["tool_calls"] == 1

# pydantic-ai:断言消息流里的 part 类型
assert sum(1 for m in msgs if type(m).__name__ == "ModelResponse") == 2
assert any(type(p).__name__ == "ToolReturnPart" for m in msgs for p in m.parts)

# smolagents:断言 model.calls 的消息条数与可见工具名
assert model.calls[0] == (2, [])          # CodeAgent 不向模型传工具 schema
assert len(model.calls) == 2
可测性维度minagentPydanticAIsmolagents
轨迹可断言Step 一维列表,detail 字符串消息流 part 类型model.calls 二元组
工具 schema 可见是(calls[i][1])是(消息 part)CodeAgent 否(传空列表)
确定性假模型自写 FakeModel内置 FunctionModel/TestModel自写 Model 子类
逐字节可复现是是是(本机实测两次运行一致)

minagent 的 Step 是扁平字符串("search({'query': ...}) -> [hit] ..."),断言只能做子串匹配,弱;PydanticAI 的消息 part 是强类型对象,断言最精确;smolagents 的 model.calls 是 (消息条数, 工具名列表) 二元组,介于两者之间。这就是「强类型框架在测试上更省事」的具体体现——你在写代码时省的行数,一部分会在写测试时以「断言更精确」的形式还回来。

4.3.5 成本怎么估:token 与延迟的口径

真跑用假模型,量不出 token 与延迟,但口径可以先定死,等第 11.2 节接真模型时直接用:

成本项口径谁便宜
输入 token每步送给模型的完整消息列表smolagents 多一轮 Observation,略贵
输出 token模型生成的文本 / 代码CodeAgent 代码通常比 JSON 调用长
模型调用次数步数三家同为 2(本题);多工具任务 CodeAgent 更少
工具往返延迟每次工具执行 + 回灌minagent / PydanticAI 每次一步
框架开销序列化 / 校验 / spanminagent 最低(无框架层)

关键判断:在「单工具、单参数」的简单题上,三家的成本几乎无差别,框架的价值体现在代码量而非运行时开销。但在「多工具、需要中间计算」的题上,CodeAgent 的「一轮代码调多个工具」会把模型调用次数从 N 降到 1,这时省下的是 N-1 轮往返的 token 与延迟——那才是「代码即行动」的收益拐点。第 11.2 节会把这套口径接到真实模型上,量出假模型与真模型的量化差距。

4.3.6 选型结论:这套框架替你做了什么、你又让渡了什么

把三节的对照收敛成一张决策表:

你的诉求首选理由
完全掌控每一轮、零锁定minagent无框架层,但代码量最高
类型安全 + 依赖注入 + 精确断言PydanticAI20 行搞定,测试最省事
多工具聚合、中间计算密集smolagents CodeAgent一轮代码完成多步,省往返
多角色委派 + 自动可观测OpenAI Agents SDKhandoff + tracing 白送
内置权限 / 会话 / 运行时Claude Agent SDK运行时级能力最全

三家的共同点值得记住:它们都替你实现了「Agent 循环 + 工具执行 + 上下文管理」这三件套。minagent 用 64 行自研的正是这三件套;框架用 20–28 行就还给你一个等价的运行。让渡的东西也一致:你接受了别人的消息契约、别人的错误语义、别人的可观测格式。选框架的本质,就是在「少写代码」和「少让渡控制权」之间按项目阶段做权衡——原型期选框架,追求极致可控的稳定期回到自研,这条路线在卷三会反复出现。

下一节 5.1 读 LangGraph:图执行器与检查点机制 会进入本卷的第二部分——不再「用」框架,而是读框架源码,看 LangGraph 的图执行器与检查点到底怎么实现;想回看 CodeAgent 的范式,见 4.2 smolagents:CodeAgent「代码即行动」重写子任务 。

阅读导航:上一节:4.2 smolagents:CodeAgent「代码即行动」重写子任务 · 下一节:5.1 读 LangGraph:图执行器与检查点机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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