Cordis 内核到底在管什么?五要素我带你看源码
上一篇我去魅了 dsh (DeepSeek Harness) 的微内核 (Cordis),但只讲到概念级。 这一篇把上次的「五要素」逐个翻成源码。看完你应该能自己读
vendor/cordis任何一行。 代码快照于 2026-08-19,仓库 commit99f6f02f(dsh0.1.0-rc.7),cordisv4.0.1。 后续版本可能变动,请以 clone 后的源码为准。
0. 地图:把 dsh 拆开,cordis 在哪一层
dsh 是个 monorepo。仓库根 package.json 名字是 @deepseek-ai/dsh-root,版本 0.1.0-rc.7 (Developer Preview),workspaces 字段把 vendor/* packages/*/* apps/* 都包进来。cordis 并不在 packages/ 下,而是被 vendor 进来的一颗第三方依赖级微内核:
text
@deepseek-ai/dsh-root v0.1.0-rc.7 (MIT)
├── vendor/cordis @deepseek-ai/cordis v4.0.1 ← 本篇源码拆解对象
├── vendor/* cosmokit 等其他被 vendor 的依赖
└── packages/* dsh 应用层 (client / runtime / providers ...)
vendor/cordis/src/ 下的 9 个 ts 文件 (我用 wc -l 实测):
| 文件 | 行数 | 角色 |
|---|---|---|
index.ts |
14 | 聚合导出 |
context.ts |
146 | Context 类与稳定键接口 |
events.ts |
352 | 事件总线 + 5 种分发 |
fiber.ts |
754 | 生命周期 + 可逆副作用 |
logger.ts |
270 | 命名 logger |
reflect.ts |
418 | 服务/依赖查找与注入 |
registry.ts |
337 | 插件 + @Inject 装饰器 |
service.ts |
115 | Service 抽象基类 |
utils.ts |
287 | 内部工具 (DisposableList, symbols, Tracker) |
| 合计 | 2693 |
数字会随版本变;我把它当 2026-08-19 这一刻的快照。下文所有的 file:line 都是这个快照。

第一个易错点:仓库名里的 Harness 是个产品,cordis 是它的微内核。上一篇我在图里把 cordis 画成 dsh 的一部分,其实cordis 是个独立的 npm 包 (
@deepseek-ai/cordis),只是被 dsh 锁进vendor/。
1. Plugin:能力注册的入口
Cordis 不发明新东西。一个插件就是一个「挂了 ctx 的对象」,挂法有两条路:
类路径 :继承 Service (service.ts:11),构造时自动注册。
ts
// service.ts:42-58
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
// ...
self.ctx = ctx
self.name = name
defineProperty(self, symbols.tracker, tracker)
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
对象路径 :{ inject?, apply }。inject 声明依赖 (registry.ts:19 是 Inject 类型),apply 拿到 ctx 干活 (registry.ts:8-10 isApplicable 检查)。
两种路径最后都汇到 ctx.reflect.provide(name, ...),而 provide 内部是这样:
ts
// reflect.ts:277-280
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
// ... 写 this.store[isolate]、注册 cleanup
})
}
这条链就是 Cordis 的关键 :插件注册不是一个赋值操作,而是一个 fiber.effect(...)。意思是你写的 new MyService(ctx, 'tools') 这一行,副作用的撤销已经被预定了。等 fiber 卸载,service 自动摘掉。这就是为什么 dsh 能「干净卸载一个插件」的源头。
2. Context:稳定键的共享仓库
Context 是个 Proxy。context.ts:16-33 是它的公开接口:
ts
// context.ts:16-33
export interface Context {
[symbols.isolate]: Dict<symbol> // 隔离表:服务名 -> 作用域标签
[symbols.intercept]: Dict // 拦截表:服务名 -> per-plugin 配置
root: this // 根 ctx (experimental)
baseUrl?: string
events: EventsService // 事件总线
logger: LoggerService // 命名 logger
reflect: ReflectService // 反射层 (provide / get / mixin)
registry: RegistryService // 插件注册 (plugin / inject)
}
只有这 6 个键是 Cordis 内置的。 你日常写的 ctx.tools / ctx.llm / ctx.fs / ctx.shell / ctx.sessions,不是 Cordis 自带的 ,是 dsh 上层包用 ctx.provide('tools', ...)、ctx.provide('llm', ...) 之类的代码注入的。
我特意把这件事标出来:上一篇把 ctx.tools 画在 Cordis 的 ctx 圆里是不严谨的,画法应该分开两圈:

两圈的分界意味着什么? 它意味着 Capability Seam (能力接缝) 这件事的根在 dsh 上层,cordis 只提供了「按名挂载/查找/撤销」三件套。模型后端能换、沙箱能换,是因为 dsh 把每个能力都当成「在某个 fiber 上注册的服务」。
3. Service:可被替换的具体能力
Service 抽象类 (service.ts:11) 是个非常薄的壳:
ts
// service.ts:11, 42
export abstract class Service<out T = never> {
// ...
constructor(protected ctx: Context, name: string) { /* ... */ }
// symbols.filter: 在事件分发时决定本服务实例要不要响应
protected [symbols.filter](ctx: Context) { /* ... */ }
// symbols.resolveConfig: 合并祖先 ctx 的 intercept config
[symbols.resolveConfig](base?: T, head?: T): T { /* ... */ }
}
所有重量都委托给 reflect.provide (第 1 节那行)。它有两个常被忽视的钩子:
symbols.filter(service.ts:61):决定这个实例在哪个隔离作用域里生效。dsh 的能力切换经常用 isolate 标签做「模型 A vs 模型 B 的两个实现并存」。symbols.resolveConfig(service.ts:86):自动从父 ctx 链上收集这个服务的 intercept 配置,按 root→head 的优先级合并。这就是首篇讲的 Trajectory 不变量的物理基础:同一服务在不同的父 ctx 下拿到的 config 不同,行为可被隔离审计。
Service 类的 Symbol.hasInstance (service.ts:104) 还做了一件贴心的事:判断 instance instanceof MyService 时会沿原型链查,而不是只看 instance.constructor,这样 Service 子类的继承也兼容。
4. Typed Events:5 种分发策略
ts
// events.ts:32
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
bail 和 serial 区别在于:serial await 串行等到首个 bail 值;bail 同步派发到首个 bail 值就立刻 return。bail 在 serial 那个长链路里省掉了所有 await 。bail 的判定在 events.ts:13:
ts
// events.ts:13-15
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
也就是「返回值是 null / false / undefined 都视为没命中,否则命中」。这个判定是其它几种分发共享的「短路」信号。
5 种分发在 events.ts:165 处的 dispatch() 汇聚:
ts
// events.ts:183, 194, 204, 217, 234
// parallel (L183) Promise.allSettled, 全部跑完不报错
// emit (L194) 同步派发, 忽略返回值
// serial (L204) await 串行, 首个 bail 值就停
// bail (L217) 同步派发, 首个 bail 值就停
// waterfall(L234) 把监听器压栈成 next 续体, 不调 next 即 veto
waterfall 是最特殊的一种。每个监听器收到 (args, next),next 是「下一个监听器」的续体;监听器不调 next() 就等于否决了整条链。这跟 Express/Koa 的中间件一样,但 cordis 的 waterfall 不依赖 Promise 链。它是 plain function 接力的纯同步机制。

