Skip to content

第38章 在关键时刻插一句话——Hooks ​

本章导读: 实现篇扩展第 1 章。mini-agent 目前只有 BeforeToolCall 这一条审批缝。这一章沿它推演一个完整的生命周期 hook 体系——不写进 src,而是讲清接口长什么样、失败语义怎么定、测试怎么设计。前提是先确认需求,再动手。

小a的 Agent 跑了一下午,他盯着日志,忽然问老z:“我想在每次工具调用前记一笔账、每轮结束打一条状态,还要在 Agent 开始和结束时各做点清理。现在的 BeforeToolCall 只能拦工具,其他时刻我什么都干不了。”

“你想加的是生命周期 hooks。”老z说,“但先别急着写。回答三个问题:这些时刻谁需要可观察?失败时该中止还是忽略?要不要让某个 hook 改写主路径?”

小a:"这三个问题为什么要在写代码前答?"

"因为它们的答案决定了接口的形状。"老z说,"mini-agent 现在只有一条 BeforeToolCall(见 types.ts),它的签名是 (call: ToolCall) => Promise<{approved: boolean; reason?: string}>——它返回值,因为它要决定主路径走不走。你想要的是记账和打状态,这些动作不决定走不走,只负责'看见'。如果你把记账塞进 BeforeToolCall,它的返回值 approved 就被两件事共用:一是审批决策,二是'记账记完了吗'。两类需求挤一个返回值,迟早会有人在审批逻辑里为了记账的副作用改返回值——这就是接口被腐蚀的开始。 所以先分清:哪些时刻要'看见',哪些时刻要'决定'。"

先分清三类 Hook ​

概念篇 第11章 在动作发生前插一句话——Hooks 把 Hook 分成三类,实现时要先决定每一类落在哪:

  • 订阅者——只观察,不改主路径。日志、指标、审计属于这类。
  • 拦截器——能允许、拒绝或改写动作。BeforeToolCall 属于这类。
  • 转换器——改写输入但不决定是否继续。上下文转换属于这类。

“这三类的失败语义完全不同。”老z说,“订阅者抛错最多丢一条日志,拦截器抛错可能让整个 turn 失败,转换器抛错会让请求带着错误上下文发出去。设计接口之前先分类,否则你会在一个接口里混进三种失败语义。”

把三类按几个维度并排:

类别作用返回什么失败语义放在哪例
订阅者观察,不改主路径不返回结果吞掉并记录旁路日志、指标、审计
拦截器允许/拒绝/改写动作决策结果向上传播主路径BeforeToolCall
转换器改写输入,不决定是否继续改写后的输入请求带错误上下文主路径上下文转换

"注意拦截器和转换器都在主路径上,但返回值不同:拦截器给出'走不走',转换器给出'变成什么样'。订阅者唯一的产出是副作用,所以它的失败只能影响自己。 这张表就是这一章的骨架,后面所有设计都在填这张表的格子。"

小a:"那 mini-agent 现在哪条缝是哪一类?"

"只有一条,而且是拦截器。"老z说,"BeforeToolCall 被 runAgent 在每个工具执行前调用(见 agent.ts 的 options.beforeToolCall?.(call)),它的返回值直接决定工具跑不跑——!approval.approved 就 continue 跳过执行。而且它的失败向上传播:测试里有个用例叫 approval hook failures close every persisted call before rethrowing(见 tool-failure-session.test.ts),hook 抛错时,当前 call 记成 'tool approval failed',后面的 call 记成 'not executed after an earlier failure',然后错误原样抛给调用方。这就是拦截器的失败语义:出错就停,并且把没跑完的 call 都标成失败闭环。 你想要的记账 hook 不能走这条路——记账失败了不该让整个回合挂掉。"

设计一套最小生命周期 hooks ​

小a的需求(记账、打状态、清理)全是订阅者。老z沿 BeforeToolCall 的模式推演了一套接口:

ts
// 设计草图:生命周期 hooks(未进 src,先确认需求)
export interface TurnHooks {
  onTurnStart?(context: { user: string }): Promise<void> | void;
  onBeforeToolCall?(call: { id: string; name: string; input: unknown }): Promise<void> | void;
  onAfterToolCall?(result: { callId: string; isError: boolean }): Promise<void> | void;
  onTurnEnd?(result: { stopReason: "complete" | "max_turns" | "aborted" }): Promise<void> | void;
}

