创见博客
Cordis 是什么:DeepSeek Harness「一切皆插件」背后的底座
七崽爱吃小饼干2026/09/21阅读 0

DeepSeek Harness(DSH)启动时只做三件事:建一个 Context、挂上 Loader、然后让配置长成一棵插件树。从 Web 界面到 agent loop,全都是挂在这棵树上的节点。

能让两百多个包各插各的、各卸各的而不互相打架的,不是 DSH 自己,而是它 vendor 进仓库的一个底层框架——Cordis。它的核心只有两千多行 TypeScript,却定义了 DSH 里所有的插件、服务、事件和生命周期规则。

这篇文章讲清楚:Cordis 是什么、它要解决什么问题、以及它的几个核心概念到底在做什么。

一、结论先行:Cordis 是一套「动态组合」的运行时

Cordis 对自己的定义是一句话:

A Meta-Framework of Spatiotemporal Composability. —— 一个关于「时空可组合性」的元框架。

翻译得直白一点:Cordis 不解决业务问题,它解决的是「组件随时能装、能卸、能换,而且互不干扰」。

几个事实可以先记住:

  • 它是被 vendor 进 DSH 仓库的,不是当依赖装的。位置在 vendor/cordis,所以你能直接在本地翻到 Context、Fiber、Loader 这些机制的实现。
  • 它自己本来就不是专门为 agent 写的。在 DSH 之前,Cordis 已经是一个通用插件框架(社区更熟悉它作为聊天机器人框架的那一面),DeepSeek 把它拿来当底座。
  • 它有正经的理论支撑。作者来自北京大学和 DeepSeek-AI,论文叫 A Programming Paradigm for Spatiotemporal Composability(arXiv:2608.25512),92 页,把 Cordis 里做的效果追踪和依赖解析形式化了一遍。

换句话说,DSH 的「一切皆插件」不是拍脑袋的设计,而是把一套有形式化基础的组合模型,落到了一个真实的大型应用上。

二、它要解决的两个问题

论文里把「动态组合」拆成两个正交的维度,这其实就是 Cordis 存在的全部理由。

时间是 temporal composability——组件被移除时,能否完全撤销它造成的副作用。

举个反例就懂了:一个插件在加载时往 ctx 上注册了工具、开了定时器、建了连接。如果卸载时只把插件代码从内存里丢掉,那些注册、定时器、连接还在,热重载几次之后系统就脏了。Cordis 要保证的是:组件消失,它的痕迹也一起消失。

空间是 spatial composability——能否声明并响应式地管理组件之间的依赖。

反过来,一个插件可能需要「文件系统就绪」「工具服务存在」才能工作。如果靠人手动编排启动顺序,组件一多就会变成一张脆弱的依赖网。Cordis 要保证的是:插件只声明它依赖什么,什么时候激活、什么时候卸载由框架算。

论文的解法是把经典的 effect / coeffect 概念上抬成运行时机制:

  • revertible effects(可逆效果):每一次上下文变换都携带一个逆操作,由运行时保管,从而实现单个组件层面上的时间可组合性。
  • reactive coeffects(响应式余效果):每一次上下文变化都拿组件的依赖声明去比对,据此驱动它的激活与失活,从而实现空间可组合性。

然后 Cordis 把 effect 上下文和 coeffect 上下文统一成同一个 Context 类型,所有效果和依赖都从这个 Context 上走。论文把这套纪律叫做 context paradigm,并证明了它能得到一个「观测等价」:不同组件的效果可以交错运行而不互相扰动。

这段话很抽象,但对应到代码就三件事:Context 是唯一的出入口、依赖用 inject 声明、注册必须可撤销。 下面一个个看。

三、五个核心概念

官方 primer 把 Cordis 提炼成五条,很值得先过一遍:

