09|不改核心代码,只插 Hook:Agent 生命周期扩展

这是《Agent全栈开发实战》的第 9 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 的设计。

上一篇聊了「韧性」------重试、降级、故障转移,让 Agent 在真实世界的混乱里活下去。但你注意到没有:那些重试逻辑是硬编码在循环里 的。这篇我们换个角度问一个问题:如果我想在工具执行前后加一段监控、给每次 LLM 调用记一笔 token 账、或者塞一段项目特有的逻辑------我要不要去改 AgentRunner 的源码?答案是不用。这篇就讲清楚那套「不碰核心,只插插槽」的扩展机制是怎么设计的。


0. 那个让我后背发凉的念头

catbuddy 的核心循环 AgentRunner 是个上千行的状态机------上一篇你已经见识过它有多忙:调模型、流式回吐、跑工具、处理截断、做重试。它是整个 harness 里改起来最让人手抖的一段代码。

有一天面试官问我你是怎么去评测Agent的, 我一脸懵逼(不过还是让我offer了,感谢),二面结束后,调研了相关框架,Langfuse是二开的最佳的开源框架之一,

Langfuse 是一个开源的 LLM 可观测性平台------你可以把它理解成「专门给大模型调用用的 APM」,记录每次调用的输入输出、token 消耗、调用链路。

通过docker自托管后

面临了一个问题是:「能不能给每次对话接个 Langfuse,把每一轮的 token 用量、工具调用链都记下来,方便我们排查成本异常?」

我脑子里第一反应是------那我得在循环里、模型调用前后、工具执行前后,分别插上埋点代码。

然后我想象了一下三个月后这段循环的样子:重试逻辑、Langfuse 埋点、未来某个项目要的脱敏逻辑、再来个调试插件......全挤在同一个状态机里。每加一个需求改一次,每改一次都怕碰坏前面的。这就是经典的「核心代码沦为意大利面」剧本。

所以真正的问题不是「怎么接 Langfuse」,而是:怎么让所有这类「观察 + 副作用」的需求,都能插进 Agent 的运行流程,却一行都不碰 AgentRunner

catbuddy 的答案是一套 Hook 系统。核心就一句话:

AgentRunner 一次迭代的关键节点上预先「开洞」,外部代码把自己的逻辑塞进洞里,循环本身完全不知道、也不关心洞里插了什么。


1. 九个生命周期插槽,长在循环的哪儿

先看全局。AgentHook(在 agent/hook.ts)定义了 9 个生命周期方法 ,对应 Agent 一次迭代里 9 个「可以被观察 / 介入」的时刻。下面这张图把它们一个个钉在 AgentRunner 循环的真实位置上:

把这 9 个节点按「时机」归个类,你就记住了:

  • 迭代两端beforeIteration(这一轮开始,能看到完整 messages)和 afterIteration(这一轮收尾,工具事件、token 都算清了)。
  • 流式三连onStream(正文每吐一个 token)、emitReasoning(推理模型每吐一段思考)、emitReasoningEnd(思考结束)。
  • 工具一钩beforeExecuteTools(工具马上要跑了,能看到这轮要调哪些)。
  • 收尾两钩finalizeContent(最终文本回给用户前,最后一次改写机会)、onStreamEnd(整个流彻底结束)。

这些位置不是我拍脑袋画的,去 runner.ts 里能一行行对上号------hook.beforeIteration(hookCtx)hook.beforeExecuteTools(hookCtx)hook.afterIteration(hookCtx)......都老老实实地嵌在循环对应的位置。

最关键的一点:这 9 个方法的默认实现,全是空操作。

javascript 复制代码
export class AgentHook {
  constructor(readonly reraise = false) {}

  wantsStreaming(): boolean { return false }          // 默认:不关心流式
  async beforeIteration(_ctx: AgentHookContext) {}     // 默认:什么都不做
  async beforeExecuteTools(_ctx: AgentHookContext) {}  // 默认:什么都不做
  async afterIteration(_ctx: AgentHookContext) {}      // 默认:什么都不做
  // ...其余 5 个同理,全是空方法体
}

