插件系统的价值,不只在于加载能力,更在于能否安全地卸载、替换和重组运行中的能力。本文从一个 Mini Cordis 出发,拆解 Context、Effect 与生命周期管理,并说明 DeepSeek DSH 为什么选择 Cordis 作为运行时基础。
本文是「企业研发 AI 自动化」系列第 39 篇,欢迎关注和交流。
提到插件系统,人们通常先想到“扩展”。
编辑器通过插件支持新的语言,浏览器通过插件增加新的能力,构建工具通过插件接入不同的编译流程。它们看起来都在做同一件事:把原本写死在系统里的功能拆出去,需要时再装进来。
扩展确实是插件系统最直观的价值。可当插件被装进一个长期运行的系统,接下来的问题更棘手:它依赖的能力从哪里来?它创建的定时器、监听器和后台任务由谁管理?插件被卸载时,如何保证这些资源一起消失?多个插件互相依赖时,怎样避免加载顺序成为一套脆弱的隐含规则?
如果这些问题没有被处理,系统虽然加载了插件,结果往往只是代码被拆到了更多文件里。功能可以加载,生命周期却仍然纠缠在一起;插件拥有名字,系统却不知道各项资源分别属于谁。
Cordis 正是从这些问题出发。
在执行插件之外,它还为动态进入系统的能力建立边界:它们可以依赖已有服务,可以注册自己的资源,也可以在退出时被完整释放。插件是能力进入系统的方式,Context 则承载了能力、依赖和生命周期之间的关系。
在只需静态加载的应用中,这些问题未必突出。到了 Agent Runtime,也就是承载模型、工具和执行流程的运行环境中,它们却很难再绕过去。
一个 Agent 系统里可能同时存在模型、工具、会话、沙箱、权限、日志、追踪器和各种后台任务。这些能力未必在启动时一次性确定,也未必拥有相同的生命周期。某个任务结束后,它创建的资源需要消失;某个插件卸载后,其他能力仍应继续运行;新的能力还要能够接入已有系统,而不要求所有模块按照严格顺序重新启动。
理解 DeepSeek Harness(以下简称 DSH) 为什么选择 Cordis,也要从这些问题出发。Cordis 为它提供了插件 API,也提供了一套组织动态能力的运行时机制。
先写一个只负责执行函数的示意版本:
插件确实运行起来了:
可问题很快就会出现。
setInterval() 创建的定时器不属于 Context,运行器也不知道它的存在。即便以后增加了 unload(timerPlugin),插件函数已经执行过,定时器仍会留在进程里继续运行。
监听事件、打开连接、注册工具、添加路由也是一样。它们在代码上只是一次函数调用,在运行时却都改变了系统状态。只记录“哪个插件被加载过”,不足以撤销这些变化。
因此,一个支持完整卸载的插件系统,需要在资源被创建时就知道两件事:
Cordis 把这类变化称为 Effect。这里的 Effect 可以先按最朴素的方式理解:插件对运行中系统产生的一次影响,以及撤销这次影响的方法。
插件加载时,Effect 创建资源;插件卸载时,Runtime 调用它返回的清理函数。插件不需要在各处分散地记住清理逻辑,Runtime 也终于知道哪些资源归属于哪个插件。
在真实 Cordis 中,事件监听、子插件和 Service 注册本身已经是可回收的 Effect。定时器、连接、文件监听器等 Cordis 无法直接管理的资源,才需要显式包进 ctx.effect()。DeepSeek Harness 的 Cordis 生命周期教程列出了插件退出的几种情况:配置变化、热更新、主动释放,以及所依赖的 Service 消失。无论从哪条路径退出,由 Runtime 管理的注册都会随插件一起撤销。
这时,插件才有了“拔出来”的能力。
资源可以回收之后,还有另一个问题:插件之间如何协作?
假设系统中有一个日志插件,另一个任务插件需要记录日志。最直接的写法,是让任务插件导入日志实现:
这样可以工作,但两个插件已经在代码层面绑在了一起。以后想把本地日志替换成远程日志,或者在测试中换成专用的 Logger,消费者也要跟着修改。
Cordis 引入 Service 来表示一项有稳定名字的能力。提供者把能力注册到 Context,消费者通过名字使用它:
消费者依赖的是 logger 这项能力,而不是某一个 Logger 实现。谁来提供 Logger,可以由当前运行配置决定。
这里的 Context 与大模型的“上下文窗口”无关。对于插件而言,它是所处运行环境的视图:通过它可以取得 Service、注册 Effect、挂载子插件和参与事件通信。
但“能够从 Context 读取 Service”仍然不够。如果消费者先启动、提供者后启动,会发生什么?如果 Logger 在运行过程中被替换,已经启动的消费者又该怎么办?
Cordis 用 inject 显式声明插件依赖:
如果 logger 尚不存在,插件不会带着残缺依赖勉强启动,而是保持等待;Service 出现以后,它再进入运行状态。更重要的是,这种依赖并非只在启动时检查一次。Service 在运行中消失,依赖它的插件会随之卸载;Service 恢复后,插件可以重新加载。
于是,配置文件里谁写在前面不再承担依赖管理的职责。加载时机由真实依赖决定,而不是靠开发者维护一套越来越脆弱的启动顺序。DeepSeek Harness 的 Service 教程专门用交换配置顺序的例子说明了这一点。
Effect 解决的是一个能力退出时,系统能否恢复原状;Service 与 inject 解决的是:能力由不同插件提供时,依赖关系能否被正确解析,并随 Service 的变化自动调整。
后来,Cordis 的预印本论文把这两件事概括为两个方向:
“时空可组合性”听起来有些抽象,落到代码中可以化成两句更直白的话:我依赖谁,以及我离开时留下什么。
只读 Cordis 源码,很容易陷入 Fiber、Scope、Service Proxy 和事件调度等实现细节。为了验证这套理解,我实现了一个 Mini Cordis。
下面直接看本文撰写时的实现。为了便于阅读,同一个 Context 被拆成几个片段展示,API 名称和关键逻辑都与仓库保持一致。
先看 Plugin 如何进入 Runtime:
export interface Plugin {
inject?: string[]
setup(ctx: Context): void
}
export class Context {
private readonly cleanups: Array<() => void> = []
private readonly services = new Map<string, unknown>()
private readonly children: Context[] = []
constructor(public readonly parent?: Context) {}
createChild(): Context {
const child = new Context(this)
this.children.push(child)
return child
}
use(plugin: Plugin): Context {
for (const name of plugin.inject ?? []) {
if (!this.hasService(name)) {
throw new Error(`Missing service: ${name}`)
}
}
const child = this.createChild()
plugin.setup(child)
for (const [name, service] of child.services) {
this.services.set(name, service)
}
return child
}
}
ctx.use(plugin) 会先检查 inject 中声明的 Service,再创建一个 Child Context,并调用插件的 setup()。插件在自己的 Context 中运行,use() 返回这个 Context,后面可以单独释放它。
Service 的注册和查找是另一组关键方法:
class Context {
provide(name: string, service: unknown): void {
this.services.set(name, service)
}
getService(name: string): unknown {
if (this.services.has(name)) {
return this.services.get(name)
}
return this.parent?.getService(name)
}
private hasService(name: string): boolean {
return this.services.has(name) ||
this.parent?.hasService(name) === true
}
}
查找从当前 Context 开始,找不到再向父级继续。插件执行完 setup() 后,use() 还会把它提供的 Service 复制到当前 Context,让后续加载的兄弟插件也能发现这些能力。这是 Mini Cordis 为了实现跨插件共享采用的简化方式,后面还会看到它与真实 Cordis 的差别。
Effect 与释放逻辑同样直接:
class Context {
effect(cleanup: () => void): void {
this.cleanups.push(cleanup)
}
dispose(): void {
while (this.children.length > 0) {
this.children.pop()?.dispose()
}
while (this.cleanups.length > 0) {
this.cleanups.pop()?.()
}
this.parent?.removeChild(this)
}
private removeChild(child: Context): void {
const index = this.children.indexOf(child)
if (index !== -1) {
this.children.splice(index, 1)
}
}
}
Mini Cordis 的 effect() 直接接收清理函数。插件创建定时器后,把 clearInterval() 注册进去;dispose() 先递归释放子 Context,再按相反顺序执行清理函数。这里比真实 Cordis 的 Effect API 更简单,但资源归属和逆序释放已经跑通了。
仓库里的演示同时运行 Timer Plugin 和 Logger Plugin:
Timer 被清理,Logger 仍在运行。这个结果证明 Runtime 已经能够回答最初那个问题:某个资源属于哪个插件,以及只卸载这个插件时应该清理什么。
这个 Mini Runtime 的价值不在于复刻 Cordis。它只保留了一个最小因果链:
当这条链跑起来以后,Plugin、Context、Service 和 Effect 就不再是四个孤立概念。它们共同回答的是动态能力如何进入、协作和退出系统。
几十行代码可以解释思想,但远不足以支撑真实系统。两者的关键差别,在于 Runtime 能否持续管理变化。
首先,Mini Cordis 只在 use() 时检查一次 inject,缺少 Service 就直接抛错。为了让兄弟插件共享能力,它还会把 Child Context 中的 Service 复制到父级;插件释放以后,这份副本不会自动消失。真实 Cordis 会把 Service 注册本身纳入 Effect:依赖未满足时,插件进入等待;Service 消失时,依赖它的插件随之卸载;Service 恢复后,再重新加载。它管理的是依赖随时间变化的全过程。
其次,Mini Cordis 用 Child Context 和清理函数数组模拟插件边界。真实 Cordis 会为每个已加载的插件实例创建 Fiber,也就是这个实例在 Runtime 中的生命周期句柄。Fiber 会经历 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 等状态,也会处理加载失败、异步清理和子插件递归释放。Mini Cordis 中的 dispose() 只是这套状态机最小的一块投影。
再次,真实 Cordis 还有事件系统。Service 适合“我知道要调用哪项能力”的直接协作,Event 适合“我只发布发生了什么”的松耦合通信。DeepSeek Harness 中的工具结果、模型请求和审批决策等交互,都可以通过不同类型的事件扩展点被观察或介入。
最后,Cordis 还包括声明式 Loader、配置校验与协调、热模块替换等能力。预印本论文《A Programming Paradigm for Spatiotemporal Composability》对 Cordis 的概括,除了 Effect Tracking 和依赖解析,还明确包括配置协调与热更新。它的职责已经从“运行一个插件函数”扩展到让一棵不断变化的组件树保持一致。
Mini Cordis 呈现的是骨架:资源归属、依赖查找和层级释放。Cordis 在骨架之上增加了响应变化所需的状态机、调度和一致性机制。
现在再回到 DeepSeek Harness。
按照它的 架构文档,模型适配器、工具注册表、Session Log 和 Agent Loop 都是插件。插件向共享 Context 提供 Service、类型化事件和可撤销的 Effect。系统没有一块需要不断修改的“特权核心”;扩展能力的主要方式,是把新的插件挂到现有插件树中。
这句话听起来很像常见的“万物皆插件”,但它在 Agent Runtime 中有很现实的含义。
模型适配器可能被替换,工具可能按不同环境启用,Web 与 Headless 模式需要不同的能力组合,沙箱和权限策略也可能由部署配置决定。Agent 运行过程中还会产生会话级、任务级和子 Agent 级资源,它们的存活时间未必与整个进程一致。
以 Agent Loop 为例,它使用的是 ctx.llm、ctx.tools 等稳定的 Service,而无需直接绑定某个模型适配器或工具实现。配置决定当前由谁提供这些能力;插件卸载时,它注册的工具、事件监听和其他 Effect 一并撤销。这样,“更换实现”和“清理旧实现”才属于同一次运行时变更,而不是两套彼此分离的工作。
如果这些能力都通过直接导入和全局单例连接起来,系统仍然能够启动。但随着能力增多,替换一个模型实现、卸载一组工具或销毁一个任务环境,都可能牵动大量隐含状态。最终,最安全的清理方式只剩下重启整个进程。
Cordis 提供了另一种组织方式:
inject 把依赖关系交给 Runtime,而不是交给配置顺序;再看 DSH 的配置,它描述的是当前 Agent Runtime 应当由哪些能力组成,包的加载清单只是这种组合的外在表达。配置发生变化,Runtime 需要把正在运行的系统从一种组合安全地变成另一种组合。
Cordis 论文讨论的“动态组合”,落到 Agent 系统里就是这样的变化。
模块化解决了一个长期问题:如何把复杂软件拆成边界相对清楚的部分。但对于一类动态系统,只能拆开还不够。能力会在运行时进入和退出,依赖会出现和消失,局部组件也需要在不重启整体的情况下被替换。
此时,系统还需要一层新的能力:持续管理这些模块当前是否存在、依赖是否满足、产生了哪些影响,以及退出时能否恢复现场。
这可以称为从“模块化”继续向“运行时化”走了一步。模块依然是代码组织的基础,Runtime 开始负责模块在时间中的状态与关系。
AI Agent 正在放大这类需求。一个成熟的 Agent Harness 很难永远由固定模型、固定工具和固定执行流程组成。它会承载越来越多可替换的模型适配器、工具提供者、权限策略、记忆机制、验证器和子 Agent。能力数量越多,困难也会从“如何接入”延伸到“如何治理它们的进入、依赖、变化和退出”。
Cordis 提供的答案未必适合所有系统,但它揭示了一个值得长期关注的方向:未来 Agent Runtime 的差异,既体现在拥有多少工具和流程,也体现在能否把不断变化的能力组织成一个可组合、可追踪、可恢复的系统。
插件只是入口。
Cordis 管理的,是这些能力之间的关系,以及它们各自的生命周期。
《企业研发 AI 自动化》是我持续记录 AI 进入真实研发流程后的实践系列。
它关注的不只是 AI Coding 工具本身,而是从需求输入、任务表示、Agent 执行到自动化验证的完整链路:AI 如何在真实工程系统里稳定运行,并逐渐形成可复用的研发自动化能力。
欢迎关注和交流~