这是《DeepSeek Harness 权威指南 》系列的第 3 篇。 本文源码基线为 deepseek-harness @ 47f9438(v0.1.0-rc.5)。源码和中文文档结论在首次出现处说明来源;本机 demo 只用于展示该脚本、命令和环境下的行为。

一个 apply(ctx) 函数,能挂进一棵大插件树。 2026-08-16 的本机 Web 会话截图显示 133 个已挂载插件,包含 llm(模型层)、agent-loop(主循环)、tool-fs(文件工具)和 ui-conversation(对话界面)。133 是该次会话的观测值;commit、profile、操作系统、已安装插件、home patch 与 CLI overlay 改变后,计数也会改变。插件树的重点不是固定数量,而是加载、依赖和卸载都由同一套生命周期管理。

DeepSeek Harness Web UI 插件列表:133+ 插件全部标记已挂载/已启用/已停用

图:2026-08-16 的本机 Web 会话插件列表。状态列是该会话的 Fiber 观察值,不是跨版本、profile 或 patch 的固定规格。

本篇把插件定义、生命周期和配置树分别用仓库中文文档、源码和本机 demo 对照。凡是本机 demo 的输出,均只说明该脚本和该环境中的行为;它不替代框架的公开契约。

一、插件是什么:apply 契约与三种形态

传统框架组织扩展的方式是"内核 + 扩展点":框架预留接口,插件继承基类、覆写框架约定的方法。dsh 把这条路整个换掉:插件是导出 apply 函数的 TypeScript 模块,框架加载时调用 apply(ctx),把当前上下文的注册权交给插件。 框架不预知插件会注册什么——因为"一切皆插件"意味着插件可能是工具、模型适配器、UI 界面、日志存储,它们之间没有共同的方法集,只有共同的上下文(ctx)。

官方《第一个插件》教程(docs/user/develop/basic/index.zh.md)给出了最小形态:

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

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // Register capabilities here.
}

这里唯一的英文注释 Register capabilities here. 的中文意思是“在这里注册能力”。这就是最小插件配置:没有基类、没有接口实现、没有额外注册表。

插件一共有三种形态,官方文档明确列出:

形态写法适用场景
函数export function apply(ctx) {}最轻,无依赖或少量依赖
对象export default { name, inject, apply }需要声明名字与依赖
class X extends Service { constructor(ctx) { super(ctx, key) } }需要向其他插件提供服务

类形态之所以存在,是因为"提供服务"是插件的一等职责。 Service 子类的构造函数里 super(ctx, 'clock') 这一句就把 clock 服务注册到了当前上下文——之后任何声明 inject: ['clock'] 的插件都能拿到它。这个模式在 dsh 源码里随处可见:core/sessionctx.sessionscore/agentctx.agentsllm/llmctx.llm,全是 Service 子类构造时注册的。

选择 apply 而不是继承框架基类,是因为继承要求框架预知插件的形态,apply 把主动权完全交给插件。工具的插件注册工具、适配器的插件注册适配器、UI 的插件监听事件流——框架不需要知道也不关心,它只提供 ctx 这棵树的入口。这是"没有特权内核"在代码层面的落点:入口唯一,形态自由。

二、生命周期:六态状态机

每个被加载的插件都拥有一个 Fiber 作用域vendor/cordis/lib/types/fiber.d.ts 中定义为 FiberState 枚举,PENDING=0 … UNLOADING=5)。官方《插件与生命周期》文档(docs/user/develop/framework/index.zh.md)给出的状态机:

PENDING → LOADING → ACTIVE
                 ↘ FAILED
ACTIVE → UNLOADING → DISPOSED

dsh 插件 Fiber 生命周期状态机:六态流转,注册即副作用、卸载即撤销

图:Fiber 六态状态机。PENDING 等待依赖、LOADING 执行 apply、ACTIVE 运行中、FAILED 加载失败、UNLOADING 清理中、DISPOSED 完全卸载。虚线回环是 HMR/依赖恢复时的重建路径。

