Vai al contenuto principale
M

dsh-model-failover

maimai0812/dsh-model-failover

Model failover router that queues every connected DSH model into priority templates, retries a failing candidate before switching, cools down rate limits briefly, quarantines failures per session, and auto-sends a continue message when a turn fails or hits the token ceiling.

Installazione

dsh plugin --profile web add github:maimai0812/dsh-model-failover

README

模型接力 · dsh-model-failover

把 已经接入 dsh 的全部模型排成优先级队列,再把这个插件本身当成一组「模型」—— 一条队列就是模型选择器里的一个模型。用户在模型选择里挑中哪条(默认那条叫 默认模板,也可以自己新建、改名),任务就按那条队列的顺序去跑:靠前的先上,不行就自动 往下换,直到任务跑完。

它解决什么

一个模型不能输出,往往是这几种:额度用尽、余额不足、key 失效、路由挂了、上游 5xx。 以前遇到这些,任务当场就断了。现在:

  1. 先用第一个模型跑;
  2. 它出问题 → 把同一个请求原样再发几次(默认 2 次,为了换掉背后那个坏账号), 还是不行再补一句「继续」试一次;
  3. 仍然不行 → 换第二个模型,这一跳补上「继续」,接着跑;
  4. 依次往下,直到某个模型把任务跑完;
  5. 某个模型今天在这个会话里出过这种问题 → 这个会话今天不再从它开始,直接用排在它后面、 已经验证过能用的那个(别的会话照常可以先用它);
  6. 到了第二天设定时间(默认 01:00,可改),隔离自动清空,重新从第一个开始试。

另外,整轮以失败告终、或被 token 上限截断时(模型自己没坏,只是这一轮没跑完), 插件会替你自动补一句「继续」接着做 —— 见下面的「DSH 异常」。

装好之后长什么样

模型选择器里多出几条(provider model-failover)——一条队列就是模型选择器里的一个 模型。默认那条叫 默认模板;你在设置页新建一个模板,模型选择器里就多一项,名字就是你 给模板起的名字。会话里选了哪条,那个会话就往那条队列上跑。

设置 → 模型接力(在「模型」那一节下面)。同一类设置放在一起,从上到下依次是:

区块作用
今日状态今天是哪天、距下次重置还有多久、当前起点、今天隔离了几个(重置时间就在这里)
队列模板一条模板一行:改名、切到哪条去编辑(编辑)、测试连接、新建、✕ 删除(会先问一次)。条数不限,新建出来是空队列;卡片底部两颗按钮把模板存成一个 JSON 文件、或者从这样的文件读回来
「某模板」的队列正在编辑的那条模板的候选清单,每行前面是今天的失败/成功次数、一个并发上限输入框、一个思考强度下拉框,按住整行拖到新位置就能排序,✕ 移除;右键任意位置弹小窗看/改该模型的上下文窗口;下面可以从目录里加回来
模型异常总开关、同一个模型失败后重试几次、多久没输出算卡住、被限流后绕开多久、全部隔离时要不要硬试。开着的时候,队列里的模型一直在后台自动接力
DSH 异常总开关、两种触发、各自的次数上限、每次失败后等多久再补(见下)。对整个 DSH 生效,不限于接力
外观会话页「隔离队列」里显示哪些会话:子智能体的(默认关)、其他工作区的(默认开)
事件日志每次隔离/复活/轮换/限流冷却/自动补发都留一条;记不记、留多少条也在这里

队列行前那两个数字是今天这个模型在全机器上的失败次数(红)与成功次数(绿)——所有 会话、所有模板合计。它是统计,不是状态:隔离按「会话 × 模板」分开算,一个模型在甲会话 的甲模板里撞了墙,在别的会话或别的模板里照样能用,所以数字旁边不画隔离徽标。

设置页不再有那张全局隔离名单。 隔离既然是按「会话 × 模板」算的,一页全局表格既说不清 是谁撞的墙,也容易被当成「解除了就处处生效」。明细只在会话页那一栏按会话出现。

打开设置页只读缓存,不重扫。 扫描结果落在 catalog.json 里,重启 DSH 之后还在 —— 「上次扫到的模型」是真的延续到下一次启动的,不是每次进来重新问一圈。要更新就点「今日状态」 卡右上角的重新扫描:新接进来的 provider、新登录的账号,点一下就在清单里。

这么改是因为反过来的代价更贵:qoder / qodercn 这类 provider 的 listModels 要打上游、还要 等账号池,冷启动那几秒清单是空的,而绝大多数时候「昨天扫到的模型」就是今天要用的那些。

删模板会先问一次:点 ✕ 不是立刻删,而是就在那一行下面摊出一句「确定删掉模板「X」吗? 队列里的 N 个模型会一起消失」和「删掉 / 算了」两颗按钮。删一条模板等于删掉一整条队列,而那颗 ✕ 和旁边的「编辑」「测试连接」长得一样、挨得也近。

模板可以导出、导入,各一颗按钮。 「导出模板」把所有模板存成一个 JSON 文件下载下来, 「导入模板」弹出系统的文件对话框,选一份这样的文件就读回来。卡片里不放粘贴框:一个要人先 选中再复制的输入框,比一次系统文件对话框更容易出错。

