一轮任务里,「规划」需要强模型,「执行」用便宜模型就够。难的不是省钱,而是把这个决策放进 harness 自己的注册表里——而不是塞进一个外部代理。
ctx.llm.stream({ provider: 'lead-worker', model: 'auto', messages })
│
├── llm/stream waterfall ← 任何插件都能观察到这次「路由调用」
│
└── RoutingAdapter(本插件)
│ 询问 ctx.llmRouter → llm-router/decide waterfall → 策略
│
└── ctx.llm.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', … })
└── 再次经过 llm/stream waterfall ← 上游那一跳同样可见
llm/stream 的 waterfall 只适合「监听器有权改写请求内容」的场景:agent loop 构造的请求是深度冻结的,并带进程内身份,loop 的不变式会校验 model / system / temperature / tools 是否仍与会话日志折叠出的一致。在那里换模型不是路由,而是触发可重建性不变式。
调用方像选择其他模型一样选择 lead-worker/auto,会话日志记录的就是它真正请求的东西;路由器的分发只是一次普通的嵌套 ctx.llm.stream()。
模型选择器、会话日志、压缩、会话标题、ACP 与 GUI 选择器都通过 listProviders() / listModels() 工作——路由直接变成可选模型,无需改动任何调用方。
| 方式 | 适用 | 入口 |
|---|---|---|
| 一 · 社区市场 | DSH Desktop 图形界面 | 添加目录源 → 安装 Lead/Worker Model Router |
| 二 · 命令行 | 脚本化 / 服务器 | dsh plugin --profile desktop add @hakehuang/dsh-llm-router@0.1.1 |
| 三 · 手工 patch 行 | 内网、离线、要自己写配置 | 把 bundle 行粘进 profile 的 patch 层 |
latestVersion 已指向 0.1.1)dsh plugin --profile desktop add @hakehuang/dsh-llm-router@0.1.1
dsh plugin 只装依赖、不写挂载清单(要确认 profile 的 dsh.profile.bundles 含 @hakehuang/dsh-llm-router);
以及 pnpm 10/11 的 store 版本冲突(报 ERR_PNPM_UNEXPECTED_STORE 时的两种处置见插件教程页)。
随包的 cordis.patch.yml 会在 bundle 安装时自动生效;手工安装时把下面这段放进 profile 自己的 patch 层,它同时也是配置的写法:
- insert:
- id: llm-router
name: '@hakehuang/dsh-llm-router'
config:
provider: lead-worker
lead: { provider: deepseek-official, model: deepseek-v4-pro, reasoningEffort: high }
workers:
- { id: flash, provider: deepseek-official, model: deepseek-v4-flash, reasoningEffort: low }
lead 也没有 workers 时只打一行日志、不挂载任何东西,所以「先插入行、后写配置」不会弄坏 profile 启动。
但写了一半的路由(lead: { provider: … } 缺 model)会明确报错——那种沉默会把一个拼写错误变成「为什么什么都没路由?」。
配置写在 patch 行的 config: 下。完整表见包内 README(中文版),这里列出实际会调的:
| enabled | 总开关,默认 true;false 时什么都不挂载。 |
|---|---|
| provider | 本插件在 ctx.llm 上拥有的虚拟提供方 id,默认 lead-worker。 |
| displayName | 模型选择器里的分组名,默认 Lead/Worker Router。 |
| lead | 「主力」路由:{ id?, provider, model, reasoningEffort?, weight?, tags?, contextWindow? }。 |
| workers | 「执行」路由数组,同结构;id 用于 worker:<id> 与决策记录。 |
| acceptReasoningEfforts | 虚拟路由接受的推理强度,默认 off, low, high, max(只决定调用方可以说什么)。 |
| forwardReasoningEffort | 是否把调用方强度转发给自身未声明强度的路由,默认 false。 |
| housekeepingRole | purpose: compaction | session-title 这类杂务调用由谁回答,默认 worker。 |
| escalateAfterFailures | 连续失败多少次后把会话升级给 lead,默认 2;0 关闭。 |
| leadAboveChars / leadToolCount | 请求规模或工具数量越过阈值时交给 lead,默认 0(关闭)。 |
| stickyTurns | 同一轮保持开始执行时选定的 worker,默认 true(保住 prompt cache)。 |
| fallback / maxFallbacks | 在产出任何内容之前失败时改试下一条路由;默认开,额外尝试上限 1。 |
| preserveReplayState | 单提供方路由表下重新标注 assistant 溯源,让 replay 状态穿过这一跳,默认 true。 |
| sessionLimit | 记住的会话路由状态上限,默认 512,超出淘汰最旧。 |
tags 是策略词汇:image 标记优先承接图片的 worker,no-image 声明某条路由绝不能收到图片。
leadTools 只适合「工具集随步骤变化」的部署:DSH 按步骤组装工具集,主会话几乎每步都提供 subagent/workflow/goal,把它们写进去等于把每一步都钉在 lead 上——配置有效,但很贵。
路由以提供方分组出现在模型选择器里,虚拟模型为 auto、lead、worker,以及每个已配置 worker 的 worker:<id>。
| 内置策略 | id 为 lead-worker;规则按下表顺序求值,命中即停,决策里会带上命中的 rule。 |
|---|---|
| 1–3 · 显式指定 | pin:runtime(运行时 pin)→ pin:model(调用方点名 lead / worker / worker:id)→ hint:routerRole(手工调用传 routerRole)。 |
| 4 · 升级 | escalate:failures:该会话连续 worker 失败达到阈值。 |
| 5 · 杂务 | purpose:housekeeping:压缩与会话标题生成。 |
| 6 · 规划 | step:turn-start:请求以一条新的指令结尾——这一轮由 lead 规划。 |
| 7 · 图片 | modality:image*:请求带图片时优先视觉 worker,其次能读图的 lead。 |
| 8–10 · 规模 | tools:lead / sticky:turn / breadth:tools、context:pressure。 |
| 11 · 兜底 | default:worker(有 worker 时)/ default:lead。 |
由此得到的「一轮成本形状」是:一次 lead 调用做规划,其余步骤交给 worker;便宜路由持续失败时再升级回 lead。 lead 决策刻意不粘滞(这正是执行阶段变便宜的空间),worker 决策粘滞(让上游 prompt cache 在整轮中保持有效)。
| 强度归路由所有 | 路由上的 reasoningEffort 就是该路由固定发送的值,默认不转发调用方的强度:把 high 转发给不支持推理的 worker 会让调用失败,把 lead 的 high 转发给 worker 又会悄悄改变 worker 的性质。 |
|---|---|
| 为什么要宽松的 accept 列表 | 它只决定调用方可以说什么;拒绝一个会话已经选中的强度,等于为一个路由器根本不会转发的值让调用失败。设成上游的词汇表,或设成 [] 拒绝一切显式强度。 |
路由本身是 cordis 服务 ctx.llmRouter,也是 ctx.llm.router 这个可赋值属性——这正是它「可替换」的入口。
// 观察:只需要一个监听器
ctx.on('llm-router/decision', (d) => log(`${d.rule} → ${d.provider}/${d.model}`))
ctx.on('llm-router/settled', ({ decision, finish, usage }) => meter(decision.routeId, usage, finish))
// 拦截:决策 waterfall 可组合;直接给出决策而不调用 next() 就决定了那一次请求
ctx.on('llm-router/decide', (view, next) => {
if (view.imageCount > 0) return { role: 'lead', rule: 'house:images', reason: '带图的任务错不起' }
return next()
})
// 替换(由轻到重):换策略 → 换整个路由器 → 卸载本插件自己注册适配器
const withdraw = ctx.llmRouter.registerStrategy({
id: 'house:cheap-first',
decide: () => ({ role: 'worker', rule: 'house:cheap-first', reason: '总是走便宜路由' }),
}, { activate: true })
// 作为调用方直接使用这条路由
for await (const chunk of ctx.llm.stream({
provider: 'lead-worker',
model: 'worker:flash', // 或 'auto',交给策略决定
messages,
routerRole: 'lead', // 可选:手工调用的提示;由路由器消费,绝不向下游转发
})) { /* … */ }
llm-router/decide(waterfall)· decision · delegatedllm-router/settled · fallback · strategy / routesdecide(view) / viewOf(options) / fallbacksFor(decision)routes() / models() / reconfigure(partial)pin(key,target,ttlMs) / unpin(key) / reset(key)stats() / snapshot() —— 每条路由的计数与可序列化快照赋给 ctx.llm.router 的对象必须实现 viewOf / decide / models / resolveModelInfo / fallbacksFor(+ 可选 note*)。形状不对会被 setter 当场拒绝;返回半成品决策会以 ROUTER_INVALID_DECISION 报在该次流上,策略抛错则以 ROUTER_STRATEGY_FAILED 点名——路由失败是可归因的,不是匿名的。
一轮只换一次模型(规划→执行),该轮其余步骤停在同一条路由上;stickyTurns: false 用缓存换逐步灵活性。
只有在还没产出任何内容时才换路由;一旦消费者见过分片,就把错误如实抛上去。失败的 lead 是终点——lead 失败后改用更便宜的模型是策略选择,不是故障转移。
失败连击会被下一次成功的路由调用清零:升级是把问题交给 lead 一次,而不是把会话永久钉在那里。
只有当同一个适配器同时拥有历史与目标路由时才保留提供方 replay 状态;跨提供方时不动溯源,由 harness 按其不变式剥掉。
本路由器的转移只覆盖「一次逻辑调用内、产出前失败」;提供方级重试策略依然作用于路由器自己的路由。
决策是一等的事件总线成员,也完整存在于 llmRouter.snapshot() 中,但 harness 的会话日志目前没有对应事件类型,因此决策无法从会话文件重放;上游那一跳本身有记录——它就是那次嵌套的 llm/stream 调用。
auto / lead / worker / worker:<id>。dsh --profile desktop --dump-config 输出里应出现 - id: llm-router / name: '@hakehuang/dsh-llm-router'(含你写的 config)。llm-router/decision 监听器,或读 ctx.llmRouter.snapshot(),确认命中的 rule 符合预期。| 装完什么都没发生 | 先看是否只插了行、没写配置:无 lead 也无 workers 时插件按设计休眠(只打一行日志)。再确认 enabled 未被设成 false,以及是否重启过 DSH。 |
|---|---|
| 启动直接报错 | 多半是写了一半的路由:lead/workers 里出现 provider 却缺 model。补全或整段删掉即可(空配置是合法的休眠态)。 |
| 模型选择器里没有这个分组 | 包没挂载:检查 profile 的 dsh.profile.bundles 是否含包名(dsh plugin 不写挂载清单),并确认已重启。 |
| 安装报 ERR_PNPM_UNEXPECTED_STORE | profile 的 node_modules 与 DSH 自带 pnpm 的 store 版本不一致:两种处置(迁 store / 用匹配版本 pnpm 手工装)见插件教程页的排错表。 |
| 强度相关报错 | 调用方选的强度不在 acceptReasoningEfforts 里。把它设成上游的词汇表,或设成 [] 拒绝一切显式强度。 |