“注意两点。”老z说,“第一,每个方法都是可选的——你不需要全部实现;第二,它们都接收最小上下文,不接收整个内部状态——订阅者拿到的信息越少,越不会诱导它去改主路径。”

把这四个方法贴到 runAgent 的循环上,看它们在哪个时刻触发:

runAgent 一轮的执行顺序(mini-agent 现循环 + 推演 hook 触发点)

  onTurnStart({ user })                  ← 记录本轮开始
        │
        ▼
  client.stream(...) ──► 收集 text / tool_call
        │
        ▼
  for each call:
    BeforeToolCall(call) ──► approved? ──否──► 记 tool error,跳过
        │ 是
    onBeforeToolCall(call)               ← 记录"这次调用即将发生"
        │
        ▼
    tool.execute(...)
        │
    onAfterToolCall({ callId, isError }) ← 记录调用结果
        │
        ▼
  onTurnEnd({ stopReason })              ← complete / max_turns / aborted

"注意这个图里有一半是虚线。"老z说,"mini-agent 的循环现在只调 options.beforeToolCall?.(call),四个新 hook 一个都没接——这张图是推演的接线图,不是已实现行为。画它的意义是让你看见:每个 hook 都钉在一个明确的时刻,onBeforeToolCall 必须在拦截器决策之后、工具执行之前,onTurnEnd 在停止判定之后。时刻错了,订阅者记的就是假账。"

小a:"最小上下文怎么定?比如 onTurnEnd 给它整个 messages 数组不行吗?"

"能跑,但后患大。"老z说,"整个 messages 是 runAgent 的内部状态——它带着所有 user/assistant/tool 消息,包括还没 trim 的原始上下文。订阅者拿到它, temptation 是'我来改一条消息'或'我根据历史做个决策'。一旦订阅者开始改 messages,它就不再是订阅者,变成了一个没有失败语义定义的转换器。给订阅者的上下文,应该刚好够它记账,不够它干预。 比如 onTurnEnd 只给 { stopReason }——它知道这轮是 complete 还是 max_turns,就够了,不需要知道之前调了哪些工具。这与 mini-agent 给 ToolResult 的设计一致:工具拿到的 input 是模型给的参数,不是整个循环状态(见 tools.ts 里 execute(input, signal))。接口收窄,是为了挡住未来的越界。"

小a:”那 onBeforeToolCall 和现有的 BeforeToolCall 什么关系?”

“分工不同。”老z说,“BeforeToolCall 是拦截器——它返回 { approved, reason } 决定是否放行;onBeforeToolCall 是订阅者——它只记录,不返回结果。**拦截器在主路径上,订阅者在旁路上。**如果你把日志逻辑塞进 BeforeToolCall,你就把一个旁路观察变成了主路径阻塞点——这是概念篇讲过的『带时序约束的观察者』陷阱,在实现里会拖慢整个循环。”

小a:”那 hook 要接收 AbortSignal 吗?万一它跑一半用户按了 Ctrl-C。”

“这是推演阶段最容易多给的一个参数。”老z说,”mini-agent 的 runAgent 到处检查 signal,连 Tool.execute 都接收它——但那是为了中止一个可能跑很久的副作用。订阅者如果只记账,几毫秒就返回了,给它 signal 就是给一个它用不上的东西;如果它做网络上报,那是它自己的问题,不该由循环替它解决。参数不是越多越稳,是'有调用方、有语义'才算数。 你真遇到'订阅者可能卡住'的证据,再回头加——那才是本章说的'被证明需要的时刻'。”

小a:"那同一个工具调用,onBeforeToolCall 和 BeforeToolCall 谁先跑?"

"BeforeToolCall 先跑,它决定放不放行;放行了才轮到工具执行,onBeforeToolCall 记的是'这次调用即将发生'。"老z说,"看 agent.ts 的顺序:先 options.beforeToolCall?.(call) 拿到 approval,!approval.approved 就 continue 跳过;放行了才 tool.execute。如果 onBeforeToolCall 跑在 BeforeToolCall 前,它会记下一笔'即将调用 bash',然后 bash 被 denyBashByDefault 拒绝——日志和实际不符。订阅者的触发点必须跟在拦截器决策之后,否则它记的是'发生了',实际却'没发生'。 顺序写进注释,别靠后人猜。"

