乐于分享
好东西不私藏

DeepSeek Harness 插件树深讲:注册即副作用,卸载即撤销

DeepSeek Harness 插件树深讲:注册即副作用,卸载即撤销

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

这是《DeepSeek Harness 权威指南》系列的第 3 篇。

本文延续第 1、2 篇:在第 2 篇架构总览的基础上,深入 Cordis 插件树——插件的三种形态、挂载与卸载生命周期、ctx.effect 的可逆副作用与 --patch 的实际用法。

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

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

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

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

importtype{Context}from'@deepseek-ai/cordis'

exportconst name ='my-plugin'

exportfunctionapply(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/session 的 ctx.sessionscore/agent 的 ctx.agentsllm/llm 的 ctx.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

状态
含义
PENDING
已声明,但所需依赖服务未就绪
LOADING
依赖就绪,正在执行 apply(ctx)
ACTIVE
插件运行中,注册生效
FAILED
apply
 抛异常或配置校验失败
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

declaremodule'@deepseek-ai/cordis'{

interfaceEvents{

'demo/tick'():void

}

}

functionstateName(s:FiberState):string{

returnFiberState[s]??String(s)

}

// ── 形态 1:函数(无依赖)──

functionlogPlugin(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:类(提供服务)──

classClockServiceextendsService{

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 撤销副作用')

})

},

}

asyncfunctionmain(){

const app =newContext()

// 故意先注册依赖方、再注册提供方 —— 验证加载顺序由依赖推导

const consumerFiber =app.plugin(consumer)

console.log('[fiber] consumer 注册后立即读状态:',stateName(consumerFiber.state))

const logFiber =app.plugin(logPlugin)

await logFiber

awaitnewPromise((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('--- 挂载中:事件分发 ---')

awaitapp.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-service 把 clock 服务提供出来才转 ACTIVE。log-plugin 无依赖,注册即加载。谁先跑不取决于谁先注册,取决于谁的依赖先齐。

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

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

四、可逆副作用: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/,写插件:

importtype{Context}from'@deepseek-ai/cordis'

exportconst name ='hello-plugin'

exportfunctionapply(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 --patchE:/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。47f9438 的 docs/subsystems/ 有 46 篇中文页面和 46 篇英文页面,提供较广入口,但不保证每个 seam 都被完整覆盖。

七、决策表

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

八、系列路线

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

下一篇:会话与事件:agent 是怎么“聊”起来的


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 写第一个插件,把这篇转给他——生命周期时序图值得收藏。


话题标签(发布时填入,便于「搜一搜」收录)

#DeepSeek #Agent #插件系统 #Cordis #架构设计 #开源框架 #AI编程