第 8 章 · Hooks 与事件系统
Harness 锚点|判别清单第 4、7 项「循环控制 / 可观察性」——用生命周期事件拦截与观察每一步。
一次差点删库的自动化
还是那个跨境电商团队。他们给运营搭了一个 Agent,用来自动整理每天的对账文件:从几个平台拉订单、跑脚本、把结果写进项目目录。用了两周都挺顺。
出事那天,一位新来的运营让 Agent“清一下 temp 目录下的旧对账缓存”。Agent 想得没错——它要执行一句 shell 命令删掉缓存。但它拼出来的命令是 rm -rf $TMP/,而那一刻环境变量 $TMP 恰好是空的。这句命令等价于 rm -rf /。没有人喊停,循环也不会替你犹豫,它照做了。
好在这台机器上跑的是隔离容器,只毁了一份沙箱,没波及生产。但团队后怕:Agent 的循环是个只会往前冲的机器,它不会在危险动作前停下来问一句“你确定吗”。 他们真正想要的,是在“模型决定执行”和“命令真的跑起来”之间,插进去一道自己的关卡——危险命令先弹窗确认,写 .env 直接拦下,每轮开始前自动做个 Git 检查点。
这道“插进循环中间的关卡”,就是本章的主角:钩子(Hooks)与生命周期事件(lifecycle events)。
循环是黑盒,我想在中间插一脚
回顾第 3 章的 ReAct 循环:模型推理 → 调用工具 → 拿到结果 → 继续。这个循环大部分时候运转得很好,但总有些时刻你想插手:
- 模型要执行
rm -rf或sudo——我想先弹窗确认。 - 模型要写
.env文件——我想直接拦下来。 - 每一轮开始前——我想自动
git stash做个检查点,出错能回滚。 - 上下文快满了——我想用自己的方式压缩,而不是走默认那套。
- 模型每说完一段话——我想顺手记一笔花了多少 token、多少钱。
如果循环是个封死的黑盒,这些都做不到。你能改的只有“喂进去什么”和“最后拿到什么”,中间那段最关键的过程完全够不着。
要解决这个问题,就得让运行时(harness,即托管 Agent 循环的那层代码)主动把循环拆解开,在每个关键节点“喊一嗓子”,允许外部代码接话、甚至改变接下来的走向。这套机制,就是钩子(Hook)。
生命周期事件:给循环装上一排接线柱
先讲清楚最核心的两个术语。
生命周期事件(lifecycle event):把 Agent 从启动到结束的整个过程,切成一连串有名字的节点——会话开始、每一轮开始、调模型之前、调模型之后、执行工具之前、执行工具之后、上下文压缩触发时、会话结束……每到一个节点,运行时就“发出”一个对应的事件。你可以把这想象成一排接线柱:循环每跑到一个位置,就在对应的接线柱上通一次电。
钩子(Hook):你写的、挂在某个接线柱上的一段回调函数。事件一发生,你的函数就被调用,拿到当下的现场数据(这次要调哪个工具、参数是什么、当前上下文多大……)。“Hook”这个词本身就是“挂钩”的意思——你把逻辑挂在循环预留的挂钩上。
行业里这些节点通常用一组约定俗成的英文名来称呼,值得记牢(下面用的是通用叫法,pi 里的真实名字略有出入,稍后对照):
- beforeToolCall(工具调用之前):模型已经决定“我要调 shell、参数是这句命令”,但命令还没真正执行。这是拦下危险操作的黄金位置。
- afterToolCall(工具调用之后):工具跑完了,结果还没回填给模型。可以在这里改写、脱敏、截断结果。
- beforeModelCall / afterModelCall(调模型之前 / 之后):把消息发给大模型之前、拿到模型响应之后。可以在这里注入上下文、记录 token 花费、检查限流响应头。
- beforeCompact(压缩触发之前):上下文(context,即喂给模型的对话历史)快撑满窗口了,运行时准备做压缩(compaction,把旧对话总结成短摘要腾出空间,详见第 9 章)。这是你插入自定义压缩策略的口子。
- onTurnStart / onTurnEnd(每轮开始 / 结束):ReAct 循环的每一轮(一次模型响应加上它触发的工具调用)的边界。
一句话:生命周期事件是循环身上的“接线柱”,钩子是你接在上面的“电线”。
拦截:钩子不只是旁观者
如果钩子只能“看”,那它顶多是个日志工具。真正让它变强的,是拦截(interception)能力——钩子不仅能观察,还能返回一个决定,改变接下来发生的事。
拦截通常有三种动作,记住这三个词:
- 放行(allow / pass):什么都不做,或明确表示“没问题,继续”。循环照常往下走。
- 阻止(block / deny):返回一个“拦下”的信号,这次工具调用直接被取消,模型会收到一条“操作被拒绝”的说明,然后重新规划。开头那个
rm -rf就该被这么拦掉。 - 修改(modify / mutate):不拦,但改。可以改工具的入参(比如给每条 shell 命令自动加上
source ~/.profile),也可以改工具的返回结果(比如把一段超长日志截断再回填)。
有了“阻止”和“修改”,钩子就从“旁观者”升级成了“行为控制器”。这也是把人重新拉回循环的技术基础——业界叫 human-in-the-loop(人在环中):让循环在关键动作前停下来,等一个真人点头,再继续。开头那个“危险命令先弹窗确认”,就是最典型的 human-in-the-loop:Agent 提议,人拍板。
值得强调一个边界:拦截之所以可能,正是因为第 4 章讲过的那条铁律——模型自己不执行任何代码,它只是“提出请求”,真正执行由运行时完成。既然执行权在运行时手里,运行时自然就能在执行前插一道关卡。拦截不是给模型的能力,是给系统的控制权。
用 pi 实现:Extensions
pi 把这套机制叫 Extensions(扩展)——用 TypeScript 写的模块,能订阅生命周期事件、注册工具(registerTool,见第 4 章)、添加斜杠命令。一个扩展就是一个默认导出的工厂函数,接收一个 pi 对象,在上面挂钩子。
先把开头那个“危险命令确认”做出来。注意 pi 里真实的事件名是 tool_call(对应上文说的 beforeToolCall):
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
export default function guardExtension(pi: ExtensionAPI) {
// 在工具真正执行前拦截(tool_call 就是通用说的 beforeToolCall)
pi.on("tool_call", async (event, ctx) => {
// 只关心 shell/bash 类工具
if (isToolCallEventType("bash", event)) {
const cmd = event.input.command;
// 危险命令:弹窗让人拍板(human-in-the-loop)
if (/rm\s+-rf|sudo/.test(cmd)) {
const ok = await ctx.ui.confirm("危险命令", `确认执行?\n${cmd}`);
if (!ok) return { block: true, reason: "用户取消了危险命令" };
}
// 修改:给每条命令自动补上环境(改入参,不是拦截)
event.input.command = `source ~/.profile\n${cmd}`;
}
// 不返回 { block: true } 即为放行
});
}
三个动作在这段代码里都出现了:return { block: true } 是阻止;直接改 event.input.command 是修改(pi 的 tool_call 事件里 event.input 是可变的,就地改就生效);其余情况函数正常返回,就是放行。而 ctx.ui.confirm 弹出的那个确认框,就是 human-in-the-loop 的落点。
pi 的扩展能力远不止确认框。官方文档列举的典型用途包括:
- 权限门(permission gate):危险操作前确认,就是上面这个例子。
- 路径保护(path protection):阻止写入
.env、node_modules/等敏感路径。 - Git 检查点(checkpoint):每轮开始
git stash,出错可回滚。 - 自定义压缩(custom compaction):用你自己的方式总结对话(连到第 9 章)。
- 外部集成:文件监听、webhook、CI 触发。
再看一个用到不同事件点的例子——自定义压缩。pi 的压缩事件叫 session_before_compact(对应通用说的 beforeCompact),它能取消压缩,也能塞进你自己写的摘要:
export default function compactExtension(pi: ExtensionAPI) {
pi.on("session_before_compact", async (event, ctx) => {
// event.reason: "manual"(手动 /compact) | "threshold"(接近上限) | "overflow"(已溢出)
const summary = await mySummarize(event.preparation); // 你自己的总结逻辑
return {
compaction: {
summary,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
};
});
}
这就把“上下文满了怎么办”从默认策略手里接管了过来。类似地,after_provider_response(对应 afterModelCall)能拿到模型响应的 HTTP 状态和响应头——想在 429 限流时记一笔、或自己退避重试,就挂在这里。
从 pi 的事件全景对照那些“通用名”
上面几个例子里,pi 的真实事件名和行业通用叫法并不完全一样。这很正常:概念是通用的,命名各家自定。把 pi 的关键生命周期事件按发生顺序摆出来,和通用名对照一遍,你就不会被名字绊住:
| 通用叫法 | pi 里的事件名 | 能干什么 |
|---|---|---|
| 会话开始 | session_start | 初始化状态、恢复持久化数据 |
| 每轮开始 | turn_start | 记录轮次、做检查点 |
| beforeModelCall | context / before_provider_request | 改写喂给模型的消息、改 payload |
| afterModelCall | after_provider_response | 看状态码/响应头、处理限流 |
| beforeToolCall | tool_call | 拦截:放行 / 阻止 / 改入参 |
| afterToolCall | tool_result | 改写、脱敏、截断工具结果 |
| 每轮结束 | turn_end | 汇总本轮消息、工具结果 |
| beforeCompact | session_before_compact | 取消压缩或自定义摘要 |
| 会话结束 | session_shutdown | 清理资源、保存状态 |
pi 循环的骨架大致是:session_start → 用户提问 → turn_start →(context → 调模型 → after_provider_response)→ 有工具调用就(tool_call → 执行 → tool_result)→ turn_end,反复直到不再调工具,最后 session_shutdown。把这张表和第 3 章那个 Thought/Action/Observation 循环叠在一起看:tool_call 正好卡在 Action 之前,tool_result 正好卡在 Observation 之前——这也是为什么拦截总发生在这两个点。
Hooks vs Tools vs Skills:三者定位
pi 里有三个容易混淆的扩展机制,新手常常分不清什么时候该用哪个。一句话区分:
| 机制 | 回答的问题 | 触发方 | 典型形态 |
|---|---|---|---|
| Tool(工具) | “Agent 能做什么?” | 模型主动调用 | registerTool,见第 4 章 |
| Skill(技能) | “遇到某类任务怎么做?” | 模型按需加载 | SKILL.md + 目录,放 .pi/skills/ |
| Hook / 扩展 | “循环运行时该拦截 / 注入什么?” | 系统在事件点自动回调 | 订阅生命周期事件 |
关键差别在触发方:工具和技能是“给模型的能力”——由模型自己决定用不用;钩子是“给系统的控制点”——由运行时在固定节点强制回调,模型无从绕过。所以权限门、路径保护这类“模型不能自己关掉”的安全措施,必须做成钩子,不能做成工具。工具是能力,技能是工作流,钩子是控制点。
动手看看
pi 把扩展的完整能力都写在了 packages/coding-agent/docs/extensions.md 里,很值得对着读一遍。
先翻到文档里的 Events / Lifecycle Overview 那张 ASCII 流程图,把它和上一节那张对照表放一起看,确认每个事件到底在循环的哪个位置发出。再重点读 tool_call 一节——留意文档里那句:event.input 是可变的,就地改就能改掉工具的真实入参,而且改完不会重新做 schema 校验(这是个坑,后面会讲)。
然后把扩展放进 ~/.pi/agent/extensions/(全局,对所有项目生效)或项目里的 .pi/extensions/(项目内),pi 会自动发现它。改完扩展不用重启 pi——在交互界面里敲 /reload 就能热重载,立刻生效。
想再深一点,就去翻 examples/extensions/ 目录,里面有可直接跑的完整例子(比如 memory.ts 类人遗忘、summarize.ts 会话总结)。挑一个改两行,/reload 一下看效果,比读十页文档都快。
实战中的几个坑
坑一:钩子在事件里写成了慢操作,把循环拖死。
- 现象:挂了个
tool_call钩子后,每次调工具都要卡好几秒,整体明显变慢。 - 原因:在事件回调里做了同步或耗时的活儿(网络请求、扫全盘、跑重脚本)。生命周期事件是同步挡在循环路径上的,钩子不返回,循环就停在那儿等。
- 对策:钩子里只做轻判断;耗时的活儿异步做,用
ctx.signal让它能被中断,别阻塞主循环。
坑二:只在 tool_call 拦名字,没拦到真实危险。
- 现象:拦了
rm -rf,结果模型用find . -delete或管道绕过去,照样删了。 - 原因:把拦截写成了“匹配某个字符串”,而不是“判断这次操作的真实影响”。
- 对策:拦截按能力和路径判断(是否触碰受保护目录、是否有写/删权限),别只靠命令文本正则;拿不准就走 human-in-the-loop 让人确认。
坑三:改了工具入参,却忘了它不会被重新校验。
- 现象:在
tool_call里改了event.input,结果传了个非法值,工具执行时才崩。 - 原因:pi 明确规定——你在
tool_call里对event.input的修改不会再走一遍 schema 校验,改错了直接带病执行。 - 对策:修改入参时自己保证合法性;拿不准就别改,改成
{ block: true }让模型自己重新生成。
坑四:把该做成钩子的安全措施做成了工具。
- 现象:写了个“删除前确认”的工具,指望模型每次删东西前都先调它——结果模型经常直接调 shell 删,跳过了确认。
- 原因:工具是“给模型的能力”,用不用由模型决定,它完全可以不用。
- 对策:安全护栏必须做成钩子(挂
tool_call),由系统强制回调,模型绕不过去。
对比:其他框架
“在循环的生命周期点插一脚”这件事,各框架的机制和粒度差别很大:
| 框架 | Hooks / 事件怎么做 | 特点 |
|---|---|---|
| LangGraph | 控制点就是图本身:想拦截就在图里插一个节点或加一条条件边,还能借 checkpointer 在节点间做人审(human-in-the-loop) | 最显式,也最重 |
| CrewAI | 提供任务级 / 步骤级 callback(task_callback、step_callback)来观察和介入,粒度落在任务与步骤 | 够不到循环内部每个事件 |
| PydanticAI | 没有专门的 Hook 系统,靠依赖注入、输出 / 工具校验器(validator)和 instrumentation 在边界处介入 | 拦截逻辑多写在类型校验里 |
| Agno | 提供工具执行前后的 hook 与 callback,可做校验、改写、审计 | 有前后钩子,但比 harness 粗 |
| pi | 用 Extensions 订阅生命周期事件,核心是拦截(放行 / 阻止 / 修改),还能注册工具、加命令,自动发现 + /reload 热重载 | 事件点最细、最透明 |
大家都在解决同一个诉求——让黑盒循环留出可编程的控制点。差别只在于这个点是“图节点”“callback”还是“事件订阅”,以及能拦到多细:越靠近循环内部每个动作,能做的拦截就越精确。
小结
- 生命周期事件(lifecycle event)把 Agent 循环切成一串有名字的节点,钩子(Hook)是你挂在这些节点上的回调——运行时在节点自动回调你的代码。
- 钩子的核心能力是拦截(interception):放行 / 阻止 / 修改。有了阻止和修改,钩子就从旁观者升级为行为控制器,也是 human-in-the-loop(人在环中)的落地基础。
- 关键事件点包括 beforeToolCall / afterToolCall(拦工具)、beforeModelCall / afterModelCall(管模型调用)、beforeCompact(自定义压缩);在 pi 里分别是
tool_call/tool_result、context/after_provider_response、session_before_compact。 - pi 用 Extensions 实现这套机制:TS 模块,订阅事件、注册工具、加命令,放
~/.pi/agent/extensions/或.pi/extensions/自动发现,/reload热重载。 - 记住三者分工:Tool = 能力(模型调),Skill = 工作流(模型加载),Hook = 控制点(系统回调)——安全护栏必须做成钩子,模型才绕不过去。
钩子解决的是“循环运行时怎么插手”,而它最常插手的那个对象——上下文——本身就是一门大学问:模型的窗口是有限的,长对话、大工具结果很快就会把它撑满。压缩事件只是个入口,真正怎么裁、怎么记、怎么让 Agent 在有限窗口里跑长任务,是接下来要专门讲的上下文工程。