导出的是文件里只含模板名和队列(含每行的思考强度),不含任何设置项 —— 这份东西是拿来 备份队列、或者发给别人复刻一条队列的,掺进隐藏名单、重置时刻这些本机偏好,导到别人机器上 就成了「顺手改了他的设置」。id 也不导出:导入时在本地重新生成,同一份文件能导进任意一台 机器而不打架。导入是追加,不是覆盖、也不合并;只跟名字较劲 —— 名字撞上了就给新来的加 一个 (1),再撞就 (2),原有的那些一条都不动。模板数量没有上限(早先有一道 8 的闸, 理由是「模型选择器里别多出太多条」,但导入一份别人给的文件被闸门截掉一半才是真的莫名其妙)。

「加不了某个 provider 的模型」多半是被藏起来了。 hiddenProviders 会把整个 provider 从 清单里拿掉,它的模型连一行都不出现。这一栏现在就在「添加模型」下拉框正下方:列出被隐藏的路由, 每条一颗「恢复」,点完自动重扫一次(不重扫就还是看不到它的模型)。

每条模板上有一颗「测试连接」,用来体检这条队列里的模型还连不连得上。点下去之后插件会 挨个探一遍(同时最多 4 个,一口气全发出去容易撞上供应商的并发限流,那反而是假故障), 结果显示在下面每个模型名字后面:绿色 通、红色 不通(悬停能看到错误码和报错原文)。

  • 探的是供应商直连,绕开接力本身 —— 一次体检不该把一个好好的模型记进隔离名单, 更不该动撞上限那本账;
  • 花销压到最低:只发一句 hi,输出上限 8 个 token。撞到上限也算通 —— 那恰恰说明它在 正常写字,所以不会因为上限太小而误报;
  • 15 秒还没回就当它连不上,免得一个卡死的供应商拖住整轮测试;
  • 结果不落盘,关掉设置页就没了。一次体检的结论过几分钟就可能不准(账号掉了、额度用完 了),留着只会误导人;
  • 点哪条模板的按钮,就把那条模板切到眼前 —— 否则结果画在下面那张队列卡里,你看不见。

同一个模型失败后,插件会先用它原样重发几次,再换下一个。 次数是「模型异常」卡里的 sameModelRetries(默认 2,也就是最多试 3 次;0 = 每个模型只试一次)。

这几次重发不是为了「再催它一遍」,而是为了换账号。一个 provider+model 背后可能挂着 好几个账号 —— dsh-our-free-model 这类把别家模型反代进来的插件就是这样:失败一次它通常 只把那一个账号 bench 掉,池子里剩下的账号完全还能用。可从 DSH 这边看出去只有一个 provider+model,想让它换个人来答,唯一的办法就是把这个请求一模一样再发一遍,让它重新 进一次账号池。只有试满这么多次仍然不行,才判这个模型今天不行、隔离并顺延到下一个。

这些重发不带「继续」:刚被换上的新账号压根没写过东西,收到「继续」只会莫名其妙。 带「继续」的只有换到队列里下一个模型那一跳 —— 换第一次带一条,换第二次带两条,最多 3 条 (MAX_CONTINUES),因为那才是「接着前面没写完的往下写」。

一个字都不吐的模型,到点就换掉。 有些上游不报错也不吐字:连接挂着、响应头也收了, 然后就再也没有然后 —— 从这边看,它跟「模型正在很慢地思考」长得一模一样,只能靠时间判。 计时器是 idleTimeoutSec(「模型异常」卡里,默认 120 秒,0 = 不管,最多 3600)。 从请求发出去那一刻起算,之后每来一个 chunk 都重新计时 —— 思考增量也算,只要它还在动 就不算卡住。到点仍然一个字都没有,就当这个模型卡住了:按失败处理(进隔离队列、换下一个, 连速度蓝框都不会留下一个数字)。

超时之后那一跳的收尾是不等的:下游正挂在一次没完没了的读上,return() 要等那个 await 自己回来才轮得到 —— 等它等于把「超时」又变回「等到天荒地老」,所以这里发出去就不管了。

限流只是「等一下」,不是「今天不行」,所以它不进隔离名单。 被上游限流时(RATE_LIMIT, 或者报错文本里明确写着 rate-limited / 429 / retry after 20s),插件只把那个模型放进一张 内存里的冷却表:队列接下来绕开它,到点自动回来,隔离名单上一条都不留。

这条规则的理由很直接:限流是上游的滑动窗口,通常几秒到几十秒就自己好了 —— 报错原文里常常 直接写着 ~5s, exponential backoff, auto-resumes when the limit clears。把它记成「这个模型 今天出不了东西」,用户看到的就是「一次限流就被踢出队列,第二天才回来」,明明还能用。

冷却多久由 cooldownSec 决定(「模型异常」卡里,默认 60 秒,最多 3600)。上游在报错里 给了具体时间就以它为准,这个值只在它没说时兜底;两种写法都认:

  • 绝对时刻:until 2026-11-04T08:44:44.803Z(带 T 和时区的 ISO 8601)
  • 相对时长:retry after 20s / try again in 30 seconds

队列行上会多一个小标签写「限流,还要 25 秒」(超过一分钟按「还要 2 分 10 秒」说), 状态卡片的「当前起点」也会跳过它 —— 免得用户看到排第一的模型被跳过却不知道为什么。

等待时间到点,也不等于马上就能把队首抢回来。 到点时如果替它跑的那一趟调用还没 收工,它还得再等 —— 标签会写成「限流已结束,等这一趟跑完」。理由是不打断手上的活: 那一趟是别的模型在替它写,跑到一半把队首还回去,等于用户手上正写着的半句话被从中间 换个人接着写。等那一趟收工(成功收尾、报错、或者被中止,都算)它就回队首。

cooldownSec 填 0 是关掉冷却,限流回到老行为(隔离到下一个逻辑日)。冷却表不落盘: 它只活在这个进程的内存里,重启 DSH 就清空 —— 反正限流本来就只值几十秒。

