← 返回文章列表

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 配置。webheadless 不是两套独立程序,而是两种插件组合。用户自己的 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-executetools/executetools/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