这意味着什么?零成本AgentRunner 永远无条件地调这 9 个方法,但如果你没插任何 Hook,跑的就是一组空函数,开销可以忽略。你想观察哪个节点,就只 override 你关心的那一个,其他的继续当空气。这是整个设计的地基------默认无害,按需开洞。

还有个小细节藏在 onStream 上:它前面有个 wantsStreaming() 的开关。流式回调是高频的(每个 token 都触发一次),所以 Runner 会先问一句「你这个 Hook 到底想不想收流式数据?」------不想就连 onStream 都不调了。高频路径上的这点克制,体现了「Hook 不该拖慢主流程」的设计自觉。


2. HookContext:每个插槽都能看到整轮的完整状态

光有插槽不够,插进去得能「看到东西」才有用。每个 Hook 方法都会收到一个 AgentHookContext------它不是一个干巴巴的状态标记,而是把这一整轮迭代的家底都端给了你:

javascript 复制代码
interface AgentHookContext {
  iteration: number            // 第几轮
  messages: LLMMessage[]       // 完整对话历史
  response?: { content; toolCalls; usage; ... }  // 本轮 LLM 响应(afterIteration 时可读)
  usage: TokenUsage            // 累计 token 消耗
  toolCalls: ToolCallRequest[] // 本轮要执行的工具调用
  toolEvents: ToolEvent[]      // 全部工具事件历史
  finalContent: string | null // 最终回复
  error: string | null         // 错误信息
}

有了这个上下文,Hook 能做的事就具体了。回到开头那个 Langfuse 需求------它要的「每次 LLM 调用的 token 用量」「这一轮调了哪些工具」,全都在 ctx.response.usagectx.toolCalls / ctx.toolEvents 里现成躺着。Hook 不需要知道 AgentRunner 内部怎么拼的 messages、怎么跑的工具,它只在自己关心的那个时刻,把上下文里的数据捞出来用。这就是「观察」二字的全部含义。


3. Langfuse 追踪:一个 Hook,零侵入

现在把这套机制落到那个真实需求上。给对话接 Langfuse,需要改 AgentRunner 吗?一行都不用。我们只写一个 LangfuseHook

javascript 复制代码
class LangfuseHook extends AgentHook {
  constructor(private readonly trace: LangfuseTrace) { super() }

  // 每轮 LLM 响应回来后:记一笔 token 用量
  override async afterIteration(ctx: AgentHookContext): Promise<void> {
    this.trace.span({
      name: `iteration-${ctx.iteration}`,
      usage: ctx.response?.usage,            // ← 上下文里现成的 token 账
      tools: ctx.toolCalls.map(t => t.name), // ← 这轮调了哪些工具
    })
  }
}

就这么十行。你把 new LangfuseHook(trace) 通过 spec.hook 传给 AgentRunner,循环跑到 afterIteration 时自然就调到它了。整个过程:

注意这张图里 AgentRunner 那个框上写着「不动」。这就是 Hook 系统兑现的承诺------新增一个完整的可观测性能力,核心循环零改动 。哪天 Langfuse 不用了,把那行 new LangfuseHook() 删掉就行,循环依然干净。

这个 LangfuseHook 不是空想------catbuddy 真的内置了一个 LangfuseAgentHook,就是用这套 Hook 机制实现的(这里是简化版,真实版会把一次 turn 拆成 Trace → Generation → Span 三层追踪树)。可观测性值得单独说,紧接着的第 10 篇会把它的完整追踪结构、成本估算、PII 脱敏和自托管一次讲透。

顺一句边界:catbuddy 当前代码里真正落地的 Hook 子类其实是 SubagentHook(在子代理系统里同步任务状态)------它只 override 了 beforeExecuteToolsafterIteration 两个方法。但子代理是个大话题,第 11 篇专门讲,这里你只需要知道「它也是 Hook 的一个普通实现」就够了。


4. CompositeHook:多个 Hook 组合,还得互不拖累

真实场景里你往往不止要一个 Hook:一个 LangfuseHook 记账,一个日志 Hook 打调试日志,未来可能再来个敏感信息脱敏 Hook。问题来了------AgentRunner 只认一个 hook 字段,怎么塞进去仨?

