乐于分享
好东西不私藏

你的插件系统是不是越写越乱?DeepSeek Harness 的解法,教科书级别

你的插件系统是不是越写越乱?DeepSeek Harness 的解法,教科书级别

你写了一个插件系统。

一开始只有 3 个插件,用个 Map 存一下,很正常。后来加到 30 个,有的插件依赖别的插件,有的插件要热加载,有的插件卸载之后别的插件直接崩了——因为依赖链断了你没发现。

你开始加依赖检查、加卸载通知、加隔离机制。代码从 200 行膨胀到 2000 行,一半是"如果 A 卸载了 B 怎么办"的防御性代码。

这不是你的问题。插件系统天然就长这样——越写越乱,越补越重。

但 DeepSeek Harness 的作者用一个办法解决了这个问题:只用三层数据结构,把"装进来、查出来、卸干净"这个铁三角彻底闭环。没有防御性代码,没有手动清理,没有"忘了通知"的 bug。

我读了源码,拆给你看。

一个插件系统要解决三个问题:怎么存、怎么查、怎么不打架。DeepSeek Harness 用三层数据结构各解决一个,而且它们之间通过一个机制完全闭环。

三层存储,各管一件事

你遇到的问题
Harness 的解法
用的数据结构
厉害在哪
怎么存?
同一插件加载多次,怎么区分"类型"和"实例"?
RegistryService
Map<Function, Runtime>
函数引用当 key,天然去重
怎么查?
插件卸载了,谁通知它的依赖者?
Fiber + ReflectService
DisposableList + Dict<Impl, symbol>
逆序清理 + epoch 自动重载
怎么不打架?
两个 Agent 同时注册同名服务,互不覆盖?
Symbol 隔离
Symbol(name) 做 key
同名服务,不同 Symbol,互不干扰

第一层:怎么存——用函数引用当 Key

大多数插件系统怎么做?用字符串当 key。比如 pluginMap['shell'] = shellInstance

问题在哪?同一个插件,被两个不同的包加载,路径不同,字符串 key 就不同。框架以为这是两个不同的插件,创建了两份 Runtime,两份配置。等你要卸载的时候,只删了其中一个,另一个变成幽灵实例——还在跑,但没人管。

Harness 的做法:用函数引用当 key

vendor/cordis/src/registry.ts 第 197 行,核心存储结构:

private _internal = new Map<Function, Plugin.Runtime>()

Key 是插件的可执行函数本身(ShellPlugin 这个函数引用),Value 是 Runtime 记录。Runtime 里存了这个插件的所有 Fiber 实例。

为什么用函数引用?

因为 JavaScript 里两个函数如果来自同一个模块的同一个 export,=== 返回 true。无论你从哪个包 import,只要指向同一个模块,就是同一个引用。Map 自动去重。

当你调用 ctx.plugin(ShellPlugin, config) 时,plugin() 方法做的事非常清晰:

// registry.ts 第 316-336 行

plugin(plugin, config) {

const callback = this.resolve(plugin)

  // 取出可执行函数,用 === 查 Map

let runtime = this._internal.get(callback)

if (!runtime) {

    // 第一次出现,创建 Runtime

    runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }

this._internal.set(callback, runtime)

  }

  // 每次调用都创建新的 Fiber

const fiber = new Fiber(this.ctx, config, resolved, runtime)

return fiber

}

一个插件类型 → 一个 Runtime → 多个 Fiber 实例。语义精确,没有歧义,没有幽灵实例。

(Map) 函数引用当 key 的妙处:同一个插件不管加载多少次,Runtime 只有一个。卸载时 RegistryService.delete(plugin) 一次性卸载所有实例。如果用字符串,你永远不知道有没有漏掉的。

第二层:Fiber —— 每个插件实例就是一个副作用容器

传统插件系统,插件加载后做了什么事,框架不知道。注册了 10 个工具、监听了 5 个事件、注入了 3 段 system prompt——这些东西散落在内存各处,没有一个统一的"收容所"。

卸载的时候,你得手动写 unregisterTool()removeListener()cleanup()。漏一个就内存泄漏。

Harness 的做法:每个 Fiber 维护一个副作用列表,加载时往里登记,卸载时逆序执行。不需要你写任何清理代码。

Fiber 存了四类数据:

1. 身份 — uid(递增整数,0 是根 Fiber)+ runtime(指向 Registry 里的 Runtime)

2. 配置 — config(已验证的最终配置)+ _config(原始配置,热重载时重新解析)

3. 生命周期 — state(六种状态:PENDING→LOADING→ACTIVE→FAILED→UNLOADING→DISPOSED)+ store(依赖服务的实现快照)

4. 副作用 — _disposables: DisposableList(每个 ctx.effect() 注册的清理函数,卸载时逆序执行)

_disposables 是"注册即可逆"的物理实现

插件里写 ctx.effect(execute, label),execute 返回一个 disposer 函数。这个 disposer 自动塞进 Fiber._disposables。Fiber 卸载时,_unload 方法逆序执行所有 disposer:

// fiber.ts 第 675-696 行

private async _unload() {

await Promise.all(

this._disposables.clear()

    // clear() 返回数组,逆序执行所有 disposer

    .map(async (dispose) => await runDisposable(dispose))

  )

this.store = undefined

}

epoch 机制:依赖变了,自动重载

插件 B 依赖插件 A。A 被卸载了,B 怎么办?传统做法是手动通知——你忘了就崩了。

Harness 用 epoch 机制自动处理。epoch 是一个字符串,由依赖服务的 Fiber uid 拼接而成。当 A 的 Fiber uid 变化(重新加载),epoch 就变了。B 的 _setEpoch 方法检测到 epoch 变化,自动触发 _unload 或 _reload:

