dsh-model-orchestrator — 模型调度(两级)
先选模型,再选这个模型所在的渠道;失败时先换渠道、再换模型。 输入框下方实时显示当前模型、渠道健康、今日用量与自动切换记录。 新会话的第一条消息就按调度结果调用模型(不再先跑选择器里的那个),新会话首屏也有同一份状态与开关。
装在 DSH 里就是一个 bundle:一个 Host 半身(路由决策)+ 一个浏览器半身(状态条与详情面板)。
配合 dsh-our-free-model(OFM)使用——推荐一起装,但不是依赖。 OFM 把免费模型接进来,本插件在这些车道之间做调度。两者零代码依赖, 只有 OFM 写在本机的状态文件被当作额外信号读进来。这是单向适配、非官方: 我们读它的文件,它不需要知道本插件的存在。详见 与 dsh-our-free-model 的关系。
与 dsh-our-free-model 的关系
本插件与 dsh-our-free-model(OFM)配合使用。 先说清楚关系的性质:这是单向适配,不是合作,也不是官方关联——OFM 的作者没有参与本插件、也没有为它背书;本插件只按 OFM 当前写盘的格式去读它留下的状态文件,不对 OFM 提任何适配要求,也不向它的仓库提交改动。分工是这样的:
- OFM 负责「把免费模型接进来,并记录渠道状态」——免登录、免注册、免 API Key,
把一批前沿模型注册成
our-free-model/our-free-model-region两条 provider 车道; 并在本机维护一份渠道账本($DSH_HOME/channel-pack/,记录账号、每账号每模型的限流解禁时间、 逐渠道首字与今日 token,以及免费车道的可用性探测结果)。 - 本插件负责「在这些车道之间做调度」——选哪个模型(智力/速度/成本/用量打分)、走哪条渠道 (实时健康、限流、首字、粘滞)、失败了先换渠道还是换模型、锁定一个模型时怎么在它的渠道间切换。 它不产生模型,只决定请求走哪条已经存在的路。
两者没有代码依赖,也没有互相 import(package.json 里没有 dependencies),
模型清单来自内核的 LLM 注册表(llm.listProviders/listModels/resolveModelInfo),不硬编码任何渠道。
| 你装了什么 | 得到什么 |
|---|---|
| 只装 OFM | 免费模型可用;走哪条渠道由 OFM 自己决定,本插件不参与 |
| 只装本插件 | 任何 DSH provider 都能正常路由;少了 OFM 那层渠道旁证,改用本插件自己观测的流量账本 |
| 两个都装(推荐) | 免费模型 + 两级调度:渠道健康排序、失败先换渠道再换模型、偏好锁定、状态条全部生效,且冷却与限流以 OFM 账号池的真实解禁时间为准 |
OFM 提供哪些信号(可选)
OFM 往本机写四个 JSON,本插件只读不写:
| 文件 | 由谁写 | 不装 OFM 时 |
|---|---|---|
$DSH_HOME/our-free-model/availability.json |
OFM | 免费车道的可用/不可用判定缺失,该渠道按「未知」处理 |
$DSH_HOME/our-free-model/stats.json |
OFM | 免费车道的今日用量缺失,用量项按 0 计 |
$DSH_HOME/channel-pack/state.json |
OFM(内嵌 channel pack) | 拿不到每账号每模型的限流解禁时间,冷却判定改用本插件自己观测到的失败 |
$DSH_HOME/channel-pack/token-ledger.json |
OFM | 少了第二优先的延迟证据,延迟只认本插件实测和报告值 |
读不到文件、格式不认、provider 不认识,一律降级成「没有信号」——signal() 返回 available: null(未知),
不是 false(不可用),所以未知不会被当成坏渠道排掉。
想彻底关掉这两个来源:配置里 channelData: false 和 freeModelData: false。
这四个文件是 OFM 的内部实现细节,没有版本承诺,也没有为第三方准备的契约。本插件单向跟随: 按它现在写盘的格式读,格式变了就退回「没有信号」,不要求 OFM 为本插件做任何改动。
证据:集成测试 tools/integration.mjs 把 DSH_HOME 指向一个空临时目录
(既没有 our-free-model/ 也没有 channel-pack/),在这个前提下加载真实的 Host 半身、
跑通路由、失败换渠道、偏好模式与暂停恢复——52 项全过。
安装
需要一个已经装好的 DSH(Desktop 或 dsh web)。
# 1) 把仓库放到任意目录,然后作为一个本地 link 依赖装进你的 DSH profile
cd ~/.dsh/profiles/web
pnpm add link:/absolute/path/to/dsh-model-orchestrator
# 2) 把 dsh-model-orchestrator 加进 ~/.dsh/profiles/web/package.json 的
# dsh.profile.bundles 数组(漏了这一步插件不会被加载)
# 3) 重启一次 DSH Desktop(或 dsh web)
Host 半身只在启动时加载一次,浏览器半身(状态条/详情面板)由 HMR 或刷新页面加载。
验证:输入框下方出现模型状态条;/route status 能列出模型组;
model_orchestrator {"action":"status"} 的 catalog.total > 0。
它解决什么
你手上有十几条免费/低价渠道,同一个模型(比如 glm-5.3)往往在 4~6 条渠道上都有。
真正要回答的问题不是「哪个模型分数最高」,而是:
- 现在该用哪个模型? —— 由报告里的智力分、实测速度、成本和你的偏好共同决定。
- 这个模型该走哪条渠道? —— 由渠道的实时健康、限流、额度和实测首字决定。
- 渠道挂了怎么办? —— 先换同模型的其它渠道;只有整个模型的渠道都用不了,才换模型。
- 我只想用某个模型。 —— 打开偏好模式:只调用该模型,自动在它的渠道间切换;全部渠道不可用时暂停偏好、改用备用模式并提醒你。
旧版把「模型」和「渠道」压成一个分数:每次失败都重新给所有 provider/model 排序,
于是可能在 glm-5.3 的另一条渠道还活着的时候,换到一个更弱的模型上。
这一版把两层分开,并把「用户明确选的模型」放到最高优先级。
优先级是怎么算的(可以逐项复述)
模型层(选哪个模型的权重):
| 项 | 含义 | 数据来源 |
|---|---|---|
| quality | 智力分 / 46,按可信度向中性收缩 | 测速报告(先验) |
| speed | 1/(1+首字/10s) |
本机实测优先 → 渠道账本 → 报告逐车道 |
| health | 1/(1+0.8×衰减失败数) |
本机流量账本 |
| cost | 1/(1+家族消耗×车道倍率) |
报告标签 + 模型名里的 x0.2 倍率 |
| usage | 1/(1+今日 token/2M) |
渠道账本今日用量 |
四档策略只是重新分配这些权重(每档权重和为 1):
- quality:智力 0.58,速度/健康/成本/用量各占少量;
- balanced(默认):智力 0.46,速度 0.18,健康 0.14,成本 0.14,用量 0.08;
- economy:成本 0.30,智力 0.34;
- speed:速度 0.34,智力 0.34。
渠道层(同一个模型内选哪条渠道):同样的五项,但 quality 相同(同模型), 真正起作用的是健康、首字、倍率、今日用量,以及「是否正持有本会话的 prompt 缓存」。
五项都是历史读数。第六项是唯一看当下的:此刻这条渠道上有几个调用正在飞 (并发分散,见后文)—— 它是扣分项,不是权重,所以五项仍然和为 1,排名依旧可审计。
粘滞:当前模型/渠道领先不到切换阈值时不换;切换阈值随任务复杂度收紧或放宽。 用户刚在选择器里手动选过的模型,切换阈值翻倍(默认 30 分钟内)。
失败处理顺序:
- 记录到实际失败的那条
provider/model(用agent/request的最终配置,而不是可能滞后的请求头); 同一车道、同一失败类型在failureBurstWindowMs(默认 5 秒)内的多次上报只算一次事件 —— 并发会话撞同一堵墙时不会把冷却翻成好几倍; - 先在同模型的其它渠道里重试(同一步内
{kind:'retry'},调用方看不见); - 整个模型都没有可用渠道时,才按策略换模型;
- 偏好模式下第 3 步会触发「偏好暂停」:改用备用模式并通知,渠道恢复后自动恢复偏好。
两种「不是我们的错、但也不能一直忍」的情况(LOCAL PATCH 2026-10-18):
- 渠道安全审核拦截(如腾讯 CodeBuddy/WorkBuddy 回
HTTP 400 请求被拦截,请重新发送 request illegal)。 这类 400 会被识别成safety判定:该渠道对这个模型冷却 30 分钟,同渠道的其它模型不受影响, 本次请求立刻换渠道重试。以前它被当作INVALID_REQUEST(「我们的请求写错了」),于是既不冷却也不换渠道, 连续两个回合都撞同一堵墙。 - 连续被同一个渠道拒绝(任意 400)。单次
INVALID_REQUEST仍然不动路由(渠道无罪), 但同一渠道requestRepeatThreshold(默认 2)次、在requestRepeatWindowMs(默认 10 分钟)内接连拒绝时, 该模型被暂停requestRepeatBlockMs(默认 30 分钟)并立刻换渠道。收到一次成功响应就清零。
并发分散与失败突发去重(LOCAL PATCH 2026-10-26)
问题:五项的分数全是历史
原来的打分只有五个读数:智力、速度、健康、成本、用量 —— 全部来自过去。历史分不清 「两个会话同时开」和「一个会话问两次」,所以 N 个会话同时发请求时,每个会话都算出同一条 渠道最优,然后一起压上去。免费渠道的额度是按 key(常常还按 IP)算的,压上去并不会更快: 一份配额被切成 N 份,接着换来一串 429,而 429 的冷却是渠道级的 —— 一个会话的限流会把 同一条渠道上所有会话一起停掉。
修法一:把「此刻在飞几个」加进打分
src/inflight.js 记录每条 provider/model 与每个 provider 当前打开的调用数(llm/stream 是
所有模型调用的唯一入口,租约在那里取、在 finally 里释放,所以成功/抛错/取消/消费者提前
break 都会归还;超过 concurrencyStaleMs 的租约自动失效,不会因为某个流没关掉就永久压死一条渠道)。
打分时按它扣分,扣分而不是排除:忙的车道只是分数更低,仍然可路由 —— 一条永远不被选中的 渠道永远没有机会证明它恢复了。
| 读数 | 含义 | 权重 |
|---|---|---|
| 本路由在飞数 | 这条 provider/model 上有几个调用正在跑 |
主项 |
| 同渠道在飞数 | 这个 provider 上(其它模型)还有几个调用在跑 | 半权重 |
实测(两条各项完全相同的车道):空闲时两者同为 0.824;A 会话占住 buddy 后,buddy 掉到
0.734,第二个会话选 raccoon。扣分上限 0.18 大于 laneSwitchMargin(0.05) 与 anchorBonus(0.04),
所以「知道它忙」一定换得动;又小于 balanced 的一整档智力权重(0.46),所以真的更好的模型仍然赢。
修法二:同一堵墙只记一次
并发的会话撞到同一堵墙时各报一次失败,而旧实现每报一次就 strikes += 1,冷却时间按
2^(strikes-1) 翻倍 —— 一次上游事件,五个会话,五倍冷却。
现在的规则很窄:同一条路由 + 同一种失败类型 + failureBurstWindowMs(默认 5 秒) 之内算一次事件。
第二次起只更新诊断信息,不加 strike、也不把冷却截止时间往后滑(往后滑正是「突发变长期」的机制)。
不同类型(429 之后 401 = key 中途失效)仍然各记一次,不同路由更是各记各的。
实测:五个并发 429 的冷却序列从 20s,40s,80s,160s,300s 变成 20s,19.5s,19s,18.5s,18s
(后四个报的是剩余时间,所以只会变短)。
面板与 route_status 的返回里新增 concurrency:当前每条路由/每条渠道各有多少个在飞调用,
「为什么选了第二名」从此有可见的答案。
渠道互借(2026-10-29,v2.6.0)
它解决什么
本机跑着两套独立 DSH(A 主实例、B 独立实例),两边的账号额度完全独立。实际用起来 总是一边先烧穿:A 的免费车道限流了,B 那边还闲着。以前只能人肉去 B 里干活。
打开这个开关后,额度充裕的一端可以把车道借给对侧用:请求真的在对侧进程里发出去, 消耗的是对侧的账号额度,本机的会话不受影响。
为什么必须走线路级中继
两端注册的 provider 列表几乎一样(同一个 channel-pack),真正不同的是账号与额度。 所以「借对侧的渠道」不是复制一份配置能成立的 —— 请求必须在对侧进程里发出。 于是实现是:
借用方 (B) 出借方 (A)
peer 适配器 ──── POST /peershare/v1/stream ────▶ ctx.llm.stream()
(一条普通 provider) (A 自己的车道)
▲ │
└────────── NDJSON: {type:'chunk',chunk} ──────────┘
线上协议不是 OpenAI wire
两端是同一个插件版本,可以直接收发 harness 自己的 GenerateOptions / StreamChunk
(都可无损 JSON 化)。走 OpenAI wire 要多写一遍 messages / tool_calls / SSE 的双向翻译,
多出来的每一行都是「翻译错了但看起来通了」的风险。协议带 protocol 字段,不匹配就明确
拒绝(PROTOCOL_MISMATCH),不做兼容猜测。
配置
- id: model-orchestrator
config:
peerShare:
enabled: true
role: both # off | both | lend | borrow
label: 'A' # 面板上显示的对端名字
host: 127.0.0.1 # 出借端点监听地址(默认只绑回环;跨机用才填局域网 IP 或 0.0.0.0)
port: 18326
token: '' # 空 = 用 $DSH_HOME/model-orchestrator/peer-token
allowProviders: [] # 只借出这些 provider(空 = 全部)
denyProviders: [] # 永不借出
peerUrl: 'http://192.168.1.20:18327' # 借用方:对侧地址
peerToken: '<对侧的 token>'
peerProvider: peer # 借用车道在本机的 provider 名
borrowWhen: exhausted # exhausted(本机全灭才借)| always(一直参与打分)
面板:设置页有一个「渠道互借」开关 + 角色 + 借用时机;命令行:
/route peer # 看状态:出借端点、可借车道数、借用方拿到几条对侧模型
/route peer on # 打开
/route peer off # 关闭(端点立即停止监听)
/route peer lend # 只出借
/route peer borrow # 只借用
模型侧:model_orchestrator action=peer,或 action=set 带 peerShare 字段。
三条必须知道的语义
borrowWhen: exhausted是默认,不是保守。互借是兜底,不是常态:本机还有任何合格 车道时借用车道不进入打分。否则会用对侧的账号替本机烧钱,而且对侧一断线,本机明明 有可用车道却先撞墙。要两端按打分自然分摊额度,才改成always。- 图片不跨实例。图片块引用的是本机附件 id,对侧读不到。适配器因此不声明 image
模态(路由层不会把带图请求派过来),真收到图片块时明确报
UNSUPPORTED_CONTENT, 而不是让模型「看不到图却照答」。 - 只借车道,不借权限。借用方能调的只有出借方当前可路由且未 denied 的
provider/model;
allowProviders/denyProviders可以再收窄。开了互借绝不会多出 一条出借方自己没有的渠道。
安全边界
- token 落
$DSH_HOME/model-orchestrator/peer-token(首次开启自动生成 32 字节), 比较走timingSafeEqual,长度不同直接 false; - 回环守卫:请求带
x-peershare-hop头,或 provider 名等于本机的借用车道,直接 508。 这条踩过:借用方是链路起点,不该带 hop 头(带了就被自己的守卫拒绝); - 出借端点默认只绑
127.0.0.1(2026-10-29 改):拿到 token 就能花对侧的额度, 一个默认开在局域网的端口等于把额度暴露给同网段任何一台机器。要跨机用就显式填局域网 地址或0.0.0.0,并自己确认防火墙只放行对侧那一端。
借来的车道是聚合体,判决只能到模型级(v2.6.1)。
peer这一个 provider 下面挂的是 对侧的全部模型(实测 B 侧 600 条)。所以「这条车道不能服务」在这里等于「对侧全灭」, 而那从来不是一次失败能证明的事。实测事故:借用方调xiaomi/mimo-v2.6-pro拿到 cline 的 402(文案是「改用其它 provider」),分类器只认「其它模型」→ 判成车道级 → 10 条 活着的借入车道被一起停到午夜。现在聚合车道上的任何scope: 'lane'判决在记账前降为scope: 'model',reason 前缀〔聚合车道:仅本模型〕;磁盘上旧代码泼下来的车道级停车 在下次加载时清掉。真实渠道的行为一个字节没变(同一条 402 在 cline 上仍是车道级)。 判据来源只认peerShare.peerProvider,关掉互借后旧记录不再被当聚合体。探活默认不探借入车道(
probeBorrowed: false)。定时巡检要回答的是「本机车道会不会 静默死亡」,拿对侧的账号额度给本机做体检是另一回事,量级还差两个数量级。
自检
node tools/peershare-smoke.mjs # 44 条断言:鉴权/协议/回环/清单/流式还原/失败透传/图片拒绝/默认只绑回环/首块即失败
node tools/probe-smoke.mjs # 34 条断言:间隔解析/跳过判定/并发池/单条超时/开关与重入
node tools/smoke.mjs # 186 条断言;[15] 段专测聚合车道降级与加载自愈
偏好模式
面板里点「偏好」,或在 /route 里:
/route prefer glm-5.3 # 只调用 glm-5.3,自动在它的渠道间切换
/route prefer glm-5.3 buddy # 限定只走 buddy 这一条渠道(该渠道挂了就算偏好不可用)
/route unprefer # 回到自动
规则:
- 同一个模型,换渠道不换模型。 渠道限流/额度耗尽/不可用时,优先在同模型的其它渠道重试。
- 只在本步不满足时临时改选。 如果这次请求需要图片、而偏好模型不支持视觉,或者上下文超过它的窗口, 这一步会临时用别的模型,但不会暂停偏好模式(下一次请求仍回到偏好模型)。
- 所有渠道都不可用才暂停。 判定标准是渠道级:冷却、被屏蔽、渠道面板关闭、免费车道探测不可用。 暂停时:状态条变黄、面板显示原因与倒计时、桌面端弹通知;默认在渠道恢复后自动恢复并再通知一次。
- 选择器跟着走。 路由切换会写入内核的
model/selection,所以浏览器里的模型选择器、会话日志、 模型收到的「模型已变更」提示、面板显示的模型是同一个。 在偏好模式下从选择器里改选模型,会把偏好模型一起改掉(可在配置里关闭)。
判据的三条修正(2026-11-05,v2.7.0)
这三条都来自 2026-11-04 的四次线上排查,每一条都对应一次可复现的事故。
1. 线路问题优先于凭据问题:断网不再被「凭据」二字判成账号失效
事故:WiFi 断 3 分钟,探活把 Loomy 凭据探测网络失败:fetch failed 原样交给分类器,
AUTH_TEXT 里的「凭据」命中——那是这条链路的显示名,不是判决——整条车道被判 auth、
停 30 分钟,网络恢复后仍锁在原地。同一段断网在另一实例只吃 30 秒模型级冷却。
改法:分类器在凭据分支之前先认网络证据(fetch failed / ECONNREFUSED / getaddrinfo /
socket hang up / 连接重置 / 证书 …),命中即为 transient + 模型级 + 30 秒量级;
真凭据失效(invalid api key / 401)不带网络词,判决一个字节不变。
同时补上第二层:探活不是证据。探活是插件自己排的 8 token 心跳,它的失败一律模型级,
冷却不超过 transient 的两倍,并在 reason 里标出 〔探活〕。真正的额度耗尽/凭据失效会在
用户的下一次真实请求上再次出现,那时再按重判决处理。
2. 端点不可达 = 整片下线(互借第二个方向)
v2.6.1 把聚合车道(互借)上的车道级判决降为模型级,修的是「一个借入模型 402 把对侧几百条
车道一起停到午夜」。但连接层失败根本不是在说某个模型:ECONNREFUSED 说的是这个端口上没有监听,
而互借的几百条借入车道共用同一个 base URL。旧方向下,路由会在同一个死端口上把 3 次换道预算烧光,
整轮 agent turn failed(实测 2026-11-04 00:09,三连)。
改法:聚合 provider 上 connectivity 类的判决升回车道级并标 endpointDown,同一端点的所有借入
车道一起退出候选;clearEndpointDown(provider) 由清单拉取成功显式解除(只在一次成功调用上解除是不够的
——整片被跳过的端点永远不会有人去调用它)。非连接类的判决(402/额度/地区/凭据)照旧降为模型级。
配套两条:
- 清单失效即不可用:借用适配器区分「端点不可达」与「对侧拒绝请求」,前者把
usable置 false, 路由层据此整片跳过借入车道(旧代码只记modelsError、留着旧清单,于是按陈旧清单把请求派给一个 已经不存在的端口)。 - 借入车道不原地重试传输层失败:
providerRetryPolicy从默认(5 次、约 15 秒、含TRANSPORT) 收紧为 1 次且不含TRANSPORT——同一个死端点原地重试只是同一堵墙撞 5 次,换道才是出路。
3. 偏好暂停自愈,不再粘在会话里
preferenceSuspended 是会话级内存态,旧代码只在「该会话下一次路由」时重新评估。断网时恰好空闲的
会话,网络恢复后仍一直显示「已暂停 / 自动」——用户看到的是「有的会话退出了偏好模式、有的没有」。
现在面板读取与每次 step 组装都会走一遍实时复核(复用 selectRoute 的 prefer 分支,保证「面板说能用」
与「路由真的会用」是同一件事);车道仍然坏着时不会自愈。
4. 换道代价随目标车道价格缩放
「额度不是变少了,是被最贵的那条车道吃干的」:缓存重读惩罚(cacheSwitchPenalty)的物理含义是
「换道后整个上下文按未命中缓存重新计费」,那它就必须乘上目标车道的价格。固定 0.35 的实测后果是——
x0.06 与 x2.0 车道的效用差只有 0.137,而换道要付 0.35,净差 -0.213,迁移在结构上不可能发生。
改为按 burn / cacheRebillRefBurn(默认 0.45,可配)缩放并夹在 [0.1, 1]:便宜车道的重读惩罚缩到零头,
基准价格的车道照旧付满额。
数据从哪来(以及为什么更准)
| 数据 | 来源 | 用法 |
|---|---|---|
| 模型清单、上下文窗口、模态、reasoning 档 | 内核 llm.listProviders/listModels/resolveModelInfo |
能力过滤、effort 选择 |
| 渠道账号、每账号每模型的限流解禁时间 | OFM 写的 $DSH_HOME/channel-pack/state.json |
渠道级可用性(只读) |
| 今日每渠道每模型的请求/错误/token/首字 | OFM 写的 $DSH_HOME/channel-pack/token-ledger.json |
第二优先的延迟证据 + 用量项 |
| 免费车道的逐模型可用性与探测延迟 | OFM 写的 $DSH_HOME/our-free-model/availability.json |
免费车道不可用/可用判定 |
| 免费车道今日逐模型统计 | OFM 写的 $DSH_HOME/our-free-model/stats.json |
免费车道用量 |
| 真实流量的首字、token、失败 | 本插件观测 llm/stream |
第一优先的延迟证据 + 健康账本 |
| 智力分、逐车道报告首字、标签 | 报告导入 / 随包 priority-data.json |
先验,可信度收缩 |
关键点:
- 报告只是先验。 报告是别的机器、别的日期、benchmark 最大 effort 下测的。
quality会按「识别可信度 × 实测/估算」向 0.5 收缩:精确别名/规范表/改名折叠 0.9, 模式猜测 0.7,报告没见过的 id 直接中性 0.5。它不会因为「报告没写」就被排除。 - 渠道改名不改家族。 同一个模型换个 id 卖(
sn-deepseek-v4-1-flash→sn-deepseek-v4-1-flash-1017、opencode/deepseek-v4.1-flash)仍归到同一个家族: 识别会先试原样、再试去掉静态标记,最后逐层折叠并回表验证(构建号/日期戳、区域标记-sg、渠道命名空间xxx/、渠道前缀sn-、虚线版本号),只有折叠后恰好命中已知模型 才采纳,所以猜错不产生任何后果,新后缀也不用改代码。见下节。 - 不再拿家族最快车道的首字当某条车道的首字。 旧版会让一条 45 秒的车道继承另一条 1 秒车道的速度; 现在只认这条车道的实测/账本/报告值,没有就承认「没有证据」。
- 渠道限流以渠道面板自己记录的为准。 同一渠道多账号时,只有所有启用账号都被该模型限流才判不可用。
- 所有数据都在本机。 不上传任何东西;渠道文件读取失败只是「没有信号」,路由照常工作。
模型识别:四道关卡
| 顺序 | 关卡 | 例子 | 可信度 |
|---|---|---|---|
| 1 | 原样命中别名 / 规范表 / 修正表 | gmodel、z-ai/glm-5.3、deepseek-flash |
1.0 |
| 2 | 折叠后恰好命中已知模型 | sn-deepseek-v4-1-flash-1017、opencode/deepseek-v4.1-flash |
0.85–0.95 |
| 3 | 命名约定模式 | mimo-v2.6-flash-ultra、glm-5.3-flashx |
0.65–0.7 |
| 4 | 认不出来 = 自己一个家族 | brand-new-model-2099 |
0(中性分) |
第 2 关的折叠规则在 src/families.js 的 FOLD_STEPS(最多折三层,先试折叠次数最少的):
| 折叠 | 去掉的东西 | 可信度 |
|---|---|---|
snapshot-date |
-2026-04-20 这类日期戳 |
0.95 |
build-tag |
-1017、-0731、-v2、-b3 这类构建 / 修订号 |
0.95 |
region-tag |
-sg、-us、-intl、-latest 这类区域 / 渠道标记 |
0.95 |
namespace |
opencode/、codearts/ 这类一层命名空间 |
0.95 |
channel-tag |
sn- 这类渠道自己的短前缀 |
0.85 |
version-dash |
把 v4-1 折成 v4.1 这类虚线版本号 |
0.85 |
为什么安全:折叠只是「提议」,采纳的唯一条件是折叠后的 id 在别名表里精确存在。
所以 brand-new-model-2099 去掉 -2099 之后 brand-new-model 谁都对不上,直接退回身份家族,
不会把两个陌生模型混成一个;模型名本身以数字结尾的(gpt-oss-20b、nemotron-3-super-120b-a12b)
也照原样保留。
批处理端点是唯一例外::batch / :async 永远保持自己的身份,绝不被折进交互式家族。
屏蔽与放行跟着家族走。 既然一个模型可以有多个 id,deny: ["deepseek-v4.1-flash"] 必须
把它所有拼写一起挡掉,否则换个后戳就是绕过自己的黑名单。规则:写家族名或任意一个已识别的 id
→ 整个家族被挡;写 provider/model → 只挡那一条车道。denyProviders(计费渠道用的那个)
本来就是渠道级,完全不经过模型识别。
界面
两个入口,同一份数据:
- 会话里:输入框下方一条状态带(
conversation.composer.dock,与「用量/余额看板」并排); - 新会话首屏:新会话用的是另一个 composer 变体(
hero),composer.dock在那一屏不渲染—— 所以另注册了一个紧凑 chip 到conversation.hero.options(工作区/预设那一行的右侧)。 还没有任何请求时它显示下一条消息将会用哪个模型,点开是同一个面板的浮层。 - 官方模型列表最上方:对话框右下角点开模型列表(两级菜单里选「模型」那一页),第一行就是
自动选择,排在
DeepSeek 账号上面。选中它 = 模型全部由调度决定;在这一页里点任何一个 具体模型 = 你明确要那个模型,自动选择随即让位(面板「设置」里也有同一个开关,可以在不选模型的 情况下关掉它)。 - 对话框右下角那个模型名:官方 seat 的标签(
conversation.input.model.label)由本插件接管。 开启自动选择后那里显示的是**「自动选择」**——选择器里那个模型名这时已经不是实际被调用的模型; 紧跟其后的推理档 chip 也一并隐藏(它同样只属于选择器)。关掉开关立刻恢复显示模型名, 模型列表内部的每一行仍然显示各自的模型名,不受影响。
会话里:[模型调度 · 自动] ● glm-5.3 可用 今日 12.4k 自动切换 2 ▾
首 屏:[● 模型调度 · 自动 glm-5.3 ▾] (点开是浮层面板)
右下角:[⌗ 自动选择 ▾] (开启自动选择时,官方 seat 被改写)
展开后的面板按标签页分(一屏之内,不再是一条几十行的长栏)。2026-11-04 起新增「巡检」页签与概览巡检卡:
- 头行:模式(自动 / 偏好 / 手动 / 关闭)、当前策略、刷新;
- 概览:当前模型卡(家族、真实渠道、推理档、请求数、自动切换、失败原因与倒计时)、 偏好卡(当前偏好 + 可用渠道数 +「设为偏好」+「浏览全部模型…」)、目录卡、巡检卡(开关状态、 上次结果通过数、一键跳到巡检页),以及策略排名前 4 的模型组;
- 模型:全部模型组(★ 设为偏好、可用渠道/总渠道、最佳渠道、智力、得分);
- 渠道:当前模型的渠道表(渠道、状态、得分、首字、今日 token);
- 巡检:定时渠道巡检的完整面 —— 开关、间隔(1/3/7/14 天)、立即跑一轮按钮、是否也探借入车道、 上次/下次时间与耗时、通过/失败/跳过计数、失败原因聚合计数(如「14× 冷却中」)、 每条车道的判决表(渠道、结果、耗时、原因原文);Host 是旧版本时这一页显示「未提供巡检数据」, 面板其余部分照常工作。
- 记录:最近切换(时间、从哪到哪、为什么);
- 设置:策略、偏好模型(下拉列出全部模型,不截断)与备用模式、推理档、单轮 token 预算、 渠道数据接入状态、首轮接管开关、自动选择开关;
- 偏好暂停时,概览顶部出现横幅:原因 + 「重试偏好」。
偏好模型有两个入口:模型组表里点 ★ 直接设;或点「浏览全部模型…」打开专用选择器——
搜索框 + 全量模型组列表(每行给出可用渠道/总渠道、最佳渠道、智力、视觉、上下文窗口),
点一行即设为偏好,另有「不锁定偏好(回到自动调度)」。下拉与列表都由 /api/model-orchestrator/families
提供:它是不截断的全量清单,只在这个界面打开时按需拉取,不跟着 10 秒轮询走。
面板只读 /api/model-orchestrator/summary、写 /api/model-orchestrator/config;巡检页签另走
/api/model-orchestrator/probe(GET 只读状态,POST {action:'run-now'} 跑一轮——读和写分开成独立路由,
因为跑一轮会真实消耗额度,不能被一次误发的配置写入顺带触发)。
所有路由都走宿主自己的鉴权闸门(ctx.connection),未登录的 loopback 调用者拿到 401。
summary 里的巡检块带条数上限(60 条,超出的只报 truncated),所以 10 秒轮询不会因为
一轮几十条的巡检结果悄悄变贵;完整明细走工具 action=probe。
给模型用的工具与命令
工具 model_orchestrator:
status/explain/lanesset(mode/strategy/preferModel/preferProvider/preferFallback/turnTokenBudget/deny/allow/denyProviders/reasoningEffort)prefer/unpreferrefresh/import_report/clear_import
命令 /route:
/route status|explain|lanes|auto|manual|off|prefer <模型> [渠道]|unprefer
/route quality|balanced|economy|speed|reasoning <auto|preserve|effort>|refresh
面板上的每个操作和工具/命令走的是同一份实现。
配置
写在 profile 的 cordis.patch.yml(优先级最高),或在面板里改(存到 $DSH_HOME/model-orchestrator/overrides.json)。
补丁层里显式写过的键会盖过面板。
| 键 | 默认 | 说明 |
|---|---|---|
enabled |
true |
总开关。 |
mode |
auto |
auto / prefer / manual / off。 |
preferModel / preferProvider |
'' |
偏好模型(家族 id、模型 id 或 provider/model)与可选渠道限制。 |
preferFallback |
auto |
偏好不可用时的模式:auto / manual / off。 |
preferNotify |
true |
暂停/恢复时发桌面通知。 |
preferAutoResume |
true |
渠道恢复后自动恢复偏好。 |
preferFollowSelection |
true |
选择器改选时同步改偏好模型。 |
strategy |
balanced |
quality / balanced / economy / speed。 |
deny / allow |
[] |
额外屏蔽 / 放行的模型(家族名、任意一个 id,或 provider/model 精确到单条车道)。写家族名或某个 id 会覆盖这个名字的所有拼写;写 lane/model 只作用于那一条车道。 |
denyProviders |
[] |
整条渠道排除(按渠道 id,与模型识别无关;计费渠道常驻这里)。 |
maxSwitchesPerStep / maxSwitchesPerTurn |
3 / 12 |
同一步/同一轮最多自动切几次。 |
requestRepeatThreshold |
2 |
同一渠道在窗口内连续拒绝几次后暂停该模型(1–10)。 |
requestRepeatWindowMs |
600000 |
上面那个「连续」的时间窗口。 |
requestRepeatBlockMs |
1800000 |
连续被拒后暂停多久(0 = 只换渠道不暂停)。 |
proactiveSwitch |
true |
冷却中的渠道直接跳过,而不是等它。 |
escalateOnFailure |
true |
失败后优先换到智力不低于失败模型的模型。 |
turnTokenBudget |
0 |
单轮输出 token 预算,超出后切省 token 档;0 关闭。 |
longContextThreshold |
120000 |
超过这个 prompt 规模就只考虑长上下文模型。 |
latencyGateMs |
10000 |
首字达到这个值时速度项为 0.5;0 关闭。 |
anchorBonus |
0.04 |
保持当前渠道的加分(只影响渠道层,不参与模型比较)。 |
switchMargin / laneSwitchMargin |
0.04 / 0.05 |
换模型 / 换渠道的粘滞阈值。 |
spreadConcurrency |
true |
并发分散:按「此刻正在飞的调用数」给车道扣分,多个会话不会一起堆在同一条最好的渠道上(见下节)。关掉退回纯历史排名。 |
concurrencySoftCap |
2 |
在飞的调用数达到几个时扣满(1–16)。 |
concurrencyWeight |
0.18 |
并发扣分的上限(0–0.3)。必须大于 laneSwitchMargin,否则「知道它忙」也换不动。 |
concurrencyStaleMs |
900000 |
一个在飞租约超过这么久就停止计数(流没关掉的兜底)。 |
failureBurstWindowMs |
5000 |
同一车道、同一失败类型在这个窗口内的多次上报算一次事件:只记一次 strike,冷却不往后滑。 |
userChoiceTtlMs |
1800000 |
手动选择器改动被尊重的时长(自动模式,翻倍阈值)。 |
routeFirstTurn |
true |
首轮接管:新会话第一条消息就按调度结果调用模型。关掉后首轮先跑选择器里的模型,第二步起才由路由器接。 |
autoSelect |
false |
自动选择:模型完全由调度决定,官方模型选择器只作展示(选它不会改变实际调用)。manual 在这个开关下按 auto 处理。 |
routeSubagents |
false |
是否也路由子代理会话(默认让子代理自己的模型选择生效)。 |
reasoningEffort |
auto |
auto(按策略)/ preserve(能保留就保留)/ 具体 effort。 |
channelData / freeModelData |
true |
是否读取渠道面板账本 / 免费车道探测。 |
catalogRefreshMs / channelDataRefreshMs |
120000 / 30000 |
模型清单 / 渠道数据的刷新间隔。 |
modelInfoTtlMs |
21600000 |
模型元数据缓存时长。 |
usageUnitTokens |
2000000 |
用量项的参考规模。 |
reportPath |
'' |
启动时自动导入这份报告。 |
probeEnabled |
true |
定时渠道巡检开关。 |
probeEveryDays |
3 |
巡检间隔(天)。被 probeIntervalMs 覆盖。 |
probeIntervalMs |
0 |
巡检间隔(毫秒)。>0 时优先于 probeEveryDays。 |
probeTimeoutMs |
20000 |
单条车道的探测超时。 |
probeConcurrency |
2 |
一轮内同时探测几条车道。 |
probeFirstDelayMs |
600000 |
首轮延迟(10 分钟),避开启动时的会话。 |
定时渠道巡检(probe)
免费车道会「静默死亡」:某个渠道 402 额度耗尽、某个免费层永久限流、某个模型悄悄下架。 这些只有被选中并失败一次之后健康账本才会知道——若很久没人选它,面板上的「可用」就是假的。
probe 按间隔把所有未 deny 的车道主动探一遍,把结果写回同一本账(health.json):
- 默认三天一次。探活要花额度,太勤等于替用户烧钱;太疏账本又失真。
- 冷却未到期的车道直接跳过:它本来就不会被选中,探了只是白烧额度。
- 探活请求带
purpose='probe',不计入会话的turnTokenBudget。 - 单条独立超时 + 并发上限:一条挂死不拖垮整轮。
- 关掉:
probeEnabled: false;立即跑一轮:model_orchestrator action=probe probeMode=run-now, 或面板「巡检」页签里的「立即跑一轮」按钮(两者走同一份实现)。
面板上的巡检页签
2026-11-04 加:在此之前 probe 只有工具入口,面板里找不到它(这也是它当时"像没做完"的原因)。 现在展开面板即可看到「巡检」页签:
| 控件 | 作用 |
|---|---|
| 巡检开关 | 写 probeEnabled。关掉后定时器停排,但已排队的一轮仍会跑完。 |
| 间隔下拉 | 1 / 3 / 7 / 14 天,写 probeEveryDays;下一轮按新值重排(不用重启)。 |
| 立即跑一轮 | POST /probe {action:'run-now'},真发请求、真花额度;跑完立刻重读 summary。 |
| 也探借入车道 | 写 probeBorrowed(默认关)。 |
| 判决表 | 上一轮每条车道的渠道 / 结果 / 耗时 / 原因原文。 |
| 失败归因 | 把失败原因按原文聚合计数,一眼看出「是整条渠道挂了还是某个模型挂了」。 |
概览页另有一张巡检卡:开关状态 + 上次通过数 + 跳到页签的按钮 —— 不打开页签也能看到「该做的事做没做」。
旧键兼容:pinProvider/pinModel 会被折叠成 preferModel/preferProvider,并把 auto 改为 prefer。
数据存在哪
$DSH_HOME/model-orchestrator/health.json—— 渠道/模型账本:状态、冷却到期、失败次数(3 小时半衰期衰减)、 首字 EWMA、token 用量(15 分钟 / 1 小时 / 5 小时 / 当天)。$DSH_HOME/model-orchestrator/overrides.json—— 面板/工具改过的设置。$DSH_HOME/model-orchestrator/priority.json—— 你导入的报告数据层;clear_import删除它。
它是怎么接进内核的
- 决策点:
system-prompt/assemble(prepend: true)。 内核在组装时快照本步模型;插件在快照前写入选择。 - 自动选择(
autoSelect)。 锚点里不再有选择器、也不再有requestHeader:只保留本插件自己写过的路由 (所以同一轮内粘滞照常)。打开时立刻按当前策略重算一次并写回 selection,模型座位当场变化;manual与它 冲突,打开时manual落到auto。在官方模型列表里点了某个具体模型 = 一条明确指令:退出自动选择、 采纳这次选择(列表首行的「自动选择」就是重新接管),并在决策日志里留一条auto-select-off。 - 列表首行是注入的,不是 slot。 官方模型选择器(
client-ui-model-selection)独占座位与菜单,只开放一个conversation.input.model.label子槽;替换座位等于影子掉整个官方选择器(搜索、推理档、子代理门禁、键盘导航)。 所以那一行由MutationObserver在模型页出现时插进渲染出来的列表里,React 把它丢掉时会补回去。 - 首轮接管(
routeFirstTurn)。 会话还没有requestHeader、本插件也还没写过路由时,选择器里的模型只算 「上次用过的那个」,不是本轮指令:锚点直接给null(不标userChoice),selectRoute从全表选最优, 再把结果写进model/selection,于是选择器跟着路由器走而不是反过来。会话建立过程中内核自己写的 初始 selection 事件在这里一并丢掉,否则下一步会立刻锚回它。 - 写选择:
sessionController.selectForNextRequest(agent, selection)。 它同时追加model/selection事件并更新选择对象,这是浏览器选择器、会话日志和面板保持一致的唯一正确路径。 - 请求兜底:
agent/request。 在next()之后再按state.applied覆盖一次provider/model/effort, 保证真正发出去的请求就是路由器选的那条。 - 同一步故障转移:
agent/request-error。 返回{kind:'retry'}让内核在同一 step 重新解析配置。 - 观测:
llm/stream。 只记录抛出的异常;带内finish.error交给agent/request-error, 避免一次失败记两次(非 agent 循环的调用例外,它们没有 request-error)。 - 同步:
llm/adapters-updated+ 自适应定时器。 新登录的渠道账号会自动出现;刷新间隔改了不用重启。
验收情况
node tools/smoke.mjs # 203 项离线校验(纯逻辑)
node tools/integration.mjs # 67 项:用 mock Cordis 上下文加载真实 Host 半身
node tools/peershare-smoke.mjs # 44 项:渠道互借(含端点不可达/清单失效)
node tools/probe-smoke.mjs # 34 项:定时渠道巡检
node tools/client-smoke.mjs # 60 项:用 stub ModuleLoader + 迷你 React/DOM 渲染浏览器半身
覆盖:配置、家族识别与可信度(含改名折叠:-1017 构建号、opencode/ 命名空间、-sg
区域标记、虚线版本号,以及「构建号不得凭空造出家族」的反例)、失败分类、账本自愈、渠道账本/限流/免费探测、
目录装饰(能力/倍率/重复 id/批处理/用户屏蔽)、两级选择、同模型换渠道、偏好模式
(生效/限流/暂停/备用关闭/本步临时改选/恢复)、回合结束恢复用户选择、粘滞与升级、effort 选择、报告导入。
集成测试还覆盖:真实插件 apply() 接线、model/selection 落盘、请求覆盖、回合边界恢复用户选择、
失败重试、偏好暂停通知、成功恢复通知、工具与命令面、首轮接管(选择器里两个不同模型得到同一条首轮路由、
下一步不回弹、关掉开关后首轮回到选择器模型)以及 summary 给新会话预告下一条路由。
浏览器半身测试覆盖:三个 slot 注册(会话状态带 + 新会话 chip + 官方 seat 标签,含它在 single 槽上
对随发行版 1017 标签的 shadow)、五个标签页、专用偏好选择器、浮层的关闭、失败态与加载态,注入到官方模型
列表的那一行(位置、可访问角色、点击写配置、被 React 丢掉后补回),以及右下角模型名的接管:
开关打开时 trigger 显示「自动选择」,列表里的 option / menu 两处仍显示模型名,关掉后立即还原。
部署动作
- 以
link:<你的插件目录>装在 web profile,并已加入dsh.profile.bundles。 - Host 半身改动需要重启一次 DSH Desktop(或
dsh web)才会加载;浏览器半身由 HMR/刷新加载。 - 重启后确认:状态条显示真实模型;
/route status能列出模型组;model_orchestrator { "action": "status" }的catalog.total> 0。
已知边界
- 只路由 agent 的模型请求。 其它直接调用
ctx.llm.stream的代码不经过本插件(但仍会被观测用量)。 - 剩余额度不可知。 免费渠道的上游不告诉你余额;面板显示的是状态、冷却、实测首字和本插件统计的用量。
- 报告是快照。 报告没有的模型按中性先验处理;跨版本 AA 分数不可比。
- 家族识别仍可能有误。 精确别名/规范表可信;改名折叠 0.85–0.95,模式匹配只有 0.7; 识别错了会把不同模型当成同一个,或者把同一条路线的两个 id 合成一组(合并只影响打分与选择器 的显示,不影响真实调用走哪个 id)。
- 对 OFM 是单向适配,不是合作,也不是官方关联。 本插件只读它写的四个 JSON:不改它、不向它提需求、 也不向它的仓库提交适配改动;OFM 作者既未参与本插件的开发,也未为它背书。
- 渠道账本读的是 dsh-our-free-model 的私有文件格式。 那四个 JSON 是 OFM 的内部状态,没有版本承诺;文件缺失或结构变化时自动退化为「没有信号」,不会阻断路由。 见 与 dsh-our-free-model 的关系。
- 偏好模式的「全部渠道不可用」是全局判断。 一次请求的图片/窗口限制只会临时改选,不会暂停偏好。
- 注入的那一行不参与官方菜单的键盘导航。 官方
↑/↓只在它自己的条目里走;这一行用 Tab 可达。 官方若改了菜单结构,最坏情况是这一行不出现,不会影响官方选择器本身。 - 自动选择是全局开关,不是每会话一份。 它决定「选择器还算不算指令」,属于同一个人的使用习惯; 会话级会是另一种产品(同一份会话列表里有的会话听调度、有的听选择器),所以没做。
- 官方 seat 标签是「接管」而不是「叠加」。
conversation.input.model.label是single槽, DSH 随发行版带的 1017 发布标签(@deepseek-ai/dsh-client-ui-deepseek-model-label-1017)也占着它, 本插件按框架自己的 shadow 规则用priority: -1取胜——代价是那个给 DeepSeek 官方 Flash 加「1017」 徽标的装饰不再显示(只影响该徽标,选择器与模型列表本身不受影响)。隐藏推理档 chip 是 DOM 层的小手术, 指向 seat 自己的标签行;官方若改了触发按钮结构,最坏情况是那枚 chip 不被隐藏,不会报错。 - 首轮接管只作用于 auto 模式的首个组装。
prefer按偏好模型走,manual按选择器走;off不介入。关掉routeFirstTurn就回到「首轮先跑选择器里的模型」。 新会话页的 chip 展示的是预计路由(那一刻还不知道这条消息要不要图片或长上下文), 真正发出去的那条由system-prompt/assemble现场算。 - 切换会改变模型看到的上下文。 换 provider 时内核会丢掉适配器私有的 replay 信封(文本与工具历史保留), 并追加一条持久提示告知模型发生过切换——这是内核设计好的行为。
开发
node tools/build-data.mjs [报告.md] # 重新生成 src/priority-data.json
node tools/smoke.mjs # 123 项离线校验
node tools/integration.mjs # 52 项接线校验
node tools/client-smoke.mjs # 44 项浏览器半身渲染校验
tools/build-data.mjs 只写「实测事实」;策略(黑名单、车道提示、权重、冷却时长、effort 选择)
在 src/policy-defaults.js,所以重新导入一份报告不会悄悄改变插件的行为。
许可
MIT
No comments yet. Be the first to write one.