状态含义
PENDING已声明,但所需依赖服务未就绪
LOADING依赖就绪,正在执行 apply(ctx)
ACTIVE插件运行中,注册生效
FAILEDapply 抛异常或配置校验失败
UNLOADING插件正在卸载,disposers 逆序执行
DISPOSED已完全卸载,注册全部撤销,不可重启

两个细节值得单独说。

第一,PENDING 不是排队,是等待。 插件注册后不保证立即执行 apply——Cordis 检查它的 inject 声明,依赖不齐就一直停在 PENDING。依赖的服务消失(比如提供方被替换),插件会被自动卸载(ACTIVE → UNLOADING → DISPOSED);服务恢复后重新加载。这套机制让"依赖变更"成为一等操作:插件的生死由依赖图驱动,不靠脚本编排。

第二,FAILED 后的恢复有前提。 apply 抛错或配置校验失败会进入 FAILED。中文生命周期文档说明:在 cordis.yml 中加载并启用 @deepseek-ai/cordis-plugin-hmr 后,修改其监视范围内的插件源文件会卸载旧实例、加载新代码并执行新的 applydump-config 出现 hmr 条目只证明该插件被配置;不能证明任意源码或配置变更都会触发热替换。

三、依赖注入:加载顺序由依赖推导

现在回答开头的反直觉结论:注册顺序 ≠ 加载顺序 ≠ 卸载顺序。 用本机一个最小 demo 实测——三个插件,三种形态各一个,故意先注册依赖方、再注册提供方:

/**
 * dsh 插件生命周期实证(A2 插件树深讲配文)
 * 运行:node --import tsx packages/core/session/scratch/lifecycle.ts
 * 解析:packages/core/session/node_modules/@deepseek-ai/cordis -> vendor/cordis (v4.0.1)
 */
import { Context, FiberState, Service } from '@deepseek-ai/cordis'

// 类型化事件:dsh 的 packages/* 正是这样扩展 cordis 的 Events
declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/tick'(): void
  }
}

function stateName(s: FiberState): string {
  return FiberState[s] ?? String(s)
}

// ── 形态 1:函数(无依赖)──
function logPlugin(ctx: Context) {
  console.log('[apply] log-plugin(函数形态)')
  ctx.on('demo/tick', () => console.log('[event] log-plugin 收到 demo/tick'))
  ctx.effect(() => {
    console.log('[effect] log-plugin 注册副作用')
    return () => console.log('[cleanup] log-plugin 撤销副作用')
  })
}

// ── 形态 2:类(提供服务)──
class ClockService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'clock')
    console.log('[apply] clock-service(类形态)注册 clock 服务')
    ctx.effect(() => {
      console.log('[effect] clock-service 注册副作用')
      return () => console.log('[cleanup] clock-service 撤销副作用')
    })
  }
}

// ── 形态 3:对象(注入依赖)──
const consumer = {
  name: 'consumer',
  inject: ['clock'],
  apply(ctx: Context) {
    console.log('[apply] consumer(对象形态)读取 clock 服务:', typeof ctx.clock)
    ctx.effect(() => {
      console.log('[effect] consumer 注册副作用')
      return () => console.log('[cleanup] consumer 撤销副作用')
    })
  },
}

async function main() {
  const app = new Context()

  // 故意先注册依赖方、再注册提供方 —— 验证加载顺序由依赖推导
  const consumerFiber = app.plugin(consumer)
  console.log('[fiber] consumer 注册后立即读状态:', stateName(consumerFiber.state))

  const logFiber = app.plugin(logPlugin)
  await logFiber

  await new Promise((resolve) => setTimeout(resolve, 50))
  console.log('[fiber] 50ms 后(clock 仍未提供):', stateName(consumerFiber.state))

  const clockFiber = app.plugin(ClockService)
  await clockFiber
  await consumerFiber
  console.log('[fiber] clock 提供后 consumer 状态:', stateName(consumerFiber.state))

  console.log('--- 挂载中:事件分发 ---')
  await app.emit('demo/tick')

  console.log('--- 卸载整棵树 ---')
  await app.fiber.dispose()
  console.log('[fiber] 卸载后 consumer 状态:', stateName(consumerFiber.state))
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})

