dsh-model-rotator

modeltested: passnpmMIT

Ranks the models a DeepSeek Harness install already discovered by measured connectivity and speed, and switches to the next candidate inside the same turn when the one in use fails without producing any output. Ships a settings page with a grouped model list, per-model and per-channel switches, and

下载量 / Downloads
3,335
依赖体积 / node_modules
0.51 MB
测试时间 / Tested
2026-11-06
DSH 版本 / Version
0.2.2-rc.1

Install

dsh plugin --profile web add dsh-model-rotator

Sandbox test results

StageResult
1 install✓
2 list✓
3 mount✓
4 boot—

Four stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).

README

dsh-model-rotator · 模型自动排序与故障转移

简体中文 | [English](./README.md) | [更新说明](./CHANGELOG.zh.md)

给 DeepSeek Harness 装上一个「换模型的自动驾驶」:当前模型撞上额度墙、密钥失效、被限流,或者网络刚好抖动时,插件会在同一轮对话之内换到下一个最合适的已配置模型,继续把这轮回答完——用户看到的仍然是一条普通回复,不会中途断掉。

本机实测规模:906 个 provider/model,其中 384 个在轮转池里,跨 48 个渠道(24 个对话渠道 + 24 个识图渠道)。

它到底做了什么

一轮对话失败时,插件依次做这几件事:

1. 摸清家底:调 ctx.llm.listProviders() 加各适配器的 listModels(),枚举本机所有能拨通的 provider/model。

2. 剔除非对话模型:向量/重排、语音合成与识别、OCR、图像与视频生成、内容审核、评判模型等,一律不进池子。理由很直接——拨它们只可能让本轮失败,还会白占一个本该留给真模型的冷却位。

3. 跳过识图孪生:dsh-vision-router 会给每个渠道套一层 <provider>-vision 的「识图外壳」,它的作用是给同一个模型加读图能力。也就是说,它是同一个模型的第二条通路,不是第二个模型。把文本轮次切过去,等于让回答绕道图像管线;更糟的是「优先选同款模型」这条规则会把孪生排到最前面。所以这些路由永不作候选(注意:只是名字里带 vision 的真对话模型不受影响,仍然照常参与)。

4. 排序:按实测连通率(拉普拉斯平滑的成功率)、速度(TTFT 中位数与吞吐)、以及当前冷却状态排出一张候选表。你正在用的那个模型、或者它在别的渠道上的近亲,会拿到加权——毕竟你选它自有道理,多半只是这条渠道坏了。

5. 先重试,再判死:渠道瞬时抽风很常见(限流毛刺、连接被重置、信道过载),所以单次失败不算故障。同一个模型还会按 retriesPerModel 再试几次(默认 2 次,可设 0–5),每次间隔 retryBackoffMs(默认 300ms,之后翻倍)。重试不等于切换——它不问你、也不改任何状态。

6. 真的换:在 llm/stream 瀑布上接管。第一次尝试走宿主原路径;只有当这次失败一个字的正文都没产出时,才放弃这次尝试、关掉它的上游流,用全新的 ctx.llm.stream() 重新下发下一候选。

两条边界,是整件事的关键

已经出过正文就不再重试。 一旦有任何正文分片吐给用户,这次尝试就锁死了——再试一遍会让用户看到重复的文字。所以「可重试窗口」精确等于「零内容窗口」。

不是所有分片都算内容。 reasoning-delta 不算:重试时宿主会把思考过程连同那一轮一起折叠掉,这正是「思考死循环」「思考强度不被支持」这类失败可以安全重试的原因。标记分片(block-start,或携带思考块的 block-end)也不算。这里踩过坑:早先版本把一个 block-start 当成内容,于是窗口刚开就被关上,紧接着的一个普通错误被当成终局——一个明明活着的模型看起来像死了。

被取消的轮次不折腾。 用户点了停止、超时到点、客户端断开——走链立即停下,不会把候选浪费在没人再看的回复上。

自动调用不当作对话轮次

压缩摘要和会话标题也走同一条 llm/stream 接缝,但它们不是用户轮次。这类调用的拒绝,通常说的是那条专用通道的状况(比如代理回一句「压缩归 bili 管」),此时换一个 provider 去干这段管道活是错误反应:既花掉一个真模型去做没人读的工作,又是虚假切换的头号来源。所以凡是带 purpose: "compaction" 或 purpose: "session-title" 的调用,一律原样透传,错误也透传;没带标记的普通轮次照旧故障转移。

