你写了一个插件系统。
一开始只有 3 个插件,用个 Map 存一下,很正常。后来加到 30 个,有的插件依赖别的插件,有的插件要热加载,有的插件卸载之后别的插件直接崩了——因为依赖链断了你没发现。
你开始加依赖检查、加卸载通知、加隔离机制。代码从 200 行膨胀到 2000 行,一半是"如果 A 卸载了 B 怎么办"的防御性代码。
这不是你的问题。插件系统天然就长这样——越写越乱,越补越重。
但 DeepSeek Harness 的作者用一个办法解决了这个问题:只用三层数据结构,把"装进来、查出来、卸干净"这个铁三角彻底闭环。没有防御性代码,没有手动清理,没有"忘了通知"的 bug。
我读了源码,拆给你看。

一个插件系统要解决三个问题:怎么存、怎么查、怎么不打架。DeepSeek Harness 用三层数据结构各解决一个,而且它们之间通过一个机制完全闭环。
三层存储,各管一件事
| 怎么存? | |||
| 怎么查? | |||
| 怎么不打架? |
第一层:怎么存——用函数引用当 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 的解法是:三层分离,各管各的,用一种机制串起来。
disabled: true |
"一切皆插件"不是口号。是 Map 存类型、Fiber 存实例、Symbol 存隔离——三层数据结构,把"装进来、查出来、卸干净"彻底闭环。下次你写插件系统,别从零开始,从这三层开始。
夜雨聆风