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
三个数字结论直接读出来:
| 维度 | minagent | PydanticAI | smolagents |
|---|---|---|---|
| 步数(模型调用) | 2 | 2 | 2 |
| 工具调用次数 | 1 | 1 | 1 |
| 消息条数 | 4 | 4 | 5 |
| 有效代码行 | 64 | 20 | 28 |
| 总行数 | 85 | 30 | 42 |
第一,行为完全一致:三家都是 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
| 可测性维度 | minagent | PydanticAI | smolagents |
|---|---|---|---|
| 轨迹可断言 | 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 每次一步 |
| 框架开销 | 序列化 / 校验 / span | minagent 最低(无框架层) |
关键判断:在「单工具、单参数」的简单题上,三家的成本几乎无差别,框架的价值体现在代码量而非运行时开销。但在「多工具、需要中间计算」的题上,CodeAgent 的「一轮代码调多个工具」会把模型调用次数从 N 降到 1,这时省下的是 N-1 轮往返的 token 与延迟——那才是「代码即行动」的收益拐点。第 11.2 节会把这套口径接到真实模型上,量出假模型与真模型的量化差距。
4.3.6 选型结论:这套框架替你做了什么、你又让渡了什么
把三节的对照收敛成一张决策表:
| 你的诉求 | 首选 | 理由 |
|---|---|---|
| 完全掌控每一轮、零锁定 | minagent | 无框架层,但代码量最高 |
| 类型安全 + 依赖注入 + 精确断言 | PydanticAI | 20 行搞定,测试最省事 |
| 多工具聚合、中间计算密集 | smolagents CodeAgent | 一轮代码完成多步,省往返 |
| 多角色委派 + 自动可观测 | OpenAI Agents SDK | handoff + 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:图执行器与检查点机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。