顺带说清一处容易混淆的地方:冷却与隔离是两把不同的尺子。隔离问的是「今天还能不能指望 它」(额度耗尽、凭证失效、服务端一直 5xx),冷却问的是「过几十秒再来」(限流)。同一张队列 上两者可以同时存在,判断时都会跳过。

会话页顶部多一栏「隔离队列」(排在「对话」右边)。一个会话一张卡,写清楚它正在用 哪个模板、哪个模型、今天在这个会话里被隔离了哪些(不属于当前模板的会额外标出模板名)、 以及已经验证过能用的又有哪些。当前会话那一张永远排在最前面,其余保持原来的顺序。 它每 15 秒自己拉一次轻量端点(GET /sessions,不拖模型清单和事件日志),也可以手动点「刷新」; 只要有一个会话在跑,轮询就自动压到 2 秒,跑完再退回 15 秒。

「正在用」那一行以此刻真的在跑的那个为准,不以「上一趟跑的是谁」为准:上一个模型报错被隔离、 下一个模型正在读上下文的那几秒里,两者是不一样的 —— 而用户此刻要看的恰恰是「现在是谁在接手」。 没有人在跑的时候,才回落到「上一趟是谁」。这两种来源在宿主那边是两个字段(running 与 active),浏览器端只负责按这个优先级显示。

每一行右边都有一个「解除隔离」按钮,只解那一行的会话(该会话下所有模板),别的会话一条 都不动 —— 在甲会话里点了「解除」,没有理由让乙会话重新去撞同一堵墙。那个会话名下没有隔离时 按钮是灰的。页顶不再有「解除本会话隔离」:按钮长在会话行上,「解哪个会话」就没有歧义。

会话标题取自宿主;拿不到就退回会话号前缀,所以任何一个跑过事的会话都不会是空白行。 没有会话号的调用(压缩上下文、后台任务)单独一条,标明是「后台任务」,不会被当成 一个没起名字的对话。隔离的完整报错原文挂在每个红胶囊的悬停提示上(宿主持久化时已截到 400 字符),点「隔离队列」里的行不用再翻日志。

在一个红胶囊上点右键,就把那条报错整块复制进剪贴板。 复制的不只是那句 Error in upstream response —— 抬头一行先写清「哪个模型 · 哪条模板 · 什么错误码 · 什么时候」,换行后才是宿主留下的报错原文。只复制正文的话,贴到别处既认不出是哪个 模型,也认不出是哪条队列上的账,等于白贴。复制成功会在那颗胶囊上闪一下「已复制」; 拿不到剪贴板权限时如实报错,不假装成功。

红胶囊的悬停弹窗里只有报错原文,一个字的说明都不掺 —— 移上去就是要把那段报错 看全,往里塞「你也可以右键复制」等于拿说明去挤正文。「能右键复制」这件事写在上面那行 说明里,鼠标形状也换成了右键菜单那种。

显示哪些会话由设置页的「外观」决定:子智能体(subagent)跑出来的会话默认不显示, 其他工作区的会话默认显示。子智能体干活时也是一次正经会话,也会走接力、也会撞墙,所以隔离 记录里确实有它;但那是你委托出去的一次执行,不是你在侧边栏里开的那个对话,摊在这里只会让人 以为凭空多了几个会话。判据用的是宿主自己的会话摘要(origin === 'subagent')。

查不到来源的会话一律保留:宿主的会话表并不保证收着每一个历史会话,所以「表里查无此号」 只说明问不出来,不说明它是子智能体 —— 早先那版把这种情况当成子智能体藏掉,结果连真实的主 会话都吞了。拿不到宿主的会话表时(比如 headless 组合)同样是一行都不删。

每个隔离/已验证的模型后面还跟一个蓝色小框,写它在这个会话里的输出速度(42 tok/s, 悬停能看到原始的 token 数、毫秒数和跑过几趟)。它回答的是另一个问题:红绿胶囊说「今天行不行」, 蓝框说「用起来多快」—— 一个模型可以刚刚还跑得飞快、只是最后撞了额度墙,那正是值得知道的 信息。再往右还有一个蓝色的「用了多久」(1.2m),从每个模型自己的第一个 token 算到收工、 跨趟累计;正在跑的那一个会自己往上涨(每秒一跳,悬停写「已经用了 …,还在往上涨」), 所以不用等下一次轮询就知道它还在干活。会话号也给的是完整的(不是前 8 位 —— 那 8 位正好 是 session-,等于什么都没说)。几个口径:

  • 口径照抄 dsh 自己那个「输出速度(TPS)」:把每一趟的 token 数与「首字到结束」的 毫秒数分别加起来,最后再除,而不是对每趟的速度取平均 —— 后者会让一趟很短的样本 权重过大。显示规则也一样:两位数以上取整(250 tok/s),个位数留一位小数(3.4 tok/s);
  • 量的是从第一个 token 到结束这一段,不含排队和首字延迟。那是延迟,不是速度; 混进来会让一个「想很久但写得很快」的推理模型显得很慢;
  • 实时更新:数字随每一趟结束往前滚,而且有会话正在跑的时候,这一栏的轮询从 15 秒 压到 2 秒,会话行上还会挂一个蓝色的「运行中」—— 中途那几十秒光看数字是看不出 还在跑的;
  • 拿不到速度(一个 token 都没吐、或者供应商根本没报 usage)就什么都不画,不会留下 一个凭空的 0.0;
  • 压缩上下文、起标题这类不是用户在等的调用不记,理由和轮尾小字一样;
  • 速度和隔离名单一样只活一个逻辑日,过点归零。

