zephyrrtos.cnZEPHYR RTOS 本土生态工作组
DSH 插件 · LLM 路由 · Lead/Worker

dsh-llm-router:Lead/Worker 模型路由

把 Lead/Worker 路由做成 harness 的一等公民:插件在 ctx.llm 上注册一条虚拟提供方路由(默认 lead-worker), 并提供 cordis 服务 ctx.llmRouter。路由不再是躲在外部二进制或旁路代理里的决策,而是每个模型调用都会经过的同一张注册表里的一条可观察、可拦截、可替换的路由。

当前版本 0.1.1 · 许可 MIT · 需要 DSH ≥ 0.1.2-rc.1 · npm 页面 · 源码仓库 · CHANGELOG

它解决什么问题

一轮任务里,「规划」需要强模型,「执行」用便宜模型就够。难的不是省钱,而是把这个决策放进 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 层

方式一 · DSH 社区市场

  1. 添加目录源 Community Market → sources → add source:https://zephyrrtos.cn/dsh-llm-router/catalog-source.json(目录里 latestVersion 已指向 0.1.1
  2. 安装条目 列表里会出现 Lead/Worker Model Router;安装动作仍然需要你确认(源只携带元数据)。
  3. 重启 DSH 桌面 profile 在启动时读取 patch 层;重启后模型选择器里出现 Lead/Worker Router 分组。

方式二 · 命令行

dsh plugin --profile desktop add @hakehuang/dsh-llm-router@0.1.1
dsh-ubuntu-sandbox 相同的两个坑:dsh plugin 只装依赖、不写挂载清单(要确认 profile 的 dsh.profile.bundles@hakehuang/dsh-llm-router); 以及 pnpm 10/11 的 store 版本冲突(报 ERR_PNPM_UNEXPECTED_STORE 时的两种处置见插件教程页)。

方式三 · 手工把 bundle 行粘进 patch 层

随包的 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总开关,默认 truefalse 时什么都不挂载。
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
housekeepingRolepurpose: compaction | session-title 这类杂务调用由谁回答,默认 worker
escalateAfterFailures连续失败多少次后把会话升级给 lead,默认 20 关闭。
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 上——配置有效,但很贵。

虚拟模型、策略与推理强度

路由以提供方分组出现在模型选择器里,虚拟模型为 autoleadworker,以及每个已配置 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:toolscontext: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 · delegated
  • llm-router/settled · fallback · strategy / routes

服务成员(节选)

  • decide(view) / viewOf(options) / fallbacksFor(decision)
  • routes() / models() / reconfigure(partial)
  • pin(key,target,ttlMs) / unpin(key) / reset(key)
  • stats() / snapshot() —— 每条路由的计数与可序列化快照

替换契约(RouterContract)

赋给 ctx.llm.router 的对象必须实现 viewOf / decide / models / resolveModelInfo / fallbacksFor(+ 可选 note*)。形状不对会被 setter 当场拒绝;返回半成品决策会以 ROUTER_INVALID_DECISION 报在该次流上,策略抛错则以 ROUTER_STRATEGY_FAILED 点名——路由失败是可归因的,不是匿名的。

成本、缓存,以及这个路由器的坦白

Prompt cache

一轮只换一次模型(规划→执行),该轮其余步骤停在同一条路由上;stickyTurns: false 用缓存换逐步灵活性。

故障转移绝不拼接答案

只有在还没产出任何内容时才换路由;一旦消费者见过分片,就把错误如实抛上去。失败的 lead 是终点——lead 失败后改用更便宜的模型是策略选择,不是故障转移。

升级是自限的

失败连击会被下一次成功的路由调用清零:升级是把问题交给 lead 一次,而不是把会话永久钉在那里。

Replay 状态

只有当同一个适配器同时拥有历史与目标路由时才保留提供方 replay 状态;跨提供方时不动溯源,由 harness 按其不变式剥掉。

重试仍归 dsh-llm-retry

本路由器的转移只覆盖「一次逻辑调用内、产出前失败」;提供方级重试策略依然作用于路由器自己的路由。

决策不在会话日志里

决策是一等的事件总线成员,也完整存在于 llmRouter.snapshot() 中,但 harness 的会话日志目前没有对应事件类型,因此决策无法从会话文件重放;上游那一跳本身有记录——它就是那次嵌套的 llm/stream 调用。

装完怎么验证、出问题怎么查

  1. 模型选择器出现分组 重启 DSH 后,选择器里应出现 Lead/Worker Router,可选虚拟模型 auto / lead / worker / worker:<id>
  2. 挂载清单里有这一行 dsh --profile desktop --dump-config 输出里应出现 - id: llm-router / name: '@hakehuang/dsh-llm-router'(含你写的 config)。
  3. 看决策 接一个 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_STOREprofile 的 node_modules 与 DSH 自带 pnpm 的 store 版本不一致:两种处置(迁 store / 用匹配版本 pnpm 手工装)见插件教程页的排错表
强度相关报错调用方选的强度不在 acceptReasoningEfforts 里。把它设成上游的词汇表,或设成 [] 拒绝一切显式强度。
问智能体 Zephyr 问题问答 · 直接问智能体