课程总纲 / 阶段 1 · 理念
L05

核心理念二:Cordis 五要素

插件框架的全部基本概念——这是你后面写插件的地基。

预计阅读 30 分钟难度 ★★★☆☆本课开始出现真实代码

Cordis 是 dsh 底下的插件框架(源码在 vendor/cordis/,可直接读;设计论文不必读)。全部骨架就五个概念:

要素一:插件是一个对象Plugin = Service

插件(plugin)就是一个"实现了 Service 约定的对象",最常用的函数形式

import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-plugin'          // ① 名字
export const inject = ['tools']              // ② 依赖(可选)

export function apply(ctx: Context) {      // ③ 安装函数
  // 框架加载插件时调用 apply,把 ctx 递给你;
  // 你要注册的一切都在这个函数里完成。
}

apply 就像新员工入职报到:公司(框架)把工牌和门禁卡(ctx)递给你,你拿着它去领电脑、开邮箱(注册你的能力)。另有类形式(继承 Service,适合提供服务,L16 专讲)和对象形式:

// 对象形式:三件套放进一个对象
export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) { /* ... */ },
}

// 类形式:继承 Service,适合提供服务(L16)
export default class MyService extends Service {
  static inject = ['tools']
  constructor(ctx: Context) { super(ctx, 'myService') }
}

要素二:上下文是服务的总插座板Context

