中文EN

dsh-model-rotator

model已测通过npmMIT

把本机已发现的 DSH 模型按实测连通性与速度排序,当前模型无输出失败时在同一回合内切到下一个候选。附带设置页:按渠道分组的模型列表、逐模型与逐渠道开关,以及额度耗尽后休息到次日的冷却策略。

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

安装

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

沙箱测试结果

阶段结果
1 安装✓
2 列装✓
3 挂载✓
4 启动—

四阶段在隔离沙箱里跑:安装(有网)→ 列装 → 挂载 → 启动(后三步断网)。

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)

以上内容来自 npm 上的 README,我们只做格式化,不改内容。

版本

npm 上共发布:17,最后更新 2026-10-18

链接

GitHub · Homepage · npm

同类插件