// fiber.ts 第 625-639 行

private _setEpoch(epoch: string) {

const oldEpoch = this._runner.epoch

if (epoch === oldEpoch) return

this._runner.epoch = epoch

if (epoch !== INACTIVE && oldEpoch === INACTIVE) {

    // 依赖就绪,加载

this.inertia = this._reload()

  } else {

    // 依赖变化,卸载

this.inertia = this._unload()

  }

}

(Fiber) 你说"我依赖 A",框架会帮你盯着 A。A 变了,自动重载你。你不需要写任何通知代码——这是声明式依赖的底层实现。

第三层:怎么不打架——用 Symbol 做隔离 Key

这是整个设计里最精巧的一层。

假设你有一个进程跑了两个 Agent。Agent A 注册了 tools 服务,Agent B 也注册了 tools 服务。如果服务注册表用字符串 "tools" 做 key,B 的注册会覆盖 A 的。A 的 Agent 突然发现自己的 tools 服务变成了 B 的——这几乎是不可调试的 bug。

Harness 的做法:不用字符串,用 Symbol。每个 isolate scope 有独立的 Symbol 标签,同名服务存到不同的 Symbol key 下,互不覆盖。

ReflectService 是服务实现注册表,vendor/cordis/src/reflect.ts 第 208 行:

public store: Dict<Impl, symbol> = Object.create(null)

// 所有服务实现,Key 是 Symbol,不是字符串

这个 Symbol 来自 Context 的隔离映射:

// context.ts 第 72 行

this[symbols.isolate] = Object.create(null)

// 服务名 → Symbol 隔离标签

当 ctx.provide('tools', toolsService) 被调用时:

// reflect.ts 第 277-304 行

provide(name, value, check) {

return ctx.fiber.effect(() => {

    // 全局注册 Symbol(只注册一次)

    ctx.root[symbols.isolate][name] ??= Symbol(name)

    // 取当前 scope 的 Symbol

const key = ctx[symbols.isolate][name]

const impl = { name, value, fiber: ctx.fiber, check }

    // 用 Symbol 做 key 存储

this.store[key] = impl

    // 同时在 Fiber 上标记依赖

    ctx.fiber.store![name] = impl

return async () => {

      // 卸载:删除 + 通知所有依赖者

delete this.store[key]

this.notify([name])

    }

  }, `ctx.provide(${JSON.stringify(name)})`)

}

两层注册表,各管各的:

Cordis 层(ReflectService.store)——存"服务实现在哪":{ Symbol("tools"): Impl, Symbol("llm"): Impl, Symbol("shell"): Impl }

服务层(各 Service 内部)——存"能力有哪些":ToolsService { "bash": Tool, "str_replace": Tool, "subagent": Tool }

(Symbol) 名字只是标签,Symbol 才是身份证。Agent A 的 tools 和 Agent B 的 tools 是两个不同的 Symbol key,存到 ReflectService.store 里互不覆盖。一个进程跑多个 Agent,互不干扰——这就是底层保障。

完整链路:一个 Shell 插件从 YAML 到运行时

把三层串起来,看一个插件从配置到运行时的完整存储路径:

1. 配置 — agent.cordis.yml 里写 shell: {}

2. Loader 解析 — 创建 Entry{ name:'shell', config:{} }

3. RegistryService.plugin() — 以 ShellPlugin 函数为 key,创建或复用 Runtime

4. 创建 Fiber — uid=42, config, runtime, _disposables=[]

5. 加载完成 — LOADING → ACTIVE

6. ShellPlugin.apply(ctx, config) 执行

→ new ShellService(ctx, 'shell')   → ctx.reflect.provide('shell', this)      → ReflectService.store[Symbol("shell")] = Impl{ name:'shell', fiber, value:ShellService }

7. ShellService 内部 — ctx.tools.defineTool({ name: 'bash', ... })

→ ToolsService 内部的 Map 注册表新增一条

8. 卸载时 — Fiber._disposables 逆序执行:

→ delete ReflectService.store[Symbol("shell")]   → ReflectService.notify(['shell'])      → 所有依赖 shell 的 Fiber 自动 _refresh()

现在你在代码里写 ctx.shell,不是直接访问字段——Context 是个 Proxy,属性读取被拦截,沿着隔离映射找到 Symbol("shell"),在 ReflectService.store 里查到 Impl,返回 ShellService 实例。没有硬编码,没有全局变量,一切都是动态解析。

回到开头的问题:你写的插件系统为什么越写越乱?

因为你把"存、查、隔离"三个问题混在一起解决。你写了一个 Map,然后往里塞插件实例、塞配置、塞依赖关系、塞清理函数——所有东西混在一个结构里,最后没人能理清谁依赖谁、谁该在什么时候清理。

Harness 的解法是:三层分离,各管各的,用一种机制串起来。

你的做法
Harness 的做法
字符串当 key,路径不同就重复
函数引用当 key,=== 天然去重
手动写 unregister/cleanup,漏了就泄漏
Fiber._disposables 逆序执行,自动清理
依赖断了手动通知,忘了就崩
epoch 自动检测依赖变化,自动重载
同名服务互相覆盖,不可调试
Symbol 隔离,同名不同 scope 互不干扰
停用一个能力要注释代码
disabled: true
 一行 YAML

"一切皆插件"不是口号。是 Map 存类型、Fiber 存实例、Symbol 存隔离——三层数据结构,把"装进来、查出来、卸干净"彻底闭环。下次你写插件系统,别从零开始,从这三层开始。