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 through
ctx.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:415:
typescript
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
effect(execute: () => Effect, label = 'anonymous'): any {
this.assertActive()
if (this.state === FiberState.UNLOADING) {
throw new CordisError('INACTIVE_EFFECT')
}
// ...
}
参数是一个函数 (execute)。执行它得到 setup 结果 + 一份"怎么清理"的 disposer 表达。有两种表达方式:
2.1 函数返回一个 disposer
typescript
ctx.effect(() => {
const timer = setInterval(tick, 1000) // setup
return () => clearInterval(timer) // teardown
}, 'my-timer')
短平快,适合"只登记一个东西"的场景。
2.2 Generator:yield 出 disposer
typescript
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:
typescript
const disposables: Disposable[] = []
// ...
runner.collect = (dispose) => {
disposables.push(dispose)
// ...
}
所以你 yield 一次 = 往 disposables 数组塞一个函数;fiber 卸载时(vendor/cordis/src/fiber.ts:431):
typescript
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" 这条规则最完整的示范:
typescript
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
const owned = new Set<string>()
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"。
一眼看得出来的对称关系:
text
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.3 handle.replace 原子替换 route
DeepSeek 的 provider 支持热更 retryPolicy(packages/llm/llm-deepseek/src/index.ts:258 附近):
typescript
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):
typescript
private commitRoutes(owned: Set<string>, 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):
typescript
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:
typescript
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')(01 讲的那个"服务落桌"操作)本质就是 ctx.fiber.effect。Service 也是 effect------一切副作用都遵循同一条规则。
6. Fiber 是一个"事务边界"
在 dsh 里"一个插件"和"一个 fiber"是一一对应的(除非有 subagent / isolation scope 引入的子 fiber)。fiber 内部维护一个 _disposables 列表:
text
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.setTimeout(Cordis 提供的 fiber-aware 版本)。 - 为什么服务注册用
ctx.reflect.provide而不是Object.assign(ctx, { llm })? 因为后者不受 fiber 管理,同名冲突和卸载语义都没有。 - 为什么
handle.replace要设计成同步原子操作? 因为 fiber 是事务,事务内部不允许中间态泄漏。
7. 完整的热更循环:一个例子
把前面 4 篇 + 这一篇的知识串起来。用户在 Web UI 上改 llm-deepseek 的 retryPolicy,会发生什么?
text
用户在 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):
text
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"。
typescript
// ❌ 反例
export function apply(ctx: Context) {
const timer = setInterval(() => {
ctx.logger.info('tick')
}, 1000)
// 期望:插件卸载时清理 timer
// 现实:ctx.effect / ctx.on 都没走,fiber 卸载不会做任何事
// 结果:timer 永远在跑,卸载后还在打日志(甚至用一个已经无效的 ctx)
}
typescript
// ✅ 正确
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.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.<key>而不是import - 依赖用
inject声明,由 Loader 拓扑推导装配顺序 - 插件之间用五种事件模式通信,waterfall 是环绕拦截的枢纽
- 一切注册都是
ctx.effect,插件是一个事务,卸载时逆序回滚