深入 Cordis 核心:这个插件框架比 Webpack Tapable 还好用

第9章:插件原理深度剖析------Cordis 框架的核心机制

系列:DeepSeek Harness 源码实战

原文仓库:github.com/deepseek-ai...

八章走完,dsh 的骨架和肌肉你都见过了。但从头到尾有个东西一直在背后默默驱动一切------Cordis 框架。它是 dsh 的插件引擎,是一切皆插件这句话的底层支撑。

这一章我们潜入 Cordis 的内部机制,把插件的三种形态、Fiber(纤维)状态机、effect(副作用)生命周期、ctx(上下文)服务容器这四个核心概念彻底拆透。理解了这些,你才能写出正确的插件,才能在插件不工作时知道去哪里查。

我是怕浪猫,这是系列的第 9 章。我们从插件的最小单元开始。

9.1 插件的三种形态

Cordis 接受三种插件形态:函数、对象、类。三种形态本质上做同一件事------提供一个 apply(ctx) 方法,Cordis 加载插件时调用它,插件在 apply 里注册自己贡献的所有内容。

来看 Cordis 教程里的定义(docs/cordis-tutorial/01-first-plugin.md):

ts 复制代码
import { Service, type Context } from '@deepseek-ai/cordis'

// 1. Function plugin:直接导出 apply 函数
export function apply(ctx: Context) {}

// 2. Object plugin:带 apply 方法的对象
export const objectPlugin = {
  name: 'object-plugin',
  apply(ctx: Context) {},
}

// 3. Class plugin:Service 子类
export class MyService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myTutorialService')
  }
}

三种形态怎么选?规则很简单:

形态 适用场景 特点
函数 不需要提供服务的插件 最简洁,一个 apply 函数搞定
对象 需要显式 name 但不需要类 少用,函数形态加 name 导出即可替代
需要提供服务(ctx.xxx Service 子类,注册为命名服务

金句:函数是插件的原子形态,类是服务的载体。写工具用函数,写能力用类------这是 dsh 的惯例。

来看一个真实例子。dsh 的工具注册插件通常用函数形态(docs/cordis-tutorial/07-into-the-harness.md):

ts 复制代码
// 来自 docs/cordis-tutorial/07-into-the-harness.md
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

注意三个关键点:name 是诊断用的显示元数据,inject 声明依赖的服务,apply 是插件体。这个插件不需要提供服务,只注册工具,所以用函数形态就够了。

而需要提供服务的插件用类形态,比如 LLM 适配器(docs/user/develop/practice/llm-adapter.md):

ts 复制代码
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'

class MyAdapter extends LlmAdapter {
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 转发请求到 provider API,把响应转成 StreamChunk
  }
}

export const name = 'my-llm-adapter'
export const inject = ['llm']

export function apply(ctx: Context, config: Config) {
  const adapter = new MyAdapter(config.apiKey)
  ctx.llm.registerAdapter(config.providers, adapter)
}

这里 MyAdapterLlmAdapter 的子类(LlmAdapter 继承自 Service),但插件入口仍是函数------类用在了需要实现抽象接口的地方,函数用在了插件入口。

9.2 Fiber 状态机:插件的生命周期

每个加载的插件实例拥有一个 Fiber------运行时句柄,跟踪插件的状态、配置和注册的 effect。

Fiber 的状态流转(docs/cordis-tutorial/02-lifecycle-and-effects.md):

bash 复制代码
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED

每个状态的含义:

状态 含义 何时进入
PENDING 等待依赖服务就绪 插件被声明但 inject 的服务尚未可用
LOADING 正在执行 apply 依赖就绪,开始加载
ACTIVE apply 执行完毕 插件正常运行中
FAILED apply 或配置校验抛出异常 加载失败
UNLOADING 正在执行清理 被卸载、热替换或依赖消失
DISPOSED 清理完成 所有 disposer 执行完毕

PENDING 是最容易被忽略的状态。教程里有一段专门的诊断说明:

inject: ['timer'] has no provider. Add - name: '@deepseek-ai/cordis-plugin-timer' to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state.

如果你写了一个插件,运行后什么也没发生、也没报错------大概率是 PENDING 了。它的 inject 声明了某个服务,但没有人提供这个服务。Cordis 不会报错,因为 PENDING 是合法状态------服务可能稍后被挂载。

来看教程里的诊断代码(docs/cordis-tutorial/06-composition-and-hmr.md):

