这是《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 改变后,计数也会改变。插件树的重点不是固定数量,而是加载、依赖和卸载都由同一套生命周期管理。

图: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/session 的 ctx.sessions、core/agent 的 ctx.agents、llm/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

图:Fiber 六态状态机。PENDING 等待依赖、LOADING 执行 apply、ACTIVE 运行中、FAILED 加载失败、UNLOADING 清理中、DISPOSED 完全卸载。虚线回环是 HMR/依赖恢复时的重建路径。
| 状态 | 含义 |
|---|---|
| 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 后,修改其监视范围内的插件源文件会卸载旧实例、加载新代码并执行新的 apply。dump-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-service 把 clock 服务提供出来才转 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 对应 removeEventListener,setInterval 对应 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.provide、ctx.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。47f9438 的 docs/subsystems/ 有 46 篇中文页面和 46 篇英文页面,提供较广入口,但不保证每个 seam 都被完整覆盖。
七、决策表
| 设计问题 | 方案 A(传统框架) | 方案 B(dsh/Cordis) | 淘汰 A 的原因 |
|---|---|---|---|
| 插件入口 | 继承框架基类、覆写约定方法 | apply(ctx) 单一契约 | 插件形态差异大,没有共同方法集,只有共同上下文 |
| 启动顺序 | 手动编排启动序列 | inject 依赖推导 | 133 插件规模下手动编排不可维护 |
| 资源清理 | 手动 removeListener / clearInterval | ctx.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 写第一个插件,把这篇转给他——生命周期时序图值得收藏。
