DeepSeek Harness Cordis:为动态 Agent 而生的组件模型
最近在看 DeepSeek Harness 的时候,我顺着它的底层框架 Cordis 找到了一篇论文:《A Programming Paradigm for Spatiotemporal Composability》。
这个标题第一次看有点劝退。"时空可组合性"听起来很像编程语言理论里的东西,论文也确实花了不少篇幅做形式化定义和演算。
但如果先把这些公式放一边,它研究的问题其实很工程:
一个正在运行的软件系统,怎么安全地安装、卸载和替换组件?
这件事在传统插件系统里已经做了很多年。但到了 Agent 系统,问题突然严重起来。
一个 Agent 运行时里可能同时存在 LLM Provider、Tool、MCP、Memory、Sandbox、RAG、Skills、事件监听器、Prompt 扩展。以后甚至可能出现 Agent 自己安装 Skill、替换工具、调整运行组件的情况。
这时候,"能加载插件"远远不够。
真正麻烦的是:插件能不能卸干净,以及插件之间的依赖发生变化后,系统还能不能保持正确状态。
这正是 Cordis 这篇论文研究的东西。
论文把问题拆成了两个方向:Temporal Composability 和 Spatial Composability,也就是时间可组合性和空间可组合性。作者进一步把传统的 Effect 和 Coeffect 概念变成运行时机制,再用一个统一的 Context 把它们组织起来。
先从最容易踩坑的那个说起。
插件真正难的地方不是加载,而是卸载
假设我们给一个 Agent 动态安装了 PDF 处理插件。
插件启动的时候做了这些事:
PDF Plugin
│
├── 注册 pdf_parse Tool
├── 注册 pdf_summary Tool
├── 监听 message/received
├── 创建临时目录
├── 启动文件 watcher
└── 注册 PDF Service
加载当然没什么难度。
麻烦出现在卸载的时候。
如果只是:
plugins.delete("pdf")
那基本什么都没解决。
之前注册到 Tool Registry 里的工具还在,事件监听器还挂着,watcher 继续工作,临时资源没有释放,其他组件手里甚至可能还拿着旧的 PDF Service 引用。
插件没了,但它留下的痕迹还在。
这类问题在大型系统里非常烦,因为错误通常不会立即出现。可能过几个小时,你才发现某个 listener 被重复注册了三遍;或者 HMR 十几次以后,一个请求会被处理十几次。
Cordis 给这个问题起了一个名字:
Temporal Composability,时间可组合性。
它要求组件被移除以后,它对运行环境造成的副作用能够被完整撤销。论文为此提出了 Revertible Effects,也就是"可逆 Effect"。运行时不只记录一个组件做了什么,同时还跟踪对应的逆操作。
在 Cordis 里,一个典型写法是:
ctx.effect(() => {
const timer = setInterval(() => {
// do something
}, 1000)
return () => {
clearInterval(timer)
}
})
前面的函数建立副作用,返回的 disposer 负责撤销。
插件被卸载时,不需要插件作者重新手动遍历自己注册过的东西,Cordis 会沿着生命周期把这些 effect 清掉。
实际开发时甚至不需要所有东西都手写 ctx.effect()。
像:
ctx.on(...)
ctx.plugin(...)
Service 注册
Tool 注册
Cordis 和 DeepSeek Harness 已经把这些操作本身设计成 effect。插件卸载时,listener 会删除,子插件会递归卸载,Service 会解绑,Tool 也会从注册表里消失。对于 Cordis 不认识的 timer、连接、watcher 等资源,开发者才需要自己提供 disposer。
这有点像给插件生命周期加了一层事务。
只是这里的"事务"不是数据库事务,而是运行时资源的事务。
组件:
安装
↓
产生 effect A
产生 effect B
产生 effect C
卸载时:
撤销 C
撤销 B
撤销 A
↓
回到组件安装前的状态
我觉得这是整篇论文里最容易理解、也最实用的一部分。
只解决卸载还不够
再来看另一个问题。
假设 PDF 插件依赖 OCR:
PDF Plugin
│
└── OCR Service
传统的依赖注入系统通常在启动阶段把 OCR 传进去:
const pdf = new PdfPlugin(ocr)
程序正常启动以后,大家基本默认:
ocr 永远在那里
可动态插件系统不能这么假设。
运行过程中,OCR 插件可能被禁用。
也可能:
OCR V1
↓
OCR V2
被另一个实现替换。
如果 PDF 插件还握着原来的 OCR V1 对象,就进入了一种很尴尬的状态:
系统认为 OCR V1 已经不存在
但 PDF Plugin 还在调用 OCR V1
这种 bug 很难查。
Cordis 把这一类问题归到 Spatial Composability,也就是空间可组合性。
解决办法来自另一个概念:
Reactive Coeffects。
如果说 Effect 表达的是:
这个组件会对环境产生什么影响
那么 Coeffect 更接近:
这个组件运行需要什么环境
于是 PDF Plugin 不需要自己寻找 OCR,而是声明:
export const inject = ['ocr']
意思很简单:
没有 OCR,我就不应该运行。
Cordis 会让这个插件保持 PENDING,直到 OCR Service 出现。
OCR 不存在
PDF Plugin
↓
PENDING
OCR 加载完成:
OCR Service
↓
PDF Plugin
↓
ACTIVE
比较有意思的是,inject 不是启动时检查一次就结束。
如果运行过程中 OCR 消失,依赖 OCR 的插件也会被卸载;OCR 恢复以后,它们再重新启动。DeepSeek Harness 的 Cordis 文档明确说明,服务依赖会在运行过程中持续跟踪,而不是一次性的启动顺序检查。
于是替换实现也变得自然:
OCR V1
│
▼
PDF Plugin
卸载 V1:
OCR V1 removed
↓
PDF Plugin unload
↓
撤销自己的 effects
然后加载 V2:
OCR V2 available
↓
PDF Plugin reload
↓
重新绑定当前 OCR Service
消费方不需要知道究竟是谁提供 OCR。
这就比我们常见的 DI 多了一层东西:
依赖关系本身也是动态的。
Effect 和 Coeffect 最终都落到了 Context
到这里,论文真正想构建的模型已经比较清楚了。
一个组件身上同时存在两个方向:
Component
│
┌─────────┴─────────┐
│ │
Coeffect Effect
我需要什么 我改变什么
│ │
▼ ▼
Context Context
组件从 Context 获取它需要的能力,也通过 Context 对系统产生影响。
论文做了一步更理论化的处理:把 Effect Context 和 Coeffect Context 统一成一个 Context Type,并在这个基础上定义 Component 和动态组合演算。
这部分是论文真正偏编程语言理论的地方。
作者关心的已经不是:
某一个 plugin 能不能正确 unload
而是:
Plugin A
Plugin B
Plugin C
Plugin D
...
不断交错加载、卸载、替换以后,整个系统是不是仍然保持那些性质。
论文通过动态组合演算和对应的元理论,把单个组件上的时空可组合性推到一组互相交错的组件系统上。
工程开发不一定需要读懂里面每一个证明,但它解释了 Cordis 为什么要把很多看起来"框架帮你做一下就行"的事情设计得这么严格。
因为 HMR、依赖替换、插件树、资源释放,其实都建立在同一套生命周期语义上。
Fiber:Cordis 怎么管理一个活着的插件
到了 Cordis 的工程实现里,每次插件挂载都会对应一个 Fiber。
Fiber 可以理解成:
某个插件实例在运行时的生命周期句柄
它大概会经历:
PENDING
↓
LOADING
↓
ACTIVE
↓
UNLOADING
↓
DISPOSED
如果依赖不满足,就留在 PENDING。
如果 apply 执行失败,则进入 FAILED。
这种状态机有一个很实用的好处:挂载插件和启动插件不再是同一个动作。
例如配置里写:
- name: './consumer.ts'
- name: './ocr.ts'
即便 consumer 写在 OCR 前面也没关系。
Consumer 声明:
export const inject = ['ocr']
它就会等。
什么时候启动不是 YAML 顺序决定,而是依赖关系决定。
这其实解决了插件系统里一个很古老的问题:启动顺序。
很多项目最后都会出现这种东西:
先初始化 database
再初始化 redis
然后 event bus
然后 tool registry
然后 llm
最后 agent
系统稍微复杂一点,就开始出现几十行初始化顺序。
Cordis 换了一种思路:
别告诉 runtime 谁先谁后。
告诉它:
我依赖谁。
剩下的让依赖图决定。
这样一来,HMR 也变得顺理成章
普通 HMR 最麻烦的地方就是旧模块留下的状态。
新代码加载进来了,但:
旧事件还在
旧 timer 还在
旧对象还在
于是 HMR 次数越多,程序状态越奇怪。
Cordis 的处理方式很直接:
发现 hello.ts 修改
↓
卸载 Plugin V1
↓
回卷 V1 所有 effects
↓
加载 Plugin V2
↓
重新执行 apply()
DeepSeek Harness 的 Cordis 教程就是这么描述 HMR 的:旧实例先卸载,effect 被回卷,再加载新的插件代码;配置文件变化时,Loader 还可以根据稳定 ID 只重新挂载发生变化的部分。
换句话说,HMR 并不是一套孤立功能。
它只是:
可逆生命周期 + 重新组合
产生的自然结果。
这一点我挺喜欢。
为什么这套东西放到 Agent 上突然变得很合适
如果只是普通 Web 后端,大部分组件其实不需要频繁动态变化。
数据库连接启动以后一般不会突然换实现,Spring Bean 运行到一半也很少自己重新组合。
Agent 不一样。
今天一个完整 Agent Runtime 里已经可能出现:
Agent
│
├── LLM Provider
├── Tool Registry
├── MCP
├── Skills
├── Memory
├── RAG
├── Sandbox
├── System Prompt
├── Session
└── Agent Loop
这些东西中的很多都天然适合动态安装和替换。
用户启用一个 Skill:
增加新的 Tool
连接一个 MCP Server:
增加一批能力
切换 Sandbox:
替换 shell Service
换模型:
替换 LLM Provider
甚至 Agent 自己以后都可能修改自身组件。
这时候 Agent Runtime 已经不像传统应用:
启动
↓
运行
↓
退出
而更像一个长期存在、不断改变内部结构的系统。
所以我觉得 Cordis 和 Agent 的结合并不是巧合。
DeepSeek Harness 本身就采用了 "everything is a plugin" 的设计。官方 README 直接写明,它由 Cordis 驱动,而 Cordis 的设计正来自这篇时空可组合性的论文。
甚至一个 Tool 也是普通插件。
DeepSeek 官方教程里的例子就是:
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(
defineTool({
name: 'greet',
// ...
})
)
}
这个插件依赖 tools Service。
加载以后 Tool 出现在 Agent 的工具注册表里。
插件卸载,Tool 自动注销。
这种模型扩展起来很舒服,因为 Tool、LLM、Shell、Memory 不需要被框架写死成几个特殊模块。
它们都只是 Component。
可逆也不是魔法
这里有个边界需要说清楚。
"Revertible Effect" 很容易让人产生一种错觉:
插件干了什么
框架都能自动 rollback
实际上没这么神。
比如:
发送了一封邮件
执行 DROP TABLE
调用支付接口
向远程服务器写入文件
这些事情发生以后,运行时不可能凭空把现实世界倒放。
Cordis 真正能保证的是它管理范围内的 Context 和生命周期副作用能够撤销。
对于框架不知道的资源,开发者仍然要提供 disposer:
ctx.effect(() => {
const resource = createResource()
return async () => {
await resource.close()
}
})
Cordis 文档也明确要求,框架没有管理的资源应该包装进 ctx.effect(),并提供释放逻辑。
所以这套范式没有消灭资源管理。
它做的是把:
"记得 destroy"
从一种开发约定,提升成运行时生命周期的一部分。
差别挺大。
我觉得这篇论文真正值得看的地方
读完以后,我对它印象最深的其实不是那些形式化定义。
而是它重新定义了"插件"。
我们以前理解插件,通常是:
一段可以动态加载的代码
Cordis 的 Component 更接近:
一个声明自己需要什么,
同时把自己产生的变化交给 runtime 管理的运行单元。
一旦这么定义,很多过去需要分别解决的问题就串起来了。
插件卸载依靠 Effect 回卷。
依赖管理依靠 Coeffect。
依赖消失会触发生命周期变化。
生命周期可靠,HMR 才能可靠。
HMR 可靠以后,运行时动态重新组合才真正可用。
最后才轮到 Agent 自己动态调整系统组成这种更激进的场景。
所以如果让我用一张图概括 Cordis,我可能会画成这样:
Context
│
┌────────────┴────────────┐
│ │
Components Services
│ │
│ inject │ provide
└──────────┐ ┌────────┘
▼ ▼
Dependency
Graph
│
Context 发生变化
│
▼
重新计算生命周期
│
┌───────────┴───────────┐
▼ ▼
unload reload
│ │
revert effects apply effects
这套设计未必适合所有软件。
如果你的应用启动以后组件基本固定,那传统 DI、模块系统和明确的初始化/销毁流程已经够用了,引入 Cordis 反而会增加理解成本。
但如果系统本身就是动态的,尤其是 Agent Harness、插件平台、IDE、机器人框架这类长期运行又需要不断增删能力的软件,我觉得这篇论文提出的问题很准确。
现在很多 Agent 框架都在讨论怎么让 Agent 获得更多 Tool、更多 Skill、更多 MCP。
Cordis 往前多问了一句:
这些能力加进来以后,将来还能不能干干净净地拿出去?
等 Agent 真开始修改自己的运行时,这个问题恐怕会比"怎么加载一个 Tool"难得多。
评论暂时无法加载。