Cordis 内核:四个原语撑起插件世界

📑 目录

前面的文章里,我们跟着 npx @deepseek-ai/dsh web 走完了启动链路:profile 与 bundle 组装成一份条目列表,boot() 把它交给 Cordis 的 Loader,树沉降之后打印 URL。整个过程中有一个角色始终在场却没有被拆开讲过——Cordis,dsh 的底层插件框架(仓库里 rescope 为 @deepseek-ai/cordis,源码在 vendor/cordis/src/,全树共享同一份,保证树外插件与树内看到的是同一个框架)。

本篇回答一个问题:一份 YAML 配置,如何变成一个由几百个服务实例组成的、可热重载的运行时?答案可以拆成五块:Context 的作用域派生、插件的三种形态、Service 的生命周期约定、事件的分发模式,以及托住一切的 Fiber 状态机。掌握了它们,你再读任何一个 dsh 插件包,看到 ctx.something 时知道它在解析什么,看到 extends Service 时知道卸载会发生什么,看到 ctx.on(...) 时知道这条监听会在何时以何种顺序被拆除。这些"知道"并不来自文档约定,而是可以在五个源文件里逐一验证的实现事实。本文基于 2026 年 10 月的 master 分支(5badb15,0.2.1-alpha.1 前后),项目处于 developer preview 阶段,API 可能变动。

Context:属性读就是服务解析

打开 vendor/cordis/src/context.ts:42,Context 类的实现短得出人意料:它不是普通对象,而是一个 Proxy(代理)。对 ctx 的任何属性读取都会走服务解析器——ctx.llm、ctx.tools 这类访问不是在读一个字段,而是在问"当前作用域下,这个名字的服务是谁提供的"。解析的尽头是 reflect 服务维护的一张实现表(vendor/cordis/src/reflect.ts:115,每条实现记录自己所在 fiber 的名字与作用域),这张表按 symbol 键存取,与 isolate 的作用域映射配合,决定一次读取最终命中哪个实现。

在这个模型上,Context 提供四个派生原语,全部返回子上下文、绝不修改父上下文:

  • extend(meta)(context.ts:99):原型继承。子上下文 prototypally 继承父级一切属性,meta 上的自有属性遮蔽继承值。不写 meta 时,extend 就是一个纯作用域分隔——同一个插件在不同子上下文里各起一个实例,互不看见。
  • isolate(name, label?)(context.ts:121):换作用域(realm)。返回的子上下文里,服务 name 的读写都解析到新标签下,父作用域的实现不受影响。同一个 label 传给两次 isolate(),两个调用就加入同一个 realm——这是"一组服务共享一个独立世界"的拼法。
  • intercept(name, config)(context.ts:128):合并配置。给子树下该服务的配置并入一段 intercept 配置,典型用途是某个子树用不同的模型参数、不同的后端或不同的限额。
  • 再加上 ctx.plugin() 挂载插件,构成第四个原语,下一节细讲。

agent preset(智能体预设)就是靠 isolate 实现隔离的。一个 preset 往往要换一组模型提供方、一套工具配置,但树的其他部分对同名服务的引用不能跟着变。packages/boot/app-boot/src/index.ts:560 的注释说得很直白:一个 group 行(cordis:group)就是"给一组 provider 与 consumer 共同一个 isolate realm"的方式——preset 里的服务行装进同一个 group,就共享同一个 realm;不同的 preset 各自一个 realm,彼此同名服务互不干扰。docs/architecture.zh.md 的能力表也印证了这条路径:"让某个会话拥有不同的能力集合"对应"组装一个 agent preset,其中的服务行需要 isolate realm"。

packages/preset/agent-preset-registry/src/mount.ts:71 的 leakedServices() 还提供了验证手段:遍历实现表,凡是不在根 realm 隔离映射里的实现都算泄漏。测试用它在 preset 挂载后断言"这个世界确实是独立的"。对读者来说,这个函数是理解 realm 语义最直的切口——隔离不是约定,是可以在运行时枚举检查的结构性质。