概念一句话
插件实现 Service 的对象,可以是带 apply(ctx) 的函数,也可以是 Service 子类
上下文服务的容器,一个服务占据一个稳定的 ctx.<key>,如 ctx.tools、ctx.llm
inject声明服务依赖,插件会等依赖就绪才启动
类型化事件服务通过声明合并注册事件名,再以五种模式分发
可逆副作用所有注册都通过 ctx.effect() / ctx.on() 安装,reload、teardown 时按预期撤销

注意第二和第五条的组合关系:其他插件通过 key 查找服务,而不是导入具体实现;而既然是通过 ctx 注册的,框架就有能力在卸载时把它抹掉。这两条一起,才撑起了「可插拔」。

四、一个插件长什么样

Cordis 支持三种等价的插件形态:

ts
// 形态一:函数(最常用)
export function apply(ctx, config) {}

// 形态二:类
export class MyPlugin {
  constructor(ctx, config) {}
}

// 形态三:带 apply 的对象
export default {
  name: 'x',
  inject: ['tools'],
  apply(ctx, config) {},
}

元数据里最有用的是两个:

  • inject:声明依赖的服务。它决定了你什么时候被激活,而不是加载顺序。
  • Config:用 Standard Schema 声明的配置结构,框架会先校验再传进来。校验失败会抛出 ValidationError,并把所有 issue 汇总成多行信息——所以配置写错是启动时就炸,而不是运行时莫名失效。

最小可跑的插件只需要一个 name 和一个 apply,不用构建、不用完整工程。

五、Context 是一个 Proxy

Cordis 里 Context 不是普通对象,它是一个 Proxy:

ts
const self = new Proxy<this>(this, ReflectService.handler)

这个实现细节解释了很多行为。ctx.tools 不是读一个对象属性,而是走一次服务解析。 于是服务可以按作用域解析、隔离、拦截——同一个 key 在不同 fiber 里可以解析到不同的实现,子上下文继承父上下文,但可以覆盖。

这也是「接缝(seam)/ 实现(provider)」这套玩法的基础:接口声明 ctx.fs 长什么样,具体是本地文件系统还是沙箱,由被加载的那个实现决定,而面向模型的工具代码完全不用改。

六、Service:能力的唯一注册方式

任何对外提供的能力都是一个 Service 子类,构造时把实例注册到 ctx.<name>:

ts
import { Service } from '@deepseek-ai/cordis'

export class MyStore extends Service {
  static inject = ['storage']

  constructor(ctx) {
    super(ctx, 'myStore') // 注册为 ctx.myStore
    this.items = []
  }

  add(item) {
    this.items.push(item)
  }
}

// 让别的插件拿到类型
declare module '@deepseek-ai/cordis' {
  interface Context {
    myStore: MyStore
  }
}

关键在于:注册即绑定生命周期。 服务随所属 fiber 卸载自动移除,你不需要手写清理代码。前面说的「可逆副作用」在这里第一次变得具体——你只管 provide,撤销是框架的事。

七、Fiber:插件的生命周期状态机

每个插件实例在 Cordis 里对应一个 Fiber。它跟 React Fiber 的直觉很像:是所有插件运行实例的生命周期管理单元,组成一棵可暂停、可更新、可销毁的树。

Fiber 的状态是一个明确的枚举:

ts
export const enum FiberState {
  PENDING,   // 等待所需服务
  LOADING,   // 插件回调执行中
  ACTIVE,
  FAILED,
  UNLOADING,
  DISPOSED,
}

它负责四件事:

  1. 保存插件自己的 Context 和经过校验的配置
  2. 等待 inject 依赖满足
  3. 执行插件代码
  4. 卸载时清理插件资源

这里最重要的一点:激活由「依赖就绪」驱动,而不是加载顺序。 配置文件里那句容易被忽略的注释就是在说这件事:

Row order carries no load semantics (activation is service-availability driven)

YAML 里行的先后不影响加载。 你声明需要 tools,框架就一直等到 tools 就绪才调用你的 apply;tools 消失了,你自动卸载。这就是「响应式余效果」在工程上的样子。