DSH 异常

上一节说的是「这个模型这次没能输出」。但有两种情况是整轮结束才看得见的, 模型自己没坏:

  • 输出撞到 token 上限被截断 —— 它写到一半没词了,等的是你再说一句;
  • 这一轮以报错告终 —— 队列里所有候选都试完了还是不行。

这两种情况下插件会替你把「继续」再发一句,接着往下做。它走的就是你自己敲回车的那条路 (agent.followup),所以你看到的是一条真的用户消息:计数、上下文、渲染都和手打的一模一样。 中途你按了停止、或是那一轮正常做完了,它都绝不会多嘴。

这一节对整个 DSH 生效,不限于「模型接力」。 监听器挂在会话事件上,不管你在模型选择器里 挑的是哪个模型,出的这两种情况它都管 —— 这也是它单独一张卡片、而不是塞进「模型异常」里的原因。

「继续」不会被无限重发,但两个上限是分开计的:

  • autoContinueMaxTokensLimit(默认 10):撞上限只是没写完,接着写就好,所以给得多;
  • autoContinueFailureLimit(默认 1):失败往往意味着「这件事现在办不成」(模型不肯、 工具报错、上下文被拒),再问一遍得到的大半是同一个结局,补十次只是把同一段错误刷十遍。

分开计是必须的:两个结局原本共用一个计数器,写一份长文档连着撞几次上限,就把「失败」那份额度 也吃光了,等哪一轮真的失败时反倒一次都补不出来。用满就停手等你;你自己发下一句话、或某一轮 正常做完,两边的计数一起重新开始。上下文超长、没挂适配器、凭证失效这三类错误直接跳过 —— 重发一遍只会撞同一堵墙。

每次失败之后会先等一会儿再补。 等待时长是 continueDelaySec(默认 5 秒,0 = 不等), 第一次失败也一样等。这一等不是拖延:刚撞完限流或上游抖动的模型,隔 0.2 秒再戳一次 只会再撞一次,除了把队列挨个隔离掉没有任何收益(实测里最难看的一段是 9 秒内落了 12 条隔离)。 等待期间你按了停止、自己先发了一句、或者那一轮已经跑完,插件就收手不再补。 等待只发生在带「继续」的那些跳前面。所以队首那个模型的几次原样重发是不等的 —— 它前面没有任何失败,等它只会让第一次调用平白慢几秒;而一旦换到队列里第二个候选, 那一跳起每次都带「继续」,也就每次都要等一等。

本机网络没通的时候也不会补发。 这条单独拿出来说,是因为本机网络故障在 DSH 里 没有专属错误码:DNS 解析不了、代理没起来、证书被本机拦截,各家适配器会分别报成 SERVER、TRANSPORT、UNKNOWN。所以插件不认错误码,而是认报错文本里的本机特征 (ENOTFOUND / EAI_AGAIN / ECONNREFUSED / socket hang up / fetch failed / local name resolution failed / tunnel_destination_denied / 证书拦截等)。 网线没通的时候,补一句「继续」既救不回来、又要白烧一次上限,还会把整个接力队列 一个个隔离掉 —— 所以这时插件停下来,只在事件日志里记一条 「本轮是本机网络问题(SERVER),没有补发『继续』」,让你知道是网络的事、不是插件坏了。

反过来,远端的问题照旧会补「继续」:上游 5xx、限流、模型太慢都还是要救的。 判据特意写得很具体(upstream returned 502 Bad Gateway、request timed out 这些 都不会被误判成本机网络),宁可漏判也不误判。

自动补发会在事件日志里记成一条「本轮…,自动补发『继续』」。

只要一轮是接力跑的,那一轮的轮尾就多一行小字,写明这一轮是哪个模型跑的,比如 「本轮:Code99 · Beta One」。之所以要它:接力一换人,屏幕上就再没有别的地方告诉你 「现在是谁在答」了 —— 模型名字原本只在你发送时选中的那一个上,换人之后它就不作数了。 判定的是「这一轮有没有真的走接力」,不是「接力有没有换过人」:顺序表里排第一的模型 接下了这一轮也一样显示。你在模型设置里手动挑了具体模型、压根没走接力的轮上不会有, 免得每一条回答下面都挂一行废话。

宿主侧是两段式配对的:适配器看得见 sessionId(DSH 在 GenerateOptions 里给)但看不见轮号, 而 turn/end 反过来。所以适配器负责按会话记下「最后真正产出内容的那个模型」, 监听器在 turn/end 时补上轮号,再用一个轻量端点 GET /notes?sessionId=… 交给前端 —— 前端只为这一句小字去拉几十 KB 的 /state 并不划算。这份便签是纯显示用的,不落盘。 会话标题、上下文压缩这类内部调用与用户看到的那一轮无关,不会被记进去。

监听器判断「这一轮是不是接力跑的」靠的是对象比对:turn/start 时记下当时那份便签, turn/end 时看它有没有被换成新的 —— 适配器每试一个候选都会写一份新的。不能只看 「便签里有没有模型」,否则上一轮接力跑过之后,你下一轮手动挑个具体模型时, 那行小字会继续拿着上一轮的结论指错人。

每个模型的思考强度

