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

不管哪个框架,主循环都是固定的几个节点:User Input → Build Prompt → LLM Call → Tool Call → Tool Result → 回到 LLM Call,循环到模型 emit stop 为止。
用户的所有定制需求,本质上都落在每个节点的"前后"。
1.2 聚焦 tool_call:8 类典型诉求
把目光放到 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 Extension:事件订阅式插件
2.1 心智模型:宿主 emit,插件 on

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 更新,不影响主流程。
2.2 tool_call 上的钩子表面
| 事件名 | 触发时机 | 派发语义 | 能干什么 | | :-- | :-- | :-- | :-- | | 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 新增工具 |
2.3 代码:在 PI 里拦 bash 工具 + 脱敏(约 18 行 TS)
bash
// ~/.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 Middleware:洋葱式中间件
3.1 心智模型:包住 handler,自己决定调不调

DeepAgents 借用了 langchain.agents.middleware.AgentMiddleware 基类。每个 middleware 是一个 Python 类 ,通过覆盖 wrap_tool_call(request, handler) 这样的方法接入主循环。
多个 middleware 按 list 顺序嵌成洋葱 ------每一层显式决定要不要、要几次地调 handler(request):
-
• 不调 handler → 短路(拦截)
-
• 调一次 handler → 常规执行
-
• 调多次 handler → 重试 / A-B 比较
-
•
try / except包住 handler → fallback -
•
handler前后改 request/response → 改参数 / 改结果
3.2 tool_call 上的钩子表面
| 方法 | 位置 | 语义 | 能干什么 | | :-- | :-- | :-- | :-- | | 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 |
3.3 代码:等价的 bash 拦截 + 脱敏(约 22 行 Python)
bash
# 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'])给一个兜底输出。
DeepAgents 写起来很自然
bash
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 写起来非常违和
PI 的 on("tool_call", ...) 钩子的语义是"通知 插件 LLM 要调用 tool 了",它不是执行点 。插件这里没有"重跑 tool"的入口(emitToolCall 是 hook,不是 exec)。
所以 PI 上要做这个需求,你只剩两条路:
结论 :「重试 / fallback / 多次调用」不是 PI 缺 feature,而是范式天花板。事件订阅模型天生只能描述"在某时机做某事",描述不了"包住某次执行"。
五、逐项对比:核心 8 维
| 维度 | 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 Extension
优势
-
• 心智成本低 :
pi.on("tool_call", fn)就是"装个监听器",前端工程师 5 分钟上手。 -
• 热加载 :把文件丢进
~/.pi/agent/extensions/,/reload立即生效。 -
• 一文件一切:events + tools + commands + UI + theme 都能塞同一个文件。
-
• UI 一等公民 :
ctx.ui.confirm/custom/setFooter/...直接接 TUI,做"问一下用户"非常顺手。 -
• 错误天然隔离:handler 抛错只产生 diagnostic,主流程不挂。
劣势
-
• 派发策略硬编码:bail / waterfall / fire-and-forget 由框架决定,你想"在 tool_call 上也走 waterfall"?做不到。
-
• 前后人为割裂 :要同时拦前和改后,得写两个
on,自己用闭包/Map 串联状态。 -
• 不擅长重试:没有"调 handler 多次"的概念,重试要自己包一层。
-
• 组合性弱 :两个 extension 想"先脱敏再截断",顺序由加载顺序决定,不容易显式编排。
DeepAgents Middleware
优势
-
• 一处搞定前后 :
wrap_tool_call同时表达 before/after/around,天然支持重试、cache、fallback、circuit-breaker。 -
• 组合显式 :
middleware=[A, B, C]即洋葱由外到内,谁先谁后一目了然。 -
• 状态托管:借 LangGraph 的 state + checkpointer,跨步骤状态、断点续跑、time-travel debug 直接可用。
-
• 子代理一等公民 :
SubAgentMiddleware/AsyncSubAgentMiddleware把多代理、远程执行做成内置能力。 -
• 生态对齐:所有 middleware 都能跑在 LangGraph / LangSmith / LangChain Studio,自带 trace、replay、debug。
劣势
-
• 心智门槛高:要懂 LangGraph 状态机、洋葱模型、Channel/Reducer 才能写出正确的 middleware。
-
• UI 不在 SDK 里 :弹窗 / 进度条 / 主题要到 CLI 包(Textual)里去写,SDK 层只有
interrupt()这种抽象 HITL 信号。 -
• 调试栈深:错误堆栈穿透 LangGraph 多个节点 + 多层 middleware,定位问题比 PI 的 emit 路径费劲。
-
• 无内置热加载:middleware 是 Python 类,改完得重启进程。
-
• 分发重:标准路径是发 PyPI,不像 PI 一个文件 scp 过去就能用。
七、为什么各自选这条路?根因在"宿主形态"
这是这次对比里最值得记一笔的观察:

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

一句话判据
| 你要表达的诉求 | 推荐 | | :-- | :-- | | 「我要在某个固定时机干件事」 | Extension | | 「我要包住某段执行,可能不调、可能多次调」 | Middleware |
优先级三条建议
演进路径建议
-
• 从 PI 起步、未来可能迁 DeepAgents 的项目:保持 extension 的"单一职责"------一个文件管一件事,避免 mutate state 跨多个事件;将来翻成 middleware 类时成本最低。
-
• 从 DeepAgents 起步、缺 UI 的项目 :把 UI 层独立做(Textual / Web),通过 LangGraph 的
interrupt把 HITL 信号传出去,避免把 UI 逻辑塞进 middleware。 -
• 既要 PI 的 UI 又要 DeepAgents 的组合表达力:可考虑把 DeepAgents 当 backend(Python 服务),PI 当 frontend harness(CLI),通过 ACP / 自定义 RPC 衔接。
九、结论:一句话浓缩
PI 把"在哪儿插"做成了 API(命名事件);DeepAgents 把"怎么插"做成了 API(包 handler 的方法)。
前者强在低门槛 + UI 直觉 + 热加载 ,是单兵作战工具的最优解;后者强在组合表达 + 平台化 + 可观测,是构建产品级 Agent 平台的最优解。
两者并非互斥------它们其实展示了"Node 生态 vs Python 后端生态"在 Agent 这个新场景下的两条惯性路径:
-
• 从 VSCode / Fastify / Probot 走出来的人,自然会写出 PI 风格的事件总线;
-
• 从 Django / Koa / LangChain Callbacks 走出来的人,自然会写出 DeepAgents 风格的中间件栈。
当你要做"定制 tool_call 循环"这件事时,记住一个判断标准:
-
• "我要在某个固定时机干件事" → 事件模型更顺;
-
• "我要包住某个执行,并能选择性地多次/不调它" → 中间件模型更顺。
这条标准比"哪个框架更好"靠谱得多------它直接决定了你接下来要写多少胶水代码。
延伸阅读
涉及代码版本:
-
• PI
f2b105dd(@earendil-works/pi-coding-agent) -
• DeepAgents
2ac7d415(langchain-ai/deepagents) -
• LangChain v1
d0d78f1aeb(DeepAgents middleware 基类来源)
外部参照范式:
-
• Webpack tapable
-
• Fastify Hooks
-
• VSCode Extension API
-
• Koa middleware(洋葱模型)