L4 · 微内核
Lesson 04 · 模块 B · 读懂

Cordis 微内核:
插件、服务与 inject。

模块 A 反复出现的「一切皆插件」,地基是 vendored 进本仓库的 Cordis 微内核。 这一课讲透它的四个核心概念——插件、context 服务仓库、inject 依赖声明、可逆的 effect。 读懂它们,packages/ 下每个包的源码你都能看懂了。

精读 20 分钟 练习 10 分钟 · 6 题 前置 见课程首页
学完你将能够
  • 说清 Cordis 在仓库中的位置(vendor/cordis/)与它对「一切皆插件」的支撑作用
  • 写出函数与 Service 子类两种插件形态,说出 fiber 生命周期的五个状态
  • ctx key + inject 解释服务的提供、发现与加载顺序
  • ctx.effect() 与 disposer 说明「注册即可逆」,并动手跑出第一个插件
01 · Why Cordis First

为什么先学 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 是「跑一遍就懂」的框架。

02 · Plugins & Lifecycle

插件的两种形态与生命周期

Cordis 插件是「实现 Service 的对象」。日常写法是函数形态:模块导出一个 apply(ctx) 函数,可选地再导出 injectname。 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 / ACTIVEapply 正在运行/已经完成。
  • FAILEDapply 或配置校验抛出异常——加载失败会明确报错,不会静默跳过。
  • UNLOADING / DISPOSED:disposer 正在运行/一切均已拆除。

卸载的触发源有四种:修改配置、热重载、显式资源释放、所需服务消失。 fiber.dispose() 会等该插件的所有清理工作(包括异步 disposer)完成后才结束, 并递归卸载它挂载的所有子插件——插件树因此可以整枝拆除,不留残渣。

03 · Context = Service Registry

context:插件的服务仓库

apply(ctx) 收到的 ctx 是插件与外界的唯一通道,同时也是一个服务仓库: 一个服务占据一个稳定的 ctx.<key>——harness 里的 ctx.toolsctx.llmctx.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 在各处通过类型检查。它不生成任何代码——没有它运行时照样工作,只是消费方失去类型安全。

llm 提供方插件 Service 子类 tools 提供方插件 Service 子类 sessions 提供方插件 Service 子类 super(ctx, 'llm') 注册服务 = 可逆 effect Context · 服务仓库(所有插件挂载到同一棵树上) ctx.llm ctx.tools ctx.sessions … 按 key 发现 · 就绪前保持 PENDING consumer 插件(函数形态) export const inject = ['llm', 'tools'] inject 持续跟踪:服务消失,依赖方 随之卸载;服务恢复,重新加载。
图 4-1 · 插件树与服务仓库:提供方把实例注册进稳定的 ctx key,消费方用 inject 按 key 发现并等待就绪——双方都不知道对方的实现。

为什么坚持按 key 发现?因为配置因此拥有了选择权:消费方只声明「我要 tools 能力」, 由 cordis.yml 决定谁来提供。换提供方不用改任何消费方代码——这就是 capability seam(能力缝隙)的根基,L9 会用它亲手写一个模型适配器。

04 · inject

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

陷阱 · PENDING 是静默的

inject 的服务名拼错,或提供方根本没挂载,插件会永远停在 PENDING:不崩溃、不报错、不输出, 甚至不会让事件循环保持活跃——组合里没有别的运行项时,进程以状态码 0 静默退出。 「我的插件为什么没输出」的第一嫌疑就是它,诊断方法见教程第 6 章。

05 · Reversible Effects

注册即可逆的 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 随时生效的底层保障。

06 · Hands-on

动手试:跑出你的第一个插件

照官方教程第 1 章(docs/cordis-tutorial/01-first-plugin.zh.md)实操一遍。 全程不需要 API key;tmp/ 已被 git 忽略,写什么都不进版本控制。

动手试 · 5 分钟
  1. 在仓库根目录创建教程用的临时目录:
mkdir -p tmp/cordis-tutorial && cd tmp/cordis-tutorial

2. 新建 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 报错、插件不生效——新增配置项「没反应」时先查拼写。

07 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
插件的 inject 字段解决的是什么问题?

inject 列出插件需要的服务,Cordis 让插件保持 PENDING 直到每项服务都存在——加载顺序由依赖关系表达,而不是手写编排,C 恰恰是 inject 要消除的做法(cordis.yml 各项并发启动,位置不保证先后)。A 是 name 导出的作用;D 说的是 loader 的 config 插值,与 inject 无关。

单选
一个插件要使用别的插件提供的服务(如 tools),正确的发现机制是?

context 是服务仓库:服务占据稳定的 ctx.<key>,其他插件按 key 查找而非 import 实现——配置因此可以替换提供方而不动消费方。B 错:cordis.yml 各项并发启动,列表位置不保证加载先后,要等提供方就绪应使用 inject。A 把消费方绑死到具体实现;D 混淆了用途——事件用于通信,不是服务发现机制。

多选
下列哪些是 Cordis 的核心思想?(选出所有正确项)

A、D 是 Cordis 入门文档五个核心概念中的两条原文。B 错:服务按 ctx key 发现而非 import 实现,绕过 context 会让配置失去替换提供方的能力。C 错:cordis.yml 各项并发启动,加载顺序由 inject 声明的服务依赖表达,与列表位置无关。

多选
关于 effect 与 disposer,哪些说法正确?

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.onctx.pluginctx.effect、服务注册与注册表 register())建立的每一项注册,都绑定到所属插件的生命周期:安装时运行主体,卸载时运行对应的 disposer;② 因此卸载一个插件不会留下监听器、定时器、服务等残渣——disposer 按注册逆序启动(异步的并发执行),fiber.dispose() 等全部清理完成后才结束,并递归卸载子插件;③ 与 inject 的持续跟踪结合,服务消失时依赖方连同自己的注册一起回收,热重载与配置变更可以反复拆装而不泄漏状态。

本课小结

微内核的全貌已经清晰:插件是实现 Service 的对象,context 是按 key 发现服务的仓库, inject 用服务依赖表达加载顺序,注册是可逆的 effect。五个核心概念还剩最后一个——类型化事件。 下一课拆开四种派发模式 emit / waterfall / parallel / serial, 以及 waterfall 监听器必须调用 next() 的语义。