顺序表里每一行都有一个档位下拉框,档位就是那个模型自己在模型设置页里的那几档 (插件向宿主问 resolveModelInfo 拿到的 reasoning.efforts,不是插件自己编的枚举)。 默认停在「默认档位」,也就是你平时直接用这个模型时的表现。

  • 这个模型不支持思考强度 → 那一行显示「无档位」,不画控件;
  • 还没问到档位表(刚装好、刚扫过目录)→ 显示「档位未知」的禁用控件,扫到之后自动可点, 卡住了再点「重新扫描」催一次;
  • 改完配置要重新扫描。 插件问过一次就把答案记下来(asked),免得每次刷新都去打一遍 上游;可宿主的答案会随 cordis.patch.yml 变 —— 你在里面给某个模型补上 reasoningEfforts 之后,旧答案还压在 asked 里,问过就再也不问,那一行会一直显示「无档位」。所以 「重新扫描」会连这份记忆一起清掉重问,改完配置点一下即可,不用删缓存、不用重装;
  • 换模型接力时,用的是那个模型自己的档位,不会被上一个模型的档位带过去;
  • 某个模型收到一个它不认识的档位会以 UNSUPPORTED_REASONING_EFFORT 拒掉整个请求, 那等于把一个好好的模型判成「今天不行」—— 所以插件只发目标模型自己声明过的档位, 认不出来就干脆不发,让它用自己的默认档位。

顺带一提:在模型选择器里给「模型接力」这条路由本身选档位是不行的 —— 这条路由对外 一个档位都不声明(resolveModelInfo 里没有 reasoning 字段)。这是刻意的:声明并集 的写法会在队列一变之后留下悬案(用户当初选的档位,可能新队列里没一个模型认得), 而宿主对「模型没声明档位 + 请求里带了档位」的处理是直接拒掉整轮,报 provider "model-failover" model "default" does not support reasoning effort "max"。 档位只在两个地方设:每个模板的每一行(见上),以及模型选择器里选具体模型时。

每个模型的上下文窗口

在队列的某一行的任意位置点右键,会弹出一个小窗,显示这个模型的上下文窗口大小 (上下文窗口 128,000,千分位格式;宿主没探到窗口的模型显示 —),下面还跟着一行 「压缩触发点」—— 那是宿主按这个窗口算出来的压缩阈值(窗口 × 0.8),告诉你这个模型 大概写到多长会被压。

小窗里可以直接给这一行设一个覆盖值(上下文窗口 输入框 + 「保存」):有些模型 宿主探到的窗口偏保守,或者你想让它在压缩前多扛一点,就填一个更大的数。保存后这一行 就带上自己的窗口,不再看宿主那份。点「恢复默认」把覆盖值摘掉,回到模型自己的窗口。 非法输入(负数、小数)不提交也不关窗。✕ 关掉小窗,什么都不写回。

队列对外声明的窗口,用的是第一个候选的窗口,不是最窄的那个。 宿主在压缩上下文时 会问「这条队列的窗口多大」,早先的回答是「队列里最小的那个窗口」—— 一个 32k 的小模型 挂在队尾,会把整轮任务的压缩阈值拖到 25k 左右,前面的模型明明有 128k 也白搭。现在改成 「队列第一个候选的窗口」:按队列顺序,第一个有已知窗口的候选说了算(每行设了覆盖值 优先)。换人落到更小的模型时,由宿主自己的「上下文超长」路径兜底 —— 它不看窗口, 先把历史裁掉再压一刀重试,不会因为窗口声明得大就爆掉。

「这一坨太大」不接力

上游说请求超出上下文窗口时,接力是最坏的应对:换下一个模型,发过去的是同一坨 一模一样的巨型请求,于是队列里每个模型排着队各撞一次墙、各被隔离一天,而历史一个 字节都没少 —— 用户看到的就是「越失败越大,最后整个会话都崩了」。

所以这类失败会先被改判成 CONTEXT_WINDOW_EXCEEDED 再原样交回宿主:不隔离、不 换人、也不补「继续」,由 agent loop 走它自己的压缩路径,压完再试一次。判据只看报错 文本 —— 上游把这件事说得五花八门(exceeds the model context window、 context_length_exceeded、prompt is too long、payload too large、 bili_gate_oversize_refusal 之类),HTTP 码却往往只是 400 / 413 / 422,光看码认不出来。 反过来说,一个 422 只要没提「太大了」,照旧当普通失败处理(该换人还是换)。

有些上游同时只收得下几个请求:并发上限

队列行上那个小输入框(在思考强度左边)填的就是它:这个模型同时允许几个会话在用。 留空 = 不限制,这是默认值。

规则很短:一个会话一旦拿到它的一格,就一直占着。换账号重发、补一句「继续」、 写到一半撞上输出上限接着写,都不放手 —— 中途把座位让出去,等于让这个会话在下一跳 被自己的账推着换模型,用户看到的就是「时不时放弃这个模型去用下一个」。

交还只有三种时机:这个模型报错(限流、连不上、吐不出东西)、会话结束(宿主发 session/disposed,见 index.js 里的订阅)、或者用户把它从队列里移走。占满了(上限 已满)时,别的会话跳过它去用队列里的下一个 —— 不是失败,也不隔离它,因为它没坏, 只是这一刻腾不出位置。

还有一条:每一趟要换模型的时候,会先回头看一遍前面被跳过的那些。别人报错、或者 会话结束把名额还回来了,这一趟就回头去用那个刚空出来的模型,而不是一路顺着队尾走。 所以队列的顺序始终是用户排的优先级,临时跳过只是让路,不是降级。

记账只存在内存里(lib/limits.js):它描述的是「此刻谁占着」,DSH 一重启就归零 —— 把上一个进程的占用数字留到下一次启动,只会让模型被一个不存在的会话永久占着。 占用情况在会话页的「并发限制」一栏里看得到(2/3 这样;占满的标红)。那一栏只在 真有模型设了上限时才出现。

