Cordis 的 API 并不复杂:
const root = new Context()
await root.plugin(ServiceA)
await root.plugin(pluginB)
插件可以通过 inject 声明依赖:
const pluginB = Object.assign((ctx: Context) => {
// use ctx.serviceA
}, {
inject: ['serviceA'],
})
插件内部的注册通过 ctx.effect()、ctx.on()、ctx.provide() 等接口完成。
真正决定这套系统能力的部分位于 API 下面:Context、Fiber、Service Registry 和 Effect cleanup 如何协同维护一个不断变化的插件图。
这一篇直接沿源码分析这条链路。
Context 保存的不是一份普通 Map
Cordis 的 Context 是一个 Proxy-backed dependency container。[2]
根 Context 创建时会安装几个基础服务:
Context
├── fiber
├── reflect
├── registry
├── events
└── logger
普通属性访问最终可以进入 Service Resolver,因此插件代码可以直接写:
ctx.tools
ctx.sessions
ctx.llm
而不需要:
container.get('tools')
这只是 API 形式上的差异。更重要的是 Context 还携带 Scope 信息。
extend() 创建子 Context;isolate() 可以让某个 Service name 在子树中进入独立作用域;intercept() 则给下层插件附加 Service-specific config。[2]
因此 Context 更接近:
service namespace
+
scope
+
lifecycle owner
+
event environment
每个 Plugin Fiber 都会从父 Context 派生自己的 Context:
this.ctx = parent.extend({ fiber: this })
这样插件产生的 Effect 可以自动归属到当前 Fiber。[1]
Fiber 是 Plugin 的运行实例
ctx.plugin() 最终会创建一个 Fiber。
当前源码给 Fiber 定义了以下状态:
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
Fiber 持有:
plugin runtime
config
inject declarations
resolved dependency implementations
owned effects
current epoch
in-flight lifecycle transition
所以它比一个简单的 PluginHandle 多承担了一层职责:维护插件在当前依赖环境中的有效性。[1]
插件声明:
inject: ['tools', 'llm']
以后,Fiber 会分别解析这两个 Service 的当前实现,并保存到 _store。
如果任一依赖缺失:
tools = available
llm = missing
Fiber 的 epoch 会变成:
__INACTIVE__
插件保持 PENDING。
只有依赖全部可用时,epoch 才会由当前 Service Provider Fiber 的 uid 组成:
:12:27
这个细节很重要。
依赖判断并不只检查 Service name 是否存在,还把“当前是哪一个实现提供了这个 Service”编码进 epoch。
因此下面两种状态会被认为不同:
tools provided by Fiber 12
和:
tools provided by Fiber 31
即使 Service name 仍然叫 tools,Provider 变化也会触发插件重新装载。[1]
Service 注册本身就是 Effect
ctx.provide(name, value) 没有简单地向 Map 写入数据。
它内部通过:
this.ctx.fiber.effect(...)
注册一个由当前 Fiber 拥有的 Effect。[2]
加载时:
store[key] = implementation
如果 Provider Fiber 此时已经是 ACTIVE,注册会立即通知依赖者;如果 Service 在插件的 LOADING 阶段注册,Cordis 会等 Fiber 转为 ACTIVE,再由状态转换统一发布该 Fiber 提供的 Service。这样依赖者不会在 Provider 自身尚未完成初始化时过早激活。[1][2]
卸载时 disposer 会:
delete store[key]
notify(name)
这形成了非常关键的生命周期关系:
Fiber owns Service
↓
Fiber unload
↓
Service removed automatically
Plugin 不需要再单独维护:
onUnload(() => unregisterService())
Service 的存在时间天然被绑定到提供它的 Fiber。
事件 Listener、Accessor、Mixin 等能力也使用类似的 effect ownership。
这正是论文中 Temporal Composability 在 Cordis 中最直接的实现。
A 被卸载以后,B 为什么会自动失效
现在分析一条依赖链:
Plugin A
└── provides serviceA
Plugin B
├── injects serviceA
└── provides serviceB
Plugin C
└── injects serviceB
初始状态:
A ACTIVE
↓ serviceA
B ACTIVE
↓ serviceB
C ACTIVE
卸载 A 时,A 的 Service Effect 开始 dispose。
ctx.provide('serviceA') 对应的 disposer 首先删除实现:
delete this.store[key]
随后调用:
this.notify(['serviceA'])
notify() 会检查当前 Registry 中的 Fiber,找到声明了对应 inject 的消费者。对 B 来说:[2]
serviceA changed
↓
B._checkImpl('serviceA')
↓
implementation missing
↓
delete B._store.serviceA
↓
B._refresh()
_refresh() 再次计算 B 的 epoch。
由于依赖缺失:
epoch = __INACTIVE__
接下来 _setEpoch() 发现:
old epoch = active dependency epoch
new epoch = INACTIVE
于是 B 进入:
UNLOADING
并执行 _unload()。[1]
这一过程回答了一个容易产生误解的问题:Provider 被卸载时,并不需要主动找到并手写销毁每一个下游 Plugin。
Provider 只撤销自己提供的 Service;Service Registry 负责通知依赖该 Service 的 Fiber,Fiber 根据自己的依赖声明决定是否失活。
B 卸载为什么会继续影响 C
B 的 _unload() 会清理 B 拥有的 Effects。
其中包括:
provide(serviceB)
所以 B 卸载期间,serviceB 同样被移除,并再次触发:
notify(['serviceB'])
于是 C 的依赖状态发生变化:
serviceB disappears
↓
C._refresh()
↓
C epoch = INACTIVE
↓
C unload
完整链路变成:
A unload
↓
serviceA removed
↓
B invalidated
↓
B unload
↓
serviceB removed
↓
C invalidated
↓
C unload
这里还有一个容易忽略的等待关系。ctx.provide() 的 disposer 在删除 Service 并调用 notify() 后,会等待受影响的依赖 Fiber await() 到稳定状态。于是 A 的 serviceA disposer 会等待 B 完成本轮生命周期协调;B 清理 serviceB 时又会等待 C 稳定。[2]
可以把异步关系画成:
dispose serviceA
│
├── remove serviceA
├── notify B
│ │
│ └── B unload
│ │
│ ├── dispose serviceB
│ │ ├── remove serviceB
│ │ ├── notify C
│ │ └── await C settled
│ │
│ └── B settled
│
└── await B settled
这解释了为什么依赖链上的资源不会被简单地“一刀切”清理。Service 对新的解析已经先变为不可用,下游 Fiber 随后完成自己的撤销;上游 disposer 会等待直接受影响的 Fiber 稳定。依赖关系继续通过每一层提供的 Service 向下传播。
这确实是一种级联,但不是一个显式写出的递归算法。
每一层只处理两个局部事实:
Service changed
Fiber dependencies changed
新的 Service 变化会继续触发下一层,最终系统进入没有更多依赖变化的稳定状态。
因此更准确的描述是:Cordis 通过 Service 变更事件驱动局部 reconcile,依赖链由多次局部状态转换自然传播。
当前 vendored 实现的 notify() 会遍历 Registry 中的 Fiber,再按 inject 和 isolation scope 筛选受影响对象。语义上是按依赖名称局部更新,但实现上并没有要求预先维护完整反向依赖图。[2]
卸载是立即发生的吗
Service 被移除以后,依赖 Fiber 会立即开始生命周期转换,但卸载过程本身可以是异步的。
Fiber 保存:
inertia: Promise<void> | undefined
表示当前正在执行的 load / unload transition。[1]
当 epoch 发生改变时,_setEpoch() 会更新目标 epoch。如果此时已有 inertia,不会再并发启动第二个 lifecycle task。
例如 B 正在卸载时,serviceA 又重新出现:
B UNLOADING
serviceA appears
此时 B 的目标 epoch 会重新变成 active epoch,但 _setEpoch() 看到 inertia 已存在,不会直接并发执行 _reload()。
当前 _unload() 完成后会再次检查目标 epoch:
if target epoch is inactive
stay pending
else
reload with latest epoch
所以真实过程是:
dependency lost
↓
target epoch = INACTIVE
↓
start unload
↓
dependency returns during unload
↓
target epoch updated
↓
finish current unload
↓
reload with latest epoch
这避免了同一个 Plugin 同时执行 load 和 unload。
这里的 epoch 可以理解成 Fiber 对“当前依赖世界”的版本标识。
只有目标依赖环境变化,生命周期才需要重新协调。
Effect 的撤销顺序
Fiber 维护自己的 _disposables。
DisposableList.clear() 会按照注册顺序的逆序返回 disposer,因此后注册的 Effect 会先进入 teardown。[1]
但 Fiber _unload() 对这些顶层 disposer 使用 Promise.all 执行异步清理。也就是说,顶层 Effect 的调用顺序按逆序发起,但异步完成顺序没有全局串行保证。
单个 ctx.effect() 内部收集的 disposer 则会按逆序逐个执行。
这也是 DeepSeek Harness 的 Cordis Primer 特别说明的一点:如果某些资源之间存在严格 teardown 顺序,应该将它们放在同一个 Effect 中,由这个 Effect 自己定义撤销顺序。[7]
这个约束很实际。框架负责生命周期所有权,不应默认猜测两个独立资源之间的业务顺序。
Context 重新计算发生在什么时候
Cordis 没有一个持续扫描所有插件的后台循环。
核心触发点来自结构变化:
Service registered
Service removed
Service implementation changes
plugin config changes
plugin restart / dispose
以 Service 为例:
ctx.provide()
↓
reflect.notify()
↓
affected fibers _checkImpl()
↓
_refresh()
↓
compute dependency epoch
↓
_setEpoch()
如果 epoch 没变化:
if (epoch === oldEpoch) return
生命周期不会发生任何动作。
如果从 INACTIVE 变成 active:
PENDING → LOADING → ACTIVE
如果从 active 变成 INACTIVE,或者 Provider identity 改变:
ACTIVE → UNLOADING
Provider identity 改变时,卸载完成后会按照新的 epoch 再加载。
因此 reconcile 的最小单位是 Fiber,而触发源是它声明依赖的运行环境发生变化。
DeepSeek Harness 为什么需要这一层
DeepSeek Harness 当前架构把几乎所有主要能力都作为插件安装:[6]
core/session → ctx.sessions
core/tools → ctx.tools
core/agent → ctx.agents
core/agent-loop → ctx.agentLoop
llm/llm → ctx.llm
system-prompt → ctx.systemPrompt
官方架构文档直接描述:模型适配器、Tool Registry、Session Log 以及 Agent Loop 本身都属于插件,没有一个要求所有扩展去 patch 的 privileged kernel。[6]
这意味着 DeepSeek Harness 的运行结构更接近:
Cordis Context
│
├── Session Service
├── LLM Service
├── Tool Service
├── Agent Registry
├── Agent Loop
├── Persistence
├── Sandbox
└── ...
Agent Loop 本身可以依赖:
Session
LLM
Tools
SystemPrompt
当某个 Provider 被替换时,依赖它的 Plugin Fiber 可以按照同一套 epoch 机制重新协调。
因此 Everything is a Plugin 需要的并不只是统一 Loader。真正困难的是:
插件出现和消失
+
Service 出现和消失
+
依赖关系变化
+
副作用完整撤销
+
异步生命周期不互相竞争
Cordis 提供的正是这部分运行时语义。
从 Spring 的角度理解
如果熟悉 Spring,可以把几个概念做有限度的对应:
Cordis Context
≈ ApplicationContext + scoped runtime environment
Service
≈ runtime-provided Bean capability
inject
≈ dependency declaration
Fiber
≈ BeanDefinition + instance lifecycle owner + scope state
Effect disposer
≈ DisposableBean / destruction callback
对应只用于建立起点,不能直接等同。
Spring 的典型 ApplicationContext 在 refresh 后结构相对稳定。Bean 依赖主要在创建期解析,运行中删除一个 Bean 并让所有下游 Bean 自动失活、再在依赖恢复后重新激活,并不是常规 BeanFactory 生命周期模型。
Cordis 把这种动态结构变化设为正常运行路径。
所以它的重点不是“比 Spring 多一个 DI 容器”,而是把 DI 与生命周期 reconcile 连在了一起。
这套模型的成本
动态组合并非没有代价。
首先,Plugin 代码需要正确声明依赖。隐式读取 Service 会破坏 Runtime 的依赖认知。
其次,所有外部注册都应该进入 Effect ownership。绕过 Context 直接向全局结构写入状态,会留下无法自动撤销的资源。
再次,Plugin 必须能够重复 load / unload。初始化函数不再默认只执行一次。
最后,动态依赖使状态机复杂度上升。PENDING / LOADING / ACTIVE / UNLOADING / FAILED、异步 cleanup、重入和 HMR 都需要严谨处理。
因此 Cordis 适合那些确实需要运行时组合的系统。对于能力固定的小型 Agent,把所有模块都抽象为动态插件只会增加理解成本。
DeepSeek Harness 的产品目标包含插件替换、配置组合、HMR、隔离 Scope 和广泛扩展点,这使得这种复杂度具有明确收益。
下一篇 Demo 会把 Cordis 的核心语义压缩到一个很小的实现:Service、Dependency、Effect、Fiber 和 reconcile。通过实际运行可以观察 A → B → C 的依赖链如何失活,再随着 A 恢复逐层重新激活。
参考资料
[1] Cordis Fiber source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts
[2] Cordis Reflect / Service resolution: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts
[3] Cordis Context source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/context.ts
[4] Cordis Registry source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/registry.ts
[5] Cordis Service source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/service.ts
[6] DeepSeek Harness Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md
[7] DeepSeek Harness Cordis Primer: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.md