插件系统真正困难的地方,不是“把功能拆成插件”。这个动作本身很简单:一个插件注册命令,另一个插件提供数据库,第三个插件监听消息。
困难出现在运行时。
一个插件可以被启用,也可以被禁用;可以被热更新,也可以因为依赖缺失而暂时停用。它加载时注册过事件监听、定时器、服务、命令和后台任务;它卸载时,这些东西应该被完整撤回。它依赖的服务可能晚一点才出现,也可能运行中被替换或消失。依赖变化以后,哪些插件要重新启动,哪些插件要停下来,不能靠每个插件自己临时判断。
Cordis 论文讨论的就是这个问题。
这篇论文题为 A Programming Paradigm for Spatiotemporal Composability。它试图给动态插件系统建立一套更一般的理论:当软件由一组运行时组件组成时,系统如何保证这些组件既能干净退出,又能稳定协作。
论文把这个目标叫做:spatiotemporal composability,可以译作“时空可组合性”。
动态组合比静态组合难在哪里
传统软件组合大多是静态的。
函数调用
模块 import
类继承
编译期链接
这些关系通常在程序启动前就确定了。模块 A 依赖模块 B,编译器或运行时加载器可以在固定时刻解析它。
插件系统不一样。插件可能在程序已经运行很久以后才被加载,也可能在用户点击“禁用”以后被卸载。更复杂的系统,比如 agent harness,还可能在运行中生成新工具、替换组件、改变权限和执行环境。
这类系统面对的是动态组合:
组件运行时加入
组件运行时退出
组件运行时替换
依赖运行时出现或消失
系统状态不能因为这些变化被随意丢弃
最粗暴的解决方案是重启进程。插件坏了,就重启宿主;依赖关系变了,就重启服务;状态不干净,就重新来过。
这当然有效,但粒度太粗。重启会丢缓存、连接、内存状态和进行中的任务。对于插件系统、开发工具、机器人框架、长期运行的 agent harness 来说,越来越多组合都发生在进程内部,只靠进程或容器级别的重启,已经不够细。
论文因此提出:动态组合至少有两个正交维度。
时间维度:组件走了,它留下的副作用能不能撤回?
空间维度:组件之间的依赖能不能声明、解析并响应变化?
这就是“时空可组合性”的来源。
时间维度:组件卸载后,副作用能不能撤回
时间维度叫 temporal composability。
它关心的是:一个组件被移除时,它对共享环境做过的修改能否完整、安全地撤回。
一个插件加载时可能做很多事:
注册事件监听
创建定时器
提供服务
添加命令
打开连接
启动后台任务
修改上下文状态
如果插件卸载时这些副作用没有清理干净,就会出现常见问题:
旧监听还在响应
热更新后逻辑执行两遍
服务残留
定时器泄漏
连接没有关闭
状态污染后续插件
很多框架把清理责任交给插件作者:加载时写 activate,卸载时写 deactivate 或 dispose。这有一个结构性缺陷:创建副作用和清理副作用分离了。
作者在一个地方注册监听,在另一个地方记得取消监听;在一个地方启动任务,在另一个地方记得停止任务。只要漏掉一处,系统就不干净。
Cordis 论文把这个问题抽象为 effects。
Effect 描述的是:组件对环境做了什么修改。
论文进一步要求:动态组件里的 effect 应该是 revertible effect,也就是可撤回副作用。每次对 Context 的修改,都要同时带上一个 inverse,也就是反向操作。
可以这样理解:
做一件事时,同时登记怎么撤回它。
注册监听时,登记取消监听;提供服务时,登记移除服务;创建资源时,登记释放资源。组件卸载时,Cordis 按顺序执行这些反向操作。
这就是 Cordis 生命周期机制背后的理论含义:
Lifecycle 清理 = Revertible Effects = 时间可组合性
空间维度:组件之间的依赖能不能响应变化
空间维度叫 spatial composability。
它关心的是:组件之间的依赖关系能否被结构化声明、解析,并在环境变化时自动更新。
比如一个机器人系统里:
数据库插件提供 database
适配器插件提供 bot
命令插件依赖 bot
用户插件依赖 database
AI 插件可能同时依赖 bot、database、http、logger
如果依赖靠每个插件自己写判断,就会变成一堆临时逻辑:
如果 database 存在就启动
否则等待
如果 database 消失就停用
如果 provider 换了就重载
如果多个依赖同时变化就自己处理顺序
Cordis 的做法是让插件声明自己需要什么:
ctx.inject(['database'], (ctx) => {
// database 可用后执行
})
另一个插件提供服务:
ctx.provide('database', db)
当 database 出现、消失或换成另一个 provider 时,Cordis 负责通知依赖它的组件,重新计算这些组件的状态。
论文把这种“组件对环境的需求”称为 coeffects。
如果 effect 是:
我改变了环境什么?
那么 coeffect 是:
我需要环境提供什么?
Cordis 把 coeffect 做成运行时机制:组件声明依赖,Context 变化时,运行时根据依赖规格判断这个组件应该激活、停用还是保持不变。
这就是:
Inject / Service / Dependency = Reactive Coeffects = 空间可组合性
Context 为什么是核心
理解这篇论文后,Cordis 里的 Context 就不只是一个方便传来传去的对象了。
工程上看,ctx 可以做这些事:
ctx.plugin(myPlugin)
ctx.provide('database', db)
ctx.inject(['database'], callback)
ctx.on('message', handler)
ctx.emit('message', session)
理论上看,Context 同时承担两类职责:
记录组件对环境做出的修改
记录组件对环境提出的依赖
也就是说:
Context = effect context + coeffect context
它既是副作用追踪器,也是依赖解析器;既负责组件如何进入系统,也负责组件如何退出系统。
这也是论文把 Cordis 称为 meta-framework 的原因。Cordis 不规定你是在写 Web 应用、机器人、编辑器还是 agent harness。它只提供一个更底层的运行时语义:组件如何动态组合。
Component 和 Fiber:组件在运行时的形态
论文里,一个 component 不是普通函数。它至少包含三类信息:
它需要哪些依赖
它会提供哪些能力
它运行时会产生哪些 effect
在 Cordis 实现里,一个组件被实例化后会形成一个 fiber。
可以把 fiber 理解成:
某个组件挂载到某个 Context 后形成的运行时实例。
它有自己的生命周期状态,例如:
LOADING
ACTIVE
UNLOADING
INACTIVE
FAILED
依赖满足时,fiber 可以从 inactive 进入 loading,再进入 active。依赖消失时,fiber 进入 unloading,执行副作用撤回,最后回到 inactive。
这个状态机很重要,因为动态组合不是瞬间完成的。加载可能是异步的,卸载也可能要等待清理完成。Cordis 要处理这些中间状态,避免依赖变化和卸载过程互相踩踏。
论文用更形式化的方式证明:如果单个组件的 effect 和 coeffect 满足要求,那么由多个交错运行组件组成的系统,也能保持时空可组合性。
Cordis 如何落地这套理论
论文把理论和实现对应起来。落到 Cordis 里,关键机制有几层。
第一层是 ctx.effect。
所有会改变 Context 的操作都应该通过它完成。ctx.effect 运行一个 callback,并收集 callback 返回或产生的 inverse。卸载时,这些 inverse 会按 LIFO 顺序执行。
后产生的副作用先撤回
先产生的副作用后撤回
这符合资源嵌套的直觉。
第二层是 coeffect store。
服务提供本身也是一种可撤回 effect。提供 database 时,Context 里出现一个 binding;撤回 provider 时,这个 binding 被移除,并通知依赖它的组件。
第三层是 reactive notification。
每次 Context 中某个 key 变化,Cordis 会检查哪些 fiber 声明依赖了这个 key,并重新计算它们的目标状态。
依赖满足 → 启动或保持 active
依赖消失 → 卸载或保持 inactive
依赖变化 → 按需要重载
第四层是 component loader。
loader 处理更上层的工程问题:声明式配置、配置差异协调、热模块替换。Koishi 控制台里启停插件、开发时保存后热更新插件,背后就需要这种能力。
Koishi 证明了什么
论文把 Koishi 作为 case study。
Koishi 是一个聊天机器人框架,有大量社区插件。论文提到 Koishi 生态有超过 4000 个社区插件。虽然论文讨论的是 Cordis v4,而 Koishi 当时使用的是 Cordis v3,但两者共享核心组合模型。
Koishi 说明 Cordis 不是纯理论模型,而是在真实插件生态里有落地价值。
它证明了两件事。
第一,插件可以在不重启整个宿主的情况下被禁用、卸载和热更新。Cordis 通过 effect tracking 把插件产生的上下文副作用集中追踪,卸载时自动撤回。
第二,插件之间可以形成真实依赖拓扑。数据库插件、平台适配器、功能插件、控制台插件都可以通过服务和依赖连接起来。依赖变化时,Cordis 只重新影响相关插件,而不是重启整个系统。
这和很多传统插件系统不同。论文以 VSCode 为例指出,VSCode 扩展通常贡献到宿主提供的固定扩展点,而不是广泛依赖彼此;禁用带可执行代码的扩展往往需要重启 extension host。Cordis 想提供的是更细粒度的动态组合。
这套理论对 agent harness 为什么重要
论文里还有一个值得注意的方向:self-evolving agent harness。
今天的 agent harness 已经不只是“调用几个工具”。它可能包含:
工具系统
权限系统
记忆系统
执行沙箱
子 agent 编排
持久化状态
用户接口
自动化任务
更进一步,未来 agent 可能会在运行中修改自己的 harness:生成新工具,替换组件,调整权限,增加子 agent,改变执行环境。
这时,动态组合就不再只是插件市场里的工程便利,而是系统可靠性的前提。
如果没有时间可组合性,agent 的一次错误自我修改可能留下污染,甚至破坏恢复路径。
如果没有空间可组合性,新组件依赖谁、谁依赖它、替换后影响哪些模块,都只能靠临时代码判断。
Cordis 论文的意义在这里变大了:它不仅解释 Koishi 这样的插件生态,也提供了一种面向可恢复、可协调、可持续演化 agent harness 的运行时模型。
边界:不是所有副作用都能撤回
这篇论文并不是说所有事情都能被 Cordis 神奇撤销。
有些副作用发生在系统边界内,例如:
注册事件监听
添加服务绑定
创建定时器
保存上下文内部状态
打开由系统掌控的资源句柄
这些可以通过 inverse 清理。
但有些副作用跨出了系统边界:
发送网络请求
写入外部数据库
给用户发出消息
调用支付接口
让外部系统执行动作
这些通常不能被严格撤回。最多只能做 compensation,也就是补偿操作,比如删除刚创建的记录、退款、发送一条更正消息。
补偿不是数学意义上的 inverse。它只能把外部世界恢复到某种业务上可接受的等价状态。
所以 Cordis 的保证主要覆盖 Context 管得住的部分。外部世界的副作用仍然需要业务设计处理。
边界:运行时不证明 inverse 一定正确
还有一个重要限制:Cordis 可以要求 effect 带上 inverse,也可以负责记录和执行 inverse,但它不能自动证明 inverse 一定正确。
如果插件作者写错了清理函数,运行时无法凭空知道。
Cordis 的价值在于把副作用创建和副作用清理放到同一个结构里,并由框架统一追踪。它减少遗漏,改善局部性,但不是形式化验证器。
这点很重要。论文提供的是一种运行时组合范式,而不是“只要用了 Cordis 就永远不会泄漏”的保证。
总结
这篇论文给 Cordis 提供了一个比“插件框架”更深的解释。
Cordis 解决的不是单个 API 怎么设计,而是动态组件系统的两个根问题:
时间上:组件退出后,它留下的副作用能不能撤回?
空间上:组件之间的依赖能不能随环境变化自动协调?
对应到论文的语言:
Temporal composability → Revertible Effects
Spatial composability → Reactive Coeffects
Unified Context → 把两者放进同一个运行时环境
Component / Fiber → 让组件生命周期围绕依赖和副作用运行
对应到 Cordis 的工程语言:
Lifecycle 清理背后是可撤回 effect
Service / Inject 背后是响应式 coeffect
Context 是两者统一的运行时载体
Plugin / Component 是动态组合单元
一句话概括:
Cordis 的核心不是“把插件加载进来”,而是让插件在运行时既能有秩序地协作,也能有秩序地退出。论文把这种能力概括为时空可组合性。