还有一个兜底:占着却再也不用的会话,名额会被收回去。会话结束的通知偶尔会漏(进程崩 过、宿主那边没来得及发 session/disposed),一个漏掉的座位就等于永久少一格。所以当一个 上限明明满了、又有人要用它的时候,账本先扫一遍:超过 30 分钟没有真的拿它跑过请求的座位 直接收掉。只在「满了又有人要」时扫,不做后台定时器 —— 平时没人抢的时候收不收都无所谓。

交还之后再歇一会儿(设置页「模型 → 模型异常 → 并发模型收工后再歇」,默认 5 秒,0 = 不歇)。 最后一格并发还回去的时候,这个模型并不立刻重新接客,而是先冷却这么久。原因是上游那边的 连接/配额通常比本地慢半拍:本地一放手就立刻把下一个会话的请求塞进去,很容易撞出一个 429 —— 那个错误跟模型质量毫无关系,却会被记成一次失败。只对填了并发上限的候选生效, 没设上限的模型本来就不在这个问题的射程里。

刚把名额还回去的那个会话自己回来时不歇:连发两句就被换到下一个模型上,正是这一版 要修掉的东西。冷却只拦别的会话。

冷却期间这一趟会绕开它;但如果后面没有人能用(整条队列就它一个,或者后面全隔离了), 那就等这一小会儿再复查,而不是直接报「没模型可用」—— 节流不是故障,不该变成假故障。 队列行上会挂一颗「收工歇 N 秒」的小标签,设置页那栏里也看得到剩余时间,免得用户以为 队列坏了。上限最多 300 秒:再久就不叫「等一下」了。

顺手撤掉的:队列行前面原来有个 ⠿ 拖拽把手,它渲染出来就是 ⠿,看着像「六个点」, 白白占掉一行最左边的那点宽度。现在整行照样能拖,那个符号撤了。

几个刻意的设计

只在自己还没「说话」之前换模型。 模型的输出会先攒在手里:一旦有任何一个字、或一次工具调用准备交给用户,就立刻原样放出, 并且从这一刻起这个模型就锁定这次任务了。它之后再崩,报错就照实报错, 不会偷偷换一个模型接着写。理由很直接:两个模型各写半句话拼在一起, 用户读到的是一个人的口吻加上另一个人的结论,出了错也不知道该找谁。 攒着的那部分没交出去之前就失败,就整段丢掉换下一个模型 —— 用户从头到尾只看到一个模型的输出。

「继续」是补在原消息后面的,不是替换。所以换到下一个模型时它看到的是完整的用户请求 + 一句「继续」,不会丢上下文。补发次数有上限(3 次),不会无限啰嗦。

同一个模型的几次重发不追加任何东西:那是给多账号反代换账号用的,请求必须和首发一字不差 (细节见上面「装好之后长什么样」里 sameModelRetries 那一段)。

撞到 token 上限不算「这个模型坏了」。 它明明在写,只是被截断在半句话上 —— 交出去的结局原样还是「撞上限」,不隔离、不换人, 所以下一轮补「继续」时用的还是原来那个模型。这一条看着琐碎,却是最容易写错的地方: 把「被截断」和「产不出内容」混成一个 NO_VISIBLE_OUTPUT,模型当场就被隔离掉, 接力起点从此换成队列里的下一个,「用原模型补继续」也就再也无从谈起。

但也不能在同一个模型上一直撞下去。 连着撞 2 次,下一次调用队列就往前挪一格,换队列里的下一个接手;再撞 2 次,再挪一格, 转完一圈从头接上(两个候选时是「甲 甲 乙 乙 甲 甲 …」)。账按会话累加而不是按模型 —— 按模型记的话,换到乙之后再撞一次就会「重置」,下一轮又回到甲,两个模型之间来回打转。 哪一轮正常写完了,账就一笔勾销,下一个任务还从队首开始。换人时事件日志里会留一条 ceiling-rotate,不然用户只看到排在最前面的模型莫名其妙被跳过。 「压缩会话」「起标题」这类内部调用跟用户看到的那一轮无关,不参与记账。

中止和上下文超长不算「模型坏了」。 用户自己按了停止 → 原样交回;上下文超长 → 交给 agent loop 去压缩,都不切换、不隔离。 只有额度/凭据/网络/上游这一类「这个模型现在就是不行」的错才触发接力。

隔离是按天记的,而且按会话分开记,写在磁盘上,跨任务、跨重启都有效,到点自动清空。 按会话记是因为同一个模型在甲会话里被额度墙挡住,不等于它在乙会话里也不行 —— 各会话的上下文、 附件、已用额度都不一样。全局隔离会让用户完全看不出为什么队首的模型被跳过了。 判定「今天它不行」要等同一个模型的几次原样重发都试完:失败一次、重发一次就接着写完的 模型是能用的,会被记成「已验证可用」而不是被隔离 —— 否则今天反而会绕开一个明明好好的模型。 没有会话号的调用(压缩上下文、后台任务、离线测试)自成一桶,不牵连任何真实会话。

所有状态都在你自己的机器上:C:\Users\superv\.dsh\dsh-model-failover\state.json(隔离与验证记录、 今日红绿计数、每个会话里各模型的输出速度)、settings.json(模板队列与开关)、log.json(事件日志)。 删掉 state.json 就等于「立刻解除所有隔离、计数与速度也归零」。 state.json 的形状是 { day, sessions: { [会话号]: { templates: { [模板号]: { quarantine, good } } } }, counts: { [模型]: { fail, ok } }, speeds: { [会话号]: { [模型]: { tokens, ms, runs, at } } } }; 从旧版(全局隔离,或按会话但还没分模板)升上来时会自动落到 default 模板名下,隔离不丢。

