乐于分享
好东西不私藏

04-插件卸载不留残渣:DeepSeek Harness 的六态生命周期和三段式拆分

04-插件卸载不留残渣:DeepSeek Harness 的六态生命周期和三段式拆分

上一篇讲了「没有特权内核」,所有东西都是插件。这篇说说这句话真做起来最难的那部分。

「什么都能换」听着爽,实际动手时最容易出事的不是换上去,是卸下来

幽灵回调

写过插件系统的人对这个场景不会陌生。

你加载一个插件,它注册了三个事件监听、两个工具、一个模型适配器,还起了一个定时器。跑得好好的。然后你要换掉它,调用卸载。

问题来了:那三个监听器谁去摘?定时器谁去清?工具注册表里那两条记录还在不在?

大多数框架的答案是「插件自己在 dispose 里写清理代码」。听起来合理,实践中必然漏。写清理代码是件极其没有成就感的事,加一个功能顺手加一行注册,很少有人记得同步加一行反注册。漏掉的那些,就变成了幽灵回调:插件已经卸了,回调还在跑,指向的对象已经被释放,报出来的错在栈里找不到源头。

HMR 场景下这个问题会被放大十倍。改一次代码重载一次,漏一个清理就累积一层,改二十次之后行为已经完全不可预测了。

DSH 的做法是把清理这件事从插件作者手里拿走了。

六个状态

每个插件在框架里走一个六态状态机:

状态
含义
PENDING
已声明,依赖还没就绪
LOADING
依赖就绪,apply 正在执行
ACTIVE
正常运行
FAILED
apply
 抛错了
UNLOADING
正在释放资源
DISPOSED
完全卸载

值得注意的是 FAILED 是一个独立的终态,不是「加载失败就当没加载过」。一个插件挂在 FAILED 上,框架知道它试过、失败了、失败在哪,这跟从没声明过是两回事。排查依赖问题时这个区分很有用。

配套四条规矩,我按重要性排:

一、注册即效应。 所有贡献都走 ctx.effect() / ctx.on(),注册函数一律返回一个 disposer。你注册了什么,框架就记得该怎么撤销它。

二、卸载自动清理,而且按注册的逆序。 插件卸载时,框架把这个 ctx 上的所有注册全部移除,事件监听、工具、LLM 适配器、自定义资源,一个不留。

逆序这条不是随手定的。举个具体例子:插件先注册了一个数据库连接,再注册了一个用这个连接的查询工具。如果正序清理,连接先关,查询工具还挂在注册表上,这段时间内有人调用它就是一个必现的空指针。逆序清理保证了「后依赖的先释放」,永远不会出现某个东西还活着但它依赖的已经没了。

三、依赖驱动装卸。 声明了 inject 的插件,会等所有依赖服务就绪才加载。反过来,服务在 Provider 替换过程中消失了,依赖它的插件自动卸载;服务回来了,自动重载。

这条是热替换能成立的关键。你换一个沙箱实现,中间会有一个短暂的「旧的已卸、新的未上」的窗口,所有依赖沙箱的插件在这个窗口里自动进入待机,不会拿着一个失效的引用继续跑。

四、HMR。 改插件源码触发卸载 → 重载 → 重新 apply,不残留旧注册。前三条做到了,这一条是自然结果。

一个插件长什么样

按文档里的写法示意(具体签名以官方文档为准):

// 声明依赖:tools 服务就绪之后我才加载exportconst inject =['tools']exportfunctionapply(ctx){// 注册一个工具,返回值是 disposer  ctx.tools.register({    name:'read_config',    description:'读取项目根目录下的配置文件',// ...参数 schema},async(args)=>{returnawaitreadConfig(args.path)})// 监听事件,同样返回 disposer  ctx.on('agent/pre-step',async(session, next)=>{// 瀑布式监听器必须调用 next(),不调就等于有意短路returnnext()})}

三处值得单独说:

1. 
你没有手写任何清理代码。register 和 on 的返回值被框架接管了,插件卸载时它自己按逆序调。这段代码里找不到 dispose、找不到 removeListener,这是设计出来的结果,不是我省略了。
2. 
inject 声明的不是「导入某个模块」,是「等某个服务活着」。 这两者的差别平时看不出来,热替换的时候才显形:import 拿到的是一个固定引用,服务被换掉之后你手里还是旧的;inject 拿到的是框架管理的活引用,服务没了你会被卸载,服务回来你会被重载。
3. 
next() 是个坑。 瀑布式(waterfall)监听器不调 next(),框架会认为你是故意要中断这条链,不报错、不警告。写漏了的表现是后面所有监听器静默失效,而你的插件本身工作得好好的。这种 bug 排查起来最费时间。

Capability Seam:三段式拆分

上面讲的是单个插件。还有一层机制处理「一个能力有多种实现」的情况,叫 Capability Seam,把一个可替换的能力拆成三个独立演进的包:

角色
职责
Service Definition
定义服务接口与请求/结果类型
Service Provider
实现具体执行逻辑
Consumer
把能力暴露成模型可调用的工具

关键在于 Provider 和 Consumer 互相不认识,都只依赖 Definition。

好处是换一次处处生效。举个实际的例子:Bash 工具、PTY 工具、LSP 工具,这三个都要在沙箱里执行东西。传统写法里它们各自持有沙箱的实现,你想把本地沙箱换成远程容器,得改三处。在 Capability Seam 下,它们都只认 Definition,你把沙箱的 Provider 换掉,三个工具的实现一起迁移,一行业务代码都不用动。

官方在这里主动泼了盆冷水:不要预先拆分,只有当三个角色确实需要独立演进的时候才分包。这句提醒挺难得的,大部分框架文档只会劝你多用它的抽象。三段式拆分是有成本的,一个能力变三个包、三份版本号、三份发布流程,收益不明确的时候纯属自找麻烦。

能拿走的两条

就算你不打算碰 DSH,这两条在自己的项目里也用得上:

注册的时候就把撤销方式一起交出去。 别指望调用方记得清理,让注册函数返回 disposer,把清理变成一件不用想的事。这个模式在前端已经很普及了(useEffect 的返回值就是它),后端和插件系统里用得还不够多。

清理必须逆序。 只要你的初始化有先后依赖,销毁就必须反着来。这条规则简单到不像个知识点,但线上因为它翻车的次数比想象中多。

下一篇讲这套架构换来的另一样东西:一份追加式的会话日志,以及它怎么顺手把成本压到了原来的 1%。

你踩过最难查的一次「已经卸载但还在跑」的 bug 是什么场景? 评论区讲讲,这类 bug 的共同点是栈里根本看不出源头,我想收集几个典型的。


《DeepSeek Harness 拆解》系列目录

1. 
01-10 万 Star 的 DeepSeek Harness:它不是国产 Claude Code,是造 Agent 的地基
2. 
02-DeepSeek Harness 上手:一条命令跑起来,四种模式怎么选
3. 
03-没有特权内核:DeepSeek Harness 把地基押在了一个第三方小框架上
4. 
插件卸载不留残渣:DeepSeek Harness 的六态生命周期和三段式拆分(本篇)
5. 
05-模型看到的每个字节都在日志里,顺手把成本压到了 1%
6. 
06-仓库里最值钱的不是代码:11 个工程 SOP 和 48 小时长出来的生态