Skip to content

Part III · 实现篇 / Ch 09

实现篇第9章 在关键时刻插一句话——Hooks ​

小A引导工具胶囊沿主路径穿过决策闸门和变形滚轮,老Z在旁路记录时掉落一颗珠子,但主路径仍继续运行。

本章导读: 实现篇扩展第 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 的原始上下文。订阅者拿到它,就难免动‘我来改一条消息’或‘我根据历史做个决策’的心思。一旦订阅者开始改 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?Tool.execute 不是有一份吗?万一它跑一半,用户按了 Ctrl-C。”

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

小A:“同一个工具调用,onBeforeToolCall 和 BeforeToolCall 谁先跑?”

“BeforeToolCall 先跑,它决定放不放行;放行了才轮到工具执行。”老Z说,“看 agent.ts 的顺序:先 options.beforeToolCall?.(call) 拿到 approval,!approval.approved 就 continue 跳过;放行了才 tool.execute。要是 onBeforeToolCall 抢在前面,它会先记一笔‘即将调用 bash’,然后 bash 被 denyBashByDefault 拒绝,日志和实际就对不上。顺序要写进注释,别靠后人猜。”

失败语义(未决):订阅者等待与投递策略 ​

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

“目标语义是订阅者失败不拖垮主路径,但当前草图直接 await 每个订阅者,与目标不一致。此取舍未决:可采用旁路非阻塞投递,或保留阻塞并加超时;定案前不要按当前草图实现。”老Z说,“它要么被 try/catch 吞掉并记录,要么放进独立的异步队列。把订阅者失败升级成主路径失败,等于让日志系统决定 Agent 能不能继续跑。”

ts
// 未决反例:下方草图会等待订阅者,尚不满足“不拖垮主路径”的目标,不可直接接入
for (const hook of hooks) {
  try {
    // 未决:保留原 await 以展示问题;超时或旁路投递均未实现,等待设计定案。
    await hook.onAfterToolCall?.(result);
  } catch (error) {
    log.warn("lifecycle hook failed", { hook: "onAfterToolCall", error });
    // 此处仅捕获抛错;无法处理永不落定的 Promise,主路径仍可能被阻塞
  }
}

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

小A接着抠细节:“订阅者吞错,吞到哪去?就 console.warn 一下?”

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

小A又想到一种情况:“订阅者要是不抛错,干脆 hang 住了呢?比如网络上报一直没返回,hook 不返回,主路径不就卡死了?”

“这是订阅者最容易踩的坑。”老Z说,“前面那两种方案仍未定:若采用阻塞加超时,就要限定等待时间,并说明超时后的失败处理;若采用旁路非阻塞投递,就可以把数据入队后返回,由后台上报。这时‘我已记下’只表示已接收投递,不保证上报到达,还要处理队列积压和退出冲刷。mini-agent 的 record 函数(见 agent.ts)是个对照:它 await appendSession,因为 session 是主路径的一部分。订阅者是否等待、等多久,需要另定契约,不能从这个对照直接选出方案。”

小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 会跑的东西。”

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 的六个 hook 摊开对照:

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

“这张表里,mini-agent 有三格是‘无’。”老Z说,“但‘无’不代表缺失。真正空白的订阅者位只有 afterToolCall 和 prepareNextTurn,你要的记账、打状态恰好落在空白位上;另外三格的职责,被内置函数和简单的停止逻辑占掉了。”

小A不甘心:“pi-mono 那六个 hook,mini-agent 一个都不该要吗?”

“现在不该。”老Z说,“你列的三个需求全是订阅者,对应 onTurnStart、onAfterToolCall、onTurnEnd 三个时刻,够用了。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 必须在拦截器决策之后、工具执行之前,否则它记的‘即将发生’会跟实际‘没发生’对不上。

订阅者失败不拖垮主路径,是待落地的目标。签名要挡住‘旁路返回审批结果’的混用,注释写明失败边界,测试用一个故意抛错的订阅者断言停止原因不变。投递方案仍未决:若保留阻塞并加超时,就要写清等待上限与超时后的处理;若采用旁路非阻塞投递,就要写清队列积压和进程退出时的冲刷责任。当前 await 草图只展示等待风险,没有实现其中任何一种方案;应先定契约,再补实现与测试。

记住这个对照,回看 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 写一条失败语义测试的断言(订阅者失败不改停止原因;拦截器失败向上传播)。