上一篇写 DeepSeek Harness 的时候,底下那层 Cordis 我只用五句话带过,因为当时手上没有它的代码。 现在代码拿到了,从头读了一遍,发现那五句里至少有一句是错的,而真正值得说的地方一句都没提到。 这篇补上:一次插件激活到底走了哪些步骤、每个模块是怎么实现的,以及我认为它在方法论上真正解决了什么。

定位

Cordis 不是为 agent 写的。它 2022 年 5 月的第一个提交,是从聊天机器人框架 Koishi 里抽出来的通用底座,作者 Shigma 一个人写了绝大部分。四年多、五百多次提交,dsh 把它 vendor 进仓库当地基。

有意思的是它现在的处境:Cordis 自己的 README 把文档链接指向了 deepseek-harness.github.io。一个为聊天机器人设计的框架,主场变成了 agent 运行时。这种「为 A 做的东西恰好是 B 的地基」在基础设施里反复出现,通常说明抽象抓对了。

README 上的自我介绍是「时空可组合性的元框架」,还挂了一篇同名论文。标语听着玄,落到代码上是两组具体机制:一组管时间——什么时候激活、什么时候撤销;一组管空间——同一份插件代码挂在不同位置,看到的世界不一样。下面大体按这两条线走。

五个概念

先纠正上一篇。我当时写「插件就是一个实现了 Service 的对象」,这句跟代码对不上。查了下出处,它是 dsh 官方那份 Cordis 入门里五条核心概念的第一条,我照抄了没核对。代码里插件的类型是:

ts
export type Plugin<T> =
  | Plugin.Function<T>
  | Plugin.Constructor<T>
  | Plugin.Object<T>

函数、构造器、带 apply 的对象,三选一。Service 是正交的另一件事——它是个基类,构造时调 ctx.reflect.provide() 把自己注册成服务。插件可以不提供任何服务,服务也不必是插件。这个区分要紧,因为依赖图上的节点是 Fiber,不是 Service。

公平地说,官方那条的后半截自己就放宽了(「可以是带 injectapply(ctx) 的函数,也可以是 Service 子类」),只是被前面那个概括盖住了。概括写得太顺口,容易被人整句抄走,我就是那个人。

五个概念摆开:

Context —— 能力视图,一个被 Proxy 包住的对象,靠原型链往下扩展 Fiber —— 一次插件激活的完整生命周期,是状态机也是 effect 容器 effect —— 可逆注册,一个返回 disposer 的闭包 Impl —— 一个服务实现,记着名字、提供它的 fiber、值,以及一个可选的就绪检查 Runtime —— 同一个 plugin 函数被装多次时,那些实例共享的壳

「一切皆插件」是对外的说法。读完代码,对内的真实结构是一切皆 effect。

插件树

插件树就是 effect 树插件树就是 effect 树:子插件是父 fiber 的一个 effect;服务内部创建的资源,归属被改写到调用方名下

交互版,可缩放、可播放调用轨迹。)

Fiber 的构造函数里有这么一行:

ts
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 的状态机Fiber 的状态机:依赖拼成 epoch 字符串,字符串一变就转换;同时只允许一次转换在飞,转换尾部再比一次

交互版

Fiber 有六个状态,转换全靠一个叫 epoch 的字符串驱动。_refresh() 的核心只有几行:

ts
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,关键就一行:

ts
if (prop === tracker.property) return ctx

tracker.property 通常是 'ctx'。服务被谁拿到,它内部的 this.ctx 就变成谁的上下文。

反向的情况也得处理:服务有时需要用自己那份上下文,比如 Loader.load() 内部要 this.ctx.plugin(...),装出来的插件不能算在调用方名下。这就是 shadow 那套东西的用途,shadow.spec.ts 里有个用例叫 strips service shadow before creating plugins,专门钉这条。

这一块是全仓最绕的,Proxy 套 Proxy,断点里看到的对象和源码里写的对象经常不是一回事。但它换来的东西很实在:资源归属不靠服务自觉。

访问边界

reflect.ts 里的 get 陷阱决定了一个插件能看见什么:

ts
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 调它的时候是这么调的:

ts
impl.check.call(getTraceable(this.ctx, impl.value))

this.ctx 是消费方的上下文。配合 Loader 的实现看:

ts
[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。同一个服务,对不同消费方呈现不同的就绪状态。

它没有为此引入任何新概念,checkintercept 本来就都有,两个凑一块就成了。这种密度我很喜欢。

事件自举

派发有五种: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 开头的标量被解析成表达式节点,求值实现直白得有点吓人:

ts
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。