DeepSeek-Harness 中的服务、依赖注入与插件生命周期
Fiber 状态机如何加载和卸载 DeepSeek-Harness 插件,inject 和 Service 类到底做了什么,以及 ctx.effect 为什么会存在。
每一个 DeepSeek-Harness(dsh)插件都会经过一套生命周期状态机——PENDING → LOADING → ACTIVE,加载出错则走 → FAILED,卸载时走 ACTIVE → UNLOADING → DISPOSED。inject、Service 类和 ctx.effect() 是插件作者用来正确挂接这套状态机、而不是跟它对着干的三个工具。
Fiber 状态机
PENDING → LOADING → ACTIVE
↘ FAILED
ACTIVE → UNLOADING → DISPOSED
插件从 PENDING 开始。如果它声明了 inject,dsh 会让它停在这个状态,直到每一个被注入的服务都存在,然后把它转到 LOADING 并调用 apply(ctx, config)。apply 干净地跑完,插件就落到 ACTIVE;如果中途抛错,就落到 FAILED。从 ACTIVE 开始,插件会转到 UNLOADING——触发条件可能是进程关闭、某个依赖消失、或者一次 HMR 重载——这时插件通过 ctx 注册过的一切都会被自动清理掉,最后到达 DISPOSED。
实际影响是:大多数常见场景下你不需要自己写 dispose 逻辑。事件监听器、ctx.tools.register() 调用,以及 apply 期间通过 ctx 注册的其他任何东西,都会在插件卸载时被框架自己清理干净。
用 inject 声明必需依赖
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx) {
ctx.tools.register(/* ... */)
}
inject 是一个服务名字符串数组。它同时做两件事:把 LOADING 推迟到每一个被点名的服务都出现在 ctx 上之后;如果这些服务里任何一个之后消失了,让插件自动卸载——比如提供 tools 的那个插件自己被卸载了。这正是为什么当 tools 在你的 inject 列表里时,你可以在 apply 内部无条件使用 ctx.tools——等你的代码运行时,框架已经保证了它一定存在。
用 ctx.get 声明可选依赖
不是每一个依赖都应该阻塞加载。当一个插件"有没有某个服务都能工作"——有就增强行为、没有就优雅降级——这时用 ctx.get('serviceName') 做一次性查询,而不是用 inject 把整个插件的加载都拴在它上面:
export function apply(ctx) {
const metrics = ctx.get('metrics')
if (metrics) {
// enhance behavior if the metrics service happens to be loaded
}
}
当一个服务是插件存在意义的必需品时用 inject;当它只是锦上添花时用 ctx.get。
构建一个服务:Service 类
类式插件写法专门用于这样的场景:这个插件本身要暴露一个能力,供其他插件依赖。
export default class MyService extends Service {
static inject = ['tools']
constructor(ctx: Context) {
super(ctx, 'myService') // mounts as ctx.myService
}
}
super(ctx, 'myService') 正是把这个实例挂载到 ctx.myService 上、供插件树里其他所有插件使用的地方。一旦挂载完成,任何其他插件声明 inject: ['myService'] 就能拿到上面描述的同样保证——在你的服务就绪之前它不会加载,如果你的服务消失了它也会自动卸载。这正是文档里"能力接缝(capability seam)"模式所指的机制:一个服务定义、一个或多个实现它的具体 provider、一个或多个注入它的消费者——ctx.tools 自己在框架内置服务底下遵循的也是同一种形状。
依赖不止一个服务
inject 不限于一个条目——一个既需要工具注册表又需要 LLM 适配器注册表的插件,可以两个都声明,同时等待两者就绪:
export const inject = ['tools', 'llm']
export function apply(ctx) {
// both ctx.tools and ctx.llm are guaranteed to exist here
}
dsh 不会在数组里每一个名字都满足之前调用 apply,而且只要其中任何一个后来消失,插件就会卸载——不存在"只保证部分被注入的服务存在"这种半吊子状态。如果一个插件在缺少某些依赖时行为依然说得通,那就是一个信号:应该把那些可选的依赖拆出来用 ctx.get() 处理,而不是塞进 inject 里,做法参照上一节。
用 ctx.effect() 做清理
大多数注册(事件监听器、ctx.tools.register())都会自动清理,你什么都不用做。ctx.effect() 存在的意义是处理那些不会自动清理的情况——比如插件自己手动打开的资源(定时器、socket、文件监听器),需要一个显式的清理函数:
export function apply(ctx) {
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer) // called automatically when the plugin unloads
})
}
传给 ctx.effect() 的函数会立即运行,并返回自己的清理函数;dsh 会在 UNLOADING 阶段调用那个返回的函数,所以 clearInterval 会自动运行,不需要你自己监听什么卸载事件。任何时候你在用一个 dsh 没有替你包装好的原生 Node.js 或浏览器 API,都应该用 ctx.effect()。
isolate:把一个服务的作用域限定在插件树的一部分
cordis.yml 支持 isolate,可以让一组具名的插件拥有自己独立的服务实例,而不是共享进程级的那一个——比如让一组插件用一个超时时间不同于另一组的 Bash 执行器,两组的配置互不干扰。这更像是配置文件层面的事,而不是你在 apply 内部写的东西;想了解声明 isolate 的那份文件里 insert/id/config 条目是如何组合的,参见《cordis.patch.yml 详解》。
HMR 和你已经拥有的生命周期
通过 @deepseek-ai/cordis-plugin-hmr 实现的热模块替换,并没有引入一套新的生命周期——它只是用"保存文件"这个事件、而不是"进程关闭",去驱动同一套已有的状态机。编辑插件源码,会对旧代码触发和上面完全一样的 UNLOADING → DISPOSED 清理流程,紧接着为新加载的模块走一遍 PENDING → LOADING → ACTIVE。这也是为什么写对清理逻辑——普通注册走自动清理路径,其他情况用 ctx.effect()——在日常开发中就能带来收益,而不只是在进程关闭时才有意义:每一次 HMR 重载都会把它跑一遍。
FAQ
如果一个插件在 apply 过程中失败了,它已经注册的东西会怎样?
它会落到 FAILED 而不是 ACTIVE。我们审阅的文档确认了这个状态的存在,但没有详细说明一个插件在抛错之前已经注册了一部分东西时的局部清理语义——最好把插件的 apply 当成理想情况下要么完全成功、要么在注册任何需要手动清理的副作用之前就尽早抛错。
ctx.tools.register() 需要配合 ctx.effect() 吗?
不需要——工具注册、事件监听器,以及大多数 ctx.* 注册,在插件卸载时都会被自动清理。ctx.effect() 专门用于你自己创建的、不在 dsh 管理的注册 API 之内的资源,比如一个原生的 setInterval。
一个插件能依赖一个现在还不存在、但之后可能会加载的服务吗?
可以——这正是 inject 要处理的场景。插件会停在 PENDING,直到依赖出现,然后自动进入 LOADING;你不需要手动轮询或重试。
每个插件都必须用 Service 吗?
不需要。大多数插件是函数式或对象式,从来不会继承 Service——只有当你的插件自己需要暴露一个供其他插件 inject 的东西时,才需要用到类式写法。
下一步
在《从零构建一个 DeepSeek-Harness 插件》里看看 inject 和 Service 在一个完整可用的插件里处于什么位置,想了解这些生命周期规则背后的框架,读《Cordis 详解:DeepSeek-Harness 背后的插件框架》。想看这套契约的理论版本,读《DeepSeek-Harness 插件底层是如何工作的》,或者到 FindHarness 的 Development & Runtime(开发与运行时)分类浏览真实的服务提供型插件。