DEEPSEEK HARNESS 工程课 · 06
你给 Agent 注册了一个工具,开发时一切正常。模型能发现它、能调用它,日志也清清楚楚。
热更新几次以后,怪事开始出现:同一个工具在列表里重复出现;你在配置里删掉了插件,旧监听器却还在响应事件;定时器还在往控制台打 tick;数据库连接始终没有关闭。
很多人把它归因于“缓存没清”。但真正的问题通常不是缓存,而是:插件贡献了能力,却没有证明自己能够在退出时把这些能力收回来。
本文拆开 Cordis effect 的“创建”与“释放”,说清“幽灵状态”从哪里来、如何根除,并给出一份可直接使用的插件卸载验收清单。
先记住这一句
能装进去只是第一步,能干净地拿出来才是插件系统的及格线。
01
插件真正改变的,不只是目录结构,而是运行环境
很多人理解插件化,只想到把代码拆成多个包:core、plugins/logger、plugins/tools、plugins/storage。但目录拆开,不等于运行状态彼此独立。
插件加载后,通常会改变宿主环境:
· 往注册表加入一个工具;
· 给事件总线添加监听器;
· 修改共享上下文;
· 创建 watcher;
· 打开网络连接;
· 启动后台任务;
· 挂载子插件。
这些都是副作用。副作用并不天然是坏事——没有副作用,插件就无法真正提供能力。真正的问题是:
系统是否知道这些副作用属于谁,以及插件退出时应该如何撤销?
如果不知道,插件卸载就只能删除一段配置,却不能恢复运行环境。
02
传统“手动 stop”为什么总会漏?四个原因
最直接的写法,是在加载时创建资源,在卸载函数里手动清理:一个 start 建定时器,一个 stop 清定时器。这个例子很简单,但系统一复杂,问题就出现了。
四个容易遗漏的原因
1. 创建和清理相隔太远:资源在一个工具函数里创建,却在另一个生命周期函数里清理,后来改创建逻辑的人未必知道要同步改清理逻辑。
2. 加载过程中也可能失败:先建定时器、再开连接、最后注册工具,第三步失败时,前两步是否会被撤销?
3. 卸载来源不止一种:配置被修改、热重载、显式 dispose、所需 Service 消失、父插件卸载——每处各写一套清理,遗漏和顺序错误几乎不可避免。
4. 清理顺序影响正确性:先关日志再停后台任务,任务退出时的日志可能丢失;先销毁父资源再清理子资源,清理本身也可能失败。
因此,生命周期不能只靠“记得写一个 stop 函数”。
03
effect 的核心思想:在哪创建,就在哪声明怎么撤销
Cordis 的做法是一句话——
在创建副作用的位置,同时声明如何撤销它。
ctx.effect(() => {
const resource = createResource()
return () => {
destroyResource(resource)
}
})
ctx.effect() 里发生两件事:effect 主体在插件加载期间运行、创建资源;返回的 disposer 在插件卸载期间运行、释放资源。创建和清理被放进同一个局部上下文。
这样做的价值不是少写几行代码,而是让运行时能够知道三件事:
· 这个资源由哪个插件创建;
· 这个资源应该怎样清理;
· 插件卸载时必须等待哪些清理完成。
04
写一个最小生命周期插件,看一次“加载→清理→卸载”
下面用一个无需 API Key 的定时器插件观察 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) {
const fiber = ctx.plugin(heartbeat)
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
不同机器上的 tick 次数可能略有差异。真正需要验证的是:
1. 插件加载后定时器开始运行;
2. fiber.dispose() 触发卸载;
3. disposer 执行并清除定时器;
4. 卸载完成后不再出现新的 tick。
不要把“必须正好出现三次 tick”当作验收标准——定时调度不是这一讲要证明的重点。
05
Fiber:插件实例的运行时句柄,而不是一个状态标签
ctx.plugin(heartbeat) 会把函数挂载为一个子插件,并返回一个 Fiber。可以先把它理解成:
某个已加载插件实例的运行时句柄。
通过它,运行时知道插件当前处于什么状态,并执行卸载。官方教程给出的状态机是:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
· PENDING:已声明,但所需 Service 尚未可用;
· LOADING:正在执行加载逻辑;
· ACTIVE:加载完成,正在运行;
· FAILED:加载或配置校验失败;
· UNLOADING:正在执行 disposer,等待资源清理;
· DISPOSED:插件及其所属资源已经完成拆除。
fiber.dispose() 不只是改一个状态标签:它会等待清理工作完成(包括异步 disposer),并递归卸载由它挂载的子插件。
06
哪些操作已经自动属于 effect?优先用官方 API
开发插件时,不是所有资源都要手写 ctx.effect()。Cordis 的一些 API 已经把注册和 disposer 绑定起来:
· 事件监听ctx.on(event, listener):监听器会在插件卸载时移除;
· 子插件ctx.plugin(child):子插件会随父插件一起 dispose;
· Service 注册:本身属于生命周期管理范围;
· Harness 注册表:例如工具注册 API 会把返回的 disposer 附着到调用插件,卸载时自动撤销注册。
插件开发的第一选择
优先使用 Cordis 或 Harness 已经纳入生命周期的注册 API;只有在管理定时器、连接、watcher 或第三方资源时,再手动使用 ctx.effect()。
07
哪些资源必须手动包装?五类常见情况
只要资源不是由 Cordis API 自动管理,就应该明确检查它的释放方式。常见类型:
· 定时器:return () => clearInterval(timer);
· 文件 watcher:返回 watcher.close();
· 数据库连接:用 async effect,返回 await connection.close();
· 第三方订阅:返回 unsubscribe();
· 临时子进程:要同时考虑终止信号、等待退出、超时处理、是否还有未读取输出、卸载期间是否允许创建新资源。
真实资源的清理通常比示例复杂。effect 解决的是“归属和触发”,不会自动替你发明正确的关闭协议。
08
两个容易忽略的边界:清理顺序与失败回滚
先看清理顺序。disposer 会按注册顺序的逆序启动:依次注册 A、B、C,加载是 A→B→C,卸载是 C→B→A。后创建的资源通常更依赖先创建的资源,所以应先释放。
但有一个重要边界:多个异步 disposer 可能并发运行。如果两步清理必须严格按顺序,不要拆成两个独立异步 disposer,应放进同一个 disposer 并明确 await:
ctx.effect(async () => {
const connection = await openConnection()
const worker = await startWorker(connection)
return async () => {
await worker.stop()
await connection.close()
}
})
这里先停 worker、再关 connection,顺序由同一个 disposer 保证。
再看失败回滚。插件不一定顺利进入 ACTIVE。假设加载过程是“创建 watcher → 打开连接 → 注册工具时抛出异常”,如果系统只在成功加载后记录清理逻辑,前两个资源就会泄漏。Cordis 的 effect 管理强调:effect 的所有权在 setup 执行前就建立,即使 setup 同步失败,已收集的清理也会被回滚。
插件失败不能只留下一个错误,它还必须尽可能恢复已经改变的环境。这也是插件系统能否支持热重载和动态替换的基础。
09
effect 能解决什么、不能解决什么?一张对比表
这些“不能撤销”的操作,需要幂等设计、补偿事务、延迟提交、人工批准、安全沙箱和外部审计。所以——
可撤销 effect 不是数据库事务,也不是安全沙箱。它处理的是插件控制范围内、能够被追踪并明确释放的运行时资源。
10
插件卸载验收清单(可收藏)
卸载前逐项确认
□ 重复加载测试:加载—卸载—再加载,行为只出现一次,不重复注册;
□ 资源停止测试:定时器不再输出、watcher 不再响应、连接已关闭、子进程已退出、工具不再出现在注册表;
□ 失败注入测试:让插件在加载中途失败,已创建的资源被回滚;
□ 异步等待测试:fiber.dispose() 会等待清理完成,而非提前报告完成;
□ 父子插件测试:卸载父插件,子插件也进入 dispose 流程。
一项资源如果无法说明“由谁创建、由谁释放、失败时怎样恢复”,就不应该被视为生命周期设计完成。
一句话收尾
“一切皆插件”真正难的地方,不是把功能拆成模块,而是保证每个模块都能安全退出。
下一篇
这一篇解决了“插件能不能干净退出”。但同一套核心,为什么既能跑在 Web 页面里,也能作为一次性 headless 任务在终端执行?下一篇拆开 profile、bundle 和 patch 三层组合。
本文教学版本 DeepSeek Harness 0.1.0-rc.5,基于锁定 commit;接口、目录与命令行为如有变化,请优先核对锁定源码。
夜雨聆风