这是《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.usage 和 ctx.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 了 beforeExecuteTools 和 afterIteration 两个方法。但子代理是个大话题,第 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.onStream、spec.onReasoning、spec.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 的------finalizeContent、beforeIteration、onStream------是设计冗余吗?
我倒觉得不是。它们是预留好的插座,每一个都对应一类「我知道迟早会来」的需求:
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 永远不会把主流程带进它自己都说不清的状态里。有时候,把扩展点的能力故意做窄,反而是种成熟。
这篇讲了什么?
AgentHook在AgentRunner一次迭代的 9 个生命周期节点 上开了插槽(迭代前置、流式/推理监听、工具执行前置、迭代后置、内容转换、流结束......),默认实现全是空操作、零成本------你只 override 关心的那一个,核心循环一行不动。- 接 Langfuse 这类「记账 + 上报」的需求,写个十行的
LangfuseHook就够了,全程不碰AgentRunner;CompositeHook让多个 Hook 组合在一起,并用try/catch + reraise做错误隔离------一个 Hook 抛异常,既不连累其他 Hook,也不拖垮主流程。 - 记住一条命名边界:Hook 是观察插槽 (站在旁边看、做副作用),回调是数据管道(在主流程里搬运、转换数据)。两者职责分离、不混用,主数据流的性能才不会被扩展逻辑污染。
下一篇预告 :Hook 系统最先派上用场的地方,往往就是可观测性 ------你把埋点插槽都备好了,第一件想干的事多半是「把每轮的 token、工具调用链、耗时记下来,方便排查」。下一篇就专门讲 catbuddy 怎么做 Agent 的可观测与可追踪:一套零依赖的本地链路日志,加上 catbuddy 真的内置的那套 Langfuse 全链路追踪------它就是用这套 Hook 机制实现的,我们会细看它的追踪结构(Trace→Generation→Span)、成本估算,以及上报前怎么做 PII 脱敏。