DeepSeek Harness 插件化架构:加载、生命周期与失败回滚
基于固定源码版本分析 Cordis Context、Fiber、effect 和 disposer 如何支撑插件加载、卸载与失败回滚。

DeepSeek Harness 的 README 里有一句很醒目的话:Everything is a Plugin。看完源码后,这句话并不是指“项目用了很多插件”,而是模型适配、工具注册、Session 日志和 Agent Loop 本身都通过同一套插件机制挂载。
本文基于 DeepSeek Harness commit 47f943859bef60e4160492346772ded9b24f765a。这个版本仍处于 Developer Preview,下面谈的是当前实现,不是稳定兼容承诺。
插件加载的对象是一棵树
DeepSeek Harness 底层使用 Cordis。运行时有一个 Context,插件向 Context 注册服务、事件监听器和 effect。插件还可以创建子 Context,于是整个应用形成一棵插件树。
Profile 决定启动哪组 Bundle,Bundle 再提供一批 Cordis 配置。web 和 headless 不是两套独立程序,而是两种插件组合。用户自己的 cordis.patch.yml 可以替换或插入配置行,所以扩展功能通常不需要修改 Agent Loop。
这种结构有一个直接好处:依赖不是靠“先 import A,再初始化 B”的手工顺序维护。插件通过 inject 声明所需服务,Cordis 等服务出现后再激活插件。缺少必需服务时,插件不会带着半套依赖继续运行。
真正重要的是 effect 可以撤销
动态加载容易,难的是卸载。一个插件注册了工具、监听了事件、启动了定时器,卸载时如果只删除插件对象,旧监听器和后台任务仍会留在进程中。
Cordis 要求这类注册通过 ctx.effect()、ctx.on() 等生命周期 API 完成。effect 返回 disposer,插件 Fiber 被销毁时,Cordis 反向执行这些 disposer。DeepSeek Harness 的工具注册也遵循这个规则:插件卸载后,对应工具会从注册表移除。
下面是一个最小生命周期实验:
import { Context } from '@deepseek-ai/cordis'
const events: string[] = []
const demoPlugin = {
name: 'lifecycle-demo',
apply(ctx: Context) {
ctx.effect(() => {
events.push('mounted')
return () => events.push('disposed')
})
},
}
const ctx = new Context()
const fiber = await ctx.plugin(demoPlugin)
await fiber.dispose()
await ctx.fiber.dispose()
console.log(events)
我在 Node.js 24.17.0 和仓库锁定的 pnpm 11.7.0 下运行了这个 Demo。正常插件与后面的失败插件合并测试后,得到:
["mounted","disposed","partial-mounted","partial-rolled-back","caught:demo failure"]
前两个元素说明销毁 Fiber 会执行 disposer。实际插件如果启动了定时器,就在 disposer 里 clearInterval;如果注册了工具,注册方法返回的 disposer 负责移除工具。
初始化到一半失败怎么办
插件最麻烦的状态是“已经注册了一部分,后面的初始化又抛错”。Cordis 把插件拥有的 effect 归到同一个 Fiber 下。插件安装失败时,Fiber 会被 dispose,已经发表的 effect 一并撤销。
可以把实验插件改成这样:
const brokenPlugin = {
name: 'broken-demo',
apply(ctx: Context) {
ctx.effect(() => {
events.push('partial-mounted')
return () => events.push('partial-rolled-back')
})
throw new Error('configuration is invalid')
},
}
try {
await ctx.plugin(brokenPlugin)
} catch (error) {
console.error((error as Error).message)
}
如果回滚生效,partial-rolled-back 会出现,失败插件留下的注册项不会继续服务。这类机制解决的是生命周期一致性,而不是把错误藏起来。配置错误仍然应该让加载操作明确失败。
Agent 的一次请求也由插件拼出来
Agent Loop 每个 step 会组装系统提示词和工具 schema,调用模型,再执行模型返回的工具调用。Session Event 保存用户消息、模型输出、工具调用和工具结果。模型看到的内容必须能从日志重建,这是当前架构的一条重要约束。
工具注册、模型适配和 Session Store 都是 Context 上的服务。更换模型提供商是在 ctx.llm 注册另一个适配器,增加业务能力是在 ctx.tools 注册工具,拦截执行则监听 tools/pre-execute、tools/execute 或 tools/post-execute。Agent Loop 不需要知道订单查询、文件系统或某个模型厂商的实现。
为什么主程序通常不会因为一个普通错误退出
这里不能简单归功于“插件化”。DeepSeek Harness 在不同位置采用了几种不同措施:
- 安装失败由插件 Fiber 回滚已注册 effect。
- 工具执行器捕获异常,把失败转换成
isError结果。 - 观察者失败会记录日志,不阻断其他观察者。
- AbortSignal 用于取消工具、命令和模型请求。
- Session Event 把可恢复状态与内存对象分开。
这些措施能覆盖常见的配置错误、网络异常和工具失败,但它们不是进程隔离。插件中的无限循环会阻塞 Node.js 事件循环,未受控的内存分配会触发 OOM,原生扩展崩溃和 process.exit() 也可能直接结束进程。需要运行不可信插件时,仍应使用独立进程、容器或远程 sandbox,把失败限制在进程边界内。
“Everything is a Plugin”真正解决的是组合和生命周期:功能可以挂载,依赖可以声明,状态可以撤销。它没有让插件变得天然安全,只是让正常故障更容易被控制和定位。
参考源码
- DeepSeek Harness 国内镜像
- 架构说明:
docs/architecture.md - Cordis 入门:
docs/cordis-primer.md - 工具开发参考:
docs/cookbook/adding-a-tool.md