
当你想给 Agent 的 tool_call 循环挂自定义逻辑——拦截、改写、重试、注入工具——会发现两个明星框架选了两条互不重合的范式:PI 用"事件订阅",DeepAgents 用"中间件洋葱"。这篇把它们放在一起对比,告诉你根因在哪、各自的天花板在哪、你的项目该选哪种。
Agent 的核心循环长这样:
用户输入 → 构造 Prompt → 调 LLM → 拿到 ToolCall → 执行 Tool → 把结果回灌给 LLM → 再来一轮
无论 PI(@earendil-works/pi-coding-agent)还是 DeepAgents(langchain-ai/deepagents),它们的 agent loop 形态完全一样。但当你想"在 tool_call 这段挂点自己的逻辑"时,两边的 API 长得完全不同:
pi.on("tool_call", handler)——熟悉的 EventEmitter 写法。def wrap_tool_call(self, request, handler)——熟悉的 Koa 中间件写法。差别不在"能不能做",而在"开放定制的范式"。这篇就把这条岔路一次讲清楚。

不管哪个框架,主循环都是固定的几个节点:User Input → Build Prompt → LLM Call → Tool Call → Tool Result → 回到 LLM Call,循环到模型 emit stop 为止。
用户的所有定制需求,本质上都落在每个节点的"前后"。
把目光放到 tool_call 这一段,你会看到 8 类几乎所有 Agent 工程都会遇到的需求:
诉求 | 典型场景 |
|---|---|
拦截 / 阻断 | rm -rf 弹确认;写 .env 直接 deny |
改写参数 | 把 --force 静默去掉;绝对路径换成沙盒映射 |
改写结果 | 脱敏 API key / 邮箱 / 内网 IP;大输出截断或落盘 |
重试 / fallback | 网络抖动重试 3 次;找不到文件转去问 LSP |
注入新工具 | MCP server、内部接口、领域专用动作 |
动态裁剪工具集 | 根据 backend / 角色 / 上下文关掉部分工具 |
埋点 / 审计 | 每次 tool_call 落日志或 Trace |
人机协同(HITL) | 关键 tool 暂停,等用户批准再继续 |
两个框架都能覆盖这 8 类——差别在"用什么形状的 API 让用户写出来"。

PI 把 agent loop 上所有关键时机做成命名事件:tool_call、tool_result、before_agent_start、context、before_provider_request …
插件用 pi.on("event_name", handler) 订阅。宿主在主循环跑到对应时机,按既定策略(fire-and-forget / bail / waterfall)串行派发:
tool_call:bail 策略——任一插件返回 {block: true} 立即短路,拒绝执行。tool_result:waterfall 策略——上一个插件的输出作为下一个的输入,链式改写。tool_execution_*:fire-and-forget——只为埋点、UI 更新,不影响主流程。事件名 | 触发时机 | 派发语义 | 能干什么 |
|---|---|---|---|
tool_call | LLM 给出 toolCall,执行前 | bail | 改 event.input 改参数;返回 {block:true} 拒绝 |
tool_execution_start | 真正调用 tool 之前 | fire-and-forget | 埋点、UI 更新 |
tool_execution_update | tool 流式输出过程中 | fire-and-forget | 进度展示 |
tool_execution_end | tool 执行完成 | fire-and-forget | 统计耗时 |
tool_result | 结果回灌给 LLM 之前 | waterfall | 改写 content / details / isError |
registerTool() | extension 加载期 | 注册而非事件 | 给 LLM 新增工具 |
// ~/.pi/agent/extensions/safety.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 1) 调用前拦截 — bail 语义
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous", "Allow rm -rf?");
if (!ok) return { block: true, reason: "User denied" };
}
});
// 2) 结果回灌前脱敏 — waterfall 语义
pi.on("tool_result", async (event) => {
const redacted = event.content.map(c =>
c.type === "text"
? { ...c, text: c.text.replace(/sk-[a-z0-9]+/gi, "sk-***") }
: c
);
return { content: redacted };
});
}关键点:
tool_call与tool_result是两个独立的事件名,分别有不同的派发策略。"前"和"后"的拆分由框架硬编码,你只能选时机、不能改派发语义。