Fiber 也把运行时该有的操作都暴露了出来:dispose() 卸载、restart() 用当前配置重载、update(config) 校验新配置后重启(并先跑 internal/update waterfall,让钩子和 HMR 有机会否决)。热重载能做到「改配置即生效」,靠的就是这套。

八、effect:把副作用做成可撤销的

ctx.effect() 是「可逆副作用」的直接入口:

ts
ctx.effect(() => {
  const timer = setInterval(tick, 1000)
  return () => clearInterval(timer) // disposer
}, 'my-timer')

语义是:

  • execute 立即执行;
  • 它产生的清理函数被收集起来,在调用返回的清理函数或卸载 fiber 时按相反顺序运行,以先发生的为准;
  • 重复调用清理函数是 no-op,fiber 已销毁时注册会抛 CordisError('INACTIVE_EFFECT')。

方向要记牢:通过 ctx 注册的东西(事件、工具、定时器、服务)会自动撤销;但你自己创建的资源(网络连接、文件句柄)必须用 ctx.effect() 返回 disposer,否则热重载会留下残留。这是写 Cordis 插件最容易踩的坑。

九、事件:五种分发模式

服务解决「拿到能力」,事件解决「广播和拦截」。Cordis 的事件是类型化的:服务通过 TypeScript 声明合并注册事件名,然后以五种模式之一分发。

模式是否 await分发顺序是否有返回值
emit否监听器按注册顺序观察否
waterfall否监听器按注册顺序观察是
parallel是所有监听器并行观察否
serial是监听器按注册顺序观察是
bail否按注册顺序观察,直到某个返回 bail 值是

五种模式的取舍很清楚:emit 只管通知,parallel/serial 要等到结果,bail 用于「第一个拍板的说了算」,而 waterfall 是扩展点。

waterfall 是环绕中间件:监听器接收 (...args, next),调用 next() 就执行下游监听器,下游的返回值会经由 next() 传回当前层,可以被这一层包装后再往外返回;不调用 next() 直接返回,就短路。

ts
ctx.on('tools/execute', async (exec, next) => {
  const result = await next()
  return transform(result)
})

调用顺序是洋葱式的,每个插件都能在真正执行器前后插入逻辑:

text
Plugin A
  ↓ next()
Plugin B
  ↓ next()
实际执行器
  ↓
Plugin B 后处理
  ↓
Plugin A 后处理

官方对短路的态度很明确:对单决策事件,短路是设计意图。 有决策权的策略监听器可以不调 next() 直接返回;只想观察、标注的监听器则必须委托。DSH 里那些「不用改源码就能改行为」的能力——工具执行前拦截、模型请求前改写、错误时决定重试——挂的全是 waterfall。

十、为什么这套东西撑得起 DSH

回到开头。DSH 的每个环节都能替换,不是因为功能多,而是因为 Cordis 提供了三个保证:

  • 装:一个模块加一个 apply 就是插件,声明 inject 就能等到依赖。
  • 卸:所有注册都走 ctx,卸载时按相反顺序自动撤销,热重载不留残留。
  • 换:服务按 key 解析而不是按实现导入,换一行配置就换了整个能力实现。

代价是概念密度高。Context、Service、Fiber、effect、五种事件模式、waterfall——第一次接触容易晕。但收益也直接:补一个能力不需要理解整个系统。

理解了 Cordis,DSH 那份看起来很长的 cordis.yml 也就变得好读了:那不是什么魔法,只是一棵树、一堆服务依赖,和一堆可以被包住的瀑布事件。

参考

  • Cordis 仓库(cordiverse/cordis)
  • Cordis 入门(DeepSeek Harness 文档)
  • Cordis 教程(DeepSeek Harness 文档)
  • Fiber API(DeepSeek Harness 文档)
  • A Programming Paradigm for Spatiotemporal Composability (arXiv:2608.25512)
评论
0/100