Skip to content

🐣 小a的困惑

小a学会调工具后越跑越欢,老z却开始皱眉:"你就让它直接执行 rm -rf ?"

小a愣住:"工具定义好了,模型要调,我当然执行啊。"

老z摇摇头:"太粗暴了。一个成熟的 Agent,在工具执行前该能拦一下——确认安全、记个日志、必要时直接挡掉;在执行后也该能插一手——改改结果、加点信息。这套『插手机制』,就叫 hook(钩子)。"

小a来了兴致:"钩子?长什么样?"

老z从抽屉里掏出 pi 的源码:"看,pi 早就给你准备好了。"


H.1 Hook 是什么:Agent 循环里的"插手点"

回忆一下 Part I 第 7 章讲的 Agent 循环:

prompt → stream → 收到 toolUse → 【执行工具】 → 回喂 → 继续

                          就是这一步,可以插手!

🧙 Hook 的定义

Hook 是 Agent 在循环的关键节点上预留的"回调函数"——你把一段逻辑挂(hook)上去,Agent 跑到那个节点时会自动调用它。

最核心的两个:

  • beforeToolCall:工具执行触发。能审查参数、拦截危险操作、改写参数。
  • afterToolCall:工具执行触发。能修改结果、标记错误、决定是否提前结束。

老z打比方:hook 就像路口的红绿灯。工具是车,beforeToolCall 是车通过前看你闯不闯红灯,afterToolCall 是车过后看看它拉了什么货。


H.2 beforeToolCall:执行前的"安检员"

它能干什么

用途例子
拦截危险操作模型要 rm -rf / → beforeToolCall 返回 {block: true} → 拒绝执行
审查参数把即将执行的命令记进日志、发通知
改写参数给路径加上白名单前缀、规范格式
二次确认弹窗问用户"确定要执行这个 bash 命令吗?"

真实签名(源码实证)

我们直接看 pi 的类型定义:

📄 源码证据 packages/agent/src/types.ts:56-64

typescript
/** Result returned from `beforeToolCall`. */
export interface BeforeToolCallResult {
  block?: boolean;   // true → 阻止工具执行,改成返回错误结果
  reason?: string;   // 阻止时显示给模型的原因
}

📄 源码证据 packages/agent/src/types.ts:92-102 —— 传给 hook 的上下文

typescript
export interface BeforeToolCallContext {
  assistantMessage: AssistantMessage;  // 请求这次工具调用的助手消息
  toolCall: AgentToolCall;              // 原始工具调用块
  args: unknown;                        // 已校验的工具参数
  context: AgentContext;                // 当前的 agent 上下文
}

看明白了吗?beforeToolCall 拿到的是完整信息:谁要调、调什么、参数是啥、当前环境。它有权决定"放行"还是"拦截"。

实现原理:loop 里怎么调用它

这是最关键的部分——hook 不是魔法,它就是 loop 里的一个 if 判断。看 pi 的真实代码:

📄 源码证据 packages/agent/src/agent-loop.ts:619-643

typescript
if (config.beforeToolCall) {
  const beforeResult = await config.beforeToolCall(
    { assistantMessage, toolCall, args: validatedArgs, context: currentContext },
    signal,  // 中断信号,hook 必须响应它
  );
  if (signal?.aborted) {                    // ① 用户中断了
    return { kind: "immediate", result: createErrorToolResult("Operation aborted"), isError: true };
  }
  if (beforeResult?.block) {                // ② hook 说"拦"
    return {
      kind: "immediate",
      result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"),
      isError: true,
    };
  }
}
// ③ 都没拦 → 正常往下执行工具
return { kind: "prepared", toolCall, tool, args: validatedArgs };

🧙 老z拆解这段代码:

注意三个细节,它们体现了 pi 的工程严谨:

  1. if (config.beforeToolCall) —— hook 是可选的。没挂就不调,零开销。这呼应了"简单优先":你不挂 hook,Agent 行为和没 hook 一模一样。
  2. signal 必须传给 hook —— 注释写明"The hook receives the agent abort signal and is responsible for honoring it"。意思是:就算你在 hook 里干慢活(比如等用户确认),也得随时响应"用户按了取消"。这是防卡死的铁律。
  3. block 走的是"错误结果"而非"静默忽略" —— 拦截后不是假装没发生,而是返回一个 isError: true 的工具结果告诉模型"你被拦了"。这样模型能知道、能调整。fail loud,不偷偷吞掉(呼应附录 E 的原则)。

