最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
深度拆解DeepSeek Harness插件热更新实现原理
时间:2026-08-21 10:00:50 编辑:袖梨 来源:一聚教程网
深度拆解DeepSeek Harness插件热更新实现原理并不只看表面做法,关键还要理解相关条件、限制和后续影响。
0. 这一篇解决什么
到这里为止四篇内容合起来是一句话:插件通过 Service / 函数插件两种形态摆到 Context 上,靠 inject 声明依赖,通过五种事件模式相互通信。

但整个体系有一个必须成立的前提:所有这些注册操作必须是可逆的。否则:
- 卸载一个插件这件事就是幻想 —— 服务、adapter、tool、listener 全留在 map / 数组里
- HMR / 热更新不可能干净 —— 老 adapter 和新 adapter 抢路由
- isolation scope里的临时服务无法安全撤销 —— 主作用域可能拿到子作用域残留的实例
这一篇讲清楚:dsh 是靠什么把"注册 = 可逆副作用"这条不变量做出来的。
1. 一切贡献都要走ctx.effect()或ctx.on()
CLAUDE.md 里那条硬约束:
Registrations are effects: every contribution goes through ctx.effect() / ctx.on(); a registry’s register() returns the disposer.
意思是:
- 你不能把 this.adapters.set(...) 直接写在 apply(ctx) 里,就完事
- 你必须把它包在 ctx.effect(() => { setup; return teardown }) 里
- 或者把它藏在 ctx.on(...) 的 listener 里 —— ctx.on 内部本身就调 ctx.effect
这样做的直接结果:每个副作用都自带撤销路径,每个插件 fiber 卸载时框架自动把它们逆序跑掉。
2.ctx.effect的两种签名
看 vendor/cordis/src/fiber.ts:415:
effect(execute: () => SyncEffect, label?: string): Disposable>effect(execute: () => Effect, label?: string): AsyncDisposable >effect(execute: () => Effect, label = 'anonymous'): any { this.assertActive() if (this.state === FiberState.UNLOADING) { throw new CordisError('INACTIVE_EFFECT') } // …}
参数是一个函数(execute)。执行它得到 setup 结果 + 一份"怎么清理"的 disposer 表达。有两种表达方式:
2.1 函数返回一个 disposer
ctx.effect(() => { const timer = setInterval(tick, 1000) // setup return () => clearInterval(timer) // teardown}, 'my-timer')短平快,适合"只登记一个东西"的场景。
2.2 Generator:yield出 disposer
ctx.effect(function* () { const timer = setInterval(tick, 1000) const port = openPort(3000) yield () => clearInterval(timer) // teardown #1 yield () => port.close() // teardown #2}, 'my-multiple-effects')yield 出来的东西会被 fiber 收集起来,逆序执行。Generator 语义完美贴合"多步 setup + 反向 teardown":
- 想加一步 setup?往前 yield 之前塞一行
- 想加对应的 teardown?把它 yield 出来
- teardown 会自动逆序跑(先关 port,再关 timer)
vendor/cordis/src/fiber.ts:424:
const disposables: Disposable[] = []// …runner.collect = (dispose) => { disposables.push(dispose) // …}所以你 yield 一次 = 往 disposables 数组塞一个函数;fiber 卸载时(vendor/cordis/src/fiber.ts:431):
for (const disposable of disposables.splice(0).reverse()) { // ← reverse! // 逐个 await 跑掉}逆序是关键:符合"资源栈"的直觉——先建的最后拆,后建的先拆。
3. 教科书样例:LlmRuntime.registerAdapter
packages/llm/llm/src/index.ts:338-367 是 dsh 里"registry 的 register() 返回 disposer" 这条规则最完整的示范:
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { const owned = new Set() let released = false const dispose = this.ctx.effect(function* (this: LlmRuntime) { if (providers.length === 0) { throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') } // ── setup ───────────────────── this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) // ── yield 出 teardown ──────── yield () => { released = true for (const provider of owned) this.adapters.delete(provider) owned.clear() this.emitAdaptersUpdated() } }.bind(this), 'llm.registerAdapter()') const handle = (() => void dispose()) as AdapterRegistrationHandle handle.replace = (next: string[]): void => { if (released) { throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED') } this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) } return handle} 三个漂亮的地方:
3.1 setup / teardown 写在一个函数里
老式的写法是"注册返回 disposer",靠命名约定;新写法用 generator,setup 和 teardown 之间只隔一个 yield,视觉上就能对齐"我登记了 X,卸载时就撤销 X"。
一眼看得出来的对称关系:
this.commitRoutes(owned, prepareRoutes(providers, adapter, owned)) ← 建yield () => { this.adapters.delete(provider); owned.clear(); emitAdaptersUpdated() ← 拆}漏写 teardown 会立刻在 code review 里被看出来。
3.2 提供三种撤销路径(都指向同一份 teardown)
- 插件 fiber 卸载 → fiber 自动跑 disposables.reverse() → teardown 执行
- 调 dispose()(即 handle 本身) → 立即触发 teardown,然后从 fiber 的 disposables 里摘掉
- 调 handle.replace([...]) → 不撤销这次 registration,而是原子替换里面的 route
第 3 点是精髓,见下节。
3.3handle.replace原子替换 route
DeepSeek 的 provider 支持热更 retryPolicy(packages/llm/llm-deepseek/src/index.ts:258 附近):
const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy if (deepEqualJson(policy, registeredPolicy)) return registration.replace([PROVIDER]) // ★ 原子替换 registeredPolicy = policy}installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source }, onChange: ensureRegistrationFacts, // 用户在 Web 改设置 → 自动重注册})replace 内部做的(packages/llm/llm/src/index.ts:405-413):
private commitRoutes(owned: Set, registrations: readonly AdapterRegistration[]): void { for (const provider of owned) this.adapters.delete(provider) // 删旧 owned.clear() for (const registration of registrations) { this.adapters.set(registration.provider.id, registration) // 加新 owned.add(registration.provider.id) } this.emitAdaptersUpdated()}
注意这是同步的 for 循环:删旧 + 加新在一个 tick 内完成,没有异步等待。中间不会有任何观察者(比如 agent-loop 里正在跑的 stream() 调用)拿到"provider 消失了"的中间态。
这就叫原子替换,是 dsh 热更能力的核心 primitive。
4. 事件监听器:同样是 effect
回顾 04 · 6 里的代码(vendor/cordis/src/events.ts:254):
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void { const method = options.prepend ? 'unshift' : 'push' return this.ctx.fiber.effect(() => { hooks[method]({ ctx: this.ctx, callback, ...options }) // setup: 塞进 hooks 数组 return () => this.unregister(hooks, callback) // teardown: 从数组里删掉 }, label)}任何一个 ctx.on('llm/stream', ...) 都是一次 ctx.effect 调用。插件卸载 → fiber 卸载 → effect 逆序跑 → listener 被 splice 掉。这是"零手工清理"的根本。
5. Service 注册:也是 effect
vendor/cordis/src/reflect.ts:277:
provide(name: string, value?: any, check?: () => boolean) { return this.ctx.fiber.effect(() => { // … const key = this.ctx[symbols.isolate][name] const impl: Impl = { name, value, fiber: this.ctx.fiber, check } if (this.store[key]) { throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`) } this.store[key] = impl this.ctx.fiber.store![name] = impl if (this.ctx.fiber.state === FiberState.ACTIVE) { this.notify([name]) } return async () => { // ← teardown delete this.store[key] const fibers = this.notify([name]) await Promise.allSettled(fibers.map(fiber => fiber.await())) delete this.ctx.fiber.store![name] } }, `ctx.provide(${JSON.stringify(name)})`)}super(ctx, 'llm')本质就是 ctx.fiber.effect。Service 也是 effect——一切副作用都遵循同一条规则。
6. Fiber 是一个"事务边界"
在 dsh 里"一个插件"和"一个 fiber"是一一对应的(除非有 subagent / isolation scope 引入的子 fiber)。fiber 内部维护一个 _disposables 列表:
plugin fiber (state = ACTIVE) _disposables: ← disposer 栈(按注册顺序) [0] service register (ctx.llm) ← super(ctx, 'llm') [1] event listener 'llm/stream' ← ctx.on [2] adapter registration ← ctx.llm.registerAdapter [3] settings section install ← installSettingsSection [4] tools register 'bash' ← ctx.tools.register …
fiber 从 ACTIVE 转 DISPOSED 时,_disposables 逆序全部跑掉。这个"逆序清理"是 vendor/cordis/src/fiber.ts:431 里的 disposables.splice(0).reverse()。
分享时最直观的类比:fiber ≈ 数据库事务。整个 fiber 是一次"要么全部生效,要么全部回滚"的事务:
- 事务开始:fiber 从 PENDING → LOADING → ACTIVE
- 每次注册 = 事务里的一步 write
- 事务结束(卸载):所有 write 逆序 undo
这条心智模型解释了 dsh 的很多设计决策:
- 为什么禁止 apply 里搞裸的 setInterval? 因为它不受 fiber 管理,卸载时不会被回收。要么写成 ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }),要么用 ctx.setTimeout(Cordis 提供的 fiber-aware 版本)。
- 为什么服务注册用 ctx.reflect.provide 而不是 Object.assign(ctx, { llm })? 因为后者不受 fiber 管理,同名冲突和卸载语义都没有。
- 为什么 handle.replace 要设计成同步原子操作? 因为 fiber 是事务,事务内部不允许中间态泄漏。
7. 完整的热更循环:一个例子
把前面 4 篇 + 这一篇的知识串起来。用户在 Web UI 上改 llm-deepseek 的 retryPolicy,会发生什么?
用户在 Settings 页改 retryPolicy ← Web 事件 │ ▼settings service 触发 onChange │ ▼ensureRegistrationFacts() (llm-deepseek 里定义的) ├─ deepEqualJson 判断变化 → true ├─ registration.replace([PROVIDER]) ← 原子替换 │ └─ commitRoutes(): │ ├─ this.adapters.delete('deepseek-official') ← 摘旧 route(同步) │ ├─ this.adapters.set('deepseek-official', {...retryPolicy: new}) ← 塞新 route │ └─ emitAdaptersUpdated() ← 广播事件 └─ registeredPolicy = policy │ ▼agent-loop / 别的 consumer 拿到 'llm/adapters-updated' 事件 └─ 可以选择刷新自己的路由缓存 —— 但不会看到"provider 消失"的中间态 │ ▼下一次 ctx.llm.stream(options) 就用新的 retryPolicy 了整个过程没有重启进程,没有卸载/重新加载插件,甚至连 waterfall listener 都不受影响。因为原子替换发生在 LlmRuntime.adapters 这张 map 里,从 map 外面观察到的只是"值变了"。
反过来,如果整个 llm-deepseek 插件被禁用(cordis.yml 里加 disabled: true):
Loader 判定 llm-deepseek 应该 disabled │ ▼fiber 从 ACTIVE → UNLOADING → DISPOSED │ ▼_disposables 逆序清理: ├─ installSettingsSection 撤销 ← 设置面板消失 ├─ registerAdapter teardown ← this.adapters.delete('deepseek-official') ├─ registerConfigurableProviders 撤销 ← Web 端选择框里 DeepSeek 消失 └─ apply 里注册的其它 effect … │ ▼所有依赖 llm-deepseek 隐含的 route 的插件(比如某个 consumer 记住了 provider)会被通知(如果它们 inject 了 llm,llm fiber 还在,所以它们不会 pending;但 provider 消失是运行时事实)注册即副作用、副作用可逆这条规律,让"禁用一个功能"从"重启服务"变成"一次事务回滚"。
8. 手写副作用:一个典型的错误
分享时可以现场演示"为什么不能绕过 ctx.effect"。
// ❌ 反例export function apply(ctx: Context) { const timer = setInterval(() => { ctx.logger.info('tick') }, 1000) // 期望:插件卸载时清理 timer // 现实:ctx.effect / ctx.on 都没走,fiber 卸载不会做任何事 // 结果:timer 永远在跑,卸载后还在打日志(甚至用一个已经无效的 ctx)}// ✅ 正确export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => ctx.logger.info('tick'), 1000) return () => clearInterval(timer) }, 'tick-logger')}或者用 Cordis 提供的 fiber-aware setInterval / setTimeout(它们内部就是走 ctx.effect 的)。
9. 代码位置速查
| 主题 | 文件 | 关键位置 |
|---|---|---|
| Effect / SyncEffect / Disposable 类型 | vendor/cordis/src/fiber.ts | 类型定义顶部 |
| ctx.effect 主实现 | vendor/cordis/src/fiber.ts | L415-561 |
| _disposables 逆序清理 | vendor/cordis/src/fiber.ts | L431 splice(0).reverse() |
| getEffects 诊断入口 | vendor/cordis/src/fiber.ts | L568-572 |
| ctx.on 走 fiber.effect | vendor/cordis/src/events.ts | L254-260 |
| ctx.reflect.provide 走 fiber.effect | vendor/cordis/src/reflect.ts | L277-305 |
| registerAdapter 教科书样例 | packages/llm/llm/src/index.ts | L338-367 |
| commitRoutes 原子替换 | packages/llm/llm/src/index.ts | L405-413 |
| ensureRegistrationFacts 热更 | packages/llm/llm-deepseek/src/index.ts | installSettingsSection 附近 |
| 硬约束"Registrations are effects" | CLAUDE.md | Conventions 段 |
| 一切副作用可逆的语义讨论 | docs/defensive-patterns.md | teardown 相关章节 |
全系列小结
“如何把一次 LLM 调用改造成可插拔、可热更、可解耦的工程系统?”
- 把每个能力做成插件(Service Definition / Provider / Consumer)
- 服务放到 Context 上,用类型化的 ctx.
而不是 import - 依赖用 inject 声明,由 Loader 拓扑推导装配顺序
- 插件之间用五种事件模式通信,waterfall 是环绕拦截的枢纽
- 一切注册都是 ctx.effect,插件是一个事务,卸载时逆序回滚