理解 Cordis:让插件像乐高一样拼装的底层引擎——《DSH 从入门到精通》系列
dsh 说"一切皆插件",但插件到底怎么挂上去的?靠的是一个叫 Cordis 的框架。这篇拆解 Cordis 的五个核心思想——你不需要精通它才能用 dsh,但读懂这些机制后,翻源码和写插件会顺畅很多。
文章目录
- 理解 Cordis:让插件像乐高一样拼装的底层引擎——《DSH 从入门到精通》系列
- Cordis 是什么
- 五个核心思想
- 思想一:插件是实现了 Service 的对象
- 思想二:上下文是服务仓库
- 思想三:通过 inject 声明依赖
- 思想四:类型化事件用于通信
- 思想五:注册是可逆 effect
- Fiber 状态机
- 加载器与 cordis.yml
- 服务定义的两种形态
- declaration merging 与类型安全
- 从 Cordis 到 dsh
- 小结
Cordis 是什么
Cordis 是 dsh 内置的插件框架(vendored 在vendor/目录下)。它的设计理念在一篇论文《A Programming Paradigm for Spatiotemporal Composability》中有完整阐述。对于 dsh 的使用者来说,Cordis 提供了三个关键能力:把功能封装为插件、通过服务 key 而非 import 来发现依赖、让所有注册可逆。
你不需要先精通 Cordis 才能用 dsh,但理解它的核心思想会让你在阅读源码和编写插件时事半功倍。
五个核心思想
Cordis 的设计可以用五句话概括。我们逐一展开。
思想一:插件是实现了 Service 的对象
Cordis 接受三种插件形态:
import{Service,typeContext}from'@deepseek-ai/cordis'// 形态 1:函数插件(最常见)exportfunctionapply(ctx:Context){ctx.effect(()=>{console.log('插件加载了')return()=>console.log('插件卸载了')})}// 形态 2:对象插件exportconstplugin={name:'my-plugin',apply(ctx:Context){// ...},}// 形态 3:类插件(Service 子类,需要暴露服务时使用)exportclassMyServiceextendsService{constructor(ctx:Context){super(ctx,'myService')}}函数形态适合只需要注册副作用的场景;类形态在需要暴露一个ctx.<key>服务时才使用。一个插件模块只需要导出一个apply函数(或apply方法的对象/类),Cordis 加载时调用它,传入上下文对象ctx。
思想二:上下文是服务仓库
ctx是一个服务仓库。每个服务从上下文中认领一个稳定的ctx.<key>(如ctx.tools、ctx.llm、ctx.sessions),其他插件通过这个 key 查找服务,而不是 import 具体实现。
这种设计的核心价值:Consumer 不知道也不关心 Provider 是谁。你把ctx.tools上注册的 Provider 从本地实现换成沙箱实现,所有注入了'tools'的插件会自动重启并绑定到新实现,Consumer 代码不需要任何修改。
思想三:通过 inject 声明依赖
一个插件通过inject字段声明它需要的服务。Cordis 会把这个插件保持在 PENDING 状态,直到所有声明的服务都存在才激活它。
exportconstinject=['llm','tools']exportfunctionapply(ctx:Context){// 到这里时 ctx.llm 和 ctx.tools 一定准备好了constllm=ctx.llmconsttools=ctx.tools// ...}这意味着cordis.yml中的行顺序不影响加载顺序——依赖关系决定激活时机。你把 Consumer 放在 Provider 前面,Cordis 会等 Provider 就绪后才激活 Consumer。
更关键的是,inject不是一次性的启动检查。如果运行中某个服务消失了(Provider 被卸载或热替换),所有依赖它的插件也会被卸载,等新 Provider 出现后再重新加载。这保证了运行中的 Consumer 永远不会持有一个不可用的服务引用。
思想四:类型化事件用于通信
服务通过 TypeScript declaration merging 声明事件名,然后以四种模式之一派发:
| 模式 | 是否 await | 派发顺序 | 有返回值 | 适用场景 |
|---|---|---|---|---|
emit | 否 | 注册顺序 | 否 | 观察通知(日志、遥测) |
waterfall | 否 | 注册顺序 | 是 | 拦截/包装(around-middleware) |
parallel | 是 | 并行 | 否 | 扇出(多监听器独立处理) |
serial | 是 | 注册顺序 | 是 | 有序决策(如 turn-stopping) |
waterfall 是最特殊的模式——它是 around-middleware 语义。监听器收到(...args, next),调用next()把可能修改后的结果委派给下一个监听器;不调next()则短路整个链:
// 一个 waterfall 监听器:拦截工具执行请求ctx.on('tools/pre-execute',(exec,next)=>{if(isDangerous(exec.name)){return{kind:'deny',reason:'危险操作被拒绝'}// 不调 next(),短路链}returnnext(exec)// 委派给下一个监听器})对于单决策事件,短路就是设计意图。策略监听器拥有决策权时可以不调next();只做标注或观察的监听器必须委派。
思想五:注册是可逆 effect
所有通过 Cordis API 注册的东西——prompt 段、工具 schema、适配器、provider、监听器——都是 effect。它们在插件加载时安装,在插件卸载时按序回退。
exportfunctionapply(ctx:Context){// 注册一个事件监听器,返回 disposerctx.on('session/event',(event)=>{console.log('事件:',event.type)})// 用 ctx.effect() 包装非 Cordis 管理的资源ctx.effect(()=>{consttimer=setInterval(()=>console.log('tick'),1000)return()=>clearInterval(timer)// 卸载时执行})}如果卸载顺序重要——比如先关闭连接再清理缓存——把相关工作放在同一个ctx.effect()中,disposer 按声明顺序的逆序执行。
Fiber 状态机
每个加载的插件实例拥有一个 fiber,经历以下状态:
理解 fiber 状态对于诊断"插件为什么没加载"很关键。一个 PENDING 状态的 fiber 不会保持 Node 事件循环活跃——如果整个应用只有 PENDING 的 fiber,进程会以 exit code 0 退出,没有任何报错。这种情况通常意味着某个inject声明的服务没有被任何 Provider 提供。
加载器与 cordis.yml
cordis.yml是一个有序的插件行列表。每行声明一个插件的id、name(模块标识符或 npm 包名)、可选的config和inject:
-id:llm-deepseekname:'@deepseek-ai/dsh-llm-deepseek'config:thinking:enabledreasoningEffort:maxinject:[llm]加载器并发挂载所有行——行顺序不决定加载顺序,服务依赖才决定。配置中支持!!js表达式插值,让环境变量选择插件成为可能:
-id:shellname:'@deepseek-ai/dsh-bash-local'disabled:!!jsprocess.env.DSH_SANDBOX === 'true'# 当 DSH_SANDBOX=true 时,这行被禁用,换用 dsh-bash-sandbox!!js在两处被插值:条目的config(在声明的 inject 激活后,针对该插件的ctx.serviceName)和disabled字段(每次挂载决策时)。其他元数据保持字面量。
服务定义的两种形态
Cordis 的 Service Definition 可以是抽象类或具体注册表:
抽象类形态(如ShellExecutor):声明接口契约,Provider 继承它并实现方法。dsh-shell声明了执行器契约,dsh-bash-local和dsh-bash-sandbox分别实现它。
具体注册表形态(如WebRuntime):Service 本身就是一个注册表,Provider 往里面注册实例。dsh-web声明了 Provider 注册和选择服务,web-search-exa和web-search-perplexity各自往注册表里注册。
两种形态的选择标准:如果能力是"找一个实现来执行"用抽象类;如果能力是"从多个候选中选一个"用注册表。
declaration merging 与类型安全
Cordis 通过 TypeScript 的 declaration merging 机制让ctx.<key>在编译期类型安全:
// 在 Service Definition 包中declaremodule'@deepseek-ai/cordis'{interfaceContext{greeter:GreeterService}}exportclassGreeterServiceextendsService{constructor(ctx:Context){super(ctx,'greeter')// 运行时注册}greet(who:string){return`Hello,${who}!`}}两段代码协作:super(ctx, 'greeter')在运行时把实例注册到ctx.greeter;declare module块在编译期把greeter加到Context接口。没有 declaration merging,服务在运行时仍然工作,但 Consumer 失去类型安全——ctx.greeter会被标红。这正是 dsh 中ctx.tools、ctx.llm、ctx.sessions等所有服务 key 的来源。
从 Cordis 到 dsh
dsh 在 Cordis 之上构建的每一样东西都遵循这五个思想:
| dsh 概念 | Cordis 机制 |
|---|---|
| 能力接缝三角色 | Service Definition(Service 子类)+ Provider(注册到 ctx.key)+ Consumer(inject 声明依赖) |
| Profile/Bundle/Patch | cordis.yml 加载器 +!!js插值 + patch 按 id 覆盖 |
| HMR 热替换 | fiber 状态机 + 可逆 effect + inject 依赖追踪 |
| 事件扩展点 | 四种派发模式 + declaration merging 声明事件 |
| 工具注册表 | ctx.tools服务 +ctx.effect()可逆注册 |
当你理解了 Cordis,dsh 的源码就不再是一个黑箱——你能在packages/目录下找到每一个 Service Definition、Provider 和 Consumer,理解它们如何通过ctx连接在一起。
小结
说实话,Cordis 的五个思想单独拎出来都不新鲜——Service Locator、依赖注入、事件总线、可逆注册,每个都是经典模式。但把它们捏在一起做成一个"一切皆插件"的运行时,效果就不一样了:热替换 Provider 不用重启、HMR 重组插件树、运行时保证依赖一致性,这些能力是自然而然流出来的,不是事后补的。
下一篇看 Agent 怎么用事件溯源来"记住"对话。
相关文件
- Cordis 入门:docs/cordis-primer.md
- Cordis 教程:docs/cordis-tutorial/index.md
- Cordis API 参考:docs/cordis-api/
- 事件语义:docs/event-producer-consumer.md