Skip to content

02 · Cordis:插件框架的五个核心概念

dsh 的插件机制由 Cordis 提供(以 vendor 方式内置)。这一章把 Cordis 的五个核心概念讲透——它们是理解后面所有内容的地基。本章代码都可以在 Demo 1 里亲手跑。

2.1 一个最小可运行的插件

先看代码,建立直观印象。下面是一个完整的 Cordis 插件:

ts
import { Context, Service } from '@deepseek-ai/cordis'

// 插件:一个普通的函数
export function greetingPlugin(ctx: Context, config: { prefix: string }) {
  console.log(config.prefix + ' hello')
}

跑起来只需要两行:

ts
const root = new Context()      // 创建根上下文
root.plugin(greetingPlugin, { prefix: 'say' })  // 挂载插件,传入配置
// 输出:say hello

够简单。但这几行代码背后藏着五个概念,逐个展开。

2.2 概念一:插件是实现服务的对象

Cordis 里"插件"有三种合法形态,可以按需选择:

ts
// 形态 1:函数插件
export function pluginA(ctx: Context, config: {}) { /* ... */ }

// 形态 2:对象插件
export const pluginB = {
  name: 'plugin-b',            // 可选:显示名,用于诊断
  apply(ctx: Context, config: {}) { /* ... */ },
}

// 形态 3:类插件(构造函数即 apply)
export class PluginC {
  constructor(ctx: Context, config: {}) { /* ... */ }
}

三种形态地位完全平等,选择标准是"怎么组织代码最清晰"。dsh 的官方教程给的建议是:函数够用时用函数,需要跨生命周期持有状态时用类

每个插件还可以带两个可选元数据:

字段作用
inject声明依赖的服务,见 2.4
Config配置校验 schema(标准 schema 协议),配错时启动即报错

为什么函数就能当插件?

因为插件的全部职责就是"对上下文做点事"(注册服务、挂监听器、产生副作用),而不需要实现某个特定接口。这跟 React 的"组件就是函数"是同一个思路:约定一个签名,剩下交给组合。

2.3 概念二:上下文是服务的容器

  • 一个服务占据一个稳定的 ctx.<key>。比如 dsh 里 ctx.llm 是模型服务、ctx.tools 是工具注册表、ctx.sessions 是会话仓库。
  • 其他插件通过 key 查找服务,而不是 import 具体实现。 这是"一切皆插件"的物理基础:你 import 的是类型,拿到的是运行时注册进来的那个实例。
  • Context 在运行时是一个 Proxy:读 ctx.llm 时,属性访问会被拦截,转交给服务解析器。所以"换掉某个服务的实现"在运行时只是一次重新注册。

Context 还能派生作用域子上下文,注意它们不修改父级

ts
const child = root.extend({ extra: 'meta' })   // 原型继承父上下文的全部属性
const isolated = root.isolate('llm')           // 在子作用域里单独提供一套 llm
const intercepted = root.intercept('llm', { foo: 1 })  // 为下方插件合并服务的配置

isolate 的意义在 dsh 里很具体:给单个 agent 一个"局部世界"。根上下文的工具是全局的,而某个 agent 自己的 agent.ctx 是个 isolate 过的子上下文——你在里面注册的工具只有这个 agent 看得见。

2.4 概念三:用 inject 声明服务依赖

传统框架里,模块加载顺序靠"启动脚本里手动排列"。Cordis 的答案是:插件声明自己需要什么,框架负责等

ts
export const name = 'my-tool'
export const inject = ['tools']        // 声明:我需要 ctx.tools

export function apply(ctx: Context) {
  ctx.tools.register(/* ... */)        // 到这里时 tools 一定已经就绪
}

工作机制:

几个关键推论:

  1. 加载顺序由依赖推导,而不是人工编排。 一百个插件,只要各自声明依赖,正确顺序自动出现。
  2. 依赖变化会触发级联重载。 提供 tools 的插件被卸载,依赖它的插件全部先卸载、等待新的提供者出现再启动。这就是热重载(HMR)能工作的原因:重载一个插件,只会波及依赖它的分支。
  3. 未满足的依赖不是错误,是等待状态。 插件的 fiber 会停在 PENDING,直到服务出现。

常见错误:inject 写错服务名

如果 inject: ['tool'] 少写一个 s,插件会永远停在 PENDING,不报错也不执行。排查方法见常见错误 FAQ。这正是 dsh 用 TypeScript 声明合并把服务名类型化、让拼写错误变成编译错误的原因。

2.5 概念四:类型化事件用于通信

服务是"直接调用"的通道,事件是"广播 + 拦截"的通道。Cordis 的事件是类型化的:先通过 TypeScript 的声明合并注册事件签名,再用五种分发模式之一派发。

