Hook系统:8扩展点的插件机制

agent的核心循环是固定的——ReAct循环反复执行”推理→行动→观察”。但在循环的每个关键节点,经常需要插入一些”侧动作”:记日志、上报监控、审计工具调用、自动注入steering信息、拦截危险的命令……

如果这些侧动作都硬编码进agent循环,会发生什么?循环本体会被各种if分支和可选回调污染,读代码的人分不清哪些是核心逻辑、哪些是附加功能。更糟糕的是,每增加一个侧动作就得改agent循环——改的人越多,循环越脆,越不敢动。

Hook系统就是为了解决这个矛盾而生的:循环保持纯粹,侧动作通过扩展点插入。这篇文章从hook设计的基本问题讲起,对比三种扩展方案各自的取舍,最后深入看aptbot如何在8个hook点上做文章。

一、概念:什么是hook,为什么需要hook系统

1.1 hook的定义

hook(钩子) 是软件工程中一种常见的扩展模式。它在主流程的关键位置预留”插入点”,允许外部代码在不修改主流程的前提下,在特定时机执行自定义逻辑。

类比现实:一家餐厅的后厨有一条流水线——洗菜 → 切菜 → 烹饪 → 装盘。如果你想在”装盘”之前加一道”摆盘”步骤,不需要拆掉整条流水线重新设计,只需要在”烹饪→装盘”之间插入一个”摆盘”工作台。这个”插入点”就是hook。

在agent系统中,hook扮演类似的角色:agent循环是一条流水线,hook是流水线上预留的插槽,用户可以把自己的代码插进去,在特定时机执行。

1.2 hook与skill、tool的区别

在aptbot的三层扩展体系中,三者的定位截然不同:

  • Tool(工具):agent用来”做事”的基本操作。读文件、执行命令、搜索网络——这些是agent行动能力的原子单位。
  • Skill(技能):告诉agent”怎么用工具做事”的知识。比如”如何调试TypeScript错误”是一篇skill,它指导agent按步骤使用read、edit、run等工具。
  • Hook(钩子):开发者在agent循环的特定时机插入自定义逻辑。它不是agent调用的,而是agent框架调用的。hook不参与agent的决策过程,而是在决策过程的”间隙”执行。

用一句话总结:Tool是agent的”手”,Skill是agent的”脑中的知识”,Hook是开发者介入agent循环的”接口”。

1.3为什么不能所有侧动作都内置

有的项目选择”把所有侧动作内置”——agent循环自带日志、自带监控、自带审计、自带限流……一切功能都在循环本体里。

这条路在小规模可行,但随着功能增多会面临三个问题:

  1. 循环膨胀:每个”还算有用”的侧动作都在循环里加几行,300行的循环膨胀到1000行,核心的ReAct逻辑淹没在附属代码中。新成员读代码要花大量时间区分”哪些是核心、哪些是可选的”。
  2. 关闭困难:用户不想用某个侧动作?要么不能关(硬编码进去了),要么加一个开关(if (config.enableAudit) { ... }),循环里的条件分支越来越多。每个条件分支都增加了一条测试路径——代码覆盖率看似100%,实际上有效的组合测试几乎为零。
  3. 定制受限:用户的日志格式和项目内置的不一样?用户想在上报监控时附加自定义标签?如果侧动作是内置的,用户只能”接受或放弃”,不能定制。

Hook系统把”可选的侧动作”从循环中剥离出去,让循环只保留”必须的核心逻辑”,侧动作通过hook插拔。这样就解决了循环膨胀、关闭困难、定制受限三个问题。

二、通用设计方案:扩展agent循环的常见模式

在agent循环中插入侧动作,有几种常见的设计模式。理解这些模式是分析具体方案的基础。

2.1扩展点的选择标准

一个好的扩展系统需要回答四个问题:

  1. 在哪里插:哪些时机提供扩展点?太少(只有start/end)不够用,太多(每行代码都提供)则过度设计。
  2. 怎么执行:同步还是异步?串行还是并行?能否阻断主流程?
  3. 怎么通信:hook之间能否交换数据?hook能否修改主流程的状态?
  4. 怎么管理:多个hook注册到同一点,执行顺序怎么定?hook如何发现和加载?