换了模型会告诉你

切换本来是无声的——回复只是换了个模型回来,你根本看不出来。现在它记录在对话顶部的 「模型切换」页签里(与 对话 / 轨迹 / 上下文 并列),逐条列出时间、从哪个模型到哪个模型、以及原因:

14:07:31  modelscope/deepseek-ai/DeepSeek-V4.1-Flash → trae/deepseek-v4.1-flash   (quota)

页签的数据来自插件自己的记录(GET /_dsh/model-rotator/switches?session=<会话 id>),不占用对话上下文。页签所在的会话由宿主注入:注册时声明 inject: (sessionId) => ({ sessionId }),组件从 props.sessionId 取——与内置的 对话 / 轨迹 页签同一条契约(0.2.11 前这里读的是 sessions.list 上一个并不存在的字段,页签因此认不出自己在哪个对话里)。记录按会话归属:宿主不向插件路由注入会话,所以会话 id 在切换发生处当场取定(ctx.get('agents').currentInitiator() 返回发起该次调用的 agent),而不是等轮次边界——边界是进程级的,谁能停轮次谁就能把别人的记录整批带走。若某次调用确实没有活动轮次(插件路由、宿主未挂 agents),记录照写、会话留空,空会话只在「全量视图」里可见。

当本对话名下一条记录都没有、而记录总数不为零时,页签会改为展示全部会话的记录,并配一行提示说明这是回退视图——避免「有历史却显示空」。

若你仍想在对话正文里看到那一行(旧行为),把 noticeSwitch 打开即可:

模型已切换:硅基流动/deepseek-4.1 → Loomy/deepseek-4.1  (rate-limit)

已知失败类型时会带上括号,比如 (quota)、(rate-limit)。

项目 · 做法

默认 · 不贴(noticeSwitch: false)。记录在页签里,通知行是可选项

形态 · 复用宿主自带的通知机制:一条 user/message,source 是 { kind: 'model-rotator/switch', form: 'notice', summary }。对话界面把它渲染成一条上下文行,而不是你发出去的消息

时机 · 通知行放在轮次边界,经 agent/turn-stopping + agent.steer 投递。切换已经发生、回答已经在流,若在轮次中途插一行,就会插在它所描述的回复正中间(记录本身不在此刻写,见上文)

范围 · 只作用于根对话。子代理记录里的切换属于别人的日志,不打扰你

打开 · noticeSwitch: true,或设置页里的开关

事件类型刻意没自创。持久化读取器会拒绝解释含有未知类型的事件日志(dsh-session 的 KNOWN_SESSION_EVENT_TYPES),自创一个类型会让带它的日志直接加载失败。user/message 是已知类型,notice 是客户端已经理解的形式——宿主自己那条「模型已切换」用的就是这一对。

渠道名优先用宿主发布的 provider displayName(siliconflow 显示成「硅基流动」、modelscope 显示成「魔塔」、buddy 显示成 «CodeBuddy (腾讯)»),没有标签的路由才回落到 id;两者不同时会写成「标签 (id)」的形式。

换之前先问你

故障转移会替换你亲手选的模型,而且可以不声不响——所以默认先问。当前模型零内容失败时,插件经宿主的人机交互接缝(ctx.userQuestions.askTimed())在对话里抛一个问题,给出两个选项:切换 和 保持当前模型。

你的回答 · 结果

切换(或任何不算拒绝的回答) · 下一候选接手本轮

保持当前模型(或直接打出「不/否/取消/拒绝/no/cancel/stop」之类) · 不再拨任何候选,本轮以原始错误结束

窗口内没回 · 继续切换——这是一次限时征询,不是否决权

提问每轮只问一次:第一次批准即覆盖这一轮剩下的走链,所以一串坏模型不会变成一串一模一样的卡片。默认等 20 秒,可配。