失败语义:订阅者不允许拖垮主路径 ​

小a:“那 onAfterToolCall 里网络上报失败了怎么办?Agent 要停吗?”

“订阅者的失败不该中止主路径。”老z说,“它要么被 try/catch 吞掉并记录,要么放进独立的异步队列。把订阅者失败升级成主路径失败,等于让日志系统决定 Agent 能不能继续跑。”

ts
// 设计草图:订阅者失败不拖垮主路径
for (const hook of hooks) {
  try {
    await hook.onAfterToolCall?.(result);
  } catch (error) {
    log.warn("lifecycle hook failed", { hook: "onAfterToolCall", error });
    // 不中止循环——日志失败不该让 Agent 停
  }
}

“但拦截器不同。”老z强调,“BeforeToolCall 的失败必须向上传播,因为『审批逻辑出错了』本身就是一个需要处理的状态——静默放行等于绕过安全策略。订阅者吞错,拦截器报错,这条分界线要写进注释,不然后来者会混。”

小a:"订阅者吞错,吞到哪去?就 console.warn 一下?"

"console.warn 是最低配,生产里你要写到独立的审计队列或落盘日志。"老z说,"关键是别让它进 messages。mini-agent 的 messages 是模型上下文的来源(见 agent.ts 里 trimContext(messages, ...) 喂给 client.stream)——你把 hook 失败的细节写进 messages,模型就看见了,可能据此调整行为。hook 失败是宿主侧的事,跟模型无关。吞错的位置必须在循环之外,吞掉的内容不能回流到上下文。 这也是为什么测试里有个 a tool throwing after cancellation records a diagnostic result(见 cancellation.test.ts)——工具在取消后抛的错,被替换成 'aborted during execution',原始的 'sensitive late failure' 字样断言不会出现在 messages 里。同一个原则:宿主侧的敏感细节,不进模型上下文。"

小a:"那如果订阅者不是抛错,而是 hang 住了——比如网络上报一直没返回?hook 不返回,主路径不就卡死了?"

"好问题,这是订阅者最容易踩的坑。"老z说,"两个办法:一是给订阅者调用加超时,超时就当失败吞掉;二是让订阅者不能是 async 阻塞的——它把要记的数据扔进一个队列就返回,真正的上报在后台异步做。订阅者的契约是'我已记下',不是'我已上报'。 mini-agent 的 record 函数(见 agent.ts)就是个对照:它 await appendSession 是同步落盘的,因为 session 是主路径的一部分;但订阅者不该学这个,订阅者要 fire-and-forget。主路径的 await 每一个都要值回票价,订阅者没这个资格。"

小a:"'没装 hook'和'hook 失败',语义上也要分开吗?"

"必须分开,而且测试要各写一条。"老z说,"BeforeToolCall 用 options.beforeToolCall?.(call) ?? { approved: true }(见 agent.ts)——没装是'没有策略,默认放行',这是设计缺口,测试要让你看见它;装了但抛错是'策略出错,向上传播',这是故障,测试断言它停。两个语义混在一起,你就会在'为什么裸跑'和'为什么停了'之间反复疑惑。订阅者同理:没挂 hook 的回合,和挂了 hook 的回合,应该产出相同的 messages——差别只在旁路副作用,不在主路径。"

两种方案各有过账,别只看好处:

方案做法好处代价
加超时Promise.race([hook(), timeout])简单直接超时只是兜底,hook 还在后台占资源
异步队列hook 立即返回,数据进队列主路径零等待要处理队列积压、进程退出时的冲刷

"第二条方案里有个容易被忽略的收尾问题:**进程要退出了,队列里的审计数据要不要冲刷?**如果要,谁在什么时候刷?这已经不是 hook 的职责,是宿主生命周期的职责——所以它要写进验收条件,不是写在接口注释里就完了。订阅者设计得再好,退出时没人冲刷队列,账还是丢。"

小a:"那'订阅者吞错、拦截器报错'这条分界线,谁来保证后来者不混?"