这四个问题的不同答案,构成了不同的设计方案。

2.2扩展点的粒度选择

扩展点的粒度是一个权衡。粒度越粗(只在agent启动和结束时提供扩展点),实现越简单,但能做的事有限——你无法在工具执行前拦截修改参数。粒度越细(每个函数调用前后都提供扩展点),灵活性越高,但实现复杂度剧增,而且大部分扩展点可能从未被使用。

实践中最常见的做法是按agent循环的天然层次划分扩展点。agent循环自然有四层:整个agent生命周期、每个用户回合(turn)、每次LLM调用、每次工具调用。每层再按before/after分为两个时机,总共8个点。这不是巧合——它是agent循环的本质结构决定的。

2.3执行模型的设计空间

执行模型有三个关键维度:

同步vs异步:同步执行意味着hook完成前主流程等待,优点是行为可预测(hook的结果立即可见),缺点是hook不能做耗时操作。异步执行允许主流程继续,hook在后台完成,优点是性能好,缺点是hook结果可能”迟到”——主流程已经进入下一阶段,hook才完成,此时修改状态的时机已过。

串行vs并行:串行执行保证hook顺序,适合需要链式传递数据的场景。并行执行性能更好,但无法保证hook之间的数据依赖。

阻断vs非阻断:阻断式hook可以阻止主流程继续(比如在tool_before中”这个工具不能执行,返回错误”)。非阻断式hook只能”观察”不能”干涉”。

这些维度组合成不同的执行模型,每种模型对应不同的使用场景。

2.4插件的发现与加载

扩展点定义好了,hook代码如何被发现和加载?常见方式有:

  • 配置注册:在配置文件中显式声明哪些hook启用。最可控,但每次加hook都要改配置。
  • 目录扫描:约定一个目录,所有hook文件放在里面,系统自动扫描注册。最无感,但用户可能不清楚系统加载了哪些hook。
  • 装饰器/注解:在代码中使用装饰器标记hook函数,系统通过反射发现。对类型系统有要求。

每种方式都有适用场景,没有绝对优劣。

三、市面其他hook / 扩展方案对比

理解了扩展系统的设计空间,接下来看三种有代表性的方案。它们代表了”不加扩展””异步扩展””同步扩展”三条不同的技术路线。

3.1方案A:无hook系统,侧动作硬编码

这条路线的选择是”干脆不做扩展系统”——所有侧动作直接硬编码在agent循环中。需要日志?在agent loop里加 log() 调用。需要监控?在每次LLM调用前后加 metric() 调用。需要审计?在每个工具调用前后加 audit() 调用。

设计特点:

  • 零抽象:没有hook接口、没有扩展点、没有插件目录。侧动作就是普通函数调用。
  • 全量内置:所有”可能有人需要的”侧动作都写在循环里,由配置开关控制启用。
  • 改动即改循环:任何侧动作的增删改都直接修改agent loop的核心代码。

优势:

  • 实现最简单——不需要设计扩展API,不需要实现插件加载器
  • 行为最透明——所有侧动作都在循环里,读循环代码就能看到全部
  • 调试最容易——不需要跟踪hook调用链,侧动作和主流程在一起

劣势:

  • 扩展性差:每个新侧动作都要改核心循环。循环维护者成为瓶颈——一个agent项目有10个用户,每个用户想要一个不同的侧动作,循环会膨胀到无法维护。
  • 定制成本高:用户想要不同的日志格式?必须fork agent循环。这意味着无法跟随上游更新——上游修复了一个bug,但用户fork的版本已经改了循环结构,合并冲突不可避免。
  • 关闭不干净:通过配置开关可以”禁用”某个侧动作,但条件分支仍然存在。在极端情况下,每个条件分支还需要额外的测试覆盖。

适用场景: 极简agent(核心循环100-150行,几乎没有侧动作需求)、教学demo(展示agent循环本身,不需要扩展能力)。

3.2方案B:事件emit异步监听

这条路线的做法是:agent循环在关键时机”发射”事件(event emit),外部监听器异步接收并处理。事件监听器和agent循环在不同的事件循环中运行,互不阻塞。