配置

设置页同一类设置放在一起,从上到下:今日状态(逻辑日、距重置、当前起点、隔离数, 以及「每天几点算新的一天」)、队列模板(改名 / 切换编辑目标 / 测试连接 / 新建 / 删除 / 导出到文件 / 从文件导入,条数不限)、 「某模板」的队列(红绿次数、并发上限、档位、拖动排序、增删)、模型异常(启用、同模型重试次数、卡住超时、 限流冷却秒数、并发模型收工后再歇、全部隔离时是否硬试)、 DSH 异常(开关、两种触发、两个分开的次数上限、等待秒数)、外观(显示哪些会话)、 事件日志(列表本身 + 记不记、留多少条)。设置页里能改的都存在 settings.json;重置时间 支持 0-23 的整点。次数上限没有上界(早先有一道 20 的闸,已经拆掉:想让它一直补下去 就填一个大数,反正每补一次都会写进事件日志)。默认值: resetHour: 1、retrySameModelOnce: true、continueText: "继续"、retryWhenAllQuarantined: true、 sameModelRetries: 2、idleTimeoutSec: 120、cooldownSec: 60、limitCooldownSec: 5、 autoContinue: true、autoContinueOnFailure: true、autoContinueOnMaxTokens: true、 autoContinueFailureLimit: 1、autoContinueMaxTokensLimit: 10、 continueDelaySec: 5、keepEventLog: true、eventLogLimit: 200、 showSubagentSessions: false、showForeignSessions: true。

模板存在 settings.json 的 templates 里,形状 [{ id, name, order: [{ provider, model, reasoningEffort?, contextWindow?, concurrency? }] }], editedTemplate 只记「设置页正在编辑哪一条」。id 是模型选择器里那一项真正的模型 id, 所以删掉一条模板等于把一个模型从选择器里拿掉 —— 还挂在那个 id 上的会话会退回第一条模板。 concurrency 是那个模型的并发上限,留空等于键不存在(不限制);上限最大 16。 它是会话级租约:一个会话拿到手就一直占着,直到这个模型在它身上报错、或者那个会话 结束才交还(早先是「按请求记账」,每跳之间都还一次,这正是「时不时换模型」的成因)。 它旁边那条 limitCooldownSec(默认 5,最多 300)是交还冷却:最后一格并发还回去之后, 这个模型还要歇这么多秒才接别的会话,0 = 不歇;刚交还的那个会话自己回来时不受这条限制。 它不属于任何单个模板 —— 那张并发账本本身是全局的。

retrySameModelOnce 和 continueText 在这一版之后没有界面入口了(设置页里那两行已删), 但键本身仍然有效:settings.json 里已经写下的值照旧被读取,没写就按默认值(true / "继续")。 要改就直接编辑文件,重启后生效。

retrySameModelOnce 管的是「用同一句『继续』再催一遍」,那件事已经被 sameModelRetries(原样重发去换账号)整个取代了,所以这个布尔开关不再影响次数: 老配置里哪怕写着 false,也不会把新的重试次数折成 0。真想要「每个模型只试一次」, 把 sameModelRetries 显式写成 0。

idleTimeoutSec 的判据是「从发请求起有多久没收到任何 chunk」,所以它同时也是一道 首字超时闸:连响应头都不回的上游会在同一个时限里被判掉。填 0 就完全不判 —— 值得注意 的是,那时候一个挂住的连接会把这一轮无限期拖下去,直到你在界面上按停止。

开发

node test/smoke.mjs        # 宿主半身:接力、隔离、复活、档位、契约、自动继续接线、补发节奏、轮尾便签、撞上限换人、按会话隔离、模板即模型、隔离按会话×模板、红绿计数、输出速度、卡住超时、限流冷却、冷却到点但要等这一趟收工、请求太大改判超长、扫描缓存、重扫重问档位、/sessions、按会话解除、同模型重发、试连接、外观开关、上下文窗口声明、并发上限(会话级租约/连跑两趟不换人/握着的名额不被抢/报错才交还/会话结束释放/闲置收回/占满跳过/还有余量照用/换人时回捡/全满不硬闯/交还冷却让位与到点回来)(90 项)
node test/client.mjs       # 浏览器半身:真渲染设置页 + 真发请求 + 队列模板增删改(含删除二次确认、导出到文件 / 从文件导入)+ 隐藏路由恢复 + 红绿数字 + 试连接结果 + 轮尾小字 + 会话页隔离队列(置顶/模板名/速度蓝框/运行中/换人瞬间/逐行解除/子智能体可选/右键复制报错/完整会话号/使用时长与实时跳秒/并发限制一栏)+ 同模型重试次数 + 卡住超时 + 限流冷却标签与设置项 + 等这一趟跑完的标签 + 外观两个开关 + 次数上限无上界 + 拖动排序 + 右键上下文窗口弹窗 + 并发上限输入框 + 并发交还冷却(设置项 / 队列行标签 / 起点跳过)(102 项)
node test/real-host.mjs    # 挂进真的 dsh-llm 注册表跑一遍,含档位校验(12 项)
node test/autocontinue.mjs # 自动「继续」的判定、两个独立上限、节流与等待、本机网络判据、轮尾便签、同模型重试次数归一(59 项)