flowchart TD
    ROOT["根上下文 realm"] --> PA["preset A 上下文
isolate(llm, labelA)"] ROOT --> PB["preset B 上下文
isolate(llm, labelB)"] subgraph RA["realm A(预设 A 的独立世界)"] PA_PRO["llm provider A"] --> PA_CON["llm consumer A"] end subgraph RB["realm B(预设 B 的独立世界)"] PB_PRO["llm provider B"] --> PB_CON["llm consumer B"] end PA -->|"读写 llm 解析到 realm A"| PA_PRO PB -->|"读写 llm 解析到 realm B"| PB_PRO PA -.->|"读根服务仍走根 realm"| ROOT

插件的三种形态,与一个语法糖

Cordis 接受三种插件(vendor/cordis/src/registry.ts):一个普通函数、一个 Service 子类、一个带 apply 方法的对象。RegistryService.resolve(registry.ts:222)在运行时把它们归一为回调函数,所以三种形态在树上完全等价,选哪种只是表达问题:函数适合一次性胶水,Service 适合有状态、要拦截配置的服务,{apply} 对象适合要携带 inject 声明的场景。

plugin(plugin, config)(registry.ts:316)做三件事:为插件建(或复用)runtime——同一插件函数在多处挂载共享一份 runtime 元数据,但每个挂载点各起一个 fiber;用插件声明的 Config schema 校验配置(基于 schemastery,registry.ts:104,走 Standard Schema 协议);然后在当前上下文起 fiber 承载它。配置不合法,插件在进入 PENDING 之前就失败,错误信息带上完整的 patch 来源链——这也是第 2 篇里启动诊断能指到具体 bundle 文件的原因。返回值是 Fiber & PromiseLike<Fiber>(registry.ts:185),既是一个可操作的状态机句柄,也可以直接 await 等它激活,两种用法在同一行代码里共存。

值得注意的是 inject(list, callback)(registry.ts:300)。它看起来像"先注入依赖再启动"的编排机制,实际上只是 { inject, apply } 的语法糖。真正的加载顺序不靠任何启动序列表达,而靠服务依赖表达:插件声明自己 inject 哪些服务,Cordis 就让承载它的 fiber 等那些服务就绪后再执行 apply。inject 字段支持嵌套对象与函数写法,registry.ts:71 的 resolve 负责把它摊平成一张依赖表。

dsh 启动时几十个 bundle 的条目并发挂载、激活顺序与行序无关,原因就在这里——依赖图就是启动图,不需要人写 await step1; await step2。前面文章提到的 web-startup 行与 webserver 行,谁的端口表达式引用了 ctx.webStartup,谁就在依赖上排到了后面;YAML 里谁先谁后并不重要。

Service:构造函数即注册

如果说插件是"如何进场",Service(vendor/cordis/src/service.ts:11)回答的是"进场之后如何提供服务"。它的约定大胆而干净:构造函数即注册。打开构造函数(service.ts:42 附近)可以看到,new MyService(ctx) 的执行体里就调用了 ctx.reflect.provide(name, this):服务名取第二个参数,缺省取类的静态 provide 字段。没有单独的 register() 步骤——忘了 new 就不会提供服务,想重复注册也由 reflect 层拒绝。

与之配套的是卸载语义:provide 返回注销函数,而框架把注销挂到 owning fiber 的卸载流程上,插件被卸载(或热替换)时服务自动消失,不存在"服务泄漏"的窗口。异步初始化走 [Service.init](符号定义在 service.ts:13)——一个异步生成器,yield 出 disposer 回调注册清理逻辑,框架保证它们按序执行、逆序拆除。

Service 上还有一组改变形态的内建符号:[Service.invoke] 让服务实例变成可调用对象(ctx.logger() 这种写法就是这么来的:构造函数里检测到 invoke 方法后,会用 createCallable 把实例接到 Function 原型上,service.ts:60 附近),[Service.check] 是 provide 时传入的可用性谓词,[Service.extend] 派生一个继承实现但可带新属性的扩展实例,intercept 配置的解析走 [Service.resolveConfig]。它们共同支撑起"服务既是依赖注入的目标、又是可组合的行为载体"这个双重身份。另外注意 service.ts:64 的 filter:服务实例自带一个监听过滤器,比对双方上下文的 isolate 映射——realm 不同的上下文,连事件都互相听不见,隔离是彻底的。

项目里有一条由此推导出的硬规约:注册是可逆的副作用。提示词片段、工具 schema、适配器、提供方和监听器,都必须通过 ctx.effect() 或 ctx.on() 安装在当前 fiber 上,reload 和 teardown 时会按预期撤销(docs/cordis-primer.zh.md:13 把这条列为第一原则)。如果拆除顺序有要求,把相关工作放进同一个 effect。直接调 Node 的 setInterval 或裸加监听器,在 HMR 换 fiber 时会泄漏。类型系统拦不住这种写法,但 fiber.ts 里的 INACTIVE_EFFECT 错误(CordisError,fiber.ts:172)会拦住一部分在失效 fiber 上继续注册的行为——注册前先问 fiber 还活着没有。

事件:五种分发模式

EventsService(vendor/cordis/src/events.ts:32)提供五种分发模式(events.ts:32 的 DispatchMode),命名即语义:

  • emit:同步调用全部监听器,不等待、不收集结果,适合"广播一下"的通知。
  • parallel:所有监听器一起 await,全部 settle 后才返回。
  • serial:按注册顺序逐个 await,任何一个返回" bail 值"(非空、非 false、非 undefined)就停下。
  • bail:同步版 serial,同样停在第一个 bail 值,常用于"谁先来谁处理"的协商。
  • waterfall:把监听器编成环绕中间件。看 events.ts:227 附近的实现,它把分发的最后一个参数当作最内层的 next,监听器由外向内执行,每调一次 next() 就 shift 出下一个回调;不调 next() 就短路掉包括内建行为在内的整条链,返回值取最外层监听器的产出。

waterfall 是拦截能力的来源。dsh 里"拦截请求、工具或轮次"这类扩展点(文档 docs/architecture.zh.md 的能力表)背后就是 waterfall 事件:插件把自己包在默认行为外面,决定是否放行、放什么行。它与 bail 的分工也清晰:bail 回答"谁处理"(竞速与协商),waterfall 回答"怎么处理"(包裹与改写)。dsh 用 TypeScript 的声明合并给事件名建立类型索引,JSDoc 的 @mode 标注再与分发点交叉校验,事件名和分发模式对不上会在构建期暴露——声明 emit 的事件用 bail 分发,类型检查直接失败。

监听器注册本身也是一条 effect(events.ts:261 的 register):在 fiber 上登记、卸载时反注册,带 prepend 选项控制插入队首。配合上一条规约,事件系统没有一条"忘了移除"的漏网之鱼,除非你绕开 ctx.on()。

每次分发还会发一个 internal/dispatch 通知(events.ts:169),带上分发模式、事件名与参数。它是观测面的钩子:调试工具与 inspector 可以借此把事件流画出来,而业务监听器无感知。这类 internal 事件不参与业务语义,却也走同一套分发管线,框架自己的可观测性没有特权通道。

Fiber:插件的状态机

每个 ctx.plugin() 调用都产生一个 Fiber(vendor/cordis/src/fiber.ts:184)。Fiber 跟踪四样东西:依赖状态、校验后的配置、生命周期 effect、以及当前状态。状态机很紧凑(fiber.ts:140 的注释给出了完整定义):

  • PENDING:等待所需服务,或插件回调正在执行(LOADING 视为 PENDING 的子相位)。
  • ACTIVE:已加载、正在提供服务。
  • FAILED:配置校验或插件回调抛错。
  • UNLOADING:disposer 正在运行。
  • DISPOSED:卸载完毕,终态。
stateDiagram-v2
    [*] --> PENDING: ctx.plugin() 起 fiber
    PENDING --> ACTIVE: 依赖就绪且回调完成
    PENDING --> FAILED: 配置校验或回调抛错
    ACTIVE --> UNLOADING: 卸载或 HMR 换 fiber
    FAILED --> DISPOSED: 清理
    UNLOADING --> DISPOSED: disposer 全部执行完
    DISPOSED --> [*]

状态迁移会发 internal/status 通知(fiber.ts:193 附近),Loader 与 HMR 靠它跟踪全树。通知有一个刻意的取舍:只有 ACTIVE 与非 ACTIVE 之间的迁移才广播(fiber.ts:588 附近的条件),PENDING 内部的子相位变化不吵观察者——沉降判定只看"能不能服务",不关心中间经过几站。

这个状态机里最常用的原语是 fiber.await()(fiber.ts:704):循环等待 Fiber 的"惯性"(inertia,即未完成的异步工作)归零,若最终状态是 FAILED 则抛出错误。它看似简单,却是两个关键机制的地基。

一是 Loader 沉降。boot() 里 ctx.loader.await() 的语义就是"全树每个 fiber 都 settle 到稳定态",web 行要等沉降完成才打印 URL,启动审计也发生在沉降之后。二是 HMR 换 fiber。热重载时新插件起新 fiber、旧 fiber 走 UNLOADING 到 DISPOSED,await() 同时等"旧的世界拆完、新的世界立起来",替换是原子的。配套的还有 fiber.restart()(fiber.ts:715 附近):dispose 后立即以当前配置重载,是配置重载路径(entry.update(...))底层的执行单元。

Fiber 的"死"还有一个精确的口径:每个 effect 携带一个 epoch(时代表记),fiber 失效时把 epoch 置为内部哨兵值 INACTIVE(fiber.ts:176),此后任何注册都会撞上 INACTIVE_EFFECT 错误(fiber.ts:172)。这个设计挡住的是一类真实事故:异步回调在插件卸载后才回来,顺手又注册一个监听器——没有这个闸门,泄漏会无声无息。

把视角拉远:Context 管作用域,Registry 管归一与挂载,Service 管生命周期,Events 管协作,Fiber 管状态——五个文件加起来不到两千行,却撑起了 dsh 全部的运行时结构。这是"内核小、生态大"架构的范本:复杂性被推到了插件层,内核只保证组合规则。

小结

Cordis 把"插件框架"压缩到四个正交原语:Context 用 Proxy 统一服务解析,extend/isolate/intercept 三个无副作用的派生原语拼出作用域,agent preset 的隔离世界由此而来;插件三种形态归一为回调,加载顺序由服务依赖而非启动序列决定,inject 只是糖;Service 把注册折叠进构造函数,靠"注册是可逆的副作用"规约绑定生命周期;五种事件分发模式里 waterfall 提供了环绕中间件式的拦截能力;Fiber 状态机则给加载、卸载、热重载一个可等待、可观测的语义,await() 是 Loader 沉降与 HMR 换 fiber 共同的底层原语。

至此"启动与内核"这一章合上:从 bin 入口、profile 组装,到 Loader 挂载、Cordis 原语,一条链路走通。下一章进入运行时状态:第 5 篇看会话日志——dsh 如何把一次 agent 交互的全部历史写成一份仅追加的事件日志,并让模型历史、fork、恢复都从它派生。