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

发布时间:2026/8/21 21:27:57
深度拆解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 throughctx.effect()/ctx.on(); a registry’sregister()returns the disposer.意思是你不能把this.adapters.set(...)直接写在apply(ctx)里就完事你必须把它包在ctx.effect(() { setup; return teardown })里或者把它藏在ctx.on(...)的 listener 里 ——ctx.on内部本身就调ctx.effect见 04 · ctx.on 内部这样做的直接结果每个副作用都自带撤销路径每个插件 fiber 卸载时框架自动把它们逆序跑掉。2.ctx.effect的两种签名看vendor/cordis/src/fiber.ts:415effect(execute:()SyncEffect,label?:string):DisposablePromisevoideffect(execute:()Effect,label?:string):AsyncDisposablePromisevoideffect(execute:()Effect,labelanonymous):any{this.assertActive()if(this.stateFiberState.UNLOADING){thrownewCordisError(INACTIVE_EFFECT)}// …}参数是一个函数execute。执行它得到 setup 结果 一份怎么清理的 disposer 表达。有两种表达方式2.1 函数返回一个 disposerctx.effect((){consttimersetInterval(tick,1000)// setupreturn()clearInterval(timer)// teardown},my-timer)短平快适合只登记一个东西的场景。2.2 Generatoryield出 disposerctx.effect(function*(){consttimersetInterval(tick,1000)constportopenPort(3000)yield()clearInterval(timer)// teardown #1yield()port.close()// teardown #2},my-multiple-effects)yield出来的东西会被 fiber 收集起来逆序执行。Generator 语义完美贴合多步 setup 反向 teardown想加一步 setup往前yield之前塞一行想加对应的 teardown把它yield出来teardown 会自动逆序跑先关 port再关 timervendor/cordis/src/fiber.ts:424constdisposables:Disposable[][]// …runner.collect(dispose){disposables.push(dispose)// …}所以你yield一次 往disposables数组塞一个函数fiber 卸载时vendor/cordis/src/fiber.ts:431for(constdisposableofdisposables.splice(0).reverse()){// ← reverse// 逐个 await 跑掉}逆序是关键符合资源栈的直觉——先建的最后拆后建的先拆。3. 教科书样例LlmRuntime.registerAdapterpackages/llm/llm/src/index.ts:338-367是 dsh 里registry 的register()返回 disposer 这条规则最完整的示范registerAdapter(providers:string[],adapter:LlmAdapter):AdapterRegistrationHandle{constownednewSetstring()letreleasedfalseconstdisposethis.ctx.effect(function*(this:LlmRuntime){if(providers.length0){thrownewLlmError(an adapter must register at least one provider,INVALID_ADAPTER)}// ── setup ─────────────────────this.commitRoutes(owned,this.prepareRoutes(providers,adapter,owned))// ── yield 出 teardown ────────yield(){releasedtruefor(constproviderofowned)this.adapters.delete(provider)owned.clear()this.emitAdaptersUpdated()}}.bind(this),llm.registerAdapter())consthandle(()voiddispose())asAdapterRegistrationHandle handle.replace(next:string[]):void{if(released){thrownewLlmError(a disposed adapter registration cannot replace its routes,REGISTRATION_DISPOSED)}this.commitRoutes(owned,this.prepareRoutes(next,adapter,owned))}returnhandle}三个漂亮的地方3.1 setup / teardown 写在一个函数里老式的写法是注册返回 disposer靠命名约定新写法用 generatorsetup 和 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原子替换 routeDeepSeek 的 provider 支持热更retryPolicypackages/llm/llm-deepseek/src/index.ts:258附近constensureRegistrationFacts():void{constpolicyoptions().retryPolicyif(deepEqualJson(policy,registeredPolicy))returnregistration.replace([PROVIDER])// ★ 原子替换registeredPolicypolicy}installSettingsSection(ctx,NS,Config,config,{setSource:(source){currentsource},onChange:ensureRegistrationFacts,// 用户在 Web 改设置 → 自动重注册})replace内部做的packages/llm/llm/src/index.ts:405-413privatecommitRoutes(owned:Setstring,registrations:readonlyAdapterRegistration[]):void{for(constproviderofowned)this.adapters.delete(provider)// 删旧owned.clear()for(constregistrationofregistrations){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:254register(label:string,hooks:Hook[],callback:any,options:EventOptions):()void{constmethodoptions.prepend?unshift:pushreturnthis.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 注册也是 effectvendor/cordis/src/reflect.ts:277provide(name:string,value?:any,check?:()boolean){returnthis.ctx.fiber.effect((){// …constkeythis.ctx[symbols.isolate][name]constimpl:Impl{name,value,fiber:this.ctx.fiber,check}if(this.store[key]){thrownewError(service ${name} has been registered at ${this.store[key].fiber.name})}this.store[key]implthis.ctx.fiber.store![name]implif(this.ctx.fiber.stateFiberState.ACTIVE){this.notify([name])}returnasync(){// ← teardowndeletethis.store[key]constfibersthis.notify([name])awaitPromise.allSettled(fibers.map(fiberfiber.await()))deletethis.ctx.fiber.store![name]}},ctx.provide(${JSON.stringify(name)}))}super(ctx, llm)01 讲的那个服务落桌操作本质就是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.setTimeoutCordis 提供的 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: trueLoader 判定 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 了 llmllm fiber 还在所以它们不会 pending但 provider 消失是运行时事实注册即副作用、副作用可逆这条规律让禁用一个功能从重启服务变成一次事务回滚。8. 手写副作用一个典型的错误分享时可以现场演示为什么不能绕过ctx.effect。// ❌ 反例exportfunctionapply(ctx:Context){consttimersetInterval((){ctx.logger.info(tick)},1000)// 期望插件卸载时清理 timer// 现实ctx.effect / ctx.on 都没走fiber 卸载不会做任何事// 结果timer 永远在跑卸载后还在打日志甚至用一个已经无效的 ctx}// ✅ 正确exportfunctionapply(ctx:Context){ctx.effect((){consttimersetInterval(()ctx.logger.info(tick),1000)return()clearInterval(timer)},tick-logger)}或者用 Cordis 提供的 fiber-awaresetInterval/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.tsL431splice(0).reverse()getEffects诊断入口vendor/cordis/src/fiber.tsL568-572ctx.on走 fiber.effectvendor/cordis/src/events.tsL254-260ctx.reflect.provide走 fiber.effectvendor/cordis/src/reflect.tsL277-305registerAdapter教科书样例packages/llm/llm/src/index.tsL338-367commitRoutes原子替换packages/llm/llm/src/index.tsL405-413ensureRegistrationFacts热更packages/llm/llm-deepseek/src/index.tsinstallSettingsSection附近硬约束Registrations are effectsCLAUDE.mdConventions 段一切副作用可逆的语义讨论docs/defensive-patterns.mdteardown 相关章节全系列小结“如何把一次 LLM 调用改造成可插拔、可热更、可解耦的工程系统”把每个能力做成插件Service Definition / Provider / Consumer服务放到 Context 上用类型化的ctx.key而不是import依赖用inject声明由 Loader 拓扑推导装配顺序插件之间用五种事件模式通信waterfall 是环绕拦截的枢纽一切注册都是ctx.effect插件是一个事务卸载时逆序回滚