这段代码用 dsh 同款 vendored Cordis(@deepseek-ai/cordis v4.0.1)运行,本机真实输出:

[fiber] consumer 注册后立即读状态: PENDING
[apply] log-plugin(函数形态)
[effect] log-plugin 注册副作用
[fiber] 50ms 后(clock 仍未提供): PENDING
[apply] clock-service(类形态)注册 clock 服务
[effect] clock-service 注册副作用
[apply] consumer(对象形态)读取 clock 服务: object
[effect] consumer 注册副作用
[fiber] clock 提供后 consumer 状态: ACTIVE
--- 挂载中:事件分发 ---
[event] log-plugin 收到 demo/tick
--- 卸载整棵树 ---
[cleanup] clock-service 撤销副作用
[cleanup] consumer 撤销副作用
[cleanup] log-plugin 撤销副作用
[fiber] 卸载后 consumer 状态: DISPOSED

输出与状态机完全对应,三个结论:

第一,依赖驱动加载是硬约束。 consumer 是第一个注册的,却最后一个执行 apply——它在 PENDING 里等了 50ms、等了 clock-serviceclock 服务提供出来才转 ACTIVE。log-plugin 无依赖,注册即加载。谁先跑不取决于谁先注册,取决于谁的依赖先齐。

第二,类型化事件是插件间通信的通道。 declare module 扩展 Events 接口、ctx.on 注册监听、ctx.emit 分发——dsh 自己的 agent/*session/* 事件就是这么声明的。事件监听也是副作用,Fiber 卸载时自动撤销。

第三,处置器的启动顺序与完成顺序要分开看。 本 demo 观察到 cleanup 的启动顺序为 clock → consumer → log。中文生命周期文档保证处置器按注册顺序的逆序开始调用,但多个异步处置器会并发执行,完成顺序不保证。跨插件存在顺序依赖的清理,必须放进同一个 ctx.effect() 返回的处置器,并由该处置器自行 await

依赖驱动加载、逆序卸载时序图:注册序 ≠ 加载序 ≠ 卸载序

图:同一 demo 的真实时序。注册序 consumer①→log②→clock③;apply 序 log→clock→consumer(依赖推导);cleanup 序 clock→consumer→log(逆序卸载)。下方为本机三次运行一致的真实输出。

四、可逆副作用:ctx.effect 与自动清理

传统代码里"注册资源"和"清理资源"是两段要人工对齐的代码:addEventListener 对应 removeEventListenersetInterval 对应 clearInterval,注册表 register 对应 unregister。漏掉任何一处,就是幽灵监听器、泄漏的定时器、残留的工具注册——在 agent 框架里,残留的工具注册是安全事故:模型随时可能调用到一个已卸载插件留下的工具。

dsh 的处理是釜底抽薪:通过 ctx 做的任何注册,都是可逆副作用,卸载时自动撤销。 官方文档列出的自动清理范围:

  • ctx.on(event, handler) — 事件监听
  • ctx.tools.register(tool) — 工具注册
  • ctx.llm.registerAdapter(names, adapter) — LLM 适配器注册
  • ctx.effect(() => cleanup) — 自定义资源

前三类由框架内部实现为 effect,最后一类把"自定义清理"也收编成同一种机制。“注册即副作用"不是某个 API 的特性,是 Cordis 的通用契约——ctx.effect(fn) 注册资源并返回 disposer,Fiber 卸载时自动执行;ctx.providectx.plugin(子插件)、ctx.inject(依赖回调)全部建立在这套契约上。ctx.fiber.dispose() 可以提前终止一个插件实例,保证:该插件所有注册被移除、子插件递归卸载、异步清理完成后 Promise 才兑现。

这套设计的工程收益在 HMR 上体现得最直接:热替换能成立,是因为旧实例的注册会自动消失。 手动清理的代码做不到这一点——它不知道插件在运行期间到底注册了什么。可逆副作用让"卸载"成为无残留操作,也让 133 个插件可以随意增删而互不污染。

五、配置树组装:–patch 把插件插进树

本节的 --patch 例子运行在已完成构建的源码 checkout 中,并通过 node --import tsx 让 Node 能加载 .ts 模块。--patch 只是在配置层插入条目,不会替你完成构建;使用已发布 CLI 或没有 TypeScript loader 时,name 必须解析为 Node 可导入的 JavaScript 或包入口。

在仓库根目录建 scratch-plugin/,写插件:

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

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  // Required dependencies are ready before apply runs.
  console.log('[hello-plugin] plugin loaded!')

  // Any registration made through ctx is undone on unload.
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('[hello-plugin] heartbeat')
    }, 60_000)
    return () => {
      clearInterval(timer)
      console.log('[hello-plugin] cleanup: timer cleared')
    }
  })
}

本例代码里的英文注释也对应实际语义:Required dependencies are ready before apply runs. 是“apply 开始前,声明的必需依赖已经就绪”;Any registration made through ctx is undone on unload. 是“通过 ctx 做出的注册会在卸载时撤销”。它们描述的是当前 demo 的生命周期边界,不能替代运行时验证。再写 patch 文件 scratch-plugin/cordis.yml

# Windows 上插件路径必须是 file:// URL(盘符路径 E:/... 会触发 ERR_UNSUPPORTED_ESM_URL_SCHEME)
- insert:
    - id: hello
      name: 'file:///E:/coding/deepseek-harness/scratch-plugin/src/hello-plugin.ts'

这里有一个官方教程没写、本机实测踩到的平台差异:插件路径必须是绝对路径,但 Windows 上必须是 file:/// URL 形式。 直接写盘符路径 E:/coding/... 会触发 ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'e:'——因为加载器把 name 当 ESM 模块说明符解析,Windows 绝对路径需要 file URL 形式。--dump-config 检查不出这个问题(它只打印配置树、不解析模块),只有启动时才会炸。

加载前先用 --dump-config 看树。下面是作者在该源码 checkout、该 patch 与该命令下的示例输出:它能证明 hello 条目被选入配置树,但不构成其他版本或其他 profile 的固定行数承诺。

$ node --import tsx apps/cli/src/bin.ts --profile web --patch E:/coding/deepseek-harness/scratch-plugin/cordis.yml --dump-config
...
- id: agent-presets
  name: '@deepseek-ai/dsh-agent-presets'
  config:
    default: standard
# == E:\coding\deepseek-harness\scratch-plugin\cordis.yml
- id: hello
  name: file:///E:/coding/deepseek-harness/scratch-plugin/src/hello-plugin.ts

# == 注释记录来源层。 这个示例里,官方组合包(@deepseek-ai/dsh-base@deepseek-ai/dsh-web-app)和 hello patch 都留下了来源。493 行、25 条来源层标记是这次命令的观测值;不同 commit、profile、home patch 或 CLI overlay 会改变它。hello 与官方插件在配置树中同样是一个条目,能否启动仍需由真实 Loader import 和 apply() 执行来验证。

然后用 patch 启动 Web UI:

node --import tsx apps/cli/src/bin.ts web --patch E:/coding/deepseek-harness/scratch-plugin/cordis.yml

本机真实启动日志:

[hello-plugin] plugin loaded!
dsh web: http://127.0.0.1:3080

apply 在真实产品里执行了。注意 name 字段(hello)就是配置树里的条目 id,也是 patch 定位与覆盖的键。

六、代价与边界

插件化不是免费午餐。三个代价摆出来:

第一,覆盖已有条目时没有深度合并。 patch 按 id 定位条目并替换整个 config,或插入新条目。因此,想改已有 id 的一个字段时,必须重述仍需保留的字段;新 insert 条目不涉及覆盖旧 config。

第二,调试要顺着 inject 图走。 插件启动顺序不是代码里写死的,是 Cordis 根据 inject 声明推导的。插件多了以后,启动失败排查要顺着服务依赖链找:谁在等谁、谁还没提供。dsh 的 --dump-config 和运行时诊断(runtime-diagnostics 包)是主要工具。

第三,跨插件的处置完成顺序不保证。 上文输出只展示本 demo 的 disposer 启动顺序;中文文档只承诺“按注册顺序的逆序开始调用”,随后多个异步处置器可并发执行。跨插件有顺序依赖的清理必须放进同一个 ctx.effect() 返回的处置器,并自行串行等待。

边界也划清楚:可替换范围取决于插件 seam 是否暴露了对应能力;若想改 Cordis 自身的加载语义,就需要源码改造或 fork。47f9438docs/subsystems/ 有 46 篇中文页面和 46 篇英文页面,提供较广入口,但不保证每个 seam 都被完整覆盖。

七、决策表

设计问题方案 A(传统框架)方案 B(dsh/Cordis)淘汰 A 的原因
插件入口继承框架基类、覆写约定方法apply(ctx) 单一契约插件形态差异大,没有共同方法集,只有共同上下文
启动顺序手动编排启动序列inject 依赖推导133 插件规模下手动编排不可维护
资源清理手动 removeListener / clearIntervalctx.effect() 自动撤销手动清理必漏,且无法支撑 HMR
服务发现import 具体实现ctx.<key> 服务查找import 实现让替换提供方成为不可能
本地插件接入改源码、重新编译在已构建源码 checkout 中以 --patch 插入条目--patch 隔离实验配置;模块仍必须能被当前 Node loader 导入
配置覆盖深度 merge按 id 整条替换merge 语义在多层叠加时不可预测

八、系列路线

下一篇进入事件与日志系统:会话日志与事件三域(session/agent/能力),为什么"模型可见即已记录"是运行时不变量,turn/step 轮次怎么在这些事件上流转。

下一篇:会话与事件系统:模型可见即已记录


FAQ

Q:dsh 插件的三种形态(函数/对象/类)怎么选? 函数形态最轻,适合没有对外服务的插件;对象形态适合需要声明 name 和 inject 的场景;类形态(extends Service)用于向其他插件提供服务,构造时 super(ctx, key) 即完成注册。

Q:apply(ctx) 在什么时候被调用? 插件声明依赖的服务全部就绪后,Cordis 执行 apply 一次。Fiber 状态从 PENDING 转 LOADING,apply 正常返回后进入 ACTIVE;抛异常则进入 FAILED。

Q:插件卸载时注册的东西真的会自动清理吗? 是。ctx.on 事件监听、ctx.tools.register 工具注册、ctx.llm.registerAdapter 适配器注册、ctx.effect 自定义资源都是可逆副作用,Fiber 卸载时自动撤销,不需要手动 removeListener 或 clearInterval。

Q:为什么 –patch 里的插件路径必须是 file:// URL? Windows 上 E:/ 盘符路径不是合法的 ESM 模块说明符,加载器会报 ERR_UNSUPPORTED_ESM_URL_SCHEME。Windows 下插件路径必须写成 file:///E:/… 形式,这是官方教程未注明的平台差异。

Q:插件加载顺序能自己控制吗? 不能直接控制。加载顺序由 inject 声明推导:Cordis 等依赖服务就绪后才启动插件。想调整顺序就调整依赖关系,而不是手动编排启动序列。

Q:修改 cordis.yml 里的插件配置会发生什么? 触发热替换(HMR):框架卸载旧实例(所有注册自动撤销)、加载新实例并执行新的 apply。因为注册都是可逆副作用,热替换后不会残留旧实例的注册。


互动模块

① 站队:插件化框架的"卸载即撤销”(dsh/Cordis)和传统的手动生命周期管理(你熟悉的框架),你更信任哪种?A. 可逆副作用是唯一正确解 B. 手动清理可控,副作用是魔法 C. 取决于插件规模

② 征集:你在生产项目里踩过"插件残留"的坑吗——监听器泄漏、定时器没清、热替换后旧注册还在?当时怎么定位的?评论区分享,我会在会话与事件篇里结合真实案例展开。

③ 转发:如果你身边有人正准备给 dsh 写第一个插件,把这篇转给他——生命周期时序图值得收藏。