上下文(Context,即 ctx是所有服务的容器,每个服务认领一个稳定的"插座名":

ctx.tools     // 工具注册表 —— 模型能调用哪些工具
ctx.llm       // 模型适配 —— 怎么调用大模型
ctx.sessions  // 会话 —— 日志与恢复
ctx.agents    // 智能体 —— 活的对话主体

ctx 是一排标准插座:任何插件想用电(用某项能力),不关心电从哪个发电厂来,插上就行。关键效果:找服务靠名字,不靠 import 具体实现——所以实现可整体替换(L06 的能力缝正建立在这上面)。

要素三:inject 声明依赖,框架管顺序Dependency Injection

inject 声明"我需要哪些服务",框架据此排加载顺序:

export const inject = ['tools']

export function apply(ctx: Context) {
  // 能走到这里,说明 ctx.tools 必然就绪
  ctx.tools.register(/* ... */)
}

像装修排期:水电工不用和泥瓦工商量谁先来,物业(框架)看依赖自动排期,谁缺料就等着。

Spring 对照:ctx ≈ ApplicationContext,ctx.toolsgetBean("tools")——按名取、不 import 实现,故可整体替换;inject ≈ @Autowired——容器读依赖图自动定初始化顺序。分工:插座板=全机共享的服务注册表,插座=服务名(tools/llm/sessions),ctx=每个插件手里的延长线——既能消费也能供电(L16)。时序:配置层 disabled: true 在模块加载之前就把行摘掉(插件根本不加载);inject 管的是幸存插件之间的先后——"去不去"归配置,"先后"归依赖。
可选依赖用 ctx.get(name)inject 声明必需依赖;"有则用之、无则跳过"的服务用 ctx.get('metrics') 拿(拿不到是 undefined,不卡加载)。有血泪教训(docs/postmortem/0001)。

要素四:类型化事件,四种派发模式Typed Events

插件之间靠事件说话。Cordis 的事件是用 TypeScript 声明合并(declaration merging)登记的类型化事件,发和收都有类型检查;每个事件有一种派发模式(dispatch mode),是事件公开契约的一部分:

模式等待?顺序返回值通俗理解
emit注册顺序广播:喊一嗓子"下雨了",各自收衣服不用回复
waterfall否*注册顺序流水线传纸条:每层可改内容再传下去;不传(不调 next)就是自己拍板
parallel同时群发任务:所有部门同时开工,全部干完才散会
serial注册顺序排队办事:挨个到窗口办,谁办成了后面不用办
bail注册顺序短路检查:挨个问"有问题吗",第一个说"有"的就定案

* waterfall 的 next 调用是异步的;"等待"列指派发本身是否 await 所有监听器。

// 发:广播"我准备好了"
ctx.emit('my-plugin/ready', { id: 'worker-1' })

// 听:收到就处理
ctx.on('my-plugin/ready', ({ id }) => {
  console.log(`${id} 就绪`)
})
waterfall 军规(写插件必考):监听器收到 (...args, next)必须调用 next() 才会把请求传给下一层;不调而直接 return 就是"短路"——流水线在你这里定案。想改写就先 const result = await next() 再改。L15 用它写权限门。

要素五:一切注册走 effect,自带撤销Reversible Effects

L04 的"注册即效果"落成代码:每个注册自带"撤销器"(disposer)

export function apply(ctx: Context) {
  // 方式一:ctx.effect —— 返回撤销函数
  ctx.effect(() => {
    const conn = openConnection()
    return () => conn.close()       // 卸载时自动执行
  })

  // 方式二:ctx.on —— 注册监听器,本身就是效果,自动回收
  ctx.on('tools/result', handler)   // 插件卸载,监听自动移除
}

经验法则:若卸载顺序重要,把相关注册放进同一个 effect,让撤销按期望顺序展开。

五要素合体:一个完整小插件Put Together

import type { Context } from '@deepseek-ai/cordis'

export const name = 'tool-logger'
export const inject = []                          // 无必需依赖

export function apply(ctx: Context) {           // ① 插件对象:apply 形式
  ctx.on('tools/result', (exec, result) => {  // ⑤ 注册即效果
    // ④ 事件:观察工具的最终结果
    console.log(`[tool] ${exec.name}`)
    const text = result.content
      .map(b => b.type === 'text' ? b.text : '')
      .join('')
    console.log(`[result] ${text.slice(0, 100)}`)
  })
}

五行代码用全五个要素——一个真实可用的"工具调用日志"插件。

Cordis
dsh 底层的插件框架,源码在 vendor/cordis/。
Context / ctx
服务的总插座板,按名取用不问出身。
inject 依赖注入
声明必需服务,框架保证就绪再启动你。
waterfall
流水线事件:可层层改写,不调 next() 即短路。
disposer 撤销器
注册自带的回收函数,卸载时执行。
声明合并
TS 机制:向已有 interface 追加字段来登记新事件/服务。
✏️ 动手练习(读代码)
  1. 通读 docs/cordis-tutorial/01-first-plugin.zh.md(官方手把手版,与本课互为印证)。
  2. 浏览 vendor/cordis/src/service.ts,找到 Service 类的定义位置。
  3. 纸笔画一遍"五要素"关系图:插件 →(apply)→ ctx →(inject 保证)→ 服务 →(事件)→ 其他插件。
📝 自测(点击展开答案)
1. apply(ctx) 何时被调用?inject 的服务此时什么状态?
框架加载插件时调用;inject 里的必需服务此时全部就绪(没就绪框架先等),所以 apply 里可放心用 ctx.tools。
2. 想让两个插件加载有确定顺序,怎么办?
不手工排——用 inject 声明依赖,让后加载的插件 inject 先加载者提供的服务;顺序由服务需求推导。
3. emit 和 waterfall 的本质区别?
emit 纯通知(无返回值,只观察);waterfall 是处理链(有返回值,可层层改写或短路)。观察用 emit,拦截/改写用 waterfall。
4. waterfall 监听器"忘了调 next()"会怎样?
流水线在你这里短路:后面监听器全不执行,你的返回值成为最终结果。设计上是特性(拦截/网关),但"只想观察"的监听器必须调 next()。
5. ctx.tools 和直接 import 一个 Tools 类的本质不同?
ctx 按名字解耦找服务,实现可整体替换(换提供者其他插件无感);直接 import 绑死实现,失去替换能力。
权威出处:cordis-primer.zh.md(本课主源) · framework/events.zh.md · cordis-tutorial/(7 章教程,强烈推荐)