从“一切皆插件”出发,理解 Cordis 背后的动态组件设计
最近,DeepSeek AI 开源了DeepSeek Harness(dsh)。它是一套面向 Agent 的开源 harness(智能体框架),目前处于开发者预览阶段,正在快速迭代,未来可能出现破坏兼容性的变更。
dsh 的一个重要设计是“一切皆插件”:系统中的能力以插件的形式组织和组合,而底层的插件运行机制由Cordis驱动。Cordis 的设计,则可以追溯到一篇更基础的研究工作——《A Programming Paradigm for Spatiotemporal Composability》。
这篇论文讨论的其实不是 Agent。
它关注的是一个更普遍的软件工程问题:
如果一个系统中的组件可以在运行时随时加入、删除和替换,我们应该怎样保证整个系统仍然是可控、可恢复、可组合的?
这个问题看起来简单,真正实现起来却并不容易。
加载一个插件通常不难。
难的是:
卸载的时候,它到底改过什么?
以及:
它离开之后,依赖它的其他组件怎么办?
这篇论文给出的答案,可以浓缩成两个概念:
Revertible Effects:让组件对系统造成的修改可以被撤销; Reactive Coeffects:让组件对系统的依赖能够随着环境变化而自动调整。
作者进一步把这两个机制统一到一个Context Paradigm中,并建立了动态组件组合的形式化模型,最终实现为 Cordis。论文还以拥有 4000 多个社区插件的 Koishi 作为实际案例。
这篇文章就从这里开始。
一、加载插件并不难,难的是把它干净地卸载
先想象一个普通插件。
它被加载以后,可能做了很多事情:
Plugin A│├── 注册 Event Listener├── 注册 Command├── 创建 Timer├── 建立 Connection├── 修改配置└── 向 Context 提供服务
从代码层面看,加载可能非常简单:
load(plugin)但卸载就不一样了。
系统必须知道:
删除 Event Listener注销 Command停止 Timer关闭 Connection恢复配置撤销 Context 中的修改
而且不能漏。
传统插件系统通常把这些工作交给插件作者:
activate()deactivate()
问题在于,activate() 和 deactivate() 是两套逻辑。
如果 activate() 做了十件事情,deactivate() 少恢复一件,系统就可能留下一个难以察觉的状态。
例如:
插件已经卸载↓但 Listener 还在↓Timer 还在运行↓旧连接还没关闭↓旧插件仍然影响系统
更麻烦的是,这种问题通常不会立即暴露。
它可能表现成:
一个事件被处理两次; 一个已经卸载的模块仍然收到消息; 某个连接无法释放; 内存持续增长; 热更新几次之后系统状态越来越奇怪。
因此,这篇论文首先提出了一个非常朴素的问题:
能不能让“修改系统”和“撤销修改”成为同一个操作的一部分?
二、Temporal Composability:组件离开之后,系统应该能够恢复
论文把第一个问题称为Temporal Composability。
这里的“Temporal”,可以理解为组件在生命周期变化过程中的可组合性。
一个组件加入系统以后,会改变系统的 Context。
那么,当它离开时:
这些变化应该能够被完整撤销。
这听起来像普通的 cleanup,但论文的区别在于:
它不希望 cleanup 再成为一套独立的、由开发者手工维护的逻辑。
为此,论文提出了Revertible Effects。
三、Revertible Effects:每一次修改都带着自己的“逆操作”
普通的 effect 可以简单理解成:
Context││ effect↓Context'
执行完之后,环境发生了变化。
而论文希望 effect 同时携带一个 inverse:
Context││ effect↓Context'││ inverse↓Context
也就是说:
系统不仅知道“做了什么”,还知道“如何撤销”。
论文形式化地规定,每个 Context transformation 都携带显式 inverse,并由 runtime 负责追踪;当组件被移除时,通过这些 inverse 恢复 Context。
比如一个组件依次做了三件事:
A:注册 EventB:注册 CommandC:建立 Connection
那么对应的逆操作就是:
inverse Ainverse Binverse C
组件退出时,并不是重新执行一段人为编写的:
uninstall()而是按照 effect 的组合关系反向恢复:
加载A → B → C↓卸载inverse C↓inverse B↓inverse A
这里真正重要的不是“自动清理”。
而是:
组件的卸载逻辑可以从它的加载行为中得到。
论文明确指出,开发者只需要为原子操作提供 inverse,复合操作的 inverse 可以通过组合得到,因此组件的 teardown 是由 loading 派生出来的,而不是再单独维护一套卸载代码。
这带来了一个很实际的变化:
以前:
开发者负责记住:我改了什么?我怎么恢复?
现在:
编程模型负责记录:这个组件通过 Context 做过什么?这些修改应该如何撤销?
这就是 Temporal Composability 的核心。
四、但组件之间还有另一个问题:依赖
假设现在有两个插件:
Database Plugin││ provides↓Database↑│ requires│Business Plugin
Business Plugin 依赖 Database。
Database 存在的时候,一切正常。
但是如果 Database Plugin 被删除:
Database Plugin↓removed↓Database 消失
Business Plugin 怎么办?
它应该:
停止?报错?等待?重新寻找一个 Database?
如果系统规模很小,可以自己写。
但一个真正的插件生态可能有数百甚至数千个组件。
这时候,如果每个插件都自己处理:
if database exists ...if database removed ...if provider changed ...
依赖关系就会散落到整个代码库里。
论文认为,这其实是另外一个维度的问题:
组件不仅会修改环境,还会依赖环境。
五、Spatial Composability:依赖关系也应该是动态的
论文把这个问题称为Spatial Composability。
如果 Temporal Composability 关注:
组件对环境做了什么?
那么 Spatial Composability 关注:
组件需要环境提供什么?
这正好对应 Programming Language Theory 中的两个概念:
Effect↓我改变了什么?Coeffect↓我依赖什么?
传统的 effect / coeffect system 主要用于描述静态程序中的环境影响和环境需求。
但动态组件有一个明显不同:
它所在的环境本身会发生变化。
组件可能出现。
组件可能消失。
组件可能被另一个实现替换。
因此,依赖也不能只在程序启动时解析一次。
这就是论文提出Reactive Coeffects的原因。
六、Reactive Coeffects:依赖不是解析一次就结束
还是刚才的例子。
Business Plugin 声明:
我需要 Database一开始:
Database 不存在Business Plugin↓inactive
然后 Database Plugin 加载:
Database Plugin↓provides Database↓Business Plugin↓activate
过了一会儿,Database Plugin 被删除:
Database 消失↓Business Plugin↓deactivate
随后另一个 Database Provider 出现:
Database v2↓provides Database↓Business Plugin↓重新激活
整个过程可以画成:
Database 不存在│↓Plugin A inactive││ Database 出现↓Plugin A active││ Database 消失↓Plugin A inactive││ 新 Database 出现↓Plugin A active
组件本身不需要到处监听:
DatabasePlugin 有没有变化?它只需要声明:
requires: Database运行时负责观察 Context 的变化,并决定组件应该:
activating deactivating neutral
论文正是用这三种状态描述 Context 变化对组件依赖的影响。
七、从这里开始,论文的两个核心概念合在了一起
到这里,我们已经得到两个方向:
Component│┌─────────┴─────────┐│ │Effect Coeffect│ │↓ ↓我修改什么? 我依赖什么?│ │↓ ↓如何撤销修改? 如何响应变化?│ │↓ ↓Revertible Effects Reactive Coeffects
这也是整篇论文最核心的地方。
作者没有把它们当成两个独立的功能,而是进一步把 effect context 和 coeffect context 统一成一个 Context。
因此,Context 成为了组件和运行环境之间的共同边界。
八、Context Paradigm:把修改和依赖放到同一个环境里
在这个模型中,一个组件可以简单理解成三部分:
Component│├── Coeffect│ 我需要什么?│├── Provision│ 我提供什么?│└── Effect + Inverse我修改什么?如何撤销?
这带来一个很重要的性质:
组件对环境的所有交互都有一个明确的归属。
传统代码里,一个函数可能:
读取全局变量调用服务修改配置注册事件创建资源
如果这些操作散落在代码各处,想知道:
“这个组件到底对系统做了什么?”
往往需要一路读它调用的函数。
而在 Context Paradigm 中,这些操作都通过明确的 Context 发生。
论文认为,这让 effect 和 dependency 都变得更加可追踪;同时,组件的 teardown 和 dependency re-wiring 可以由 runtime 统一处理。
这其实是这篇论文一个很朴素、但很重要的工程目标:
让系统的正确性更多来自抽象本身,而不是依赖每个开发者都记得把所有事情处理好。
九、为什么还需要一套形式化模型?
如果只是设计 API,前面的内容已经足够。
但动态组件真正复杂的地方在于:
A 正在加载B 正在卸载C 的依赖发生变化D 被替换
这些操作可能互相交错。
那么问题来了:
多个组件同时动态变化时,前面的性质还能成立吗?
因此,论文进一步建立了一个Calculus of Dynamic Composition。
它把系统拆成:
ComponentFiberRegistryContextLifecycleTransition
然后为组件的生命周期建立操作语义。
最终研究整个系统的性质,包括:
PreservationProgressTemporal ComposabilitySpatial ComposabilityConfluence
也就是说,论文并不只是证明:
“一个组件可以正确卸载。”
而是进一步研究:
多个组件交错进行加载、卸载和依赖变化时,整个系统能否仍然保持这些性质。
十、Confluence:不同的操作顺序,结果应该保持一致
其中一个很值得注意的性质是Confluence,可以理解成“收敛性”。
假设系统里有三个操作:
A 加载B 卸载C 替换
不同的执行顺序可能产生:
A → B → C也可能是:
B → A → C如果最终状态完全取决于偶然的执行顺序,那么动态组件系统很难维护。
因此,论文进一步研究不同合法执行路径之间的收敛性质。
作者给出的结果意味着,在满足模型假设的情况下,可以把一个经过多次动态添加、删除、替换的系统,理解成某个最终组合状态,而不是必须记住所有历史操作。论文也明确指出,这种性质让开发者可以更多地从最终 quiescent state 来推理系统。
当然,这里的保证并不是“任何错误都能自动恢复”。
论文也明确讨论了 failure,以及系统边界对 recovery 能力的限制。
十一、一个重要的边界:不是所有副作用都能自动撤销
这里其实是理解这套设计时非常容易忽略的一点。
如果一个操作发生在系统能够控制并恢复的范围内,那么它可以被追踪:
Context↓Effect↓Inverse
但如果操作已经越过了系统边界,例如系统无法独占控制某个外部资源,那么就不能简单地保证:
做了什么↓一定能恢复
论文专门讨论了System Boundary:只有系统能够控制并恢复的环境位置,才属于可以被追踪和恢复的范围。超出这个边界的操作,不会被当作普通的可恢复 effect。
这也是这类设计必须面对的现实:
可撤销不是魔法,它依赖于系统对资源边界的控制。
十二、Cordis:把这套模型变成运行时机制
理论之外,论文进一步实现了Cordis。
Cordis 并不是一个面向某个具体业务领域的应用框架。
论文把它定义为一个meta-framework of spatiotemporal composability。
它的职责不是告诉你:
怎么做 Web怎么做 ORM怎么做 UI
而是提供:
Effect Tracking+Coeffect Resolution+Component Loader+Configuration Reconciliation+Hot Module Replacement
也就是:
提供动态组件组合本身所需要的运行时语义。
论文将 Cordis 分成三个层次:
Cordis Core↓Effect / CoeffectComponent Loader↓配置协调 / HMRApplication Framework↓Koishi 等具体应用
这也是为什么 Cordis 这个名字在 dsh 的架构里值得注意:
它并不是简单地“管理插件”。
它实际上承担的是:
插件如何进入系统、如何影响系统、如何离开系统,以及依赖关系如何随之变化。
十三、Koishi:这套模型有没有真实使用价值?
为了验证设计,论文选择了 Koishi。
Koishi 是一个开源 chatbot application framework,经过多年发展,已经积累了4000 多个社区插件,包括:
IM Adapter Database Driver 管理后台 用户功能 各类第三方扩展
这样的系统非常适合验证动态组件组合,因为插件不是由同一个人统一开发的,而是来自一个开放生态。
论文指出,Koishi 的插件之间形成了真实的依赖拓扑:
IM Adapter↓提供消息能力Database Driver↓提供持久化能力Functional Plugin↓声明自己需要这些能力
因此,Provider 被替换或者删除时,依赖它的组件也需要跟着变化。
更重要的是,Koishi 已经实际使用这种动态能力。
例如禁用一个插件:
Disable Plugin↓Effects withdrawn↓系统继续运行
开发过程中修改插件:
Save↓HMR↓重新应用插件↓其他部分继续保持运行
论文指出,Cordis 会追踪通过 Context 执行的 effects,并自动组合 inverse,因此插件作者不需要自己维护完整的 uninstall path。
十四、这和传统的“重启服务”有什么区别?
实际上,很多系统早就可以处理动态变化。
只是它们处理问题的粒度比较粗。
例如:
组件出问题↓重启进程
或者:
服务变化↓由容器编排系统重新调度
这种方式当然有效。
但代价是:
你恢复的是整个进程或者整个服务,而不是一个组件。
论文特别指出,进程级重启会丢失缓存、连接和正在进行的计算等进程内状态;容器级编排也无法很好地表达同一个地址空间内部组件之间的依赖。
因此,问题并不是:
“重启有没有用?”
而是:
如果我们真正需要的是组件级别的动态变化,为什么一定要用进程级别的手段解决?
这就是论文所谓的granularity mismatch。
十五、回到 DeepSeek Harness:为什么“一切皆插件”值得关注?
现在再回头看 dsh 的设计,会更容易理解它背后的意义。
如果系统采用:
一切皆插件
那么系统中的能力就不再是固定写死的一组模块,而是可以被动态组合的组件。
这意味着:
Plugin APlugin BPlugin CPlugin D
之间不仅存在调用关系,还存在:
谁提供什么谁依赖什么谁修改什么谁应该在什么时候激活谁应该在什么时候退出
这时候,一个普通的:
Plugin Registry显然不够。
真正需要处理的是:
组件生命周期+依赖关系+环境变化+修改的撤销+组件替换
而这恰好是 Cordis 所关注的事情。
因此,如果把 dsh 和这篇论文放在一起看,可以得到一个很清晰的关系:
DeepSeek Harness││ 一切皆插件↓Cordis│├── Effect Tracking├── Coeffect Resolution├── Component Lifecycle├── Configuration Reconciliation└── HMR│↓A Programming Paradigmfor Spatiotemporal Composability│├── Temporal Composability│ ↓│ Revertible Effects│└── Spatial Composability↓Reactive Coeffects
从这个角度看,dsh 的“一切皆插件”并不是简单的工程组织方式。
它意味着:
系统中的能力被当成可以独立组合、替换和撤销的运行时组件。
而 Cordis 提供的,则是让这种动态组合变得可控的一套底层机制。
十六、这套设计最值得借鉴的地方
我觉得这篇论文最有价值的地方,并不是某一个 API。
而是一种软件设计思路:
把容易依赖开发者记忆和纪律的事情,尽可能变成系统模型本身的一部分。
传统插件系统里:
开发者:“我这里注册了一个 Listener,卸载的时候别忘了删。”
Context Paradigm 希望变成:
系统:“这个 Listener 是通过这个 Context安装的,我知道如何撤销它。”
传统依赖关系:
开发者:“这个插件依赖 Database,Database 没了记得处理。”
Reactive Coeffects 希望变成:
系统:“这个插件声明需要 Database。Provider 发生变化时,我负责重新解析。”
两者的区别可以概括成一句话:
从“开发者记住系统应该怎么恢复”,转向“系统知道自己应该怎么恢复”。
论文也把这一点称为 locality of concern:原本分散在各个插件作者身上的正确性责任,由底层抽象统一承担。
十七、这篇论文也没有解决所有问题
这一点值得特别说明。
论文的 Koishi 案例很有说服力,但它并不是一个大规模的性能对比实验。
作者自己也承认,目前的证据来自:
一个生态; 一种宿主语言; 一个具体的生产系统。
因此,它能够说明:
这套模型可以实际使用,并且已经被一个真实插件生态采用。
但还不能据此证明:
它在所有架构下都优于其他组件模型。
论文还指出,对 runtime overhead 和开发者生产力的量化比较仍然属于后续工作。
这其实也是比较健康的研究结论。
它没有把一个成功案例直接等同于“所有动态软件都应该这样设计”。
十八、从“插件”重新理解动态软件
如果把整篇论文压缩成一张图,我觉得可以这样理解:
动态组件│┌──────────┴──────────┐│ │↓ ↓我修改什么? 我依赖什么?│ │↓ ↓Effect Coeffect│ │↓ ↓能否撤销? 如何响应变化?│ │↓ ↓Revertible Effects Reactive Coeffects│ │└──────────┬──────────┘↓Context│↓Component Model│↓Cordis│↓Koishi / dsh
这也许是理解这篇论文最简单的方式。
它并不是在发明一种新的“插件 API”。
它试图回答的是一个更底层的问题:
当软件组件可以在运行时不断发生变化时,我们应该怎样描述组件与环境之间的关系?
论文的答案是:
组件一方面通过 Effect 修改环境,另一方面通过 Coeffect 声明自己对环境的依赖;前者需要能够撤销,后者需要能够响应变化,而两者通过统一的 Context 组织起来。
结语:真正变化的,不只是插件
过去,我们习惯把软件理解成一个相对固定的东西:
编译↓启动↓运行↓退出
插件系统打破了这个模式:
启动↓加载插件↓运行↓再加载一个插件↓卸载一个插件↓替换一个插件↓继续运行
而当组件可以在运行过程中不断加入、删除和替换之后,“软件是什么”这件事情本身也发生了变化。
它不再只是:
一组已经写好的代码。
而更像是:
一组正在运行、彼此依赖、持续组合的组件。
在这样的系统里,真正重要的问题就不再只是:
“这个插件怎么加载?”
而是:
它改变了什么?
它依赖什么?
它离开之后能否恢复?
它依赖的东西变化之后,系统能否重新组合?
这正是Spatiotemporal Composability这篇论文试图解决的问题。
而从这个角度再去看 DeepSeek Harness 的“一切皆插件”,也许会更容易理解 Cordis 为什么要把Effect、Coeffect、Context、生命周期和动态加载放在同一个体系里。
更动态的组件模型,并不只是让软件“更灵活”,也意味着我们需要一套新的方法,去保证这种灵活仍然是可控的。
这也是我认为这篇论文最值得读的地方。
参考
本文主要依据论文《A Programming Paradigm for Spatiotemporal Composability》的内容整理,重点涉及论文提出的Temporal Composability、Spatial Composability、Revertible Effects、Reactive Coeffects、Context Paradigm、Dynamic Composition Calculus,以及 Cordis/Koishi 实现与案例。
夜雨聆风