第 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 -rfsudo——我想先弹窗确认。
  • 模型要写 .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):阻止写入 .envnode_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记录轮次、做检查点
beforeModelCallcontext / before_provider_request改写喂给模型的消息、改 payload
afterModelCallafter_provider_response看状态码/响应头、处理限流
beforeToolCalltool_call拦截:放行 / 阻止 / 改入参
afterToolCalltool_result改写、脱敏、截断工具结果
每轮结束turn_end汇总本轮消息、工具结果
beforeCompactsession_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_callbackstep_callback)来观察和介入,粒度落在任务与步骤够不到循环内部每个事件
PydanticAI没有专门的 Hook 系统,靠依赖注入、输出 / 工具校验器(validator)和 instrumentation 在边界处介入拦截逻辑多写在类型校验里
Agno提供工具执行前后的 hook 与 callback,可做校验、改写、审计有前后钩子,但比 harness 粗
piExtensions 订阅生命周期事件,核心是拦截(放行 / 阻止 / 修改),还能注册工具、加命令,自动发现 + /reload 热重载事件点最细、最透明

大家都在解决同一个诉求——让黑盒循环留出可编程的控制点。差别只在于这个点是“图节点”“callback”还是“事件订阅”,以及能拦到多细:越靠近循环内部每个动作,能做的拦截就越精确。

小结

  1. 生命周期事件(lifecycle event)把 Agent 循环切成一串有名字的节点,钩子(Hook)是你挂在这些节点上的回调——运行时在节点自动回调你的代码。
  2. 钩子的核心能力是拦截(interception)放行 / 阻止 / 修改。有了阻止和修改,钩子就从旁观者升级为行为控制器,也是 human-in-the-loop(人在环中)的落地基础。
  3. 关键事件点包括 beforeToolCall / afterToolCall(拦工具)、beforeModelCall / afterModelCall(管模型调用)、beforeCompact(自定义压缩);在 pi 里分别是 tool_call / tool_resultcontext / after_provider_responsesession_before_compact
  4. pi 用 Extensions 实现这套机制:TS 模块,订阅事件、注册工具、加命令,放 ~/.pi/agent/extensions/.pi/extensions/ 自动发现,/reload 热重载。
  5. 记住三者分工:Tool = 能力(模型调),Skill = 工作流(模型加载),Hook = 控制点(系统回调)——安全护栏必须做成钩子,模型才绕不过去。

钩子解决的是“循环运行时怎么插手”,而它最常插手的那个对象——上下文——本身就是一门大学问:模型的窗口是有限的,长对话、大工具结果很快就会把它撑满。压缩事件只是个入口,真正怎么裁、怎么记、怎么让 Agent 在有限窗口里跑长任务,是接下来要专门讲的上下文工程