ts
// 1. 声明事件签名(声明合并,全局可见)
declare module '@deepseek-ai/cordis' {
  interface Events {
    'greet'(name: string): void                        // @mode emit
    'resolve'(question: string, next: () => Promise<string>): Promise<string>  // @mode waterfall
  }
}

// 2. 派发
ctx.emit('greet', 'world')                    // 广播,不等监听者
const answer = await ctx.waterfall('resolve', 'what is 1+1', async () => '42')

// 3. 监听(注册是 effect,随插件卸载自动移除)
ctx.on('greet', (name) => console.log('hello', name))

五种分发模式,这张表值得背下来:

模式是否 await?顺序返回值典型用途
emit注册顺序通知/广播,如 session/event
waterfall否(同步组合)注册顺序,包裹式可拦截的决策链,如 agent/pre-step
parallel并发扇出等待全部完成
serial注册顺序有(第一个 bail 值)顺序执行直到有人拍板
bail注册顺序有(第一个 bail 值)同步询问"谁来处理"

waterfall 是重中之重

dsh 几乎所有"可拦截点"都是 waterfall。它的语义是环绕式中间件

ts
// 监听者收到 (…args, next)。调 next() 执行下游;不调 next() 就是短路(veto)
ctx.on('resolve', async (question, next) => {
  if (question.includes('密码')) {
    return '拒绝回答'          // 不调 next():短路,后面的监听者和默认行为都不会执行
  }
  return next()                 // 委托:执行下游,拿回结果
})

这个语义对应真实业务里的"审批链":政策监听器拥有决策权时可以不调 next() 直接返回(否决);只做观察的监听器必须无条件委托。dsh 的 agent/pre-step(决定这一步模型看什么)、llm/stream(包裹每一次模型请求)、tools/pre-execute(工具执行前审批)全是 waterfall。

纪律:观察者必须调 next()

在 waterfall 里"忘了调 next()"是最容易犯的错——你的监听器本来只想打个日志,结果把整条链短路了,模型请求再也发不出去。规则很简单:只读监听必须委托;只有明确要否决时才短路。(Demo 2 会亲手演示这个 bug 的现场。)

2.6 概念五:注册是可逆的副作用

这是 Cordis 与"随手 addEventListener"式框架的分水岭。所有注册都是 effect(副作用),插件卸载时自动按逆序撤销。

ts
export function apply(ctx: Context) {
  const off = ctx.on('greet', listener)     // 注册监听器
  // off() 可手动移除;不手动移除的话,插件卸载时自动移除

  const timer = setInterval(tick, 1000)     // 非托管资源怎么办?
  ctx.effect(() => () => clearInterval(timer))  // 用 effect 包起来,交给框架清理
}

每个插件实例对应一个 fiber(纤维),fiber 持有这个插件产生的全部 effect。fiber 的状态机:

推论:

  • 热重载 = dispose 旧 fiber(逆序撤销全部 effect)→ 用新代码启动新 fiber。 没有任何"幽灵状态"残留,因为插件留下的东西都登记在案。
  • 注册即清理纪律ctx.on()、服务注册、ctx.tools.register()ctx.llm.registerAdapter() 全都是 effect,你不需要记住任何"别忘了在析构里移除"的清单——框架替你记着。
  • 如果清理有顺序要求(比如先关连接再删文件),把它们放在同一个 effect 里,逆序执行保证顺序。

2.7 五概念如何拼出 dsh

把五个概念拼起来,dsh 的骨架就出来了:

  • ctx.llm 是一个服务,由 llm 插件提供,deepseek 适配器插件往它上面注册自己的适配器。
  • agent-loop 插件通过 inject 声明它需要 agents、sessions、llm、tools、systemPrompt——它自己是最后启动的那批插件之一。
  • 拦截请求靠 waterfall 事件,广播状态靠 emit 事件
  • 所有注册都是 effect,所以 dsh --profile headless 退出时,整棵树干净利落地逆序拆除。

2.8 本章小结与练习

Cordis 五个概念:插件(三种形态)、上下文(服务容器 + 作用域派生)、inject(依赖驱动加载)、类型化事件(五种分发模式)、可逆 effect(注册即清理)。就这五个,没有第六个隐藏大招。

小练习:默写五概念

合上页面,回答:1)插件有哪三种形态?2)isolateextend 的区别?3)waterfall 和 serial 的区别?4)一个插件卸载时,它注册的监听器、定时器、服务会怎样?

答不上来的回头扫一眼对应小节,然后去 Demo 1 亲手验证一遍。

延伸阅读

Cordis 官方教程(docs/cordis-tutorial/)的 01–07 章与本教程思路一致:形态 → 生命周期 → 服务 → 事件 → 配置 → 组合与 HMR → 接入真实系统。它的设计论文 A Programming Paradigm for Spatiotemporal Composability 解释了背后的时空可组合性理论,学有余力可以读。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写