设计特点:

  • 事件驱动:agent循环在特定时机触发事件(如 "llm:before""tool:after"),不等待监听器处理完成就继续执行。
  • 异步非阻塞:监听器在独立的事件循环中运行。即使某个监听器耗时很长(如写入远程数据库),agent循环不受影响。
  • 一对多:同一个事件可以被多个监听器订阅。日志、监控、审计各自独立订阅自己感兴趣的事件。
  • 无返回值:事件监听器是”发后即忘”的——它们不会向agent循环返回任何值,也无法修改主流程的状态。

优势:

  • 性能好——agent循环不需要等待hook完成,hook在后台并行
  • 隔离性强——hook出错不会影响主流程(异步错误通常被吞掉或写入错误日志)
  • 添加方便——在任意代码中 on("llm:after", handler) 即可,不需要修改循环

劣势:

  • 无法保证顺序一致性:这是最根本的问题。假设两个hook:hook A(记录日志)和hook B(修改LLM响应内容)。如果hook A和hook B都监听 llm_after 事件,由于异步执行,无法保证hook A在hook B之前看到的是”修改前”还是”修改后”的内容。更糟的是,agent循环可能在hook处理完之前就进入了下一轮推理——agent基于”旧”状态决策,而hook在处理”新”数据,二者脱节。
  • 无法阻断主流程:事件监听器无法说”这个工具调用不合法,别执行了”。tool_before 监听器即使发现参数危险,也只能记录警告,无法阻止工具执行。
  • 状态同步困难:如果hook需要修改agent循环的状态(如注入一段steering到system prompt),异步模型下无法安全做到——修改发生在agent循环进入下一阶段之后。

适用场景: 对”副效应”无顺序要求的场景(纯日志收集、纯指标上报)、对主流程性能敏感的端到端系统。

3.3方案C:同步hook链式注入

这条路线的选择是”在主流程的关键节点同步执行hook链”——hook是同步函数,返回一个可选的修改后的上下文,后一个hook在前一个的基础上继续处理。

设计特点:

  • 同步执行:hook函数必须同步返回(或返回Promise,但主流程await等待完成)。主流程在全部hook执行完毕后才继续。
  • 链式传递:每个hook接收当前上下文,可以选择修改后传递给下一个hook。最后一个hook返回的结果影响主流程。
  • 优先级排序:多个hook可以注册到同一个扩展点,通过优先级字段控制执行顺序。
  • 可阻断:hook可以通过返回”停止”信号来阻断主流程(如”工具调用参数非法,拒绝执行”)。

优势:

  • 顺序一致性:hook的执行顺序完全可控,后一个hook能看到前一个hook的修改结果。agent循环在所有hook完成后才继续,不存在”状态脱节”。
  • 可修改主流程:hook可以修改上下文中的任意字段——比如在 llm_before 中注入额外的system prompt内容,agent循环会使用修改后的内容调用LLM。
  • 可阻断主流程tool_before hook可以拒绝非法工具调用,返回自定义错误信息,agent循环会把这个错误交给LLM而不是真的执行工具。
  • 行为可预测:给定一组hook和输入,执行结果完全确定。调试时只需要跟踪hook链的执行路径。

劣势:

  • 所有hook必须轻量快速——任何一个hook耗时过长都会拖慢整个agent循环
  • 同步模型对”后台任务”不友好(如远程日志上报),需要hook自己fire-and-forget
  • hook链中的错误处理需要小心:一个hook抛错需要决定”跳过它””终止整个链”还是”吞掉继续”

适用场景: 需要精确控制hook顺序和数据的场景(审计、steering注入、参数校验),项目规模可控的场景。

3.4三种方案对比

维度 方案A(无hook系统) 方案B(事件异步监听) 方案C(同步hook链式注入)
核心哲学 侧动作内置,零抽象 事件发射后异步处理 关键节点同步链式注入
执行模型 同步(与主流程混合) 异步(独立事件循环) 同步(主流程等待)
能否修改主流程 能(直接改循环代码) 不能(监听器无返回值) 能(mutate上下文)
能否阻断主流程 能(条件分支控制) 不能 能(返回阻断信号)
hook顺序控制 不适用(代码顺序) 无法保证 优先级排序
性能影响 低(直接调用) 极低(异步非阻塞) 中等(同步等待)
实现复杂度 中(需要事件总线) 中高(需要hook管理器)
可定制性 差(必须改核心代码) 好(外部监听) 好(插拔式hook)
调试难度 高(异步跟踪困难) 中(可串行跟踪)

