一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

深度拆解DeepSeek Harness插件热更新实现原理

时间:2026-08-21 10:00:50 编辑:袖梨 来源:一聚教程网

深度拆解DeepSeek Harness插件热更新实现原理并不只看表面做法,关键还要理解相关条件、限制和后续影响。

0. 这一篇解决什么

到这里为止四篇内容合起来是一句话:插件通过 Service / 函数插件两种形态摆到 Context 上,靠 inject 声明依赖,通过五种事件模式相互通信。

深度拆解DeepSeek Harness插件热更新实现原理

但整个体系有一个必须成立的前提:所有这些注册操作必须是可逆的。否则:

  1. 卸载一个插件这件事就是幻想 —— 服务、adapter、tool、listener 全留在 map / 数组里
  2. HMR / 热更新不可能干净 —— 老 adapter 和新 adapter 抢路由
  3. 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.

意思是:

  1. 你不能把 this.adapters.set(...) 直接写在 apply(ctx) 里,就完事
  2. 你必须把它包在 ctx.effect(() => { setup; return teardown }) 里
  3. 或者把它藏在 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":

  1. 想加一步 setup?往前 yield 之前塞一行
  2. 想加对应的 teardown?把它 yield 出来
  3. 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)

  1. 插件 fiber 卸载 → fiber 自动跑 disposables.reverse() → teardown 执行
  2. 调 dispose()(即 handle 本身) → 立即触发 teardown,然后从 fiber 的 disposables 里摘掉
  3. 调 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.effectService 也是 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 是一次"要么全部生效,要么全部回滚"的事务:

  1. 事务开始:fiber 从 PENDING → LOADING → ACTIVE
  2. 每次注册 = 事务里的一步 write
  3. 事务结束(卸载):所有 write 逆序 undo

这条心智模型解释了 dsh 的很多设计决策:

  1. 为什么禁止 apply 里搞裸的 setInterval? 因为它不受 fiber 管理,卸载时不会被回收。要么写成 ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }),要么用 ctx.setTimeout(Cordis 提供的 fiber-aware 版本)。
  2. 为什么服务注册用 ctx.reflect.provide 而不是 Object.assign(ctx, { llm })? 因为后者不受 fiber 管理,同名冲突和卸载语义都没有。
  3. 为什么 handle.replace 要设计成同步原子操作? 因为 fiber 是事务,事务内部不允许中间态泄漏。

7. 完整的热更循环:一个例子

把前面 4 篇 + 这一篇的知识串起来。用户在 Web UI 上改 llm-deepseekretryPolicy,会发生什么?

用户在 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.tsL415-561
_disposables 逆序清理vendor/cordis/src/fiber.tsL431 splice(0).reverse()
getEffects 诊断入口vendor/cordis/src/fiber.tsL568-572
ctx.on 走 fiber.effectvendor/cordis/src/events.tsL254-260
ctx.reflect.provide 走 fiber.effectvendor/cordis/src/reflect.tsL277-305
registerAdapter 教科书样例packages/llm/llm/src/index.tsL338-367
commitRoutes 原子替换packages/llm/llm/src/index.tsL405-413
ensureRegistrationFacts 热更packages/llm/llm-deepseek/src/index.tsinstallSettingsSection 附近
硬约束"Registrations are effects"CLAUDE.mdConventions 段
一切副作用可逆的语义讨论docs/defensive-patterns.mdteardown 相关章节

全系列小结

“如何把一次 LLM 调用改造成可插拔、可热更、可解耦的工程系统?”

  1. 把每个能力做成插件(Service Definition / Provider / Consumer)
  2. 服务放到 Context 上,用类型化的 ctx. 而不是 import
  3. 依赖用 inject 声明,由 Loader 拓扑推导装配顺序
  4. 插件之间用五种事件模式通信,waterfall 是环绕拦截的枢纽
  5. 一切注册都是 ctx.effect,插件是一个事务,卸载时逆序回滚

热门栏目