dsh-llm-router
Verified@hakehuang/dsh-llm-router · v0.1.1 · MIT
Cordis-native Lead/Worker model routing for DeepSeek Harness: a replaceable `ctx.llm` provider route plus an observable, interceptable routing service.
Install
dsh plugin add @hakehuang/dsh-llm-router Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
description: "面向 DeepSeek Harness 的 Cordis 原生 Lead/Worker 模型路由:在 ctx.llm 上注册一条虚拟提供方路由,并把路由本身做成可观察、可拦截、可替换的服务。" kind: "package-reference"
@hakehuang/dsh-llm-router
English | 中文
把 Lead/Worker 路由做成 harness 的一等公民。
插件在 ctx.llm 上注册一条虚拟提供方路由(默认 id lead-worker),并提供 一个 cordis 服务 ctx.llmRouter。模型路由因此不再是藏在外部二进制或旁路代理里的决策:它是每个模型调用都要经过的同一张注册表里的一条已注册路由,是其他插件可以 inject 的服务、可以拦截的 waterfall、以及可以直接替换的对象。
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 监听器
llm/stream waterfall 确实支持 routing,但仅限「监听器有权改写请求内容」的场景。agent loop 构造的请求是深度冻结的,并带有进程内身份(markAgentLoopRequest);loop 自己的不变式(见 @deepseek-ai/dsh-agent-loop/invariant)会校验 options.model、options.system、options.temperature、options.maxTokens、options.stop、options.tools 是否仍与从会话日志折叠出的 request header 一致。在那里替换模型不是「路由」,而是触发可重建性不变式。
注册一条路由则是在兑现承诺而不是绕过它:调用方像选择任何其他模型一样选择 lead-worker/auto,会话日志记录的就是调用方真正请求的东西,而路由器的分发只是又一次普通的嵌套 ctx.llm.stream() 调用——它会再次穿过 llm/stream,因此真实的上游跳与任何模型调用一样可观察。
安装
从 DSH 社区市场安装——在 Community Market → sources → add source 里添加本工作组源:
https://zephyrrtos.cn/dsh-llm-router/catalog-source.json
之后 Lead/Worker Model Router 会作为可安装条目出现(安装仍是需要你确认的动作;源只携带元数据)。
从 npm 安装——包名 @hakehuang/dsh-llm-router:
dsh plugin --profile desktop add @hakehuang/dsh-llm-router
两条路都要让 bundle 行进入 profile 名册:随包的 cordis.patch.yml 在 bundle 安装时自动完成,手工安装则可把下面这段粘进 profile 自己的 patch 层。随后重启 DSH(桌面 profile 在启动时读取 patch 层)。发布流程见 PUBLISHING.md,目录源本身见 docs/README.md。
- 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/Worker Router 出现在模型选择器里,虚拟模型为 auto、lead、worker,以及每个已配置 worker 的 worker:<id>。选择器由 ctx.llm.listProviders() / listModels() 构建,因此不需要任何额外胶水就能让「路由」变成可选模型。
在没有任何路由时插件是休眠而不是报错的。 既没有 lead 也没有 workers 时只打一行日志、不挂载任何东西,所以「先插入行、后写配置」不会弄坏 profile 启动。但写了一半的路由(lead: { provider: … } 缺 model)仍会明确报错——那种沉默会把一个拼写错误变成「为什么什么都没路由?」
配置
| 字段 | 默认 | 含义 |
|---|---|---|
enabled |
true |
总开关;false 时什么都不挂载。 |
provider |
lead-worker |
本插件在 ctx.llm 上拥有的虚拟提供方路由。 |
displayName |
Lead/Worker Router |
模型选择器里的分组名。 |
lead |
— | 「主力」路由:{ id?, provider, model, reasoningEffort?, weight?, tags?, contextWindow? }。 |
workers |
[] |
「执行」路由,同结构;id 用于 worker:<id> 与决策记录。 |
acceptReasoningEfforts |
off, low, high, max |
虚拟路由接受的推理强度(见下文「推理强度」)。 |
forwardReasoningEffort |
false |
是否把调用方的强度转发给自身未声明强度的路由。 |
maxTokens / contextWindow |
未设 | 虚拟路由对外声明的能力;未设=未知,由 harness 自行估算。 |
inputModalities |
text, image |
对外声明的模态。建议两者都留,让每条嵌套路由自己做图片投影。 |
housekeepingRole |
worker |
purpose: compaction | session-title 这类杂务调用由谁回答。 |
escalateAfterFailures |
2 |
连续失败多少次后把会话升级给 lead;0 关闭。 |
leadAboveChars |
0 |
请求估算规模达到该值时交给 lead;0 关闭。 |
leadToolCount |
0 |
提供的工具数量达到该值时交给 lead;0 关闭。 |
leadTools |
[] |
一旦出现就强制走 lead 的工具名。注意下面的警告。 |
stickyTurns |
true |
同一轮里保持开始执行时选定的 worker。 |
fallback |
true |
路由在产出任何内容之前失败时,改试下一条路由。 |
maxFallbacks |
1 |
一次请求最多额外尝试几条路由。 |
preserveReplayState |
true |
单提供方路由表下重新标注 assistant 溯源,让提供方的 replay 状态穿过这一跳。 |
pinTtlMs |
0 |
llmRouter.pin() 的默认存活时间;0=直到 unpin/reset。 |
sessionLimit |
512 |
记住的会话路由状态上限,超出后淘汰最旧的。 |
路由的 tags 是策略词汇:image 标记优先承接图片的 worker,no-image 声明某条路由绝不能收到图片。
leadTools只适用于「工具集随步骤变化」的部署。 DSH 是按步骤组装工具集的,而主会话几乎每一步都会提供subagent/workflow/goal;把它们写进来等于把每一步都钉在 lead 上——配置有效,但很贵。这条规则是给那些按步骤限制工具(或只让 lead 的步骤看到它们)的部署用的。
策略
内置策略 id 为 lead-worker。规则按下表顺序求值,命中即停,决策里会带上命中的 rule:
| # | 规则 | 触发条件 |
|---|---|---|
| 1 | pin:runtime |
llmRouter.pin() 把该会话钉在某个角色/worker 上。 |
| 2 | pin:model |
调用方点名 lead、worker 或 worker:<id>。 |
| 3 | hint:routerRole |
手工构造的调用传了 routerRole。 |
| 4 | escalate:failures |
该会话连续 worker 失败达到 escalateAfterFailures。 |
| 5 | purpose:housekeeping |
purpose 为 compaction 或 session-title。 |
| 6 | step:turn-start |
请求以一条新的指令结尾——这一轮由 lead 规划。 |
| 7 | modality:image* |
请求带图片:优先视觉 worker,其次能读图的 lead。 |
| 8 | tools:lead |
出现了配置为「仅 lead」的工具。 |
| 9 | sticky:turn |
本轮已经在某个 worker 上执行。 |
| 10 | breadth:tools / context:pressure |
工具数量或请求规模越过 lead 阈值。 |
| 11 | default:worker / default:lead |
机械性场景;或没有配置 worker 时。 |
由此得到的「一轮成本形状」是:一次 lead 调用做规划,其余步骤交给 worker;当便宜路由持续失败时再升级回 lead。lead 决策刻意不做粘滞——这正是让执行阶段便宜下来的空间;而 worker 决策会粘滞,使上游 prompt cache 在整轮中保持有效。
推理强度
强度归路由所有:路由上的 reasoningEffort 就是该路由固定发送的值,默认不转发调用方的强度。对混合配对来说这是唯一合理的切分——把调用方的 high 转发给一个不支持推理的 worker 会让调用失败,把 lead 的 high 转发给 worker 又会悄悄改变 worker 的性质。若希望调用方强度能到达那些自身未声明强度的路由,可设 forwardReasoningEffort: true。
虚拟路由会对外声明 acceptReasoningEfforts——默认是 DeepSeek 真正使用的 off/low/high/max——以免 profile 里已经选好的强度变成校验错误。这个列表只决定调用方可以说什么:真正到达提供方的强度由路由决定,所以它必须宽松(拒绝一个会话已经选中的强度,等于为一个路由器根本不会转发的值让调用失败)。把它设成你上游的词汇表,或设成 [] 拒绝一切显式强度。
作为调用方使用
任何插件或工具都可以直接调用这条路由:
for await (const chunk of ctx.llm.stream({
provider: 'lead-worker',
model: 'worker:flash', // 或 'auto',交给策略决定
messages,
routerRole: 'lead', // 可选:手工调用的提示;由路由器消费,绝不向下游转发
})) { /* … */ }
ctx.llm.listModels('lead-worker')、resolveModelInfo()、prepareCall() 的行为与其他提供方完全一致,因此 agent loop、压缩、会话标题生成、ACP 与 GUI 选择器都无需改动。
服务:ctx.llmRouter
| 成员 | 用途 |
|---|---|
provider / strategyId / active |
已挂载路由与当前策略的身份。 |
viewOf(options) |
某次请求的冻结决策视图。 |
decide(view) |
请求(并提交)一次决策。 |
| 决策字段 | { role, routeId, provider, model, reasoningEffort, effortSource, rule, reason, strategy, sticky, virtual, key, turn, loopBuilt, sameProvider }。 |
routes() / models() / resolveModelInfo() |
路由表与虚拟模型目录。 |
reconfigure(partial) |
运行时重新校验并替换路由表(或任意配置字段)。虚拟 provider id 在挂载时固定。 |
fallbacksFor(decision) |
某次决策的故障转移候选。 |
strategies() / registerStrategy() / setStrategy() |
策略注册表与当前生效策略。 |
state(key) / states() / lastDecision(key) / reset(key) / resetAll() |
会话路由状态、失败连击、最近决策(不传 key 即全局最近一次)。 |
pin(key, target, ttlMs) / unpin(key) |
覆盖某个会话的路由("lead"、"worker" 或某个 worker id)。 |
stats() / snapshot() |
每条路由的计数(调用、错误、转移、token)与一份可序列化的整体快照。 |
ctx.llm.router 是同一个对象站在 llm 服务内部的样子——一个普通的可赋值属性,这正是它「可替换」的入口。
事件
| 事件 | 模式 | 载荷 |
|---|---|---|
llm-router/decide |
waterfall | (view, next) → decision——拦截或改写每一次路由决策。 |
llm-router/decision |
emit | 已提交的决策(观察用)。 |
llm-router/delegated |
emit | 某次决策即将分发到上游。 |
llm-router/settled |
emit | { decision, finish, usage }——该路由实际如何结束。 |
llm-router/fallback |
emit | { from, to, failure }——某条路由在产出内容前失败。 |
llm-router/strategy / llm-router/routes |
emit | 策略或路由表被替换。 |
此外还有框架自身的 llm/stream waterfall 与 llm/adapters-updated,它们会像看待任何模型调用一样看到这次路由调用与上游那一跳。
改变路由的三种方式
观察——只需要一个监听器:
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 与其他 DSH waterfall 一样可组合;直接给出决策而不调用 next() 的监听器就决定了那一次请求:
ctx.on('llm-router/decide', (view, next) => {
if (view.imageCount > 0) return { role: 'lead', rule: 'house:images', reason: '带图的任务错不起' }
return next()
})
替换——由轻到重三档:
// 1. 换策略:路由表不变,换「大脑」
const withdraw = ctx.llmRouter.registerStrategy({
id: 'house:cheap-first',
decide: () => ({ role: 'worker', rule: 'house:cheap-first', reason: '总是走便宜路由' }),
}, { activate: true })
// 2. 换整个路由器,已注册的路由保持不动
ctx.llm.router = myRouter // 必须满足 RouterContract
// 3. 注册表级替换:卸载本插件,自己注册适配器
RouterContract
任何赋给 ctx.llm.router(或在隔离作用域中作为 llmRouter 提供)的对象都必须实现:
{
viewOf(options) // → view
decide(view) // → { role, routeId, provider, model, … } 必须已materialize
models() // → [{ id, name, description? }]
resolveModelInfo(provider, model)// → LlmResolvedModelInfo
fallbacksFor(decision) // → [{ routeId, provider, model, reasoningEffort?, role? }]
noteDelegated?(decision) // 可选观察者,随适配器分发被调用
noteSettled?(decision, reason, usage)
noteFallback?(decision, failure, next)
}
形状不对的替换会被属性 setter 当场拒绝;返回「半成品决策」的替换会以 ROUTER_INVALID_DECISION 报在该次流上,而不是稍后变成一个含义不明的 “no adapter for provider undefined”。决策过程中抛错的策略会以 ROUTER_STRATEGY_FAILED 报出并点名该策略:路由失败是可归因的,不是匿名的。
成本、缓存,以及这个路由器的坦白
- Prompt cache。 一轮只换一次模型(规划→执行),该轮其余步骤保持同一条路由。
stickyTurns: false用缓存换逐步灵活性。 - 故障转移绝不拼接答案。 只有在还没有产出任何内容时才换路由;一旦消费者见过分片,就把错误如实抛上去。失败的 lead 是终点——lead 失败后改用更便宜的模型是策略选择,不是故障转移。
- 升级是自限的。 失败连击会被下一次成功的路由调用清零,因此升级是把问题交给 lead 一次,而不是把会话永久钉在那里。
- Replay 状态。 只有当同一个适配器同时拥有历史路由与目标路由时,harness 才会保留提供方的 replay 状态。记在虚拟提供方名下的历史其实属于上游适配器,所以路由器会重新标注 assistant 溯源——但仅在所有路由都指向同一个提供方时这么做,那时它是可靠的。跨提供方时不动溯源,由 harness 按其适配器身份不变式剥掉 replay 状态。
- 重试仍然归
dsh-llm-retry。 本路由器的转移只覆盖「一次逻辑调用内、产出前失败」的路由;dsh-llm-retry在 agent 失败步骤钩子上执行的提供方级重试策略依然作用于路由器自己的路由(默认normal模式)。 - 决策不在持久会话日志里。 它们是一等的事件总线成员,也完整存在于
llmRouter.snapshot()中,但 harness 的会话日志目前没有对应事件类型,因此决策无法从会话文件重放。上游那一跳本身有记录——它就是那次嵌套的llm/stream调用。
开发
npm test # node test/run.mjs —— 策略、打包、集成、加载器
npm run pack:check
测试跑在真实的 cordis Context 与真实的 @deepseek-ai/dsh-llm 服务之上——真提供方注册表、真 llm/stream waterfall、真分片协议、真错误分类——上游用桩适配器;其中一个测试通过真实的 @deepseek-ai/cordis-plugin-loader 挂载随包附带的 bundle patch 行。node --test 会为每个文件派生子进程;test/run.mjs 改为把同样的文件导入同一进程,以便在禁止管道子进程的沙箱中也能跑。
| 文件 | 职责 |
|---|---|
lib/index.js |
插件本体:注册适配器、提供 llmRouter、挂上 ctx.llm.router。 |
lib/config.js |
schemastery Config 与 normalizeConfig()——休眠/严格两种判定以及路由表。 |
lib/policy.js |
buildRequestView() 与纯函数 LeadWorkerStrategy。 |
lib/router.js |
llmRouter 服务:策略注册表、会话状态、转移候选、统计、事件。 |
lib/adapter.js |
虚拟路由背后的 LlmAdapter:委派、流式、转移、溯源。 |