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 支持三种等价的插件形态:
// 形态一:函数(最常用)
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:
const self = new Proxy<this>(this, ReflectService.handler)
这个实现细节解释了很多行为。ctx.tools 不是读一个对象属性,而是走一次服务解析。 于是服务可以按作用域解析、隔离、拦截——同一个 key 在不同 fiber 里可以解析到不同的实现,子上下文继承父上下文,但可以覆盖。
这也是「接缝(seam)/ 实现(provider)」这套玩法的基础:接口声明 ctx.fs 长什么样,具体是本地文件系统还是沙箱,由被加载的那个实现决定,而面向模型的工具代码完全不用改。
六、Service:能力的唯一注册方式
任何对外提供的能力都是一个 Service 子类,构造时把实例注册到 ctx.<name>:
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 的状态是一个明确的枚举:
export const enum FiberState {
PENDING, // 等待所需服务
LOADING, // 插件回调执行中
ACTIVE,
FAILED,
UNLOADING,
DISPOSED,
}
它负责四件事:
- 保存插件自己的 Context 和经过校验的配置
- 等待
inject依赖满足 - 执行插件代码
- 卸载时清理插件资源
这里最重要的一点:激活由「依赖就绪」驱动,而不是加载顺序。 配置文件里那句容易被忽略的注释就是在说这件事:
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() 是「可逆副作用」的直接入口:
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() 直接返回,就短路。
ctx.on('tools/execute', async (exec, next) => {
const result = await next()
return transform(result)
})
调用顺序是洋葱式的,每个插件都能在真正执行器前后插入逻辑:
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 也就变得好读了:那不是什么魔法,只是一棵树、一堆服务依赖,和一堆可以被包住的瀑布事件。