Appearance
🐣 小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-64typescript/** Result returned from `beforeToolCall`. */ export interface BeforeToolCallResult { block?: boolean; // true → 阻止工具执行,改成返回错误结果 reason?: string; // 阻止时显示给模型的原因 }📄 源码证据
packages/agent/src/types.ts:92-102—— 传给 hook 的上下文typescriptexport interface BeforeToolCallContext { assistantMessage: AssistantMessage; // 请求这次工具调用的助手消息 toolCall: AgentToolCall; // 原始工具调用块 args: unknown; // 已校验的工具参数 context: AgentContext; // 当前的 agent 上下文 }
看明白了吗?beforeToolCall 拿到的是完整信息:谁要调、调什么、参数是啥、当前环境。它有权决定"放行"还是"拦截"。
实现原理:loop 里怎么调用它
这是最关键的部分——hook 不是魔法,它就是 loop 里的一个 if 判断。看 pi 的真实代码:
📄 源码证据
packages/agent/src/agent-loop.ts:619-643typescriptif (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 的工程严谨:
if (config.beforeToolCall)—— hook 是可选的。没挂就不调,零开销。这呼应了"简单优先":你不挂 hook,Agent 行为和没 hook 一模一样。signal必须传给 hook —— 注释写明"The hook receives the agent abort signal and is responsible for honoring it"。意思是:就算你在 hook 里干慢活(比如等用户确认),也得随时响应"用户按了取消"。这是防卡死的铁律。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-90typescript/** 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 的三个精髓:
- 精准介入:用
if (ctx.toolCall.name !== "bash")只管 bash,别的工具不耽误。hook 可以做"选择性介入"。- 柔性拦截:不是无脑挡,而是问用户。hook 可以做"异步交互"(await 等用户回应)。
- 告知原因:
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(观察者/责任链模式)