答案是 CompositeHook:它本身也是个 AgentHook,但内部持有一组子 Hook,把每个生命周期事件「广播」给所有子 Hook。对 AgentRunner 来说,它面对的永远是「一个 Hook」,根本不知道这背后其实站着一排。

这里有个不能省的设计 ------错误隔离。如果三个 Hook 串在一起,其中日志 Hook 因为某个边界情况抛了异常,会不会把 Langfuse 上报也带崩、甚至连累整个 Agent 主流程挂掉?绝不能。看 CompositeHook 怎么处理:

javascript 复制代码
private async forEachHookSafe(method, ...args): Promise<void> {
  for (const hook of this._hooks) {
    try {
      await hook[method](...args)        // 挨个调用子 Hook
    } catch (err) {
      if (hook.reraise) throw err        // 关键 Hook:异常照样往上抛
      console.error(`[AgentHook] ${method} error in`, hook.constructor.name, err)
      // 普通 Hook:吞掉异常,继续下一个 ------ 不连累别人
    }
  }
}

一个子 Hook 炸了,catch 兜住、打条错误日志、然后接着跑下一个。一颗老鼠屎坏不了一锅汤。

用图说更直观:

那个 reraise 开关是留给「关键 Hook」的逃生口:有些 Hook 一旦失败就意味着系统状态已经不可靠(比如它负责的是某种强一致的状态同步),这种你可以把它构造成 reraise = true,让异常照常向上冒泡、中断流程------这时候继续跑反而更危险。默认隔离、按需升级为致命,是个挺克制的取舍。


5. 一条容易混的命名边界:Hook 是观察插槽,回调是数据管道

讲到这你可能会问:catbuddy 里不是已经有 spec.onStreamspec.onReasoningspec.progressCallback 这一票回调了吗?把流式数据往 UI 推,靠的就是它们。那为啥不干脆用 Hook 统一了,还要分两套机制?

这是 catbuddy 里一个值得记住的命名边界,两者职责泾渭分明、刻意不混用:

Hook 显式回调
定位 观察插槽 数据管道
干什么 观察 Agent 内部状态、做副作用 在主流程里转换 / 搬运数据
谁用 框架层 / 子系统 / 第三方扩展 AgentRunner直接使用者 (AgentLoop)
典型 记 Langfuse、打日志、同步状态 把 token 流推给 MessageBus → UI

一句话拎清:

  • Hook 是观察插槽 ------它站在循环边上「看」,看完了做点副作用(上报、记日志),但它不在主数据流里。你拔掉所有 Hook,Agent 照样把对话干完,只是没人在旁边记笔记。
  • 回调是数据管道 ------它是 AgentLoop 把 Agent 产出的数据导流出去 的正经通道,流式 token 经由 onStream 一路推到前端。它是主数据流的一部分,拔了 UI 就黑屏。

为什么这条线必须画清楚?想象一下如果用 Hook 去承载流式输出会发生什么:每加一个「日志 Hook」,都要在每个 token 的高频路径上多走一遍 Hook 调度------UI 渲染延迟就被你这点观察逻辑给拖累了。让 Hook 只做轻量观察,让回调专心当数据管道,两边各司其职 ,主数据流的性能才不会被扩展逻辑反向污染。这也呼应了第 1 节那个 wantsStreaming() 开关的存在意义------能不让 Hook 碰高频路径,就尽量不碰。


6. 「空插座」也是一种架构表达

AgentHook 有 9 个方法,可现实里 SubagentHook 只用了 2 个。剩下那些没人 override 的------finalizeContentbeforeIterationonStream------是设计冗余吗?

我倒觉得不是。它们是预留好的插座,每一个都对应一类「我知道迟早会来」的需求:

  • finalizeContent:回复用户之前 ,对最终文本做一次同步改写。自动翻译、敏感信息脱敏、给回答注入项目特有的免责声明------往这儿插,比去改 AgentRunner 安全一百倍。(它还是 CompositeHook 里唯一走「管道模式」的方法:Hook A 改完传给 Hook B,像 Express 中间件那样链式接力,而不是各看各的。)
  • beforeIteration:每轮 LLM 调用之前做条件检查。比如「累计 token 超预算了,强制终止」------不必去循环深处的治理代码里动刀。
  • onStream:实时分析模型的输出流。比如检测「Agent 是不是在车轱辘话来回说」,触发提前中断。