三种方案没有绝对优劣。方案A适合极简教学项目,方案B适合对性能有极致要求的在线服务,方案C适合需要精确控制扩展行为的项目。选择哪条路线取决于项目的核心诉求。

四、aptbot的设计特点

aptbot选择了方案C——同步hook链式注入,并在其基础上做了三项定制:8个hook点的拓扑设计、两层插件目录的加载策略、以及无沙箱的设计哲学。

4.1 8 hook点拓扑:agent循环四层 × before/after的自然收敛

aptbot在agent循环中设置了8个hook点,按循环的天然层次分为四组:

Agent级(整个agent生命周期)

  • agent_before:agent启动时触发,适合做全局初始化(如加载外部配置、建立数据库连接)
  • agent_after:agent退出时触发,适合做全局清理(如关闭连接池、写入最终状态)

Turn级(每个用户回合)

  • turn_before:每轮用户交互开始前触发,可以修改这一轮的初始context(如根据当前时间注入”现在是下班时间,建议简短回答”的提示)
  • turn_after:每轮交互结束后触发,可以观察这一轮的最终状态(如记录本轮耗时、整理本轮关键信息到长期记忆)

LLM级(每次LLM调用)

  • llm_before:调用LLM前触发,可以修改messages、tools、systemPrompt等输入(如自动注入项目约定的代码规范)
  • llm_after:LLM返回结果后触发,可以观察LLM输出、进行输出后处理(如检测是否触发了敏感内容)

Tool级(每次工具调用)

  • tool_before:工具执行前触发,可以拦截非法调用、修改调用参数(如给bash命令自动加上超时限制)
  • tool_after:工具执行后触发,可以修改返回值、记录审计日志(如屏蔽包含敏感信息的工具输出)

Hook 8点拓扑图

这8个点的设计并非随意的选择。如果你把agent循环画出来,它天然就有四层边界:agent启动/退出、每轮开始/结束、每次LLM调用前后、每次工具调用前后。每层边界都有before/after两个时机,4 × 2 = 8,刚好8个点。

这不是巧合。任何认真设计的agent框架,只要它试图在循环中提供合理的扩展点,最终都会收敛到相似的8点拓扑。因为agent循环的结构是有限的——它必须处理生命周期、对话轮次、推理调用和执行动作,这四层的边界就是扩展点的最佳位置。

相比于方案B的”事件随意发射”(可能在任意代码位置发射事件,监听器需要自己过滤),aptbot的8点拓扑提供了结构清晰的扩展位置——每个hook点有明确的语义(这是什么时机、我在这里能做什么事),用户看到 llm_before 就知道它是在LLM调用前触发,能修改system prompt。

4.2同步执行 + ctx mutate链式传递 + priority升序

多个hook可能注册到同一个扩展点(比如两个hook都注册到 llm_before,一个负责注入steering,一个负责注入项目规范)。aptbot的执行模型保证它们有序、可预测地协作:

同步执行:所有hook都是同步函数(或返回Promise但被await等待)。agent循环在调用所有 llm_before hook之前,不会进入LLM调用阶段。这保证了”hook说改了什么,就真的改了”——不存在hook还没执行完、LLM已经调用的竞态条件。

同步执行的代价是hook不能做重活。如果一个hook需要发HTTP请求上报数据,它应该自己fire-and-forget(执行HTTP请求但不await,让请求在后台完成),不阻塞hook链。aptbot的sync模型要求hook作者对”什么应该同步完成、什么可以异步执行”有清晰的判断。

ctx mutate链式传递:每个hook接收一个context对象(包含当前执行阶段的所有相关数据),可以直接mutate它。后一个hook看到的是前一个hook修改后的context。

举个具体例子:假设有两个 llm_before hook——hook A在system prompt中注入”当前时间:2026-07-02”,hook B在system prompt中注入”项目约定:使用pnpm作为包管理器”。执行流程如下:

  1. agent循环构造初始context,system prompt为空
  2. hook A执行,在context.systemPrompt中追加时间信息
  3. hook B执行,它看到的context.systemPrompt已经包含时间信息,在此基础上追加项目约定
  4. agent循环使用最终的systemPrompt调用LLM