DeepAgents 借用了 langchain.agents.middleware.AgentMiddleware 基类。每个 middleware 是一个 Python 类,通过覆盖 wrap_tool_call(request, handler) 这样的方法接入主循环。
多个 middleware 按 list 顺序嵌成洋葱——每一层显式决定要不要、要几次地调 handler(request):
try / except 包住 handler → fallbackhandler 前后改 request/response → 改参数 / 改结果方法 | 位置 | 语义 | 能干什么 |
|---|---|---|---|
before_agent | 整个 agent 进入前 | 返回 state 更新 | 注入初始状态 |
before_model / after_model | LLM 调用前后 | 返回 state 更新 | 统计、限流 |
wrap_model_call(req, handler) | 包住 LLM 调用 | 洋葱 | prompt 注入、动态裁剪 tools、模型 fallback |
wrap_tool_call(req, handler) | 包住一次 tool 执行 | 洋葱 | 权限校验、重试、超时、大结果落盘、脱敏 |
self.tools = [...] | 类属性 | 声明 | 向 agent 注入新工具 |
state_schema | 类属性 | 声明 TypedDict | 让 LangGraph 管跨步骤状态、checkpoint、replay |
# my_safety_middleware.py
from typing import Callable
from langchain.agents.middleware import AgentMiddleware
from langchain.tools.tool_node import ToolCallRequest
from langchain_core.messages import ToolMessage
from langgraph.types import Command, interrupt
class SafetyMiddleware(AgentMiddleware):
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
call = request.tool_call
# 1) 调用前拦截 — 不调 handler 即 short-circuit
if call["name"] == "bash" and "rm -rf" in call["args"].get("command", ""):
ok = interrupt({"prompt": "Allow rm -rf?"})
if not ok:
return ToolMessage(
content="Blocked by SafetyMiddleware",
name=call["name"], tool_call_id=call["id"], status="error",
)
# 2) 真实执行
result = handler(request)
# 3) 结果回灌前脱敏
if isinstance(result, ToolMessage) and isinstance(result.content, str):
result.content = redact_secrets(result.content)
return result关键点:拦截 / 改写 / 重试 / fallback 全部在一个方法里表达,靠"调不调
handler()、调几次、改 request 还是改 response"来区分语义。多个 middleware 自动嵌成洋葱(list 顺序 = 由外到内)。
光看 API 表面还不够。我们换一个稍微复杂的真实需求体感一下:
需求:给
bash工具加 3 次重试,每次失败 sleep 1 秒;如果最后还是失败、且命令包含git,自动 fallback 到本地subprocess.run(['git', 'status'])给一个兜底输出。
class GitRetryMiddleware(AgentMiddleware):
def wrap_tool_call(self, request, handler):
if request.tool_call["name"] != "bash":
return handler(request)
last_err = None
for _ in range(3):
try:
return handler(request)
except Exception as e:
last_err = e
time.sleep(1)
if "git" in request.tool_call["args"].get("command", ""):
out = subprocess.run(["git", "status"], capture_output=True, text=True)
return ToolMessage(
content=out.stdout, name="bash",
tool_call_id=request.tool_call["id"],
)
raise last_err"调几次 handler" 在洋葱模型里就是一个普通的 for 循环,自然到不需要思考。
PI 的 on("tool_call", ...) 钩子的语义是"通知插件 LLM 要调用 tool 了",它不是执行点。插件这里没有"重跑 tool"的入口(emitToolCall 是 hook,不是 exec)。
所以 PI 上要做这个需求,你只剩两条路:
bash_with_retry 完全替代 bash,把重试 / fallback 逻辑塞进新 tool 内部;AgentSession 里 tool 执行的那一段代码。结论:「重试 / fallback / 多次调用」不是 PI 缺 feature,而是范式天花板。事件订阅模型天生只能描述"在某时机做某事",描述不了"包住某次执行"。
维度 | PI · Extension | DeepAgents · Middleware |
|---|---|---|
抽象单位 | 一个 .ts 文件,导出 (pi) => void 工厂 | 一个 Python 类继承 AgentMiddleware |
接入方式 | 订阅 — pi.on("tool_call", fn),字符串选时机 | 覆盖 — def wrap_tool_call(req, handler),方法名选时机 |
"前"与"后" | 拆成两个事件 tool_call / tool_result | 同一个 wrap_tool_call 同时管前后,用 handler() 切开 |
派发策略 | 框架硬编码(fire-and-forget / bail / waterfall) | 统一"洋葱包装",由 middleware 自己决定 |
阻断调用 | 返回 {block: true, reason} | 不调 handler,直接 return ToolMessage(status="error") |
重试 / fallback | 无原生 hook,要在 extension 内自己包 | 原生:for _ in range(3): handler(request) |
跨步骤状态 | 模块变量 / pi.appendEntry() 写 session 文件 | state_schema + LangGraph 自动 reduce / checkpoint / replay |
人机协同(HITL) | ctx.ui.confirm/select/input(同步阻塞、TUI 自动弹窗) | LangGraph interrupt()(暂停图、控制权抛出 SDK 外) |
加载方式 | 运行时 jiti 动态加载 .ts,/reload 热重启 | 构造期 list,改完重启进程 |
分发 | 拷贝 .ts 到 ~/.pi/agent/extensions/ 即用 | 发 PyPI 包,受众 pip install 再 import |
错误隔离 | handler 抛错走 emitError,不影响主流程 | 默认上抛,需 handle_tool_errors 或自己 try/except |
心智成本 | 低 | 中-高(要懂 LangGraph 状态机、Channel/Reducer) |
适合形态 | 单进程 TUI 客户端 / 个人定制 | SDK / 平台 / 多代理 / 线上服务 |
PI 把"在哪儿插"做成了 API(命名事件);DeepAgents 把"怎么插"做成了 API(包 handler 的方法)。
优势
pi.on("tool_call", fn) 就是"装个监听器",前端工程师 5 分钟上手。~/.pi/agent/extensions/,/reload 立即生效。ctx.ui.confirm/custom/setFooter/... 直接接 TUI,做"问一下用户"非常顺手。劣势
on,自己用闭包/Map 串联状态。优势
wrap_tool_call 同时表达 before/after/around,天然支持重试、cache、fallback、circuit-breaker。middleware=[A, B, C] 即洋葱由外到内,谁先谁后一目了然。SubAgentMiddleware / AsyncSubAgentMiddleware 把多代理、远程执行做成内置能力。劣势
interrupt() 这种抽象 HITL 信号。这是这次对比里最值得记一笔的观察:

