dsh-purge
YuJunZhiXue/dsh-purge在设置页编辑 prompt-inject.md、管理 AGENTS.md 或 CLAUDE.md 规则集、检查插件更新,并对本机包做可回滚的应用或还原。
模型接力路由器:将已接入 DSH 的全部模型排成优先级队列,失败时自动重试「继续」,不行则切换下一模型,并将故障模型隔离至次日。
把 已经接入 dsh 的全部模型排成优先级队列,再把这个插件本身当成一组「模型」—— 一条队列就是模型选择器里的一个模型。用户在模型选择里挑中哪条(默认那条叫 默认模板,也可以自己新建、改名),任务就按那条队列的顺序去跑:靠前的先上,不行就自动 往下换,直到任务跑完。
一个模型不能输出,往往是这几种:额度用尽、余额不足、key 失效、路由挂了、上游 5xx。 以前遇到这些,任务当场就断了。现在:
还是不行再补一句「继续」试一次;
已经验证过能用的那个(别的会话照常可以先用它);
另外,整轮以失败告终、或被 token 上限截断时(模型自己没坏,只是这一轮没跑完), 插件会替你自动补一句「继续」接着做 —— 见下面的「DSH 异常」。
模型选择器里多出几条(provider model-failover)——一条队列就是模型选择器里的一个 模型。默认那条叫 默认模板;你在设置页新建一个模板,模型选择器里就多一项,名字就是你 给模板起的名字。会话里选了哪条,那个会话就往那条队列上跑。
设置 → 模型接力(在「模型」那一节下面)。同一类设置放在一起,从上到下依次是:
区块 · 作用 今日状态 · 今天是哪天、距下次重置还有多久、当前起点、今天隔离了几个(重置时间就在这里) 队列模板 · 一条模板一行:改名、切到哪条去编辑(编辑)、测试连接、新建、✕ 删除。最多 8 条,新建出来是空队列 「某模板」的队列 · 正在编辑的那条模板的候选清单,每行前面是今天的失败/成功次数、一个思考强度下拉框,↑ ↓ 排序,✕ 移除;下面可以从目录里加回来 模型异常 · 总开关、同一个模型失败后重试几次、全部隔离时要不要硬试。开着的时候,队列里的模型一直在后台自动接力 DSH 异常 · 总开关、两种触发、各自的次数上限、每次失败后等多久再补(见下)。对整个 DSH 生效,不限于接力 外观 · 会话页「隔离队列」里显示哪些会话:子智能体的(默认关)、其他工作区的(默认开) 事件日志 · 每次隔离/复活/轮换/自动补发都留一条;记不记、留多少条也在这里
队列行前那两个数字是今天这个模型在全机器上的失败次数(红)与成功次数(绿)——所有 会话、所有模板合计。它是统计,不是状态:隔离按「会话 × 模板」分开算,一个模型在甲会话 的甲模板里撞了墙,在别的会话或别的模板里照样能用,所以数字旁边不画隔离徽标。
设置页不再有那张全局隔离名单。 隔离既然是按「会话 × 模板」算的,一页全局表格既说不清 是谁撞的墙,也容易被当成「解除了就处处生效」。明细只在会话页那一栏按会话出现。
打开设置页会自动扫一次目录,所以新接进来的 provider、新登录的账号,进来看一眼就在 清单里,不用先去点一下「重新扫描」。扫完回来的就是一份完整快照,不会再单独拉一次状态。 「重新扫描」那颗按钮仍然在「今日状态」卡右上角,随时可以再催一次。
每条模板上有一颗「测试连接」,用来体检这条队列里的模型还连不连得上。点下去之后插件会 挨个探一遍(同时最多 4 个,一口气全发出去容易撞上供应商的并发限流,那反而是假故障), 结果显示在下面每个模型名字后面:绿色 通、红色 不通(悬停能看到错误码和报错原文)。
更不该动撞上限那本账;
hi,输出上限 8 个 token。撞到上限也算通 —— 那恰恰说明它在正常写字,所以不会因为上限太小而误报;
了),留着只会误导人;
同一个模型失败后,插件会先用它原样重发几次,再换下一个。 次数是「模型异常」卡里的 sameModelRetries(默认 2,也就是最多试 3 次;0 = 每个模型只试一次)。
这几次重发不是为了「再催它一遍」,而是为了换账号。一个 provider+model 背后可能挂着 好几个账号 —— dsh-our-free-model 这类把别家模型反代进来的插件就是这样:失败一次它通常 只把那一个账号 bench 掉,池子里剩下的账号完全还能用。可从 DSH 这边看出去只有一个 provider+model,想让它换个人来答,唯一的办法就是把这个请求一模一样再发一遍,让它重新 进一次账号池。只有试满这么多次仍然不行,才判这个模型今天不行、隔离并顺延到下一个。
这些重发不带「继续」:刚被换上的新账号压根没写过东西,收到「继续」只会莫名其妙。 带「继续」的只有换到队列里下一个模型那一跳 —— 换第一次带一条,换第二次带两条,最多 3 条 (MAX_CONTINUES),因为那才是「接着前面没写完的往下写」。
会话页顶部多一栏「隔离队列」(排在「对话」右边)。一个会话一张卡,写清楚它正在用 哪个模板、哪个模型、今天在这个会话里被隔离了哪些(不属于当前模板的会额外标出模板名)、 以及已经验证过能用的又有哪些。当前会话那一张永远排在最前面,其余保持原来的顺序。 它每 15 秒自己拉一次轻量端点(GET /sessions,不拖模型清单和事件日志),也可以手动点「刷新」。
每一行右边都有一个「解除隔离」按钮,只解那一行的会话(该会话下所有模板),别的会话一条 都不动 —— 在甲会话里点了「解除」,没有理由让乙会话重新去撞同一堵墙。那个会话名下没有隔离时 按钮是灰的。页顶不再有「解除本会话隔离」:按钮长在会话行上,「解哪个会话」就没有歧义。
会话标题取自宿主;拿不到就退回会话号前缀,所以任何一个跑过事的会话都不会是空白行。 没有会话号的调用(压缩上下文、后台任务)单独一条,标明是「后台任务」,不会被当成 一个没起名字的对话。隔离的完整报错原文挂在每个红胶囊的悬停提示上(宿主持久化时已截到 400 字符),点「隔离队列」里的行不用再翻日志。
在一个红胶囊上点右键,就把那条报错整块复制进剪贴板。 复制的不只是那句 Error in upstream response —— 抬头一行先写清「哪个模型 · 哪条模板 · 什么错误码 · 什么时候」,换行后才是宿主留下的报错原文。只复制正文的话,贴到别处既认不出是哪个 模型,也认不出是哪条队列上的账,等于白贴。复制成功会在那颗胶囊上闪一下「已复制」; 拿不到剪贴板权限时如实报错,不假装成功。
红胶囊的悬停弹窗里只有报错原文,一个字的说明都不掺 —— 移上去就是要把那段报错 看全,往里塞「你也可以右键复制」等于拿说明去挤正文。「能右键复制」这件事写在上面那行 说明里,鼠标形状也换成了右键菜单那种。
显示哪些会话由设置页的「外观」决定:子智能体(subagent)跑出来的会话默认不显示, 其他工作区的会话默认显示。子智能体干活时也是一次正经会话,也会走接力、也会撞墙,所以隔离 记录里确实有它;但那是你委托出去的一次执行,不是你在侧边栏里开的那个对话,摊在这里只会让人 以为凭空多了几个会话。判据用的是宿主自己的会话摘要(origin === 'subagent')。
查不到来源的会话一律保留:宿主的会话表并不保证收着每一个历史会话,所以「表里查无此号」 只说明问不出来,不说明它是子智能体 —— 早先那版把这种情况当成子智能体藏掉,结果连真实的主 会话都吞了。拿不到宿主的会话表时(比如 headless 组合)同样是一行都不删。
每个隔离/已验证的模型后面还跟一个蓝色小框,写它在这个会话里的输出速度(42 tok/s, 悬停能看到原始的 token 数、毫秒数和跑过几趟)。它回答的是另一个问题:红绿胶囊说「今天行不行」, 蓝框说「用起来多快」—— 一个模型可以刚刚还跑得飞快、只是最后撞了额度墙,那正是值得知道的 信息。几个口径:
毫秒数分别加起来,最后再除,而不是对每趟的速度取平均 —— 后者会让一趟很短的样本 权重过大。显示规则也一样:两位数以上取整(250 tok/s),个位数留一位小数(3.4 tok/s);
混进来会让一个「想很久但写得很快」的推理模型显得很慢;
压到 2 秒,会话行上还会挂一个蓝色的「运行中」—— 中途那几十秒光看数字是看不出 还在跑的;
usage)就什么都不画,不会留下一个凭空的 0.0;
上一节说的是「这个模型这次没能输出」。但有两种情况是整轮结束才看得见的, 模型自己没坏:
这两种情况下插件会替你把「继续」再发一句,接着往下做。它走的就是你自己敲回车的那条路 (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,不是插件自己编的枚举)。 默认停在「默认档位」,也就是你平时直接用这个模型时的表现。
打开设置页本来就会自动扫一次,卡住了再点「重新扫描」催一次;
UNSUPPORTED_REASONING_EFFORT 拒掉整个请求,那等于把一个好好的模型判成「今天不行」—— 所以插件只发目标模型自己声明过的档位, 认不出来就干脆不发,让它用自己的默认档位。
顺带一提:在模型选择器里给「模型接力」这条路由本身选档位也是可以的,插件对外声明的是 队列里所有已知档位的并集,轮到一个候选时再按它自己的表过滤一遍(用户给某个模型 单独设过的档位优先于路由档位)。
只在自己还没「说话」之前换模型。 模型的输出会先攒在手里:一旦有任何一个字、或一次工具调用准备交给用户,就立刻原样放出, 并且从这一刻起这个模型就锁定这次任务了。它之后再崩,报错就照实报错, 不会偷偷换一个模型接着写。理由很直接:两个模型各写半句话拼在一起, 用户读到的是一个人的口吻加上另一个人的结论,出了错也不知道该找谁。 攒着的那部分没交出去之前就失败,就整段丢掉换下一个模型 —— 用户从头到尾只看到一个模型的输出。
「继续」是补在原消息后面的,不是替换。所以换到下一个模型时它看到的是完整的用户请求 + 一句「继续」,不会丢上下文。补发次数有上限(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、 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? }] }], editedTemplate 只记「设置页正在编辑哪一条」。id 是模型选择器里那一项真正的模型 id, 所以删掉一条模板等于把一个模型从选择器里拿掉 —— 还挂在那个 id 上的会话会退回第一条模板。