这种链式传递让多个hook能协作——几个hook各自负责不同的注入内容,最终组合成完整的system prompt。如果其中一个hook出错了(抛异常),aptbot会吞掉错误、记录到stderr,当前context保持上一个成功hook后的状态,链继续执行。

priority升序:每个hook注册时带一个 priority 数字字段。数字小的先执行(升序)。这让用户能精确控制hook的执行顺序。

1
2
3
4
// priority 10 的先执行
hooks.register('llm_before', injectTimeHook, { priority: 10 });
// priority 20 的后执行
hooks.register('llm_before', injectProjectRulesHook, { priority: 20 });

如果所有hook的priority相同,执行顺序由注册顺序决定(先注册先执行)。但依赖”隐含顺序”是危险的——更好的做法是显式设priority,让顺序一目了然。

这套执行模型的设计取向是”简单优先“——同步(不用处理并发)、mutate(不用学习不可变数据模式)、固定优先级(不用设计复杂的排序策略)。每个决策都选了最朴素的方案,代价是hook不能做重活,但收益是hook的行为可预测、易调试、新手可以在一小时内理解。

4.3两层插件目录:workspace覆盖builtin

Hook的存放和加载方式与Skills系统完全一致,也分两层:

builtin hooks(内置层):随aptbot代码发布的默认hook。项目维护者编写,提供通用的功能——比如基础日志hook、默认的tool_after审计hook。用户clone aptbot后,这些hook已经可用。

workspace hooks(工作区层):用户项目本地目录 .aptbot/hooks/ 下的自定义hook。用户为自己的项目编写的定制hook。加载时,workspace层同名hook覆盖builtin层。

两层加载解决的核心问题和Skills系统一样:开箱即用与项目定制的矛盾。没有builtin hook,用户clone后是一个”空”系统,需要自己写hook才能有基本功能;没有workspace hook,用户无法定制内置hook的行为,只能接受项目默认。

与Skills系统的两层加载共用同一套机制,意味着用户只需要学习一次”两层覆盖”的模式,就能理解hooks和skills的加载逻辑。这是aptbot跨子系统的一致设计语言——Config、Memory、Skills、Hooks都遵循”builtin提供默认值 / workspace提供覆盖”的约定。

4.4无沙箱设计哲学

aptbot的hook 没有沙箱——hook是普通TypeScript代码,直接import aptbot内部模块,能访问文件系统、网络、进程等任何资源。如果一个hook写错了导致进程崩溃,那是用户的责任。

这是有意识的设计选择,它基于”信任边界内“的核心假设:

  • hook由用户自己编写:用户在自己的workspace目录下写hook,不是从互联网下载并安装不明来源的插件。用户已经能直接修改aptbot的代码,自然也能写hook——hook的信任等级和aptbot本体一致。
  • hook不需要权限隔离:不像浏览器插件需要沙箱(因为插件来源不可信),aptbot hook与aptbot本体在同一信任层。用户信任自己写的代码,不需要操作系统级别的隔离。
  • 沙箱会限制能力:如果hook在沙箱里运行,它能做的事大幅受限。很多有价值的hook就做不了了——比如一个 llm_before hook想读取当前项目的 .env 文件来注入环境信息,如果hook在沙箱中且没有”读文件”权限,这个能力就丧失了。

无沙箱的代价是安全责任在用户。用户要为自己写的hook负责——写错了可能让agent崩溃、泄露数据、或执行危险操作。但aptbot提供了一道安全网:hook抛错会被吞掉,写入stderr,不影响主流程。这让用户敢于写实验性的hook——错了就改,agent还能继续工作。

这道安全网是”无沙箱哲学”的必需品。没有它,一个hook的错误就能拖垮整个agent。有了它,用户可以放心地编写和调试hook,即使hook写得不够完善,也不会影响agent的基本可用性。

这和方案B的”事件异步监听”形成了有趣的对比:方案B通过异步隔离来保护主流程(hook在独立事件循环中运行,出错了不影响主流程),aptbot通过”吞错误 + stderr”来保护主流程。两种方式都能达到”主流程不受hook错误影响”的目标,但前者限制了hook的能力(不能同步修改主流程状态),后者要求用户对hook的质量负责。