这些插座今天是空的,但插座存在本身就是一种架构意图的表达------它在代码里写明了「这里以后会有东西插进来,位置我先给你占好」。下一个来接需求的人,看到这排插座,第一反应是「哦原来该插这儿」,而不是「我得改哪段核心代码」。这个心智差别,长期看就是「能持续加功能」和「一加功能就提心吊胆」的分水岭。


7. 如果重来一次

Hook 系统有个我想吐槽的局限:这 9 个方法虽然大多是 async,但它们是顺序执行、只观察不干预的------Hook 返回了,Runner 才往下走,Hook 本身改变不了主流程的走向。

如果重来,我会考虑给 beforeExecuteTools 一个「否决权」:让它能返回 Promise<boolean>,返回 false 就跳过这次工具执行。这样 Hook 就从「旁观者」升级成了「守门员」------比如一个安全 Hook 可以拦下危险的 rm -rf 调用。

但转念一想,这会立刻引出一堆新问题:Hook 否决了工具执行,Agent 接下来该怎么办?给模型回个什么消息让它别懵?多个 Hook 各执一词怎么仲裁?......所以当前这个「只观察、不干预」的边界,虽然能力上有取舍,却换来了一份难得的清晰------Hook 永远不会把主流程带进它自己都说不清的状态里。有时候,把扩展点的能力故意做窄,反而是种成熟。


这篇讲了什么?

  1. AgentHookAgentRunner 一次迭代的 9 个生命周期节点 上开了插槽(迭代前置、流式/推理监听、工具执行前置、迭代后置、内容转换、流结束......),默认实现全是空操作、零成本------你只 override 关心的那一个,核心循环一行不动。
  2. 接 Langfuse 这类「记账 + 上报」的需求,写个十行的 LangfuseHook 就够了,全程不碰 AgentRunnerCompositeHook 让多个 Hook 组合在一起,并用 try/catch + reraise错误隔离------一个 Hook 抛异常,既不连累其他 Hook,也不拖垮主流程。
  3. 记住一条命名边界:Hook 是观察插槽 (站在旁边看、做副作用),回调是数据管道(在主流程里搬运、转换数据)。两者职责分离、不混用,主数据流的性能才不会被扩展逻辑污染。

下一篇预告 :Hook 系统最先派上用场的地方,往往就是可观测性 ------你把埋点插槽都备好了,第一件想干的事多半是「把每轮的 token、工具调用链、耗时记下来,方便排查」。下一篇就专门讲 catbuddy 怎么做 Agent 的可观测与可追踪:一套零依赖的本地链路日志,加上 catbuddy 真的内置的那套 Langfuse 全链路追踪------它就是用这套 Hook 机制实现的,我们会细看它的追踪结构(Trace→Generation→Span)、成本估算,以及上报前怎么做 PII 脱敏。

相关推荐
浪遏1 小时前
06|眼睛:LLM 的「视野」怎么拼出来,又怎么不爆
ai编程
浪遏1 小时前
04|手脚①:工具的注册、调度与文件安全边界
ai编程
浪遏1 小时前
07|记忆:从 JSONL 持久化到 Dream 后台学习
ai编程
浪遏7 小时前
08|韧性:一套接口接三家 API —— LLMProvider
ai编程
chaors7 小时前
DeepResearchSystem 0x06:LLM as Judge
llm·agent·ai编程
浪遏8 小时前
02|50 行跑通一个 Agent Loop:harness 的最小内核
ai编程
小虎AI生活9 小时前
workbuddy 获客自动化,每天让浏览器 Agent 帮你巡场
ai编程
wechatbot88814 小时前
企业微信API开发:登录-联系人查询-消息发送完整开发流程分享
汇编·微信·自动化·企业微信·ai编程·rpa
AI大模型-小华14 小时前
Codex 反复重试仍完不成任务?判断 ChatGPT Plus 是否需要调整到 Pro
人工智能·chatgpt·ai编程·codex·开发效率·chatgpt plus·chatgpt pro