插件系统的三层存储结构:DeepSeek Harness 技术拆解
插件规模上来之后,麻烦通常是后置的:几个插件时随便一个 Map 就够;几十个插件、互相之间有依赖和热加载时,卸载顺序、依赖断裂、同名服务覆盖这类问题会逼着你写大量防御性代码。DeepSeek Harness 的做法是把存储拆成三层,分别解决“怎么存”“怎么查”“怎么不打架”。
核心代码在 vendor/cordis/src/registry.ts、fiber.ts、reflect.ts、context.ts。整体结构如下:

一个插件系统要解决三个问题:怎么存、怎么查、怎么不打架。Harness 用三层数据结构各解决一个,并且都挂在 Fiber 的生命周期上。
| 问题 | 解法 | 核心数据结构 | 关键点 |
|---|---|---|---|
| 同一插件加载多次,如何区分“类型”和“实例” | RegistryService | Map | 函数引用当 key,天然去重 |
| 插件卸载后,如何让依赖者感知 | Fiber + ReflectService | DisposableList + Dict | 逆序清理 + epoch 自动重载 |
| 两个 Agent 注册同名服务,如何互不覆盖 | Symbol 隔离 | Symbol(name) 做 key | 同名服务,不同 Symbol |
第一层:怎么存——用函数引用当 Key
多数插件系统用字符串做 key,比如 pluginMap['shell'] = instance。问题出现在同名插件被不同路径加载时:框架认为这是两个插件,于是创建两份 Runtime;卸载时只删一份,另一份变成无人管理的“幽灵实例”。
Harness 存插件类型用的是函数引用本身。registry.ts 第 197 行的核心存储:
private _internal = new Map()
key 是插件入口函数(如 ShellPlugin),value 是 Plugin.Runtime。由于 ES module 的 export 是同一份对象引用,从不同路径 import 同一个模块时,=== 依然成立,Map 会自然去重。
ctx.plugin(ShellPlugin, config) 的处理逻辑:
// 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 实例。
卸载时调 RegistryService.delete(plugin) 只删一次,就能拿掉该类型对应的所有实例,不用逐个找字符串 key。
第二层:Fiber —— 把副作用装进一个容器
插件运行时会注册工具、监听事件、注入 prompt。如果这些副作用散落在各自模块里,卸载时只能靠开发者逐个调用 unregisterTool()、removeListener()。漏一个就是泄漏。
Fiber 是这个问题的收口:每个插件实例对应一个 Fiber,Fiber 里保存四类数据。
- 身份:
uid(递增整数,0 是根)+runtime(指向 Registry 里的 Runtime) - 配置:
config(已验证的最终配置)+_config(原始配置,热重载时重新解析) - 生命周期:
state(PENDING → LOADING → ACTIVE → FAILED → UNLOADING → DISPOSED)+store(依赖服务的实现快照) - 副作用:
_disposables: DisposableList,存放所有通过ctx.effect()注册的清理函数
插件里写:
ctx.effect(() => {
// 返回 disposer
return () => { /* 清理 */ }
})
disposer 自动放入 Fiber._disposables。Fiber 卸载时逆序执行全部 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 拼接出来的字符串。依赖发生变化时,对应 Fiber uid 变化,epoch 随之变化。Fiber 的 _setEpoch 检测到后自动选择加载还是卸载:
// 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()
}
}
所以“我依赖 A”是声明式的:框架盯着 A 的状态,A 变了就自动 reload/unload,不需要业务代码触发通知。
第三层:怎么不打架——用 Symbol 做隔离 Key
这是三层里比较绕的一层。
假设一个进程里跑两个 Agent,都注册了一个名为 tools 的服务。如果注册表用字符串 "tools" 做 key,后注册的会覆盖先注册的。这种覆盖非常难排查。
Harness 的 ReflectService.store 不用字符串,而用每次调用 ctx.provide 时生成的 Symbol:
// reflect.ts 第 208 行
public store: Dict = Object.create(null)
Symbol 隔离依赖一个 Context 级别的映射:
// context.ts 第 72 行
this[symbols.isolate] = Object.create(null)
// 服务名 → Symbol 隔离标签
提供服务时的逻辑:
// reflect.ts 第 277-304 行
provide(name, value, check) {
return ctx.fiber.effect(() => {
// 全局只注册一次 Symbol
ctx.root[symbols.isolate][name] ??= Symbol(name)
// 当前 scope 的 Symbol——不同 scope 的 Symbol 不同
const key = ctx[symbols.isolate][name]
const impl = { name, value, fiber: ctx.fiber, check }
this.store[key] = impl
ctx.fiber.store![name] = impl
return async () => {
// 卸载:删除 + 通知所有依赖者
delete this.store[key]
this.notify([name])
}
}, `ctx.provide(${JSON.stringify(name)})`)
}
注意这里有两层注册表:
- Cordis 层:
ReflectService.store,存“某个 Symbol 对应什么实现”。 - 服务层:具体 Service 内部,比如
ToolsService的{ bash: Tool, str_replace: Tool, subagent: Tool }。
服务名只是标签,Symbol 才是真正的身份。不同 Agent 作用域里的 "tools" 是不同 Symbol,所以 store 里 Symbol("tools") 可以被多个不同 Key 同时存在——互不覆盖。
完整链路:Shell 插件从 YAML 到运行时
- 配置:
agent.cordis.yml写shell: {} - Loader 解析:创建
Entry { name: 'shell', config: {} } RegistryService.plugin():以ShellPlugin函数为 key,创建或复用 Runtime- 创建 Fiber:
uid=42,带 config、runtime,_disposables=[] - 状态迁移:
LOADING → ACTIVE - 执行
ShellPlugin.apply(ctx, config):
new ShellService(ctx, 'shell')ctx.reflect.provide('shell', this)ReflectService.store[Symbol("shell")] = { name: 'shell', fiber, value: ShellService }
ShellService内部调用ctx.tools.defineTool({ name: 'bash', ... }),注册到 ToolsService 的 Map- 卸载时:Fiber 逆序执行 disposer,删除
ReflectService.store[Symbol("shell")],再notify(['shell']),所有依赖 shell 的 Fiber 触发自动刷新
卸载链条是自动的,不依赖插件作者手写清理代码。
ctx.shell 也不是直接访问某个字段。Context 是一个 Proxy,读取属性时被拦截,按当前 scope 的隔离映射找到 Symbol("shell"),再到 ReflectService.store 里取回实现。没有硬编码全局变量,所有依赖在调用时动态解析。
为什么常见实现会越写越乱
常见做法是把所有信息塞进一个 Map:插件实例、配置、依赖关系、清理函数全放一起。当数量增加时,谁依赖谁、谁在何时需要清理就变成一团乱麻。
Harness 的思路是把问题拆开:
| 常见做法 | Harness 做法 |
|---|---|
| 字符串当 key,路径不同就重复 | 函数引用当 key,=== 去重 |
| 手动写 unregister/cleanup | Fiber._disposables 逆序执行 |
| 依赖断裂时手动通知 | epoch 检测变化,自动 reload/unload |
| 同名服务互相覆盖 | 不同 scope 使用不同 Symbol |
| 停用能力改注释代码 | disabled: true 一行 YAML |
适用与不适用场景
这套设计适合插件数量多、生命周期长、需要热插拔和依赖关系的场景。Fiber 提供了统一的副作用登记,依赖恢复和卸载不需要人工编排;Symbol 隔离让同一个进程内多套上下文(例如多个 Agent)安全共享同一套基础设施。
如果插件系统只有少量扩展点,或插件之间几乎没有依赖、运行期也不需要重启或热替换,那三层结构有些重:Map + 基础事件的通知已经足够,引入 Fiber、ReflectService、Context Proxy 会带来不必要的理解和维护成本。函数引用当 key 的方案也依赖模块引用稳定;如果运行时存在模块热替换或打包导致同一模块产生多份副本,就不适合只拿裸函数做标识,需要再加一层版本或封装策略。
总结:Map 存插件类型,Fiber 存运行实例和副作用,Symbol 存隔离边界。三层各管一件事,再通过 Fiber 的生命周期收口。插件系统的“装进来、查出来、卸干净”在这套结构里是内建的,不是补丁。