ts 复制代码
import { FiberState, type Context } from '@deepseek-ai/cordis'

export const name = 'diagnose'

export function apply(ctx: Context) {
  setTimeout(() => {
    for (const runtime of ctx.registry.values()) {
      for (const fiber of runtime.fibers) {
        if (fiber.state === FiberState.PENDING) {
          console.log(`${fiber.name} is PENDING --- a required service is missing`)
        }
      }
    }
  }, 500)
}

这个诊断插件遍历注册表,找出所有 PENDING 状态的 Fiber。实际开发中,如果你不确定插件为什么没加载,写一个类似的诊断器是第一步。

金句:PENDING 不是 bug,是设计。但如果你的插件应该加载却没加载,PENDING 就是线索。

Fiber 的另一个重要特性:依赖追踪是动态的。如果插件 A 依赖服务 X,服务 X 的 Provider 被卸载时,插件 A 也会被自动卸载;服务 X 重新出现时,插件 A 会重新加载。教程原文(docs/cordis-tutorial/03-services.md):

inject is not a one-shot boot check. If a required service disappears while the app runs --- its provider was unloaded or hot-replaced --- every dependent plugin is unloaded too, and loads again when the service returns.

这意味着热替换 Provider 是安全的------所有依赖它的插件会自动重启,不需要手动协调。

9.3 Effect 生命周期:注册即清理

Cordis 的核心设计原则之一:所有注册都是可逆的副作用(reversible effects)。插件注册的监听器、工具、服务,在插件卸载时自动清理。

来看 Cordis API 文档的定义(docs/cordis-api/fiber.md):

ts 复制代码
// ctx.effect 注册一个 effect:
// execute 立即执行,返回的 disposer 在卸载时执行
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>

ctx.effect() 的用法(docs/cordis-tutorial/02-lifecycle-and-effects.md):

ts 复制代码
import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  const fiber = ctx.plugin(heartbeat)
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

关键点:ctx.effect() 的 body 在加载时立即执行,返回的 disposer 在卸载时执行。你永远不需要自己调用 disposer------Cordis 会在正确的时机调它。

但大部分时候你不需要手写 ctx.effect(),因为 Cordis 的内置注册 API 已经是 effect 了:

注册 API 自动清理行为
ctx.on(event, listener) 卸载时移除监听器
ctx.plugin(child) 卸载时 dispose 子插件
ctx.tools.register(...) 卸载时注销工具
Service 注册(super(ctx, name) 卸载时移除服务

教程原文(docs/cordis-tutorial/02-lifecycle-and-effects.md):

You rarely write ctx.effect() yourself, because the built-in registration APIs are effects already.

一个需要注意的顺序问题(教程原文):

disposers start in reverse registration order, but multiple async disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there.

翻译:disposer 按注册的逆序启动,但多个异步 disposer 会并发执行。如果清理步骤必须按顺序执行,把它们放在一个 disposer 里。

金句:注册是副作用,清理是自动的。这不是便利,是架构约束------因为 HMR(Hot Module Replacement,热模块替换)和依赖追踪都依赖这个保证。

9.4 ctx 服务容器:插件的通信枢纽

ctx(Context,上下文)是 Cordis 的核心对象。每个服务、事件、生命周期 API 都通过 ctx 访问。ctx 是一个代理------属性读取经过服务解析器,extend()isolate()intercept() 创建作用域子上下文。

来看 Cordis API 文档(docs/cordis-api/context.md):

ts 复制代码
// ctx.extend(meta?) --- 创建子上下文,继承当前上下文的所有属性
extend(meta = {}): this

// ctx.isolate(name, label?) --- 创建子上下文,隔离指定服务
isolate(name: string, label?: symbol)

// ctx.intercept(name, config) --- 为服务添加拦截配置
intercept(name: string, config: any): this

三个核心操作:

1. extend:创建子上下文,添加额外元数据。子上下文原型继承父上下文,不修改父上下文。

2. isolate :创建子上下文,隔离指定服务。两个 group 可以各自看到不同配置的同一个服务实现。教程里给出了实际用法(docs/user/develop/framework/service.md):

yaml 复制代码
- id:
group-a
  name:
'@deepseek-ai/cordis-plugin-group'
  group:
true
  isolate:
    shell:
true
  config:
    - name:
'@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs:
5000
    - name:
'./src/plugin-a.ts'
- id:
group-b
  name:
'@deepseek-ai/cordis-plugin-group'
  group:
true
  isolate:
    shell:
true
  config:
    - name:
'@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs:
60000
    - name:
'./src/plugin-b.ts'

plugin-aplugin-b 各自看到自己 group 里的 Bash 实例,互不影响。这在多 agent 场景下很有用------每个 agent 可以有自己的 shell 配置。

3. intercept:为服务添加拦截配置。子上下文里的插件会看到合并后的配置。

服务通过 ctx.<key> 访问。dsh 注册的核心服务包括(来自 docs/cordis-primer.md):

服务名 访问方式 提供者
tools ctx.tools dsh-tools
llm ctx.llm dsh-llm
agents ctx.agents dsh-agent
session ctx.session dsh-session
shell ctx.shell dsh-bash-local / dsh-pwsh-local
fs ctx.fs dsh-fs-local / dsh-fs-sandbox
subprocess ctx.subprocess dsh-subprocess-local
terminals ctx.terminals dsh-pty

服务名在一个应用里是扁平命名空间。dsh 占用了 toolsllmagents 等普通名字,你自己的服务应该加前缀或命名空间。

声明服务的 TypeScript 类型需要声明合并(declaration merging,docs/cordis-tutorial/03-services.md):

ts 复制代码
import { Service, type Context } from '@deepseek-ai/cordis'

// 编译时类型声明
declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')  // 运行时注册
  }

  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'