两类失败不问了。 额度耗尽(quota)与提供方不支持(unsupported)是确定性死路:余额不会在 10 秒里回来,不支持的请求下一次仍会被同样拒绝。这种时候「保持当前模型」并不能让模型继续工作,只会让本轮失败——白白多读一张卡片、再等一次超时。所以这两类直接切换,页签里的记录照常写明原因。鉴权(auth)仍然问:403 常常来自突发限速的代理,等一会儿再试是真实的选择而不是形式。

整个机制刻意是「失败即放行」的:接缝不存在、提问抛错、本轮没有活着的根代理(被委托的子代理没人可答)、或者等超时了——走链都会在未确认的情况下继续。无人值守的宿主绝不该卡在一张没人看的卡片上。

失败类型决定后事怎么办

失败不只是一个字符串,它会被归类,而类别决定这个模型要「歇」多久。

额度墙和限流是两码事。 429 insufficient balance 和 429 too many requests 是同一个 HTTP 状态码,但一个几秒后自己就活了,另一个得等额度重置。插件会从消息正文里读语义——insufficient quota、out of credits, please top up、余额不足,请充值、额度已用尽、402 Payment Required 这类——然后区别对待:

类型 · 冷却多久 · 要不要先重试

quota(额度耗尽) · 歇到次日 quotaCooldownHour(默认本地 00:00),且不少于 quotaCooldownMs · 不重试——两次下发之间余额不会自己长回来

auth(鉴权失败) · 常规指数退避 · 重试——被突发限速代理解出来的 403 确实会好

unsupported(请求形态不被支持) · 常规指数退避 · 不重试——同一个 provider 对同一个请求只会给同样的拒绝

rate-limit(限流) · 常规指数退避 · 重试

server / network(服务端/网络) · 常规指数退避 · 重试

未分类 · 常规指数退避 · 重试

unsupported 指的是请求形态本身被拒,比如「provider X model Y does not support reasoning effort high」「unsupported model」。它在读请求之前就判定了,重试只会逐字复现同一个拒绝——跳过重试,才能把一次会话级不匹配压成一次切换,而不是三次。

鉴权失败会先于裸 429 被归类,因为等待永远修不好一个密钥:对 429 invalid api key 来说,诚实的名字是 auth 而不是 rate-limit。这个区分只用于记录,不改变处置——两者退避一样;只有 quota 会改变策略。

冷却的下限是「次日 quotaCooldownHour」。23:50 撞上的额度墙仍要付满 quotaCooldownMs,所以它会在次日 11:50 回来,而不是十分钟后的午夜。

model_rotator_status 会把这些都摊开给你看:排名表把这类行标成 QUOTA-REST(并附剩余小时数)而不是 COOLDOWN,type 列写明类别,last failure seen 行列出每个模型最近一次错误及其类型。

实测状态——计数器、最近错误与类型、冷却到期时间——跨进程存活。理由是现实的:「刚被告知余额用完」之后做的那次重启,不该把冷却一并抹掉。它写在 %DSH_HOME%\model-rotator\stats.json(或者 config.json 所在目录),去抖落盘,首次使用时读回一次。延迟采样则刻意只留在内存——它们是 20 个样本的滑动窗口,陈旧的窗口比短的更糟。statsPersistTtlMs 限制读回文件的年龄(默认 24 小时;设 0 表示完全不用这个文件)。

它不碰什么

它从不读、写或改写模型/provider 配置。 不碰 cordis.patch.yml、不碰 agent-default-model、不碰 llm-pi-ai、也不碰任何 provider 注册。它只在运行时观察流式瀑布。如果你的 provider 配置被人动过,那一定不是它干的。

哪些模型会被过滤掉

宿主适配器在交给 listModels() 之前,会把目录自带的 wire: 'chat' 标记剥掉,所以没法从返回行上读能力。名字匹配是唯一可用的信号,而且刻意写得很窄——模式绝不能误伤只是名字里提到某种模态的对话模型(Qwen3-VL-8B-Instruct 是对话模型,保留;Z-Image-Turbo 不是,剔除)。

过滤器覆盖向量与排序、语音、图像生成、文档解析、安全分类器这几大族,普通 provider 与 -vision 变体都算。它在发现阶段就跑,所以插件报的 discovered 已经是过滤后的池子。也因此,一台机

…(truncated)

Rendered from the README published on npm. Formatted only, not edited.

Versions

Published on npm::17,最后更新 2026-10-18

Links

GitHub · Homepage · npm

Same category