Cordis:把卸载做完备的插件框架
CordisTypeScript插件化架构上一篇写 DeepSeek Harness 的时候,底下那层 Cordis 我只用五句话带过,因为当时手上没有它的代码。 现在代码拿到了,从头读了一遍,发现那五句里至少有一句是错的,而真正值得说的地方一句都没提到。 这篇补上:一次插件激活到底走了哪些步骤、每个模块是怎么实现的,以及我认为它在方法论上真正解决了什么。
定位
Cordis 不是为 agent 写的。它 2022 年 5 月的第一个提交,是从聊天机器人框架 Koishi 里抽出来的通用底座,作者 Shigma 一个人写了绝大部分。四年多、五百多次提交,dsh 把它 vendor 进仓库当地基。
有意思的是它现在的处境:Cordis 自己的 README 把文档链接指向了 deepseek-harness.github.io。一个为聊天机器人设计的框架,主场变成了 agent 运行时。这种「为 A 做的东西恰好是 B 的地基」在基础设施里反复出现,通常说明抽象抓对了。
README 上的自我介绍是「时空可组合性的元框架」,还挂了一篇同名论文。标语听着玄,落到代码上是两组具体机制:一组管时间——什么时候激活、什么时候撤销;一组管空间——同一份插件代码挂在不同位置,看到的世界不一样。下面大体按这两条线走。
五个概念
先纠正上一篇。我当时写「插件就是一个实现了 Service 的对象」,这句跟代码对不上。查了下出处,它是 dsh 官方那份 Cordis 入门里五条核心概念的第一条,我照抄了没核对。代码里插件的类型是:
export type Plugin<T> =
| Plugin.Function<T>
| Plugin.Constructor<T>
| Plugin.Object<T>函数、构造器、带 apply 的对象,三选一。Service 是正交的另一件事——它是个基类,构造时调 ctx.reflect.provide() 把自己注册成服务。插件可以不提供任何服务,服务也不必是插件。这个区分要紧,因为依赖图上的节点是 Fiber,不是 Service。
公平地说,官方那条的后半截自己就放宽了(「可以是带 inject 和 apply(ctx) 的函数,也可以是 Service 子类」),只是被前面那个概括盖住了。概括写得太顺口,容易被人整句抄走,我就是那个人。
五个概念摆开:
Context—— 能力视图,一个被 Proxy 包住的对象,靠原型链往下扩展Fiber—— 一次插件激活的完整生命周期,是状态机也是 effect 容器effect—— 可逆注册,一个返回 disposer 的闭包Impl—— 一个服务实现,记着名字、提供它的 fiber、值,以及一个可选的就绪检查Runtime—— 同一个 plugin 函数被装多次时,那些实例共享的壳
「一切皆插件」是对外的说法。读完代码,对内的真实结构是一切皆 effect。
插件树
插件树就是 effect 树:子插件是父 fiber 的一个 effect;服务内部创建的资源,归属被改写到调用方名下
(交互版,可缩放、可播放调用轨迹。)
Fiber 的构造函数里有这么一行:
this.dispose = parent.fiber.effect(() => { /* ... */ }, 'ctx.plugin()')子插件的整个生命周期,注册成了父 fiber 上的一个 effect。所以整个框架里找不到一段「遍历插件树逐个卸载」的代码——父 fiber 卸载时按后进先出跑完自己的 disposer 列表,子插件自然跟着倒下。插件树和 effect 树是同一棵树,一套机制不是两套。
同一张图的右半边是空间那条线。插件 A 通过 ctx 拿到的服务不是服务本身,是一层代理,代理把服务内部的 this.ctx 换成 A 的上下文。于是 A 调 ctx.timer.interval(),TimerService 里那个 this.ctx.effect() 注册到的是 A 的 fiber 上,A 卸载定时器跟着清。服务本身既不需要提供 unregister,也不需要知道是谁调了它。
状态机
Fiber 的状态机:依赖拼成 epoch 字符串,字符串一变就转换;同时只允许一次转换在飞,转换尾部再比一次
(交互版)
Fiber 有六个状态,转换全靠一个叫 epoch 的字符串驱动。_refresh() 的核心只有几行:
let epoch = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) { epoch = INACTIVE; break }
epoch += ':' + impl.fiber.uid
}
this._setEpoch(epoch)把「依赖齐不齐」和「依赖是谁」压进了同一个字符串。缺任何一个,整串置成 __INACTIVE__;都齐了就是 :3:7:12 这样。provider 被换成另一个实例、编号变了,字符串就变,消费方跟着重启。
一次比较,一个状态机,没有依赖图算法。我读到这儿停了一会儿,这是我见过最省的写法。
配套的是那个叫 inertia 的字段,管并发。转换途中又收到新目标时,_setEpoch 只把目标记下来直接返回,不打断正在跑的那一次;跑完了在尾部再和目标比一次,不一致就接着转。没有队列也没有锁对象,收敛靠的是比较本身幂等。fiber.spec.ts 里三个叫 inertia lock 的用例就是在钉这个语义,其中一个把 provider 在加载中途整个换掉,最终状态仍然收敛到 ACTIVE。
依赖快照
_reload 的第一行是 this.store = { ...this._store }。
一次激活期间,ctx.foo 解析的是激活那一刻的快照,不是实时值。依赖在你脚底下被换掉这种事不会发生——真换了,你会被重启。对应地,_unload 里把 store 置成 undefined,卸载之后再访问直接抛 cannot get required service in inactive context。
要么是激活时那个,要么响亮地失败,没有第三种。这个选择看着严格,但它把「插件持有过期引用」这一整类 bug 从可能性里删掉了。
归属改写
上面提过一句代理改写 this.ctx,具体是 utils.ts 里的 createTraceable,关键就一行:
if (prop === tracker.property) return ctxtracker.property 通常是 'ctx'。服务被谁拿到,它内部的 this.ctx 就变成谁的上下文。
反向的情况也得处理:服务有时需要用自己那份上下文,比如 Loader.load() 内部要 this.ctx.plugin(...),装出来的插件不能算在调用方名下。这就是 shadow 那套东西的用途,shadow.spec.ts 里有个用例叫 strips service shadow before creating plugins,专门钉这条。
这一块是全仓最绕的,Proxy 套 Proxy,断点里看到的对象和源码里写的对象经常不是一回事。但它换来的东西很实在:资源归属不靠服务自觉。
访问边界
reflect.ts 里的 get 陷阱决定了一个插件能看见什么:
let fiber = (ctx[symbols.shadow] ?? ctx).fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) throw error
if (!fiber.runtime) throw error
if (fiber.parent[symbols.isolate][prop] !== key) throw error
fiber = fiber.parent.fiber
}三条规矩:没 inject 就拿不到,报错是 cannot get property "x" without inject;祖先 inject 过的可以继承,因为祖先能活着本身就说明那个服务确实在;一路上隔离符号必须一致,越过隔离边界就断链。
隔离用 symbol 做命名空间。ctx[symbols.isolate] 把服务名映射到符号,store 用符号当键,两个隔离域各自 provide('database') 互相看不见,而各自域内的 ctx.database 都成立。第二档玩法是把同一个符号传给两个 isolate(),它们就组成一个共享域——isolate.spec.ts 里那个 shared label 用例演示得很清楚:一次 provide,两边一起激活;一次 dispose,两边一起卸。
访问控制在这里不是文档约定,是 Proxy 陷阱里的运行时执法。
就绪判定
这是我在整份代码里最欣赏的一处,也是最不容易注意到的。
provide 可以带一个 check 回调,用来说「我现在还不算就绪」。_checkImpl 调它的时候是这么调的:
impl.check.call(getTraceable(this.ctx, impl.value))this.ctx 是消费方的上下文。配合 Loader 的实现看:
[Service.check]() {
const config = Service.prototype[Service.resolveConfig].call(this)
if (config.await && this.getTasks().length) return false
return true
}resolveConfig 沿着的是消费方的 intercept 原型链。结果就是:写 inject: { loader: { await: true } } 的插件会一直等到整棵配置树加载完,不写的立刻就能拿到 loader。同一个服务,对不同消费方呈现不同的就绪状态。
它没有为此引入任何新概念,check 和 intercept 本来就都有,两个凑一块就成了。这种密度我很喜欢。
事件自举
派发有五种:emit 只观察,parallel 并行等全部,serial 按序且可中断,bail 一有返回就停,waterfall 是环绕式中间件。waterfall 的实现只有八行,把最后一个参数取出来当内层函数,监听器拿到的是 (...args, next)。
官方文档那张表只列了四种,漏掉的是 bail。它不是废弃品,核心自己就在用:ctx.on() 注册监听器时先 bail 一发 internal/listener,谁接住了这一发,注册行为就归谁劫持——internal/update 的监听器被改存到 fiber 私有表,走的就是这条路。
比五种模式更值得说的是 internal/* 那一组自举事件。属性读、属性写、插件创建与销毁、状态迁移、服务上下线、配置更新、监听器注册,全都走事件。所以 loader 想做配置回写,不用改核心一行代码,挂个 ctx.on('internal/update', ...) 就够了。
EventsService 的构造函数里还有个自指的用法:它监听 internal/listener,把 internal/update 的监听器改存到 fiber 私有的表里,而不是全局表。事件系统用事件系统改造自己。
配置与错误
Loader 把一份 yaml 变成插件树,而且是双向的。配置改了就算 diff,只有 config 变走 fiber.update(),disabled 变就卸载;反过来插件自己调 ctx.fiber.update(...),新配置会写回文件。
配置里还能写表达式。include 那个包给 yaml 注册了一个自定义标签,!!js 开头的标量被解析成表达式节点,求值实现直白得有点吓人:
export const evaluate = new Function('ctx', 'expr', `
with (ctx) {
return eval(expr)
}
`)表达式在插件自己的上下文里求值,ctx 上有什么名字就能直接写什么。官方文档补了时机上的细节:条目的 config 要等它声明的注入激活之后才插值,而 disabled 是每次做挂载决策时重新算的。
其中最脆的一段是自卸载检测。插件自己调 ctx.fiber.dispose(),loader 要往配置里写 disabled: true;而因为热重载、父组停用、依赖检查失败被卸的,不能写。区分这两者靠六个条件层层排除,注释写得很清楚,但也说明这个判定本身是间接的——它得靠「registry 是否还持有这个 callback」这类信号反推。
跟配置配套的还有一套长栈机制。异步边界会把调用栈冲掉,Cordis 在注册时刻先抓一份外层栈,等错误真抛出来的时候缝回去。Entry 提供的那份长这样:
at file:///home/me/app/#a1b2c3d4于是插件里的一个异步错误,栈底落在配置树里的那个条目上,而不是一串认不出来的匿名帧。对一个「跑的是配置叠出来的树」的系统,这大概是唯一能让错误可定位的办法。
热重载
HMR 那个包用的是 Vite 那套传播算法:改动的文件进 accepted,CLI 入口的依赖树进 declined,然后做不动点迭代——任一依赖被接受则自己被接受,依赖全被拒绝则自己被拒绝。
重载时两份模块缓存都要清,ESM 的 loadCache 和 CJS 的 require.cache,因为 Node 24 里 CJS 模块经 import() 加载后两边都有。清之前全量备份,任何一步失败就整体回滚:恢复两份缓存、用旧插件重新注册。失败不留半吊子状态,这点做得干净。
代价是整块建立在 Node 私有 API 上,要 --expose-internals,而且已经为 Node 22/23 和 Node 24 分叉出了两套接口。这是全仓最脆的地方,Node 一个小版本就能打断它。
这里还有个刻意留的悬挂,挺有意思:registry.delete() 先从表里移除,再逐个卸载 fiber,而 fiber 的卸载逻辑里有个 if (this.ctx.registry.has(...)) 判断,此时已经为假,于是 fiber 不会把自己从 runtime.fibers 里摘掉。看着像漏了一步,其实是给 HMR 留的——卸完之后还要靠这份列表,把每个实例用原来的配置重建回来。
方法论
插件化的难点从来不是加载。加载谁都会写,写不对的是卸载。
最典型的形态是:插件在异步回调里注册了个监听器,而它已经被卸载了。我前几天刚在这个博客自己的代码里修过同类 bug——一个 IntersectionObserver 换页时没断开,强引用一路拽住整篇文章的 DOM,每读一篇泄漏一份。Cordis 的做法是 effect() 开头就 assertActive(),已卸载的 fiber 上创建 effect 当场抛错。这类代码根本过不去。
effect 接受的四种返回形态里,生成器那种是部分回滚的入口。dispose.spec.ts 有个用例把它钉得很死:生成器 yield 了第一个 disposer 之后抛错,结果是抛错、且第一个被撤销、第二个从未存在。异步生成器还多一层,每次推进前检查 epoch 有没有变,中途被卸载就停止推进,只撤已经产出的那部分。
对比 React 的 useEffect:那边只能返回一个 cleanup,异步初始化的竞态得自己拿标志位挡。Cordis 把这件事做进了类型里。
官方文档里有条实践规则跟这个正好接上:几件事的释放如果有先后要求,就把它们放进同一个 effect。因为跨 effect 之间的顺序只由注册顺序决定,而同一个生成器里 yield 出来的那串 disposer,顺序是你自己写死的。
横向看,最接近的其实是 Erlang/OTP。fiber 像 process,父子 effect 像 supervision tree,依赖变了重启像 one_for_one。区别在于 OTP 的清理保证来自进程隔离——进程死了内存自然就没了;Cordis 在共享堆里靠 effect 纪律拿到同样的保证,更难,但不用付进程边界的代价。跟 Angular、Nest 那种依赖注入比就更不一样了,那边的依赖图是静态的,启动时解析一次,压根没有撤销这回事。
回到 agent 这条线。上一篇我的判断是「没有不可变内核的运行时更适合被 agent 自己改」。读完 Cordis 之后,这句可以说得更具体:dsh 敢让配置层决定一切,前提是底下这层的卸载是完备的。如果卸载会漏——漏监听器、漏定时器、漏服务实例——那么「agent 写个插件挂上去,不对就撤」这个循环第一轮就开始积累污染,第十轮之后系统行为就没法解释了。自进化的物理前提是可撤销,而可撤销恰好是 Cordis 全部设计收敛到的那个点。
这比「一切皆插件」那句口号重要得多。口号谁都能喊,effect 的四种形态加上部分回滚语义,才是能不能兑现的分界线。
代价
上面都是好话,不好的地方也有几处。
- Proxy 嵌套是有成本的。
ctx.foo.bar()一次调用可能要穿过三层代理。这不是理论担忧,仓库里有专门为事件派发去掉回调绑定的性能提交。 - 调试体验被代理污染。 断点里看到的
ctx是 Proxy,this.ctx可能是 shadow 也可能是调用方的 ctx。项目加了自定义 inspect 和getEffects()来补救,但根子上的别扭还在。 - 热重载建在私有 API 上。 上面说过,不重复。
- 有一段是理解悬崖。 loader 里处理隔离配置热更新的那个插件,七步流程加一个异或判定,我读了三遍才确认它在干什么。它对应的测试文件在 loader 包里是最大的一个,这本身就说明了难度。
- 配置文件是可执行代码。
!!js表达式走的是with (ctx) { eval(expr) },等于把配置文件的信任级别提到了跟源码一样高。自己写的 yaml 无所谓,分发别人的配置就得掂量一下。 - API 明说不稳定。 README 第一段就写着会不打招呼地改。
一个正面的证据:core 的测试量和源码量差不多,而且测的都是语义边界——加载中途换 provider、异步生成器中途中断、服务归属、隔离域里的事件可见性。对一个自称 API 不稳定的项目来说,这个测试密度是它可信度的主要来源。
尾声
Cordis 有意思的地方在于,它不是冲着 agent 去设计的,甚至不是冲着「可替换面要大」去设计的。它想解决的是聊天机器人框架里一个很土的问题:插件热重载之后别留垃圾。为了这个目标它把撤销做成了一等公民,然后正好撞上了 agent 运行时的需求。
所以下次再看到哪个 agent 框架说自己「一切皆插件」,我大概会先去翻它的卸载路径。加载写得再漂亮也说明不了什么,卸载写不干净的插件化,撑不过第十次热重载。
代码在 cordiverse/cordis,MIT。