Deepseek Agent Harness教程(四) | 为什么插件必须可逆

上一章我们聊了插件怎么"挂"上去——通过 inject 声明需要什么服务,框架帮它把依赖凑齐。这一章反过来,问一个更扎手的问题:
一个插件被卸掉了,它之前注册的那些东西——事件监听、工具、定时器——会自己消失吗?
答案可能让你意外:不会。除非你明确告诉框架怎么收尾。
而整套热重载(改完代码保存,页面不用刷新就能生效)之所以能工作,全押在"能收尾"这一条上。
3.1 先看看"不管收尾"会出什么事
想象一个场景。你写了一个插件,干了三件事:
- 往工具箱里放了一个叫
read_file的工具 - 监听一个叫
tool/result的事件,每次工具运行完就记一笔 - 每隔 5 秒把统计结果打印到日志里
代码大概长这样:
// 反面教材 —— 这三行都没有"撤销"的机制
export function apply(ctx) {
toolRegistry.push(readFileTool) // ① 往全局工具箱里塞东西
eventBus.on('tool/result', countIt) // ② 挂一个事件监听器
setInterval(() => console.log(stats), 5000) // ③ 开一个定时器
}
现在你改了一行代码,保存文件。热重载触发了:
- 旧插件被卸载 —— 但卸载的时候啥也没干,因为这三行没有一行提供了"怎么撤销"
- 新插件被加载 —— 又把这三行跑了一遍
现场变成了什么样子?
- 工具箱里现在有 两份
read_file。模型调用工具时看到重复项,而且第一份指向的是已经作废的旧代码——那份代码还在内存里,因为工具箱还拽着它不放。 - 事件上挂了 两个 监听器。每次工具返回结果,"统计数字加一次"这个操作被执行了两遍。你会以为工具被调用了两次。
- 定时器有 两个 在跑。日志每 5 秒出两条,两条数字还不一样——因为它们各自记各自的账。
再改一次代码,就变成三份。而从头到尾,没有任何报错。
为什么这类 bug 特别难查
它只在"重新加载过"之后才出现。你在本地改了十几次代码,发现数字开始不对劲;同事从他那边从头启动一次,一切正常。你关掉重开,也正常。
这个故障绑定的是"你做过哪些操作"的历史,而不是绑定"当前代码长什么样"。复现不出来,就修不掉。
这不是假设。有人在 Cordis 里挂了一个插件,apply 里就一行裸的 setInterval,没有做任何包装。等它跑了一会儿,调用卸载方法把插件卸掉,然后继续观察——卸载之后的 100 毫秒里,这个定时器又触发了 5 次。插件已经"不在了",但它留下的定时器还在跑。
为什么框架拦不住?
简单说:框架只管"经过它登记"的东西。你把定时器的句柄直接丢给 setInterval,这个句柄从头到尾没经过 ctx,框架不知道有这么个东西存在,所以卸载的时候根本不知道要去停它。
这就好比你用图书馆的系统借了书(系统有记录),和你自己偷偷把书塞进包里带出去(系统没记录)——关门的时候,系统只会追回它有记录的那批书。
3.2 ctx.effect():拿资源的时候,顺手把"怎么还"也交代了
Cordis 给的解法很简单:在获取任何"框架管不着"的资源时,用 ctx.effect() 包起来,同时告诉框架"将来怎么释放它"。
function heartbeat(ctx: Context) {
ctx.effect(() => {
// 获取资源:开一个定时器
const timer = setInterval(() => console.log('tick'), 200)
// 返回释放函数:告诉框架怎么关掉这个定时器
return () => {
clearInterval(timer)
console.log('定时器已清理')
}
})
}
这段代码跑起来的效果是:
- 插件加载时:打印"tick"(每 200 毫秒一次)
- 插件卸载时:打印"定时器已清理",然后 tick 停止
结构就两层:
- 外层(effect 体):加载时立刻执行,负责"拿资源"
- 内层(return 的函数):卸载时由框架调用,负责"还资源"
打个比方
你去图书馆借书。借书的时候,管理员把书给你,同时在系统里记一笔——“谁、什么时候、借了什么书”。闭馆的时候,系统自动发通知催还,不需要你主动去记"我该还书了"。
这里的关键区别是:你几乎不需要主动调用释放函数。框架会在插件卸载时替你调用。教程的原话是:对于生命周期和插件绑在一起的资源,你绝不需要自己调用 disposer。
什么东西需要包 ctx.effect()?
判据很直接:Cordis 不认识的东西。
- 定时器(
setInterval/setTimeout)—— 不认识 - 网络连接(WebSocket、数据库连接)—— 不认识
- 文件监听(
fs.watch)—— 不认识 - 你自己维护的一个全局数组或 Map —— 不认识
而 Cordis 自己的 API(事件监听、工具注册、服务提供)—— 它认识,所以已经帮你处理好了。
3.3 哪些操作已经自带"撤销"了(新手最容易白费力气的地方)
看到这里,很多人会紧张过头,把每一行注册都包一层 ctx.effect()。
千万别。 Cordis 的内置注册 API 自己已经带撤销机制了。
举个例子:你什么都不用写
// 这样就行了 —— 不需要额外包 effect
ctx.on('tool/result', countIt)
在 Cordis 源码里,ctx.on() 内部已经调用了 ctx.fiber.effect(),把移除监听器的函数登记上去了。卸载插件时,监听器会被自动移除。
哪些是"自带撤销"的?
| 你写的代码 | 卸载时框架自动做什么 |
|---|---|
ctx.on('事件', 监听器) | 移除这个监听器 |
ctx.plugin(子插件) | 子插件一起卸载 |
ctx.provide('服务名', 实现) | 注销这个服务 |
ctx.tools.register(工具) | 从工具箱里移除这个工具 |
ctx.systemPrompt.section(...) | 撤下这段提示词 |
拿 3.1 那个反面教材来说,三行里有两行根本不需要你操心:
eventBus.on(...)—— 在真 Cordis 里应该写成ctx.on(...),自带撤销toolRegistry.push(...)—— 在真 Cordis 里应该写成ctx.tools.register(...),自带撤销
只有 setInterval 那一行,框架不认识,需要你手动包 ctx.effect()。
最常见的两个错误
错误一:重复包装。
// 多此一举
ctx.effect(() => ctx.on('x', f))
这不会报错,但让读代码的人以为 ctx.on 需要手动清理,于是他在别的地方也照抄,徒增理解成本。
错误二:自己另找地方清理。
// 有害
const listeners = []
listeners.push(ctx.on('x', f))
process.on('exit', () => listeners.forEach(...))
插件卸载(而不是进程退出)才是常态。热重载、改配置、换服务提供方,都会触发卸载但进程不退。你挂在 process.on('exit') 上的清理根本不会执行。
一个让人安心的数据
在 Cordis 整个仓库里数了一遍:
- 自带撤销的注册 API 被调用了约 401 处
- 需要手写
ctx.effect()的约 165 处
也就是说,大约 71% 的注册动作,插件作者一个字都不用多写。框架把"可逆"做进了 API 本身——正确的写法同时也是最省事的写法。
剩下那 29% 是"纪律"——定时器、连接、文件监听这些,得自己包。哪个插件忘了,热重载就在哪个插件上悄悄失效,不报错。
3.4 一个插件实例的"生命周期":六种状态
每个被加载的插件实例,框架里都有一个对应的"句柄",叫做 fiber。
同一个插件被加载三次,就有三个 fiber,各自独立。卸载其中一个,不影响另外两个。
一个 fiber 有六种状态,可以想象成一条流水线:
等待中 → 加载中 → 运行中 → 卸载中 → 已销毁
↘ 失败
| 状态 | 什么意思 | 接下来发生什么 |
|---|---|---|
| 等待中 | 插件刚创建,但它要的服务还没凑齐 | 等依赖到齐了 → 开始加载 |
| 加载中 | 正在执行插件的 apply 函数 | 跑完了 → 进入运行中;抛异常 → 回滚后进入失败 |
| 运行中 | 正常工作 | 依赖没了 / 被卸载 → 开始卸载 |
| 失败 | 启动时抛了异常,或配置没通过校验 | 修好配置后可以重试 |
| 卸载中 | 正在执行释放函数 | 释放完了:还要用 → 重新加载;彻底移除 → 已销毁 |
| 已销毁 | 彻底没了 | 出不去了,不能重启 |
"运行中"是怎么判断的?
有意思的是,fiber 的"状态"并不是某个变量存着的,而是实时推算出来的:
- 如果句柄被清空了 → 已销毁
- 如果记着一个错误 → 失败
- 如果依赖都凑齐了 → 运行中
- 否则 → 等待中
"依赖都凑齐了"是怎么判断的?框架把插件 inject 的每个服务名,和"当前是谁在提供这个服务"的编号拼成一个字符串。比如一个插件 inject 了 greeter,当前提供 greeter 的是编号为 #3 的 fiber,那 epoch 就是 "greeter:3"。
如果 #3 被卸载了,换成了 #7 来提供 greeter,epoch 变成 "greeter:7"——变了。依赖这个服务的所有插件就会被发现,然后自动卸载、重载。这就是上一章说的"依赖持续追踪"的全部实现。
插件卡住了怎么办?
三种"没反应",三种查法:
- 卡在"等待中":缺服务。检查它
inject了什么,谁应该提供。 - 卡在"失败":启动时抛了异常。去日志里找错误信息。
- 卡在"卸载中":某个释放函数一直不结束。往下看 3.5 节。
还有一个信号:如果你在调用 ctx.effect() 或 ctx.on() 时看到 cannot create effect on inactive context,说明你手里的 ctx 属于一个已经被卸载的插件。最常见的成因是把 ctx 存进了闭包,在延迟回调里用它注册东西。
3.5 释放顺序:倒着启动,但一起跑
前面说框架会在卸载时替你调用释放函数。但有个细节容易踩坑。
先看源码怎么写的
Cordis 把所有释放函数存在一个列表里。卸载时,它做两件事:
- 把列表倒过来 —— 最后注册的,最先被启动
- 用
Promise.all同时启动它们 —— 所有释放函数一起跑,不等谁先结束
一个具体例子
假设你按 A、B、C 的顺序注册了三个 effect。各自的释放函数耗时不同:A 要 120 毫秒,B 要 60 毫秒,C 是同步的(瞬间完成)。
卸载时打上时间戳,看到的是:
C 启动 0ms ← 最后注册的,最先启动
B 启动 0ms ← 三个都在同一刻启动
A 启动 0ms
B 完成 60ms ← 60ms 的先结束
A 完成 120ms ← 120ms 的后结束
关键两件事:
- 启动顺序是逆序:C 先,B 其次,A 最后
- 它们是同时启动的:B 不等 A,A 也不等 B。总耗时取决于最慢的那个(120ms),不是三个加起来
这个坑长什么样?
你写了一个插件,做了两件事:
- 先建立数据库连接
- 再开一个写入器,每秒往数据库写一笔数据
卸载时,你以为"逆序释放" = 先停写入器、再关连接。但实际上两个释放函数同时启动。如果"关连接"是同步操作、瞬间完成,而"停写入器"要等最后一笔数据写完,那结果就是:连接先断了,最后一笔数据落在一个已经关掉的连接上——报错。
怎么解决?
如果几个清理步骤必须按顺序执行,把它们放在同一个释放函数里,在里面依次 await。
// 正确写法:两个步骤串行
ctx.effect(() => {
const conn = createDbConnection()
const writer = createWriter(conn)
return async () => {
await writer.stop() // 第一步:先停写入器
await conn.close() // 第二步:再关连接
}
})
同一个 effect 内部的多个 await 是串行的。不同 effect 之间的释放才是并发的。
还有一条硬规矩:释放必须真正停稳,而不是只发一个"你该停了"的信号就返回。 如果只发信号不等结果,就会留下孤儿进程。
3.6 热重载凭什么能工作?
热重载看起来像魔法:你改一行代码保存,页面上的功能就更新了,不用刷新、不用重启。
其实拆开看就两件事:
- 旧插件卸载干净(这一章的内容)—— 所有注册、监听、定时器都收回
- 新插件按依赖加载(上一章的内容)—— 新实例自动等它要的服务就绪
热重载插件本身做的事情极其简单:
监听到文件变化 → 卸载旧插件 → 加载新插件
就这三步。没有更复杂的魔法。
一句话记住: 热重载不是框架送给你的额外礼物,它是"卸载能卸干净"加上"加载遵循依赖"这两条性质的必然结果。
卸载会递归,而且会等
如果一个插件挂载了子插件,子插件又挂了孙插件:
父插件
└── 子插件
└── 孙插件
调用父插件的卸载,框架会:
- 先清理父插件自己的 effect
- 再递归卸载子插件
- 子插件再递归卸载孙插件
- 等所有层级的清理都完成,才返回
实测:三层结构,每一层都打一条日志,调用最外层的卸载并 await 之后,三条日志全部出现了。没有一层被漏掉。
注意:日志的打印顺序可能让你意外
实测看到的顺序是:父先打印,然后子,然后孙。你可能会想:“3.5 节不是说逆序释放吗?那应该是孙先、子后、父最后才对。”
原因是:父插件有两个释放动作——① 卸载子插件(异步,要等子清理完),② 清理自己的定时器(同步,瞬间完成)。两者同时启动,同步的那个瞬间就打印完了,异步的那条链还在往下走。所以看到的是"父先打印",但父的异步卸载实际上还在进行。
这再次验证了 3.5 节的规则:逆序的是启动,不是完成。
那如果释放函数永远不结束呢?
这是 Cordis 的一个设计选择,也同时是一个争议点。
源码里,_unload() 用 Promise.all() 等所有释放函数完成,没有任何超时机制——整个 fiber.ts 文件 754 行,一个定时器都没有。
这意味着:只要有一个释放函数永远不结束(比如等一个永远不会回来的网络响应),整个卸载过程就会永远卡住。热重载卡住、配置更新卡住、依赖它的其他插件重启也卡住。
官方文档的要求是"dispose 必须达到完全停稳,而不仅仅是请求停止"——这是对插件作者的要求。但它没有回答:插件做不到的时候,框架该怎么办?
- 设超时强行往下走 → 可能留下只清理了一半的现场
- 无限等下去 → 可能把整棵树拖死
两种做法都有道理,也都有翻车的案例。目前 Cordis 选了"无限等"这一边。你可以自己去比较 Kubernetes(选了超时)和 systemd(也选了超时)的做法,形成自己的判断。
本章小结
- 不可逆的注册在热重载之后会叠加,不是被替换。工具表里多一份、监听器多一个、定时器多一个,而且不报错。这类 bug 和操作历史绑定,复现不出来。
- 解法是
ctx.effect():获取资源的同时,把"怎么释放"也交给框架。但多数时候你不需要亲自写——Cordis 自己的 API 已经自带撤销了。大约 71% 的注册动作是框架保证的。 - 每个插件实例有一个 fiber,六种状态是实时推算出来的。卡住的时候,先看它停在了哪个状态。
- 释放函数逆序启动,但并发推进。要顺序执行,就把多个步骤放进同一个释放函数里。
- 热重载不是魔法,是"卸载能卸干净"加上"加载遵循依赖"的推论。哪个插件破坏了可逆性,热重载就在那儿静默失效。
更多推荐



所有评论(0)