export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

两个部分协作:super(ctx, 'greeter') 在运行时注册服务实例,declare module 在编译时给 ctx.greeter 加类型。声明合并不产生代码------没有它服务在运行时照样工作,但消费方失去类型安全。

消费方通过 inject 声明依赖(同文件):

ts 复制代码
export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

inject 让 Cordis 在服务就绪前保持插件 PENDING。apply 运行时,ctx.greeter 保证可用。加载顺序不影响------依赖关系决定启动顺序,不是文件中的位置。

可选依赖不用 inject,用 ctx.get() 在使用点探测:

ts 复制代码
export function apply(ctx: Context) {
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

ctx.get('greeter') 在没有 Provider 时返回 undefined,插件仍然正常运行。

金句:inject 是硬依赖------服务不在就不加载。ctx.get 是软依赖------服务不在也能跑,只是功能降级。

9.5 插件加载流程:从 cordis.yml 到 apply

把前面的概念串起来,看一个插件从配置到运行的完整流程。

当 dsh 启动时(docs/cordis-tutorial/01-first-plugin.md):

配置条目的结构(docs/cordis-tutorial/06-composition-and-hmr.md):

yaml 复制代码
- id:
greeter
# 稳定标识,HMR 用
  name:
'./greeter.ts'
# 模块指定符
  config:
# 传给 apply 的配置
    greeting:
'Hello'
  disabled:
false
# 跳过挂载但保留条目

id 的作用:让 Loader 区分「编辑已有条目」和「删除旧的加新的」。没有 id 的条目每次读取都生成新 id,配置文件编辑后会被视为删除+新增,导致不必要的重载。

disabled 的作用:跳过挂载但保留条目。改回 false 就恢复------不需要删除条目。

config 的校验(docs/cordis-tutorial/05-config.md):

ts 复制代码
import Schema from '@deepseek-ai/schemastery'

export interface Config {
  greeting: string
  targets: string[]
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  targets: Schema.array(String).default(['world']),
})

export function apply(ctx: Context, config: Config) {
  for (const target of config.targets) {
    console.log(`${config.greeting}, ${target}!`)
  }
}

导出的 Config 同时是 TypeScript 接口和运行时 schema------消费方拿类型,Cordis 拿校验器。dsh 使用 Schemastery 做 schema 校验。教程明确警告:

Do not export a plain object as Config; it does not implement the Standard Schema interface required by Cordis.

配置校验失败时,Fiber 进入 FAILED 状态,启动器以退出码 1 报错:

bash 复制代码
ValidationError: invalid config:
  - $.targets expected array but got not-an-array (at targets)

!!js 标签支持计算配置值(docs/cordis-tutorial/05-config.md):

yaml 复制代码
- name:
'./config-demo.ts'
  config:
    greeting:
!!js
process.env.DEMO_GREETING
??
'Hello'

!!js 只在 config 字段和 disabled 字段中可用。disabled: !!js ... 在每次挂载决策时求值,可以按平台或环境门控插件。

9.6 HMR 热替换实战