"靠三层。"老z说,"第一层,接口签名——订阅者的方法声明里没有返回审批结果的类型,你没法从旁路返回一个'放行';第二层,注释——分界线写进接口文档,标注每一类的失败语义;第三层,测试——用一个故意抛错的订阅者,断言主路径没受影响,把'不拖垮主路径'变成回归用例而不是口头约定。签名挡得住大多数混用,测试守得住最后一次回退。"

测试要点 ​

老z列了推演这套 hooks 时的测试清单:

  • 每个 hook 被触发一次、按顺序触发;onTurnStart 在第一次模型调用前,onTurnEnd 在停止判定后。
  • 订阅者抛错不改变停止原因——complete 还是 complete。
  • onBeforeToolCall 不返回值,也不能 block——它的签名里就没有 block 字段。
  • 用 fake ModelClient 驱动完整回合,断言 hook 调用序列与日志一致。

小a:"第二条'订阅者抛错不改变停止原因',怎么断言?"

"用 fake client 制造一个已知结局的回合,再让订阅者故意抛错,对比停止原因有没有变。"老z说,"mini-agent 的测试就是这套路子——看 agent.test.ts 里 writes through a tool-call loop:用 FakeClient 喂两轮事件(一轮 tool_call,一轮 text),断言 stopReason === 'complete'。你把订阅者挂上去,让它在 onAfterToolCall 里 throw new Error('log failed'),再跑同一个回合——stopReason 必须还是 complete,messages 里的内容也必须跟没挂订阅者时一样。这就是'订阅者不影响主路径'的可证伪断言:相同输入,相同输出,不管订阅者干了什么。"

"那顺序怎么测?"小a追问。

"用一个数组收集 hook 名字。"老z说,"每个 hook 往数组里 push 自己的名字,回合结束后断言数组等于 ['onTurnStart', 'onBeforeToolCall', 'onAfterToolCall', 'onTurnEnd']。注意 onBeforeToolCall 要在 BeforeToolCall 之后——所以如果你同时挂了审批 hook 和订阅 hook,数组里审批的决策得先发生。顺序是契约的一部分,不是实现细节。 mini-agent 没有这套 hooks,所以这是推演阶段的测试草稿,不是 npm test 会跑的东西。"

老z又补了一张完整回合的测试草图:

ts
// 测试草稿:订阅者失败不改变停止原因
const order: string[] = [];
const hooks = {
  onTurnStart: async () => { order.push("onTurnStart"); },
  onBeforeToolCall: async () => { order.push("onBeforeToolCall"); },
  onAfterToolCall: async () => {
    order.push("onAfterToolCall");
    throw new Error("log failed");          // 故意抛错
  },
  onTurnEnd: async () => { order.push("onTurnEnd"); },
};
const result = await runAgentWithHooks({
  client: new FakeClient([
    [toolCall("work"), { type: "text", text: "done" }],
  ]),
  hooks,
});
assert.equal(result.stopReason, "complete");          // 不变
assert.deepEqual(order, ["onTurnStart", "onBeforeToolCall", "onAfterToolCall", "onTurnEnd"]);

"这个草图有两个断言,一个验证顺序,一个验证失败隔离。"老z说,"onAfterToolCall 故意抛错,但 stopReason 还是 complete——**如果 hook 失败真的拖垮了主路径,这一行就会红。**注意它没有断言 messages 的内容——不过你也应该加:把结果 messages 和没挂 hooks 的对照回合 deepEqual,证明订阅者不仅没改变停止原因,连模型看到的内容都没变。两个断言,一个看控制流,一个看数据流,都要。"

与 pi-mono 的对照 ​

“pi-mono 的低层 loop 有更完整的 hook 集合。”老z打开附录 扩展机制补遗:Hooks 的接口边界,“convertToLlm、transformContext、beforeToolCall、afterToolCall、shouldStopAfterTurn、prepareNextTurn——它们分别落在拦截器、转换器、订阅者三类里。mini-agent 的推演是这套集合的最小切片:只做订阅者,把拦截器留给 BeforeToolCall。不要照抄 pi-mono 的完整集合,从你被证明需要的两个时刻开始。”