on 注册路径简单但关键:
ts
// events.ts:288-309 (节选)
on(name, listener) {
this.assertActive()
// ... 权限过滤
return this.register(name, listener.bind(this.ctx))
}
每个 on 都会 assertActive():fiber 不在 ACTIVE 状态时,事件注册直接抛错。
5. Effects:可逆副作用
Cordis 真正的杀招是 fiber.effect。我在 0 节已经剧透了它一半的代码:
ts
// fiber.ts:415-418
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
effect(execute: () => Effect, label = 'anonymous'): any {
this.assertActive()
if (this.state === FiberState.UNLOADING) {
throw new CordisError('INACTIVE_EFFECT')
}
// ...
}
构造时新建一个空 disposables[] 。body 执行期间,任何内部嵌套的 ctx.fiber.effect(child) 都会通过 runner.collect(dispose) 把子 effect 的 disposer 推进这个数组。父吃子,子吃孙,一棵树就这样在 effect 数组里套起来。
卸载时:
ts
// fiber.ts:675-686
private async _unload() {
await Promise.all(this._disposables.clear().map(async (dispose) => {
try {
await composeError(async (info) => {
await Promise.resolve()
info.error = new Error()
await runDisposable(dispose)
}, this._runner.getOuterStack)
} catch (reason) {
this.ctx.logger.error(reason)
}
}))
this.store = undefined
// ...
}
注意三件事:
_unload用this._disposables.clear()把外层 disposers 一次性取走 (顺序无所谓,因为接下来每个 effect 自己再逆序);- 每个 effect 内部的
dispose()(L427-442) 用disposables.splice(0).reverse():LIFO,最后登记的先撤销; - 任何 disposer 抛错都被
this.ctx.logger.error吞掉,不会影响兄弟 disposer 跑完。

