Cordis 微内核:
插件、服务与 inject。
模块 A 反复出现的「一切皆插件」,地基是 vendored 进本仓库的 Cordis 微内核。
这一课讲透它的四个核心概念——插件、context 服务仓库、inject 依赖声明、可逆的 effect。
读懂它们,packages/ 下每个包的源码你都能看懂了。
- 说清 Cordis 在仓库中的位置(
vendor/cordis/)与它对「一切皆插件」的支撑作用 - 写出函数与
Service子类两种插件形态,说出 fiber 生命周期的五个状态 - 用
ctxkey +inject解释服务的提供、发现与加载顺序 - 用
ctx.effect()与 disposer 说明「注册即可逆」,并动手跑出第一个插件
为什么先学 Cordis
Cordis 是 DeepSeek Harness 底层的插件框架,以 vendor 方式引入:源码固定在
vendor/cordis/,以 @deepseek-ai/cordis 的包名出现在每个 harness 包的
peerDependency 里。agent loop、工具系统、模型适配器、会话日志——packages/
下你看到的每一个包,都是挂载到 Cordis 共享上下文中的插件。
这意味着不懂 Cordis 就读不懂这个仓库:随便打开一个包的源码,apply(ctx)、
ctx.effect()、inject 会扑面而来。好消息是 Cordis 本身很小,核心思想只有五个
(docs/cordis-primer.zh.md 的五个核心概念)。本课讲透其中四个;
第五个——类型化事件——留给 L5 单独展开。
注意 Cordis 不是普通 npm 依赖,而是 vendored 的源码副本:vendor/README.md
记录了对应的上游 SHA 与同步流程。这对学习者是好事——框架源码就在仓库里,
本课讲的每个行为都能在 vendor/cordis/ 下找到对应实现,随时可查可验证。
本课每个概念都对应官方教程的一个可运行章节(docs/cordis-tutorial/ 第 1–3 章),全程不需要 API key。读完正文务必把第 06 节的动手试做一遍:Cordis 是「跑一遍就懂」的框架。
插件的两种形态与生命周期
Cordis 插件是「实现 Service 的对象」。日常写法是函数形态:模块导出一个
apply(ctx) 函数,可选地再导出 inject 与 name。
Cordis 加载模块时用一个上下文调用 apply,插件通过它注册自己贡献的一切。
// hello.ts —— 最小插件(教程第 1 章)
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello' // 可选:诊断信息里的显示名
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
函数形态还有一个对象变体(带 apply 方法的对象);而当插件需要公开服务时,
改用类形态:继承 Service 的子类,其生命周期由 Cordis 挂载到当前上下文
(第 03 节完整展示)。教程的建议是:在需要公开服务之前,一直用函数形态。
每个已加载的插件实例对应一个 fiber(运行时句柄),在以下状态之间转换:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED- PENDING:已声明,但所需服务尚不可用(第 04 节的主角)。
- LOADING / ACTIVE:
apply正在运行/已经完成。 - FAILED:
apply或配置校验抛出异常——加载失败会明确报错,不会静默跳过。 - UNLOADING / DISPOSED:disposer 正在运行/一切均已拆除。
卸载的触发源有四种:修改配置、热重载、显式资源释放、所需服务消失。
fiber.dispose() 会等该插件的所有清理工作(包括异步 disposer)完成后才结束,
并递归卸载它挂载的所有子插件——插件树因此可以整枝拆除,不留残渣。
context:插件的服务仓库
apply(ctx) 收到的 ctx 是插件与外界的唯一通道,同时也是一个服务仓库:
一个服务占据一个稳定的 ctx.<key>——harness 里的 ctx.tools、
ctx.llm、ctx.sessions 都是如此。关键规则是:其他插件通过 key 查找服务,
而不是 import 提供方的具体实现。
提供方写一个 Service 子类,super(ctx, 'greeter') 以名字把实例注册进
ctx;此后任何插件都能通过 ctx.greeter 访问它。
注册本身属于 effect——卸载提供方时,服务随之从 ctx 移除。
// greeter.ts(教程第 3 章,节选)
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // 运行时:以 'greeter' 注册到 ctx
}
greet(who: string) {
return `Hello, ${who}!`
}
}
编译时还有一块配套代码:declare module '@deepseek-ai/cordis' 用 TypeScript
声明合并把 greeter 加进 Context 接口,让 ctx.greeter
在各处通过类型检查。它不生成任何代码——没有它运行时照样工作,只是消费方失去类型安全。
为什么坚持按 key 发现?因为配置因此拥有了选择权:消费方只声明「我要 tools 能力」, 由 cordis.yml 决定谁来提供。换提供方不用改任何消费方代码——这就是 capability seam(能力缝隙)的根基,L9 会用它亲手写一个模型适配器。
inject:用依赖表达加载顺序
// consumer.ts(教程第 3 章)
export const name = 'consumer'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world')) // 到这里 greeter 一定就绪
}
inject 列出插件需要的服务。Cordis 让插件保持 PENDING,直到列出的每一项服务都存在,
然后才运行 apply——所以在 apply 内可以保证 ctx.greeter 已经就绪。
cordis.yml 中各项并发启动,列表位置不保证加载先后:交换 YAML 里提供方与消费方的两行顺序,
输出不变。加载顺序由服务需求表达,无需手写启动编排。
inject 不是一次性的启动检查。运行期间所需服务消失(提供方被卸载或热替换),
每个依赖插件随之卸载;服务恢复后再次加载。结合 effect,运行中的消费方不会保留对失效服务的引用——
依赖消失时,它自己的注册也一并撤销。
这正是配置可以替换服务的机制:卸载配置项 dsh-bash-local、挂载另一个
shell 提供方,所有 inject 了 'shell' 的插件都会自动重启并使用新实现。
如果某项功能缺失时插件仍可运行,那就跳过 inject,在使用处探测:
ctx.get('greeter') 在无提供方时返回 undefined。
inject 的服务名拼错,或提供方根本没挂载,插件会永远停在 PENDING:不崩溃、不报错、不输出, 甚至不会让事件循环保持活跃——组合里没有别的运行项时,进程以状态码 0 静默退出。 「我的插件为什么没输出」的第一嫌疑就是它,诊断方法见教程第 6 章。
注册即可逆的 effect
Cordis 里没有「永久注册」:通过 Cordis API 建立的注册都属于 effect, 在所属插件卸载时撤销——配置修改、热重载、显式释放、服务消失触发的卸载都一样。
对 Cordis 不管辖的资源(定时器、连接、watcher),用 ctx.effect() 包起来并返回
disposer:主体在加载期间运行,返回的 disposer 在卸载期间运行。
生命周期与插件一致的资源,你永远不需要自己调用 disposer。
// 教程第 2 章的心跳示例(节选)
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => clearInterval(timer) // disposer:卸载时由 Cordis 调用
})大多数注册你连 ctx.effect() 都不用写,因为内置 API 本身就是 effect:
ctx.on(event, listener):监听器在卸载时移除。ctx.plugin(child):子插件随父插件一同 dispose。- 服务注册属于 effect:卸载提供方,服务即从
ctx移除。 ctx.tools.register(...)等 harness 注册表:返回的 disposer 会附着到调用插件上,自动撤销。
一项顺序注意:disposer 按注册顺序的逆序启动,但多个异步 disposer 会并发运行。 拆除步骤必须有序时,把它们放进同一个 disposer 里依次 await。
卸载 = 把插件对世界的所有修改逐项撤销,重载 = 从零重建。旧实例的监听器、定时器、服务不会泄漏进新实例,配置变更因此可以反复拆装——这正是 L2 里 patch 随时生效的底层保障。
动手试:跑出你的第一个插件
照官方教程第 1 章(docs/cordis-tutorial/01-first-plugin.zh.md)实操一遍。
全程不需要 API key;tmp/ 已被 git 忽略,写什么都不进版本控制。
- 在仓库根目录创建教程用的临时目录:
mkdir -p tmp/cordis-tutorial && cd tmp/cordis-tutorial2. 新建 hello.ts(就是第 02 节那个最小插件),再新建 cordis.yml:
# cordis.yml —— 一组 Cordis 配置项,loader 会挂载每一项
- name: './hello.ts'3. 运行教程启动器(创建根 Context、挂载 Loader、读取当前目录的 cordis.yml):
node --import tsx ../../vendor/cordis/bin.js预期输出 hello from my first plugin,随后进程自行退出。
你的文件里没有任何框架启动代码:插件描述贡献,cordis.yml 组合应用。
4. 制造错误观察行为:让 apply 抛异常,进程会因错误终止(加载失败明确报错);
把 YAML 里的路径拼错,则只是 logger 报错、插件不生效——新增配置项「没反应」时先查拼写。
课堂练习
inject 字段解决的是什么问题?inject 列出插件需要的服务,Cordis 让插件保持 PENDING 直到每项服务都存在——加载顺序由依赖关系表达,而不是手写编排,C 恰恰是 inject 要消除的做法(cordis.yml 各项并发启动,位置不保证先后)。A 是 name 导出的作用;D 说的是 loader 的 config 插值,与 inject 无关。
context 是服务仓库:服务占据稳定的 ctx.<key>,其他插件按 key 查找而非 import 实现——配置因此可以替换提供方而不动消费方。B 错:cordis.yml 各项并发启动,列表位置不保证加载先后,要等提供方就绪应使用 inject。A 把消费方绑死到具体实现;D 混淆了用途——事件用于通信,不是服务发现机制。
A、D 是 Cordis 入门文档五个核心概念中的两条原文。B 错:服务按 ctx key 发现而非 import 实现,绕过 context 会让配置失去替换提供方的能力。C 错:cordis.yml 各项并发启动,加载顺序由 inject 声明的服务依赖表达,与列表位置无关。
B 与 Cordis 的基本规则相反:没有永久注册,effect 随所属插件卸载而撤销。D 错:生命周期与插件一致的资源绝不需要自己调用 disposer——fiber.dispose() 会等所有清理(含异步 disposer)完成。A、C 正是「注册即可逆」的两个侧面:注册返回 disposer,卸载自动回收。
问答为什么服务之间用 ctx key 发现,而不是 import 具体实现?写下答案后点击对照›
参考要点:① 解耦提供方与消费方——消费方只依赖能力名(如 'tools'、'llm'),不依赖提供方模块,双方可以独立演化;② 可替换性是 capability seam 的根基:配置可以换掉提供方(卸载 dsh-bash-local、挂载另一个 shell 提供方),所有 inject 该服务的插件自动重启并使用新实现,消费方代码零改动;③ 若直接 import 实现,消费方被绑死在某个模块上,cordis.yml 的组合能力、inject 的依赖编排与 PENDING 机制都无从谈起。
问答用自己的话解释「注册是效果(effect)」,以及它如何让插件卸载变得安全。写下答案后点击对照›
参考要点:① 通过 Cordis API(ctx.on、ctx.plugin、ctx.effect、服务注册与注册表 register())建立的每一项注册,都绑定到所属插件的生命周期:安装时运行主体,卸载时运行对应的 disposer;② 因此卸载一个插件不会留下监听器、定时器、服务等残渣——disposer 按注册逆序启动(异步的并发执行),fiber.dispose() 等全部清理完成后才结束,并递归卸载子插件;③ 与 inject 的持续跟踪结合,服务消失时依赖方连同自己的注册一起回收,热重载与配置变更可以反复拆装而不泄漏状态。
本课小结
微内核的全貌已经清晰:插件是实现 Service 的对象,context 是按 key 发现服务的仓库,
inject 用服务依赖表达加载顺序,注册是可逆的 effect。五个核心概念还剩最后一个——类型化事件。
下一课拆开四种派发模式 emit / waterfall / parallel / serial,
以及 waterfall 监听器必须调用 next() 的语义。