HMR(Hot Module Replacement,热模块替换)是 dsh 开发体验的关键。因为注册是 effect、卸载自动清理,Cordis 可以卸载旧插件实例、加载新实例,不需要重启进程。

教程的 HMR 演示(docs/cordis-tutorial/06-composition-and-hmr.md):

yaml 复制代码
- id:
logger
  name:
'@deepseek-ai/cordis-plugin-logger-console'
- id:
timer
  name:
'@deepseek-ai/cordis-plugin-timer'
- id:
hmr
  name:
'@deepseek-ai/cordis-plugin-hmr'
  config:
    root:
['.']
- id:
hello
  name:
'./hello.ts'

三个注意点:

要点 说明
logger 依赖 HMR 通过 Cordis logger 服务输出日志,没有 console 导出器看不到消息
timer 依赖 HMR inject 了 timer 服务用于防抖,没有 timer 插件它永远 PENDING
id 必须显式 没有 id 的条目每次读取生成新 id,配置文件编辑后被误判为删除+新增

编辑 hello.ts 保存后,HMR 输出:

bash 复制代码
hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin

旧实例卸载(所有 effect 清理)、新代码加载、apply 重新执行。编辑 cordis.yml 本身也会被检测------Loader 按 id diff 条目,只挂载、卸载或重新配置变化的部分。

金句:HMR 不是魔法,是 effect 清理保证的原子替换。如果注册不是 effect,HMR 就会泄漏------这就是为什么 Cordis 强制所有注册走 effect 机制。

9.7 Cordis 五个核心概念总结

Cordis Primer(docs/cordis-primer.md)用五句话概括了 Cordis 的核心思想:

概念 一句话 代码体现
插件 实现 Service 的对象 apply(ctx) / Service 子类
上下文 服务容器 ctx.tools / ctx.llm
依赖声明 inject 等待服务就绪 export const inject = ['tools']
类型化事件 声明合并定义事件签名 interface Events { 'x/y': ... }
可逆副作用 注册自动清理 ctx.on() / ctx.effect()

这五个概念是写任何 dsh 插件的基础。不管是注册一个工具、监听一个事件、还是实现一个 Provider,都离不开它们。

本章小结

要点 说明
插件三种形态 函数(最常用)、对象(少用)、类(Service 子类)
Fiber 六状态 PENDING / LOADING / ACTIVE / FAILED / UNLOADING / DISPOSED
PENDING 诊断 inject 的服务未被提供,写诊断器遍历 registry 查找
Effect 自动清理 ctx.on / ctx.plugin / ctx.tools.register 等注册自带 disposer
ctx 服务容器 通过 ctx.<key> 访问,声明合并加类型,inject 声明硬依赖
HMR 基于 effect 清理的原子替换,需要显式 id 和依赖服务到位

我是怕浪猫,第九章拆完。Cordis 的插件引擎不复杂,但细节多------PENDING 静默、effect 顺序、id 稳定性,这些坑踩过一次就记住了。

如果这篇对你有帮助,收藏起来,写插件卡住时翻回来查。有问题评论区聊,有纠错也欢迎指出。

下一章我们动手写一个完整的 dsh 插件------从工具定义到打包发布。

系列进度:9/12 | 未完待续

相关推荐
sg_knight1 小时前
openCode 安装与初始化配置(Windows / macOS / Linux)
linux·windows·macos·llm·agent·ai编程·opencode
AI发掘1 小时前
知网AIGC检测怎么降?快降重和比话降AI谁更稳?实测对比
人工智能·aigc
Helen_Liang_七仔的AI工具箱1 小时前
找工作难在投错方向,附自动找Offer skill安装地址
aigc·找工作
七牛云行业应用3 小时前
Cursor 如何接入自定义模型:Override Base URL 完整配置与四类踩坑速查
人工智能·agent·ai编程
张忠琳11 小时前
【deepseek-harness】DeepSeek Harness (dsh) 系统级架构分析之三
ai·agent·deepseek·harness
Behaviour12 小时前
Unity AI 生态横评对比:官方 AI Beta / Unity CLI / 团结 Codely / 社区 MCP 四大阵营选型
人工智能·unity·ai·游戏引擎·aigc·ai编程
学习星球12 小时前
OpenHarness 全面配置教学——从游戏开发工作流引入
c++·游戏·ai·ai编程
uncle_ll13 小时前
LlamaIndex 全栈实战指南:架构解构、RAG 核心原理与私有知识库工程化落地方案
架构·llm·agent·rag·llamaindex