02|核心必须消失:插件框架如何让一切变得可卸载
先别把它当成普通的依赖注入容器
看见"注册服务"、"声明依赖"、"插件对象"这些说法,很多人会立刻在脑子里翻译成常见的依赖注入框架:容器负责管依赖关系,插件往容器里注册东西,用的时候容器帮你找出来,完事。
这个翻译能让你把一个插件跑起来,但会让你把整套设计读错。
依赖注入容器解决的核心问题是"谁构造谁、按什么顺序构造"。这套仓库真正贵的是另外两件事:每一笔贡献都必须能被干净地撤走,以及每一次拦截都必须能被合作方否决或委托。前一条让"卸载一个插件"变成一件可信的事,后一条让"多个插件同时想管一件事"变成一条有明确规则的链,而不是谁先跑谁说了算的竞速。
底层框架的官方定位很直白:没有特权核心可以打补丁。这句话不是口号,它成立的前提,是"核心"里的每一块贡献都真的挂在某个可以被整体卸载的生命周期上——卸掉这个生命周期,贡献就消失,一点痕迹都不留。
1. 插件不是一个文件,是一段带析构的生命周期
先给判断:一个东西算不算插件,不看它写在哪个文件里,看它被卸载之后,现场能不能完全复原。
系统认几种插件写法(普通函数、带固定接口的对象、类),但无论哪种写法,都要声明自己需要哪些别的服务才能工作。缺的服务不到位,这个插件就不会被激活。这意味着加载顺序是一张由"谁需要谁"推出来的依赖图,不是某个人手写在启动脚本里的固定队列。
服务本身也不是"塞进一个单例字典里的一个值"。一个服务对象被创建出来的那一刻,就会把自己注册到共享环境里,同时暗中记下一条规则:拥有它的那段生命周期一旦结束,这条注册会自动撤销。你在别的插件里拿到的"某个能力的入口"只是一个键,不是某个具体文件的引用——真正提供这个能力的插件随时可能被换掉,用它的人完全感觉不到。
如果没有这一层纪律会怎样?每个新能力都要在启动脚本里手动占一个位置,卸载变成"祈祷没有别的代码还攥着那个对象的引用"。界面热更新、产品配置整层替换、某个 Agent 实例被销毁,都会在进程里留下没人管的幽灵工具和幽灵监听器。这套系统把这件事写成了不可讨论的纪律:注册是一种效果,效果必须能被撤销。
2. "登记一个效果"才是插件的最小原子操作
判断:登记一个效果,不是"记一笔清理回调"这么简单的语法糖。它是这份贡献的所有权证明。
它的契约很短,但很硬:登记的操作会立刻执行;执行过程中产生的撤销动作会被收进一份清单;无论是主动调用撤销,还是所属的插件被整体卸载,谁先发生就先执行撤销;如果一次登记了好几个撤销动作,撤销的时候按登记顺序的反序执行;已经撤销过的效果,再撤销一次什么都不会发生;插件已经死了还想登记新效果,直接会被拒绝。
为什么反序撤销不是风格问题,而是必须:先登记的能力常常被后登记的能力引用(比如先挂一个工具,后挂一段依赖这个工具的提示词说明)。撤销的时候必须先撤后挂的那一层,否则会出现"被撤了引用的东西还留在原地"的悬空状态。
监听事件走的是同一条路——"我订阅了这个事件"和"我的插件还活着"是同一件事,插件一旦被卸载,它订阅的所有事件监听器会跟着自动消失,不需要额外写一段"退订"逻辑。
工具注册表把这套机制原样用在了产品层面:往工具表里加一个工具,本质上就是登记一个效果;插件被卸载,这个工具会自动从注册表里消失,连带它在提示词里占的那一段说明也会一起消失,不需要任何人手写"删除工具"的逻辑。
如果需要一次性挂上好几件互相关联的东西(比如"切换到某种模式"同时还要"往提示词里追加两段说明"),系统支持把这几件事写在一次登记里,撤销的时候会按反序一次性、干净地全部撤回——这就是前面说的"拆除顺序重要时,相关工作要放进同一次登记"。
如果没有这一层会怎样?界面热更新会退化成"重启整个进程";一个 Agent 实例被销毁会变成"希望没有别的代码还在调用它的工具";产品配置的热替换会导致新旧两套监听器同时生效、行为被叠加成两份。这不是代码洁癖,这是把循环本身也做成插件之后,唯一还能让系统保持可预测的办法。
3. 拦截链不调用"继续",后面所有人包括默认行为都看不见
判断:这套系统里有一种事件分发方式,行为更像"层层嵌套的中间件",不是"广播通知"。官方文档把这种分发方式叫 waterfall(瀑布式事件),这一章统一用"拦截链"这个更直白的说法指代它,后面翻源码看到 waterfall 这个词,认的就是这一套规则。不调用"继续"是一种合法的、正式的否决,不是遗漏了一行代码。
它的规则同样很短:最内层是这次拦截的默认行为;监听器按注册顺序从外往里一层套一层;任何一层监听器不调用"继续",后面所有更内层的监听器,包括最内层的默认行为,全部不会执行。这是一份写进纪律的规矩:想要把决定权让给下一层,就必须显式调用"继续";不调用,就等于自己拍板、否决到底。只做记录、不打算干预结果的监听器,必须主动委托,绝不能忘记调用"继续"。
产品里一个很典型的用法是工具执行前的审批链:默认情况下,如果没有任何插件关心这次执行,最内层的默认行为就是直接放行。一旦有审批插件挂在这条链上,它有两种合法姿态——如果它想否决或者批准(拒绝、需要确认、允许),它可以直接返回结论,不必调用"继续",这次决定就是最终决定;如果它只是想附加一些上下文信息、不打算真正拦下这次执行,它必须调用"继续",把决定权交给下一层,让后面的策略仍有机会否决。
对比另一种典型行为:有些监听器天生就没有资格做决定,它们只是记录一些元信息(比如给这次执行的输出加一个长度上限提示),这类监听器永远应该调用"继续",而且往往会要求自己排在链的最外层,确保自己"记录一下就放行",不会意外挡住真正有决定权的策略。
哪些时刻属于这种"可否决的拦截链"、哪些时刻只是"谁都拦不住",是这个仓库明确区分好的两类扩展点。在准入检查、请求发出前、模型流式返回、以及工具执行前后这几个时刻,拦截链是可以否决的;而"这一轮对话要结束了"这类时刻,监听器是按顺序依次等待执行的,但没有"继续"可以调用,谁都无法拦下轮次收尾。这后一类不是完全不等待的"发出去就不管"(那种更纯粹的广播另有名字),只是没有短路/否决的权力——用错类型,等于把否决权做没了:监听器能听见事情发生,还能依次排队处理,却怎么都拦不住。
如果没有这一层会怎样?想要加一条策略,唯一的办法就是回头去改循环内部的判断逻辑,或者在几个插件之间约定一个共享的布尔标志位当暗号。谁的策略优先级更高,全靠口头约定,一旦两个插件同时想否决同一件事,没人说得清最终听谁的。拦截链把"这个决定归我管"直接编码进了控制流本身:你不把决定权往下传,这个决定就永远是你的。
4. 核心消失之后,扩展是"挂上去",不是"改进去"
把前两节拼在一起,"没有特权核心"这句话才不是一句空话。
循环是一段可卸载的生命周期。工具表是一段可卸载的生命周期。模型适配器是一段可卸载的生命周期。你新写的审批插件也是一段可卸载的生命周期。它们共享同一套运行环境,靠"我需要哪个服务"来表达先后关系,靠拦截链来表达"谁能否决谁"。没有一块代码天生有资格说"你必须先依赖我才能活下去"——声明依赖只表达"我需要这个能力",从不表达"我绑死了某个具体实现"。
所以扩展的正确姿势永远是:在旁边挂一个新的、同级的贡献者,用登记效果的方式声明自己贡献了什么,需要否决权就在拦截链里不调用"继续"。错误的姿势是:打开循环内部,加一个条件判断。第 00 章说过,改动循环本身必须回头更新架构总览文档——现在你知道那份文档在保护什么,它在保护"核心必须始终保持可卸载"这条底线。
这一章不教你怎么写第一个插件,落地的手把手教程住在这个项目自己的开发文档里。这里只要求你改掉一个错误的心理翻译:这套框架不是一个依赖注入容器。它是一台要求每一笔贡献自带撤销方式、每一次拦截自带否决权的运行时——有了这两条,循环才敢被做成一个可以随时换掉的插件。
5. 结语:带走一句话
这套框架让核心消失的办法,不是把代码藏起来,而是逼每一笔贡献交出自己的撤销方式,逼每一条拦截交出继续或否决的权力——你不交撤销方式,它就不承认你挂上去了;你不主动否决,后面所有人包括默认行为都会当作什么都没发生。