扩展机制不是独立设计选项,而是被"宿主长什么样"反推出来的。
PI 的宿主是 AgentSession——一个写死流程的 TypeScript 类,循环展开成顺序代码。在这种宿主上开放定制,最自然的方式就是「在关键时机喊一嗓子让插件知道」——也就是 EventEmitter 风格的命名事件。它根本没有"重新编排循环"的可能,所以 bail / waterfall 这种合并策略只能由宿主硬编码。
PI 沿袭的范式都是事件订阅系:
EventEmitteronRequest / preHandler)server.ext()app.on("issues.opened")context.subscriptions.push(workspace.onDidSaveTextDocument(...))DeepAgents 的宿主是 LangGraph StateGraph——agent loop 是一张运行时编译的图,每个 middleware 编译进图节点。在这种宿主上开放定制,最自然的方式就是「让你的 middleware 也成为图的一部分」——也就是 Koa 风格的洋葱包装。handler 是 LangGraph 给你的"下一个节点",你可以选不调(短路)、多调(重试)、并行调(fork-join),都还在图模型里。
DeepAgents 沿袭的范式都是中间件链系:
AsyncSeriesWaterfallHooktransform hookPI 的开发者大概率来自前端 / 工具链 / VSCode 扩展圈,自然写出事件订阅;DeepAgents 的开发者来自 LangChain / Python 后端圈,自然写出中间件。
底座一旦选定,扩展模型几乎就被锁死。
事件驱动 → 用命名事件订阅;图驱动 → 用节点中间件。

你要表达的诉求 | 推荐 |
|---|---|
「我要在某个固定时机干件事」 | Extension |
「我要包住某段执行,可能不调、可能多次调」 | Middleware |
.ts extension 放进 ~/.pi/agent/extensions/ 即用,/reload 调试节奏快。一旦发现自己在写"重试包一层"或"多 middleware 显式编排"的代码,再考虑迁移。interrupt 把 HITL 信号传出去,避免把 UI 逻辑塞进 middleware。PI 把"在哪儿插"做成了 API(命名事件);DeepAgents 把"怎么插"做成了 API(包 handler 的方法)。 前者强在低门槛 + UI 直觉 + 热加载,是单兵作战工具的最优解; 后者强在组合表达 + 平台化 + 可观测,是构建产品级 Agent 平台的最优解。
两者并非互斥——它们其实展示了"Node 生态 vs Python 后端生态"在 Agent 这个新场景下的两条惯性路径:
当你要做"定制 tool_call 循环"这件事时,记住一个判断标准:
这条标准比"哪个框架更好"靠谱得多——它直接决定了你接下来要写多少胶水代码。
涉及代码版本:
f2b105dd(@earendil-works/pi-coding-agent)2ac7d415(langchain-ai/deepagents)d0d78f1aeb(DeepAgents middleware 基类来源)外部参照范式: