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

但这整套插件系统能成立,有一个绝对不能被破坏的前提:所有注册操作都必须可逆。否则:
- 卸载一个插件就只是理想状态 —— 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)
- 插件 fiber 卸载 → fiber 自动执行
disposables.reverse()→ teardown 被触发 - 主动调用
dispose()(也就是 handle 本身) → 立即执行 teardown,并从 fiber 的disposables中移除 - 调用
handle.replace([...])→ 不销毁这次 registration,而是对内部 route 做原子替换
第 3 点尤其关键,也是 dsh 支持热更新与插件热重载的核心能力,下一节继续展开。
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 被从 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-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 接入 fiber 生命周期管理的。
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,让插件成为一个事务单元,在卸载时按逆序自动回滚