按三个类别把 pi-mono 的六个 hook 摊开,对照关系一眼看清:

pi-mono hook类别做什么mini-agent 对应
convertToLlm转换器把内部消息转成 provider 格式toAnthropicMessages(内置函数)
transformContext转换器改写发给模型的消息trimContext(内置函数)
beforeToolCall拦截器允许/拒绝工具调用BeforeToolCall(已实现)
afterToolCall订阅者观察工具调用结果无
shouldStopAfterTurn拦截器决定回合是否停止无(calls.length === 0 内置)
prepareNextTurn订阅者准备下一轮输入无

"这张表里,mini-agent 有三格是'无'。"老z说,"但注意,'无'不代表缺失——convertToLlm 和 transformContext 的职责被内置函数占了,因为它们的契约足够明确;shouldStopAfterTurn 被简单的停止逻辑占了,因为不需要外部干预。只有 afterToolCall 和 prepareNextTurn 是真正空白的订阅者位——你想要的记账、打状态恰好落在空白位上。"

小a:"pi-mono 那六个 hook,mini-agent 一个都不该要吗?"

"现在不该。"老z说,"你列的三个需求——记账、打状态、清理——全是订阅者,对应 onTurnStart、onAfterToolCall、onTurnEnd 三个时刻,够用了。pi-mono 的 convertToLlm 和 transformContext 是转换器,它改写发给模型的消息——mini-agent 的 trimContext(见 context.ts)已经干了这件事,但它是内置函数不是 hook,因为它有明确的契约(保持 assistant+tool 结果成组)和明确的失败(预算塞不下最小单元就抛错)。如果哪天你要插自定义的上下文转换,再把它提成 hook。shouldStopAfterTurn 是拦截器,它决定回合停不停——mini-agent 现在的停止逻辑是 calls.length === 0 就 complete、达到 maxTurns 就 max_turns(见 agent.ts),简单到不需要 hook。每多一个 hook,你就多一份'它失败了怎么办'的债——只在被证明需要的时刻开口子。"

小结 ​

生命周期 hooks 让 Agent 的每个关键时刻变得可观察、可审计。设计的关键是先按订阅者/拦截器/转换器分类——分类决定了失败语义,失败语义决定了它该在主路径还是旁路。订阅者抛错吞掉并记录,拦截器抛错向上传播,这条分界线是整个 hook 体系的骨架。想清楚哪些时刻要"看见"、哪些时刻要"决定",再动手写接口,顺序不能反。

接口形状由分类决定。订阅者不返回值、只收最小上下文——信息越少越不会诱导它干预主路径;拦截器返回决策结果、收的是完整 ToolCall;转换器改写输入但不决定继续与否。上下文收窄不是吝啬,是挡住未来的越界。触发顺序要写进契约:onBeforeToolCall 必须在拦截器决策之后、工具执行之前,否则它记的"即将发生"会跟实际"没发生"对不上。

订阅者不允许拖垮主路径,这条纪律要同时落在签名、注释和测试上。签名挡住"旁路返回审批结果"的混用,注释写明分界线,测试用一个故意抛错的订阅者断言停止原因不变。同步落盘是主路径的特权,fire-and-forget 才是订阅者的常态;选异步队列方案,还要回答"进程退出时谁冲刷"——这是宿主生命周期的职责,不是 hook 注释能交代完的。

记住这个对照,回看 mini-agent 现有代码:BeforeToolCall 是唯一的拦截器缝,它抛错时当前 call 记成 'tool approval failed'、其余记成 'not executed after an earlier failure'、错误原样上抛(见 tool-failure-session.test.ts)——这就是拦截器失败语义的模板,订阅者绝不能学它。本章的四条 hook 全是推演,src 一行没改;要落地,先写测试草稿,让需求证明每个时刻都被需要。

阶段验收 ​

不修改 src。在 examples/mini-agent 写一个独立的推演笔记或测试草稿:

  1. 列出你的 Agent 至少需要观察的 3 个时刻,并为每个时刻写出它会接收的最小上下文。
  2. 判断每个时刻需要的是订阅者、拦截器还是转换器,说明理由。
  3. 为每个 hook 写一条失败语义测试的断言(订阅者失败不改停止原因;拦截器失败向上传播)。