把所有东西串起来:reflect.provide (reflect.ts:278) + events.register (events.ts:256) + Service 构造 (service.ts:57) + ctx.inject 解析 (registry.ts:300) + ctx.on 注册 (events.ts:288) 全部走 ctx.fiber.effect(...)。所以卸载 fiber = 一键撤销整条插件链,没有手动 cleanup。
6. Fiber 6 态 = 插件生命周期
一个 dsh 插件的「在世」就是它所挂载的 fiber 的生命周期。FiberState 是个 const enum,6 个状态 (fiber.ts:147-154):
ts
// fiber.ts:147-154
const enum FiberState {
PENDING, // 已创建, 等依赖就绪
LOADING, // 依赖已就绪, body 在跑
ACTIVE, // 跑完, 监听中
FAILED, // 初始化失败
DISPOSED, // 已销毁
UNLOADING, // 正在 _unload
}

assertActive() (events.ts 内部、effect 入口、provide 入口到处都有) 是状态机的硬门:LOADING 期间不能挂新 effect (fiber.ts:420-422 直接 throw INACTIVE_EFFECT)。这逼着所有副作用都得在「明确 ACTIVE 之后」做,避免在初始化中途被外部事件触发脏写。
update() (fiber.ts:736) 走 internal/update 这个 waterfall 事件 (L748),允许在已 ACTIVE 的 fiber 上做「热更新」:重新跑 body 但保留 disposers,再合并新收集到的 disposers。
7. 决策产出:这套设计给你什么约束和好处
读源码后我修正了上一篇的若干结论。这五条全部有 file:line 当后盾:
-
稳定键是硬约束 。
Context接口 (context.ts:16-33) 只暴露 6 个 Cordis 内置键。你想用ctx.tools,必须先有人provide('tools', ...),否则 ctx 代理会返回undefined。ctx.tools?.foo会在所有用了它的插件里埋雷。(依据:context.ts:16-33 + reflect.ts:277) -
每个副作用都必须经
fiber.effect登记 。如果你的 Service 子类在构造时开了个setInterval、开了个fs.watch、挂了个全局事件监听器而没收进 effect 数组,fiber 卸载时它们会泄漏 。dsh 上层包自己就是这么写 Service 的。(依据:service.ts:57 + reflect.ts:278) -
同一接口多实例靠 isolate 标签隔离 。
[symbols.isolate](context.ts:18) +Service.[symbols.filter](service.ts:61) 一起决定哪个实例响应哪个 ctx 的事件。这是 dsh 跑「模型 A vs 模型 B 并存对比」的物理基础。(依据:context.ts:18, service.ts:61) -
5 种分发不要瞎选 。
emit适合广播;parallel适合互不依赖;serial适合有顺序依赖;bail是 serial 的同步快路径;waterfall是「不调 next 就否决」的拦截器语义。上一篇我画成 4 种是错的 。(依据:events.ts:32, 183, 194, 204, 217, 234) -
Trajectory 不变量靠 Service 的
[symbols.resolveConfig]物理保证 。每个 Service 实例拿到的 config 都是「root 到当前 ctx 的整条 intercept 链」按 root→head 优先级合并 (service.ts:86-102),不同 ctx 下同一服务的 config 必然可追溯、可审计。(依据:service.ts:86-102)
这些约束不轻松,但好处也明确:插件可独立测试 (ctx.isolate() 出子 ctx)、可安全卸载 (ctx.dispose() 整链逆序)、可做消融 (临时 ctx.intercept 屏蔽某服务)。
收尾
下一篇我打算用这篇读到的代码作为底座:写一个真实的最小插件,跑过 dsh dev,再把它拆掉。如果你想看哪一部分被拆开讲:ctx.inject 的依赖解析、waterfall 拦截器的实战写法、还是 Service 子类在 dsh 里的真实形状。评论区留言,分享你的问题和心得吧。