首页 > 教程攻略 > ai教程 >深度拆解DeepSeek Harness插件热更新实现原理

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

来源:互联网 时间:2026-08-20 20:05:41

0. 这一篇解决什么

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

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

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

  • 卸载一个插件

    这件事就是幻想 —— 服务、adapter、tool、listener 全留在 map / 数组里
  • HMR / 热更新

    不可能干净 —— 老 adapter 和新 adapter 抢路由
  • isolation scope

    里的临时服务无法安全撤销 —— 主作用域可能拿到子作用域残留的实例

这一篇讲清楚:

dsh 是靠什么把"注册 = 可逆副作用"这条不变量做出来的

1. 一切贡献都要走ctx.effect()或ctx.on()

CLAUDE.md 里那条硬约束:

注册是副作用:每个贡献都要经过 ctx.effect()ctx.on();注册表的 register() 方法会返回一个释放器。

意思是:

  • 你不能把 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)

  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.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()。这样做的目的是确保依赖先于依赖者被销毁,遵循后进先出(LIFO)的原则。例如,插件A依赖于插件B,如果先销毁插件A,而插件B还被其他部分引用,可能会导致错误。通过逆序清理,可以保证插件B先被清理,然后再清理插件A,从而避免潜在的问题。

分享时最直观的类比: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-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 调用改造成可插拔、可热更、可解耦的工程系统?”

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