
字节笔记本
2026年10月6日 · 约 8 分钟读完
Cordis 插件教程 02:生命周期与 effect
DeepSeek 开源的 agent 框架 DeepSeek Harness 把 Cordis 用作插件运行时,能力几乎都以插件形态挂载在上下文上。本系列教程用一批可以直接跑起来的小例子带你从零上手 Cordis 插件开发,从装好第一个插件起步,这一篇处理所有插件都绕不开的问题:生命周期。插件不是装上就完事的,一次配置改动、一次热重载、一次显式释放,甚至只是它依赖的某个服务消失,都会让插件退出运行。它在加载时注册的定时器、建立的连接、启动的文件 watcher 由谁负责收拾,就是本文要讲清的 effect 机制。
插件随时可能被卸载
Cordis 插件可能因为四种原因退出运行:配置文件被修改、热重载、代码里显式调用 dispose、或者它依赖的某个必需服务不再可用。规则也相应分成两类。凡是经过 Cordis API 做出的注册,都属于 effect,所属插件卸载时会自动撤销;在这些 API 之外管理的资源,则必须包进 ctx.effect(),并返回一个 disposer,也就是清理函数。

最小示例:心跳插件
对于 Cordis 尚未管理的资源,比如定时器、连接或 watcher,把它包进 ctx.effect() 并返回清理函数即可。下面用一个心跳示例走完整个生命周期,在 tmp/cordis-tutorial 目录新建 lifecycle.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) {
// 挂载子插件,保存返回的 fiber,之后用它主动卸载
const fiber = ctx.plugin(heartbeat)
// 演示定时器本身也是 effect:若本插件先被卸载,
// 待触发的回调会被取消,而不是打在已经死掉的应用上
ctx.effect(() => {
const timer = setTimeout(async () => {
await fiber.dispose()
console.log('disposed')
process.exit(0)
}, 700)
return () => clearTimeout(timer)
})
}再让 cordis.yml 指向这个文件:
- name: './lifecycle.ts'运行(node --import tsx ../../vendor/cordis/bin.js)后可以看到:
heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed这段不到一秒的演示把生命周期完整走了一遍,有三点值得注意。
第一,ctx.plugin(heartbeat) 是从代码里把一个函数直接挂载为插件,YAML 加载器对每条配置项执行的也是同一操作。函数插件不需要 apply 方法,Cordis 会直接调用这个函数,函数名只用于诊断日志;只有对象形态 ctx.plugin({ apply(ctx) { /* ... */ } }) 才要求提供 apply。调用会返回一个 fiber,即这个已加载插件实例的运行时句柄,示例里正是拿着它,在 700 毫秒后主动卸载了子插件。
第二,传给 ctx.effect 的函数体在加载阶段执行,它返回的 disposer 在卸载阶段执行。对于生命周期与插件一致的资源,你永远不需要自己调用 disposer。示例里主插件自己的 setTimeout 也包在 ctx.effect 里,这样如果主插件先被卸载,还没触发的回调会被直接取消,而不是打在一个死掉的应用上。
第三,fiber.dispose() 要等该插件的全部清理工作完成之后才会结束,异步 disposer 也不例外,并且会递归卸载它挂载的所有子插件。这保证了输出里 heartbeat cleaned up 一定出现在 disposed 之前。
fiber 状态机
每个已加载的插件实例都持有一个 fiber,它沿着一条状态链推进:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
- PENDING:插件已经声明,但它依赖的必需服务还没就绪。这也是「为什么我的插件什么都没打印」最常见的答案,多数时候就是卡在了这里。
- LOADING 与 ACTIVE:apply 函数正在运行,或者已经运行完毕。
- FAILED:apply 执行或配置校验抛出了异常。
- UNLOADING 与 DISPOSED:disposer 正在逐个执行,或者一切已经拆除干净。
大多数资源已经是 effect
实际开发里你很少亲手写 ctx.effect(),因为内置的注册 API 本身就是 effect。ctx.on(event, listener) 注册的事件监听器会在插件卸载时自动移除;ctx.plugin(child) 挂载的子插件会随父插件一起释放;服务注册也是 effect,框架里的 ctx.tools.register(...) 这类注册表会把返回的 disposer 挂到调用方插件上,随它一起自动撤销。
真正需要 ctx.effect() 的场景只有一种:资源不归 Cordis 管,比如自己开的定时器、数据库连接、文件 watcher。把它放进 ctx.effect() 里获取,返回释放它的 disposer,之后无论是正常卸载还是热重载,Cordis 都会替你调用这段释放逻辑。
卸载顺序的一个坑
最后是一条顺序上的注意事项:disposer 按注册的逆序启动,但多个异步 disposer 会并发执行。如果某些拆除步骤必须一前一后,比如先关连接再删临时文件,就不要拆成两个异步 disposer,而要放进同一个 disposer 里依次 await,把顺序握在自己手里。
小结
生命周期机制表面上看只是一个小小的 ctx.effect,背后是一整套确定性:资源与插件同生共死,卸载顺序可预期,fiber 状态可观测。把这套模型吃透,写出来的插件才能在频繁热重载的运行时里保持干净。



