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 代码经常是缺失的。原因是:

  1. cleanup 散落在各处,和业务逻辑混在一起
  2. 插件不知道自己被谁依赖,卸载时不敢清理,怕误伤依赖方
  3. 重启进程是最粗暴但最安全的清理方式,所以很多框架就躺平了

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 框架的设计:

  1. Koa(监视):监听源文件变化
  2. Module(卸载):当前插件进入 UNLOADING 状态,逆序执行所有 effect
  3. 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 需要哪些技术栈》