4.5典型使用场景

8个hook点的典型应用场景:

日志(turn_before / turn_after:在每轮交互的开始和结束时记录输入输出。相比agent内置的日志系统,hook日志更灵活——用户可以自定义格式、写入独立的日志文件、或同时输出到不同目标。

监控(llm_before / llm_after:在每次LLM调用前后记录token用量、请求延迟、是否失败。这些数据可以上报到Prometheus、Datadog或简单的统计文件。agent循环本身不需要知道监控系统的存在——监控是hook的责任。

审计(tool_before / tool_after:记录每次工具调用的参数和返回结果。对于bash这类”高危”工具,审计hook可以在 tool_before 中记录”谁、什么时候、调用了什么命令”,在 tool_after 中记录”命令的退出码和输出摘要”。这在多人共享agent的场景中尤其重要。

自动注入steering(llm_before:检测当前对话的上下文,自动注入相关的指导信息。比如检测到对话中出现了”测试”相关的关键词,自动注入”项目测试约定”段落。这是”动态system prompt”的基础——不需要把所有可能的信息都塞进静态system prompt,按需注入,上下文更精简、更相关。

这四类场景的共同特征:它们都是”侧动作”,与agent核心循环的解耦逻辑。把它们做成hook而不是硬编码进agent循环,不仅让循环保持纯粹,也让这些侧动作能独立演进、独立配置、独立启停。

五、发展方向

5.1有限异步支持

当前aptbot的hook执行模型是纯同步的。这在大多数场景下足够,但有些hook天然需要异步——比如上报监控指标到远程服务,如果同步等待HTTP请求完成,会拖慢agent循环。

一个可能的方向是引入”标注式异步”:hook可以声明自己是”fire-and-forget”类型,aptbot在执行这类hook时不await它的完成,让它在后台运行。这样既保持了hook链的同步语义(顺序执行、链式传递),又为”不需要等待结果”的hook提供了异步出口。

5.2条件式hook执行

当前所有注册到某个扩展点的hook都会执行。随着hook数量增多,可能出现”99% 的场景不需要这个hook,但它每次都执行”的情况——虽然很少,但存在浪费。

条件式hook执行允许hook声明一个”触发条件”(predicate),aptbot在调用hook前先检查条件是否满足。条件不满足就跳过。这可以减少不必要的hook调用,同时保持”hook在需要时一定会触发”的可靠性。

5.3可选的沙箱模式

对于”从社区安装hook”的场景(市场或插件生态),信任假设会发生变化——用户不再自己写hook,而是从社区下载他人编写的hook。这时无沙箱设计的安全风险就暴露了。

长期来看,aptbot可能引入可选的沙箱模式:默认不启用(兼容现有hook),但当用户从社区安装hook时,可以选择在沙箱中运行。沙箱模式限制hook的文件系统访问、网络访问、进程创建等能力。这是”自由优先、安全可选”的路线。

小结

Hook系统是agent循环的”扩展点机制”,让侧动作从核心循环中解耦出来,以插拔的方式插入。

  1. 概念层面:hook是在主流程关键节点插入自定义逻辑的机制,与tool(行动能力)和skill(使用知识)互补。内置所有侧动作会导致循环膨胀、关闭困难、定制受限三个问题。

  2. 方案对比:方案A(无hook系统)简单但扩展性差,所有侧动作侵入核心循环;方案B(事件异步监听)性能好但无法保证侧动作与核心循环的顺序一致性,且不能修改或阻断主流程;方案C(同步hook链式注入)最可控但要求所有hook轻量快速。

  3. aptbot的设计:8个hook点覆盖agent/turn/llm/tool四层的before/after,是agent循环结构的自然收敛;同步执行 + ctx mutate + priority升序的模型追求简单可预测;两层插件目录与skills系统一致,降低学习成本;无沙箱哲学在”信任边界内”假设下提供最大灵活性,用”吞错误 + stderr”替代隔离来保护主流程。

理解Hook系统的设计后,下一篇我们向上走一层,看Channel与多端接入:当agent循环跑起来之后,如何让多个客户端同时接入同一个agent会话。