H.3 afterToolCall:执行后的"质检员"

它能干什么

afterToolCall 在工具执行之后触发,能修改结果:

用途例子
修改结果内容工具返回了敏感信息 → afterToolCall 把它打码
标记为错误工具返回成功但内容不对 → afterToolCall 设 isError: true 让模型重试
追加信息给结果加上 usage 统计、调试 details
决定提前结束某工具跑完后,告诉 agent "够了,别再调了" → terminate: true

真实签名(源码实证)

📄 源码证据 packages/agent/src/types.ts:66-90

typescript
/** Partial override returned from `afterToolCall`. */
export interface AfterToolCallResult {
  content?: (TextContent | ImageContent)[];  // 替换结果内容(整体替换,非深合并)
  details?: unknown;                          // 替换 details
  isError?: boolean;                          // 替换错误标记
  usage?: Usage;                              // 替换 usage
  terminate?: boolean;                        // 提示 agent 这批工具跑完就停
}

🧙 关键的合并语义(源码注释明确写了):

  • 提供的字段 → 替换原值
  • 省略的字段 → 保留原值
  • 没有深合并 —— content 给了就整体替换,不会和原 content 拼接

这是个"部分覆盖"语义。设计成这样是为了可预测:你改什么、不改什么,一目了然,不会出现"我以为只改了一段,结果整段被换了"的意外。

terminate 的精妙之处

terminate: true 是个很有用的能力——它让 hook 能主动结束循环:

📄 源码注释:"Early termination only happens when every finalized tool result in the batch sets this to true."

翻译:一批工具同时跑,要这一批里每一个的 afterToolCall 都说 terminate,才会真的停。有一个说"别停",就继续。

这是"全员同意才停"的保守策略——避免一个工具误判就草草收场。


H.4 不止两个:pi 的完整 hook 家族

beforeToolCall/afterToolCall 是最常用的,但 pi 还有更多 hook。看完这张表你就知道 pi 给了多少"插手点":

Hook触发时机干什么
beforeToolCall工具执行安检:拦截/审查/改写参数
afterToolCall工具执行质检:改结果/标错/终止
shouldStopAfterTurn一轮对话结束判断要不要再来一轮(默认看 stopReason)
prepareNextTurn准备下一轮改下一轮的 context/model/thinkingLevel
convertToLlm每次调 LLM 把 AgentMessage 转成 LLM 能懂的格式(过滤 UI 消息等)

🧙 看出门道了吗?

这些 hook 覆盖了 Agent 循环的每个关键节点——工具前后、轮次前后、调 LLM 前。等于 pi 把循环"拆开了接口",让你能在任何环节插手,而不用改 loop 本身的代码

这就是 hook 的本质价值:开放扩展,封闭修改(开闭原则)。pi 的 loop 写好后基本不动,所有定制行为都通过挂 hook 实现。


H.5 Hook 的本质:观察者模式 + 责任链

老z给小a讲清楚 hook 背后的设计模式——这能帮你理解"为什么这么设计"。

它是观察者模式

🧙 观察者模式(Observer)

Agent(loop)是"被观察者",hook 是"观察者"。loop 跑到节点就"通知"观察者:"我到这了,你要不要管?"

回顾 Part II 第 18 章:pi 的 agent.ts 还有 subscribe() —— 那是事件观察(只看不动),而 hook 是拦截观察(能看也能改/拦)。两者是观察者模式的两种强度。

它也带责任链的影子

🧙 责任链(Chain of Responsibility)

beforeToolCall 的拦截逻辑,和 Part II 第 17 章讲的 auth 责任链很像——一路往下判断,任一环说"停"就停。

区别:auth 责任链是"找凭据"(找成功为止),beforeToolCall 是"安检"(发现危险就拦)。但结构都是"串行判断、短路退出"。


H.6 实战:用 hook 做一个"危险命令确认"

光说不练假把式。老z给小a演示一个最常见的 hook 用法:模型要执行 bash 命令前,先问用户确认

