游乐游手机版
首页/AI教程/文章详情

DeepSeek Harness插件热更新原理深度解析与实现方法

时间:2026-08-20 18:12
0 这一篇解决什么 到这里为止,前四篇内容可以浓缩成一句话:插件以 Service 函数插件两种形态挂载到 Context 上,通过 inject 声明依赖关系,再借助五种事件模式完成彼此通信。 但这整套插件系统能成立,有一个绝对不能被破坏的前提:所有注册操作都必须可逆。否则: 卸载一个插件就

0. 这一篇解决什么

到这里为止,前四篇内容可以浓缩成一句话:插件以 Service / 函数插件两种形态挂载到 Context 上,通过 inject 声明依赖关系,再借助五种事件模式完成彼此通信。

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

但这整套插件系统能成立,有一个绝对不能被破坏的前提:所有注册操作都必须可逆。否则:

  • 卸载一个插件就只是理想状态 —— service、adapter、tool、listener 仍会残留在 map / 数组中
  • HMR / 热更新无法真正干净 —— 旧 adapter 与新 adapter 会同时争抢路由
  • isolation scope中的临时服务不能安全回收 —— 主作用域可能拿到子作用域遗留的实例

这一篇要讲清楚:dsh 到底依赖什么机制,才能把“注册 = 可逆副作用”这条核心不变量真正落实下来

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

CLAUDE.md 里有一条非常关键的硬约束:

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

它的含义其实很直接:

  • 你不能在 apply(ctx) 里直接写 this.adapters.set(...) 就结束
  • 你必须把这类注册动作包进 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?直接在前面继续加代码
  • 想补对应的 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,视觉上就能明确看出“注册了什么,卸载时就撤销什么”。

这种对称关系几乎一眼可见:

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 点尤其关键,也是 dsh 支持热更新与插件热重载的核心能力,下一节继续展开。

3.3handle.replace原子替换 route

例如 DeepSeek provider 支持热更新 retryPolicypackages/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 被从 hooks 数组中移除。这正是“零手工清理”能够成立的根本原因。

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——在 dsh 里,所有副作用最终都遵守同一套注册与回收规则。

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)原则。先注册、先依赖的资源,会被留到最后再销毁,从而尽量避免引用悬空和状态不一致。

做分享时,一个最容易理解的类比是:fiber ≈ 数据库事务。整个 fiber 就像一次“要么全部生效,要么全部回滚”的事务边界:

  • 事务开始:fiber 从 PENDING → LOADING → ACTIVE
  • 每一次注册 = 事务中的一次 write
  • 事务结束(卸载):所有 write 按逆序执行 undo

用这个心智模型,可以解释 dsh 中很多看似严格、实则非常必要的设计决策:

  • 为什么不允许在 apply 里直接写裸的 setInterval?因为它不受 fiber 管理,插件卸载时无法自动回收。正确方式要么是 ctx.effect(() => { const t = setInterval(...); return () => clearInterval(t) }),要么使用 Cordis 提供的 fiber-aware 版本 ctx.setTimeout / ctx.setInterval
  • 为什么服务注册要走 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 接入 fiber 生命周期管理的。

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,让插件成为一个事务单元,在卸载时按逆序自动回滚
来源:https://www.jb51.net/ai/1038971.html
上一篇DeepSeek Harness十大实用插件详解与安装指南 下一篇DeepSeek Harness桌面端实现示例与开发指南
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
CAD零基础入门教程:坐标输入、图层管理与基础绘图命令
AI教程 · 2026-09-01

CAD零基础入门教程:坐标输入、图层管理与基础绘图命令

本文面向CAD零基础学习者,系统讲解坐标输入、图层管理与基础绘图命令的核心用法。通过分步实操与常见问题排查,帮助新手建立精确绘图习惯,掌握规范出图的基础能力。

CAD从入门到项目交付:绘图、标注、图块与实战工作流
AI教程 · 2026-09-01

CAD从入门到项目交付:绘图、标注、图块与实战工作流

掌握CAD的核心在于建立“画得准、标得清、复用快、交付稳”的工作流。本文提供从环境设置、高频命令组合、标注规范、图块标准化到项目分阶段交付的完整路径,帮助初学者避免常见返工陷阱,独立完成可检查、可复用、可打印的工程图纸。

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤
AI教程 · 2026-09-01

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤

本文详细解析 Claude Code 登录前的账号类型区分方法,涵盖个人订阅、Teams 席位与企业 Enterprise 席位的授权路径差异。提供终端登录命令、环境变量排查及常见异常处理步骤,帮助用户快速完成正确授权并避免登录路径混淆。

Claude Code 文件修改前的权限模式配置与命令审批指南
AI教程 · 2026-09-01

Claude Code 文件修改前的权限模式配置与命令审批指南

本文详细介绍Claude Code在修改文件前的权限模式配置方法,包括defaultMode可选值、permissions allow与deny规则设置、多层级配置文件管理以及 status验证技巧,帮助开发者安全高效地使用AI编程助手。

Claude Code接入VS Code后先测扩展和终端命令
AI教程 · 2026-09-01

Claude Code接入VS Code后先测扩展和终端命令

在VS Code中接入Claude Code后,建议优先验证扩展面板与集成终端两条入口。本文提供标准检查顺序、关键命令与常见故障排查路径,帮助你快速确认环境就绪,避免后续开发受阻。