test/client.mjs 里有一个几十行的 React 替身,它把 client.js 当真实 bundle 加载 (走 window.__ModuleLoader__.load)、真的把设置页渲染出来、真的按按钮, 所以「页面会不会崩、按钮发的请求对不对」是断言出来的,不是看出来的。 改完 client.js 只要 node test/client.mjs,不用开浏览器。

test/real-host.mjs 用的是真宿主:真的 @deepseek-ai/dsh-llm 注册表、真的 waterfall、 真的校验函数,只把外面两个模型换成脚本化的替身。它证明的不只是「逻辑对」,还包括 「这个适配器装得进去」—— 适配器返回值形状写错时 dsh 会抛 INVALID_ADAPTER / INVALID_CATALOG / INVALID_MODEL_INFO / INVALID_MODEL_CONTEXT,插件会装不上而不是跑错。 档位那部分同样交给真校验器判:这条路由不声明任何档位(normalizeModelInfo 不接受 没 name 的档位,会被拒成 INVALID_MODEL_REASONING),而硬塞进来的路由级档位必须被 UNSUPPORTED_REASONING_EFFORT 挡下;候选模型各自收到的那一份则要过它自己的表。 它需要本机 _dsh-research/ 下那份从 app.asar 解出来的 dsh 包;找不到就整份跳过(退出码 0), 换位置用 DSH_LLM_HOST_DIR 指向包含 @deepseek-ai/ 的那个 node_modules。

边界

  • 隔离是**按「会话 × 模板」**算的:同一个模型在甲会话的甲模板里撞了墙,不影响乙会话、 也不影响同一个会话里另一条模板。红绿数字则相反 —— 那是全机器合计的统计,不分会话不分 模板,只回答「这个模型今天靠不靠谱」。
  • 限流不算隔离。 被上游限流(RATE_LIMIT,或报错文本里写着 rate-limited / 429 / retry after Ns)的模型只进一张内存里的冷却表,绕开 cooldownSec 秒(默认 60)就自动 回来,隔离名单上不留记录。理由是限流本来就是「过一会儿再来」,把它当成「今天废了」是 冤枉它。cooldownSec 填 0 可以关掉这条规则,回到「限流也隔离到明天」的老行为。 冷却表不落盘,重启 DSH 就清空。
  • 会话页「隔离队列」每一行右边的按钮只解那一行的会话(该会话下所有模板),别的会话一条 都不动。设置页不再提供全局解除(原来那个按钮已经撤掉),真要全清就删 state.json。
  • 会话页那一栏只显示跑过这个插件的会话:隔离记录知道哪些会话撞过墙,轮尾便签知道 哪些会话跑过;两条都不沾的会话(比如全程用别的模型、一次都没失败)不会出现在那里。 子智能体的会话默认被剔掉(可在「外观」里打开),其他工作区的会话默认保留。
  • 会话页那一栏显示哪个会话,判据来自宿主的会话摘要(origin、cwd):查不到的会话 一律保留 —— 宿主的会话表并不保证收着每一个历史会话,把「查无此号」当成子智能体藏起来 会把真实的主会话也吞掉。
  • 只在没有可见输出之前切换模型(见上)。要跨过这条线,得等 dsh 给插件一个 能安全丢弃已发出内容的位置。
  • 「自动补发继续」需要 composition 里有 agents 服务(桌面/网页版都有)。没有它 (比如纯 TUI、离线测试)时这项功能静默关闭,插件的其余部分照常工作 —— 它走的是 ctx.inject(['agents']),不会因为缺这个服务而整个装不上。
  • 「本轮失败」判据是 turn/end 报的结局,所以只认真失败的轮。模型自己吐了一句 「抱歉我做不到」但正常收尾的轮,插件不会替你追问。
  • 候选清单来自宿主里已注册的模型;如果你想排除某个 provider,在设置页里把它移除、或填进 hiddenProviders。填进 hiddenProviders 的路由会整条从清单里消失(连一行都不出现), 所以设置页在「添加模型」下拉框正下方留了一栏「被隐藏的路由」,在那里点「恢复」就能撤销。
  • 插件自己的路由永远不会出现在候选里(否则会自我递归)。
  • 扫描结果落在 catalog.json 里,重启之后接着用;只有缓存为空、点「重新扫描」、或恢复了一条 被隐藏的路由时才会真的去问宿主。缓存带版本号,读不懂就整份丢掉重扫 —— 宁可多扫一次, 也不能把上个版本的数据当成这个版本的清单。
  • 档位表是启动后异步问出来的(问一次记一次,不再重复问),结果也跟着 catalog.json 落盘。 万一某次任务来得比扫描还早,那一个模型这一轮会按「不知道它认什么档位」处理 —— 不发档位, 用它自己的默认档位。这是刻意的取舍:宁可按默认档位跑,也不能发一个它认不出的档位把它误判成 「今天不行」而隔离掉。设置页里点一次「重新扫描」就会补齐。
  • 「问一次记一次」记的是宿主那一刻的答案,而宿主会随配置改口(补上 reasoningEfforts 之后它就开始报档位)。所以「重新扫描」在重列清单之前会先清掉这份记忆(forgetAsked), 否则改完配置怎么扫都还是旧的「无档位」—— 这是踩过的坑,不是理论问题。
  • 这条路由自己不声明档位,所以在模型选择器里它对「思考强度」是禁用状态。真想在 路由级别选档位就得声明一个并集,而队列是会变的 —— 把一个上一轮还成立、这一轮已经 没人认得的档位发给宿主,换来的是一整轮的 UNSUPPORTED_REASONING_EFFORT。档位请在 队列的每一行上设(那才是「这个模型用哪一档」的正确归属)。

Plugin correlati