typescript
const agent = new Agent({
  // ... 其他配置
  runtimeOptions: {
    beforeToolCall: async (ctx, signal) => {
      // 只管 bash 工具
      if (ctx.toolCall.name !== "bash") return;

      const cmd = String(ctx.args.command ?? "");
      // 危险命令清单(简化版)
      const dangerous = /\b(rm\s+-rf|mkfs|dd\s+if=|:\(\)\{)\b/;
      if (!dangerous.test(cmd)) return;  // 不危险 → 放行

      // 危险 → 问用户
      const ok = await askUser(`要执行危险命令:\n  ${cmd}\n确认?(y/N)`);
      if (!ok) {
        return { block: true, reason: "用户拒绝了危险命令" };  // 拦!
      }
      // 用户同意 → 放行(return undefined)
    },
  },
});

🧙 这段代码体现了 hook 的三个精髓:

  1. 精准介入:用 if (ctx.toolCall.name !== "bash") 只管 bash,别的工具不耽误。hook 可以做"选择性介入"。
  2. 柔性拦截:不是无脑挡,而是问用户。hook 可以做"异步交互"(await 等用户回应)。
  3. 告知原因:reason 会变成错误结果告诉模型"你被拦了,因为用户拒绝"。模型收到后能调整策略,而不是傻等。

H.7 Hook vs Skill vs Extension:别搞混

这三个都跟"扩展 agent"有关,容易混。老z画了张对比表:

干什么谁定义何时介入
Hook在循环节点插手(拦/改)开发者写代码挂上工具执行前后、轮次边界
Skill(第 11 章)给 agent 新增能力包(工具+提示词+知识)用户安装agent 启动时加载
Extension(Part II 20.7)加新的 provider/model用户安装agent 启动时加载

一句话区分:

  • Hook = 改行为(拦截、修改现有流程)
  • Skill = 加能力(让 agent 会干新事)
  • Extension = 加资源(让 agent 能用新模型/厂商)

🐣 小a顿悟:"所以 trust-manager(pi 的信任机制,Part II 20.5)其实底层就是 hook?"

🧙 "聪明!pi 进新目录要确认、危险操作要拦截——这些『安全行为』很多就是靠 beforeToolCall 实现的。hook 是骨架,trust 是长在骨架上的肉。"


H.8 Hook 的代价(诚实讲)

老z不让小a只看好处:

🧙 代价 1:hook 让循环变难预测

没挂 hook 时,工具调用是确定的(模型要调就调)。挂了 beforeToolCall,执行可能被拦、被改——模型不知道自己的请求会不会被改。调试时容易困惑:"我明明让它读文件,怎么返回错误?"

缓解:拦截时务必给清晰的 reason,让模型(和调试的你)知道发生了什么。

🧙 代价 2:hook 串多了会乱

如果同时挂好几个 beforeToolCall(一个查安全、一个记日志、一个改参数),执行顺序、互相覆盖就成了问题。pi 的设计是一个 hook 函数,你要多逻辑就自己在函数里组织——避免了多 hook 的协调问题,但也把复杂性留给了你。

🧙 代价 3:async hook 可能拖慢循环

beforeToolCall 是 async 的(要等用户确认、要查远程)。如果 hook 干慢活,整个循环就卡那。所以源码强制要求 hook 响应 signal(中断信号)——不能让一个慢 hook 把 agent 挂死


H.9 一句话总结

Hook 是 Agent 循环里的"插手点"——beforeToolCall 在工具执行前安检(能拦),afterToolCall 在执行后质检(能改)。pi 把循环的每个节点都开了 hook 接口,让你不改 loop 代码就能定制行为。

它的本质是观察者模式,价值是开闭原则:开放扩展,封闭修改。


参考

  • 源码:packages/agent/src/types.ts(56-118 行,BeforeToolCall/AfterToolCall 类型)
  • 源码:packages/agent/src/agent-loop.ts(619-664 行,beforeToolCall 调用与拦截逻辑)
  • 源码:packages/agent/src/agent.ts(183-221 行,hook 字段定义)
  • 相关章节:Part I 第 7 章(Agent 循环)、Part II 第 17 章(auth 责任链)、第 18 章(subscribe 观察者)、第 20 章(trust 机制)、附录 D(观察者/责任链模式)