Cordis 深度拆解:为什么「时空可组合性」是 Agent 运行时的正确抽象
从 Koishi 四年实战到被 DeepSeek vendor 进 dsh,Cordis 的核心创新在于时间可组合性(可逆副作用)与空间可组合性(响应式依赖注入)。本文不带 Harness 单独讲透 Cordis 本身。
引子:DeepSeek 为什么要 vendor 一个两千行的框架
2026年8月13日,DeepSeek 开源了 DeepSeek Harness(DSH),MIT 许可证,GitHub 五天内星数破16万。比起这个数字,更值得注意的细节是:DSH 的源码仓库里,vendor 进了一个叫 Cordis 的子模块,占了整整一个 packages/cordis/ 目录,代码量两千行左右,作者是北大出身的 Shigma(施一帆)。
DeepSeek 为什么要把一个和模型预测毫无关系的运行时框架塞进自己的 Agent 产品里?
答案是:Cordis 解决了大模型推理之外、但直接影响 Agent 可靠性的那层问题——插件的安装、卸载、热更新,以及插件之间的依赖关系管理。
这听起来像是很底层的工程问题。但 Cordis 的解决方案足够优雅,值得单独拿出来讲清楚。这也是我写这篇文章的动机:不带 Harness,单独拆解 Cordis 本身。
Cordis 是什么
Cordis 是一个 TypeScript 编写的元框架(meta-framework),核心代码约两千行,MIT 许可证。作者 Shigma 同时是 Koishi(QQ 机器人框架)的核心维护者,Koishi 已经有四年以上的生产级实战经验,Cordis 是从 Koishi 中抽离出来的核心抽象。
从定位上说,Cordis 不是 DI 容器,不是插件管理器,也不是消息总线——它是「可组合性的基础设施」,目标是让任意两个插件 A 和 B 可以安全地互相依赖、互相替换、互相卸载,而不需要知道彼此的存在。
这个目标听起来简单,实现起来有两个正交的难点:Cordis 称之为时间可组合性和空间可组合性。
时间可组合性:插件卸载时,副作用谁来清理?
传统方案的问题
想象你写了一个插件 A,它在启动时做了三件事:连接数据库、启动定时器、注册 HTTP 路由。现在用户禁用了 A,优雅的做法是这三件事全部回滚——断开数据库、清除定时器、注销路由。
但在实际项目中,cleanup 代码经常是缺失的。原因是:
- cleanup 散落在各处,和业务逻辑混在一起
- 插件不知道自己被谁依赖,卸载时不敢清理,怕误伤依赖方
- 重启进程是最粗暴但最安全的清理方式,所以很多框架就躺平了
Cordis 的解法是让副作用「可逆」。
ctx.effect():副作用的自动回滚
const ctx = new Context()
// 启动时:注册数据库连接
const dispose = ctx.effect(() => {
const db = connectDatabase()
// 返回一个 dispose 函数——即"可逆"的证明
return () => db.disconnect()
})
// 卸载时:Cordis 自动调用 dispose()
// 多个 effect 按 LIFO(后进先出)顺序逆序执行
ctx.dispose() // 触发上面所有 effect 的清理
ctx.effect() 接收一个回调函数,回调返回另一个清理函数。Cordis 把这些清理函数收集到一个栈里,卸载插件时按 LIFO 顺序逆序执行——后注册的效果先清理,符合常理。
这就是时间可组合性:一个插件不需要知道谁依赖它,也不需要关心自己什么时候被卸载,它只需要声明「我做了一件需要清理的事」,Cordis 保证这件事在合适的时候被清理。
Fiber 状态机:热更新的骨架
Cordis 里每个插件都有一个 Fiber(纤维)对象,记录它的生命周期状态:
PENDING ──→ LOADING ──→ ACTIVE ──┬──→ DISPOSED
│ │
└──→ FAILED ──┴──→ UNLOADING
| 状态 | 含义 |
|---|---|
PENDING | 依赖未就绪,等待中 |
LOADING | 正在解析依赖和注入 |
ACTIVE | 正常运行中 |
FAILED | 运行时出错,隔离错误防止扩散 |
DISPOSED | 被显式禁用/卸载 |
UNLOADING | 正在执行清理,正在逆推 effect 栈 |
关键设计:状态转换不是代码硬编码的 if-else,而是由依赖图的变化驱动的。当一个插件的某个依赖消失时,Cordis 自动把这个插件推进 DISPOSED 状态;当依赖恢复时,自动触发重载。整个过程无需插件自身写任何重连逻辑。
这个机制在 Agent 场景下的价值尤为明显:大模型 API 的 rate limit 触发限流 → provider 插件进入 DISPOSED → 所有依赖它的工具自动降级 → API 恢复后自动重连。全程无硬编码。
KMR:热更新的三步
Cordis 的热更新机制被称为 KMR(Koa → Module → Reload),源自 Koishi 框架的设计:
- Koa(监视):监听源文件变化
- Module(卸载):当前插件进入 UNLOADING 状态,逆序执行所有 effect
- Reload(重载):重新加载插件模块,新 Fiber 进入 LOADING → ACTIVE
这个流程保证了热更新过程中不会出现「旧的 effect 还在跑、新的 effect 已经注册」的空窗期。Fiber 状态机把并发不安全的状态转变成顺序状态转移,用空间换时间。
空间可组合性:依赖消失时,依赖方怎么办?
传统 DI 的盲区
传统依赖注入(DI)容器(比如 Angular 的 Injector、NestJS 的 module system)解决了「谁来创建对象」的问题,但有一个默认假设:服务是永续的。如果数据库连接断了,依赖数据库的服务会直接报错,而不是被优雅地降级。
Cordis 的空间可组合性回答的是另一个问题:当一个服务消失或替换时,依赖它的对象应该处于什么状态?
inject:声明式依赖
const app = new Context()
// 插件 A 提供了一个 greet 服务
app.plugin({
name: 'greet',
apply(ctx) {
ctx.provide('greet', (name: string) => `Hello, ${name}!`)
}
})
// 插件 B 声明依赖 greet 服务
app.plugin({
name: 'consumer',
inject: ['greet'], // 声明式依赖
apply(ctx) {
ctx.on('ready', () => {
console.log(ctx.greet('Cordis')) // "Hello, Cordis!"
})
}
})
app.emit('ready')
inject 数组告诉 Cordis:「这个插件需要 greet 这个 key 对应的服务」。Cordis 的调度器(Scheduler)维护一张依赖图,当 greet 的提供者进入 DISPOSED 时,consumer 插件也会被推进 DISPOSED——不需要 consumer 写任何重连或降级逻辑。
路径无关性:插件启停顺序不影响最终状态
这是 Cordis 最有意思的特性之一。
// 方式一:先 greet 后 consumer
app.plugin(greetPlugin)
app.plugin(consumerPlugin)
// 方式二:先 consumer 后 greet
app.plugin(consumerPlugin)
app.plugin(greetPlugin)
// 方式三:中间插一个 disable 再 enable
app.plugin(greetPlugin)
app.plugin(consumerPlugin)
app.plugin(greetPlugin) // 重启 greet,consumer 自动重载
这三种方式最终得到的状态完全一致——ctx.greet 有值,consumer 处于 ACTIVE 状态。这叫路径无关性(Path Independence),是插件系统可靠性的重要保障:系统的最终状态和达到这个状态的路径无关,只和当前的插件集合有关。
路径无关性是 HMR(热模块替换)、fork/replay(分支调试)、fork/exec(子会话)的底层前提。如果插件系统对启动顺序敏感,这些能力都无法实现。
Context Proxy:服务仓库的查找机制
ctx.greet 里的 .greet 不是普通的属性访问,而是一个 Context Proxy 的 getter 陷阱:
const ctx = new Context()
// ctx.provide('greet', fn) 实际上做了什么?
// 1. 在全局 registry 中注册 key='greet', value=fn
// 2. 把当前 Context 变成一个 Proxy
// 3. 任何 ctx.someKey 的访问都变成 registry.find('someKey')
ctx.provide('greet', fn)
console.log(ctx.greet) // 实际是 registry.get('greet')
这个设计的代价是:高频率访问同一个 service key 时,Proxy get 陷阱有轻微的性能开销。Cordis 官方在文档中也承认了这一点,并建议对热路径做本地缓存(比如 const greet = ctx.greet 然后用本地变量)。
Waterfall 事件系统:拦截点的编排
Cordis 的事件系统不只是 on(event, handler) 和 emit(event, payload),它还有一个关键扩展:拦截器链(interceptor chain),即多个 handler 按序执行,每个都可以短路、修改参数或传递到下一个。
ctx.on('tool/execute', (next, payload) => {
console.log('工具执行前', payload.name)
const result = next(payload) // 调用下一个拦截器
console.log('工具执行后', result)
return result
})
这种模式对应了 LLM Agent 工具调用流水线的典型场景:日志记录 → 权限检查 → 参数校验 → 实际执行 → 结果审查。每一个环节都是一个拦截器,可以单独增删,不需要改核心代码。
DeepSeek Harness 的工具执行管线就建立在 Cordis 的这个事件系统之上——tool/execute 是 Cordis 事件,search_arxiv / read_pdf 是具体的 handler,中间可以插任何拦截器。
对 Agent 工程实践的独特价值
说了这么多设计细节,回到一个实际问题:Cordis 的思路能给 Agent 开发者什么实在的借鉴?
1. LLM Provider 的优雅降级
// provider 插件
app.plugin({
name: 'openai',
inject: ['llm'],
apply(ctx) {
ctx.provide('llm', createOpenAIProvider())
ctx.effect(() => {
const cleanup = monitorRateLimit(() => ctx.dispose())
return cleanup
})
}
})
// 工具插件——不需要知道 provider 怎么实现的
app.plugin({
name: 'toolkit',
inject: ['llm'],
apply(ctx) {
ctx.on('tool/execute', (next, payload) => {
// 如果 llm provider 已经被 dispose,这里 ctx.llm === undefined
// Cordis 会把这个插件也推进 DISPOSED,不会执行到这里
return next(payload)
})
}
})
Provider 断线 → Cordis 把 provider 推进 DISPOSED → 依赖它的所有工具插件自动降级 → Provider 恢复 → Cordis 自动触发重载。整个过程没有心跳检测、没有错误捕获、没有手动重连。
2. 工具的注册与注销不需要手动管理
app.plugin({
name: 'arxiv-tool',
apply(ctx) {
// 工具注册
ctx.provide('tool/search_arxiv', searchArxiv)
ctx.provide('tool/read_pdf', readPdf)
// ctx.effect 的返回值就是注销函数
ctx.effect(() => {
return () => {
// 工具注销——Cordis 在插件卸载时自动调用
// 不需要遍历所有工具手动删除
}
})
}
})
3. 配置即组合
Cordis 支持通过 YAML 或 JSON 配置文件声明插件组合:
# cordis.yml
plugins:
- name: openai
enabled: true
- name: arxiv-tool
enabled: true
config:
proxy: "http://127.0.0.1:7890"
替换 LLM Provider 只需要改一行配置,所有依赖方自动重建。这对于调试和 A/B 测试非常有价值。
局限性:诚实说
Cordis 不是一个银弹,它有几个值得诚实面对的局限:
1. v4 仍在 RC 阶段
Cordis v4 还未正式发布,API 可能会有破坏性变更。官方在 README 里明确写了 “THERE WILL BE COMPATIBILITY-BREAKING CHANGES”。在生产项目中引入时需要锁定版本号,或者做好升级预案。
2. async disposer 的并发清理没有严格串行保证
Cordis 的 effect 清理机制在同步场景下是严格的 LIFO 顺序。但在 async disposer 场景下,如果一个 effect 的清理是异步的,下一个 effect 的清理可能在它之前开始。Cordis 目前不保证跨 async effect 的严格串行清理,这在某些边界情况下可能导致竞态。实际影响面有限,但需要知道。
3. Context Proxy 的性能开销
所有 ctx.xxx 的访问都经过 Proxy get 陷阱,对于高频访问同一个 key 的场景(比如每毫秒都访问 ctx.llm),有可见的性能损耗。官方建议对热路径做本地缓存:const llm = ctx.llm。
4. 生态仍在早期
Cordis 本身的 API 文档和示例相对简洁,对于习惯了完整文档的开发者来说,上手曲线略陡。另外,Cordis + DeepSeek 的联合论文虽然形式化地定义了「时空可组合性」的语义,但论文描述的某些特性(如 async 场景下的精确语义)和当前实现的细节并不完全对齐——这是学术工程化的常见 gap。
结论:Cordis 给 Agent 开发者带来了什么
Cordis 的核心贡献不是又一个 DI 容器,而是一套关于「插件何时活着」的形式化抽象:
- 时间维度:插件做的副作用是可逆的,卸载时自动清理,不需要手动写 cleanup
- 空间维度:插件的依赖是响应式的,依赖消失时自动降级,不需要写心跳或重连
- 组合维度:最终状态和启动路径无关,插件可以安全地增删替换,不需要考虑顺序
这三个维度合在一起,解决了 Agent 运行时最棘手的一类问题:当 LLM Provider 限流、工具注册表更新、用户切换模型时,Agent 的整个工具链怎么安全地重新配置,而不会导致静默错误或状态不一致。
DeepSeek 把 Cordis vendor 进 DSH 不是偶然。它解决的不是模型预测的问题,而是让 Agent 系统在模型能力之外变得可信赖的问题。对于任何在做 Agent 运行时设计的开发者,Cordis 的设计值得认真读一遍——即使你不打算用它,它的思路会留在你对插件系统的认知里。
本文是「Agent 技术栈」系列的第四篇。前三篇:《与神经网络共事的这一年》、《国内四大模型平台横向对比》、《搭一个 Agent 需要哪些技术栈》