dsh-llm-auto
给 DSH 加一个 auto 模型:模型选择器里多出一个 Auto 分组,组内一条 auto。
选它以后,请求按你给的顺序依次尝试多条「provider + model」,某条失败会先按官方同款策略
在该路由内原地重试(瞬时错误,默认最多 5 次),重试耗尽才静默切下一条 ——
对上层(agent loop / 会话日志 / 压缩链路)它就是一个普通模型。用来把多个模型订阅"合并"成一个入口。
还能把自动压缩点钉在你要的位置(compactWindow,默认 50 万 token):DSH 的压缩引擎按
"该请求声明的窗口"算阈值,而本插件的窗口是按压缩点反算出来的。
0.5.0 起,插件页那张卡片上还有一块只读的 回退链面板:当前生效的有序链(第几条是首选、
重试开不开) + 最近若干次请求实际怎么回退的(哪条失败、什么错误码、切给了谁、花了多久)。
详情见 §2「回退链面板」。
┌─ 用户选了 auto/auto ─┐
请求 ──► AutoAdapter.stream()
│
├─ 1) opencode-go/deepseek-v4.1-flash
│ ├─ 第 1 次失败(SERVER 502) ──► 白名单内 ⇒ 退避 500ms 后原地重试
│ ├─ 第 2 次失败 ──────────────► 退避 1s 后原地重试 ……(默认最多重试 5 次)
│ └─ 重试耗尽 ────────────────┐
├─ 2) commandcode/deepseek/deepseek-v4.1-flash ▼
│ └─ 成功 ⇒ 分片原样透传给上层(前面各次的 usage 等协议分片已被丢弃)
└─ 3) deepseek-official/deepseek-flash ──► (没轮到)
│
└─ 全部失败 ⇒ 抛 AUTO_ROUTES_EXHAUSTED,消息里逐条列出「哪条路由、试了几次、怎么失败的」
1. 快速开始
本包是组合包(bundle):注册行(id: llm-auto)随包发布在包内 cordis.patch.yml 里,
宿主按 profile package.json 的 dsh.profile.bundles 加载 —— 不需要再往 profile 的
cordis.patch.yml 里贴任何 - insert: 行。
在 DSH profile 目录(~\.dsh\profiles\web\)下:
# ① 装进 profile:dshpm 会顺带把包名写进 dsh.profile.bundles
node <工作区>\dsh-plugin-manager\dshpm.mjs add file:./plugins/dsh-llm-auto --profile web
# 公开环境:profile 的 package.json 里加 "dsh-llm-auto": "github:liuyun847/dsh-llm-auto"
# 再 pnpm install(或 npm install) —— 同样只要包名在 dsh.profile.bundles 里
# ② 按本机 provider 改 routes:改包内 cordis.patch.yml 那一行(profile 层也可按 id 覆写)
# ③ 重启一次 dsh web(见 §4「生效条件」)
- 启停:Web 侧栏「插件」页 →「已安装」区里本卡的总开关(写
dsh.profile.bundles); 点开卡片后每一行还有行级开关(向 profile 的cordis.patch.yml写disabled覆盖 —— profile 层在包层之后应用,所以覆写优先)。 - 卸载:
node dshpm.mjs remove dsh-llm-auto --profile web。 ⚠ 本 profile 的 pnpm 带供应链策略,pnpm remove会报ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED;绕过办法是进 profile 目录直接跑pnpm remove dsh-llm-auto --config.minimum-release-age=0(dshpm 的--fast只对add有效 ——pnpm remove不接受--minimum-release-age这类参数)。 - ⚠ 0.4.0 新增的浏览器半侧必须重启一次 dsh 才会被收录:宿主
dsh-client-modules把 "本包不是客户端包"这一否定结论按 specifier 缓存在pkgMeta里,只写不删 (其lib/index.js:510/:703),HMR 不会重新扫描 ⇒ 半侧新增后先重启,插件页才会 长出配置表单(见 §2)。
重启后:模型选择器出现 Auto 分组 → 选 auto → 发一条消息 → 打开
http://127.0.0.1:3080/api/llm-auto/routes 看它到底走了哪条(以及当前声明的上下文窗口)。
compactWindow 的图形入口在插件页:「已安装」→ 点开 dsh-llm-auto 那张卡片,表单就在
卡片描述与行列表之间(0.4.0 起,见 §2「插件页里的 compactWindow 表单」)。
2. 配置
配置有两个入口:
- 插件页里的表单(0.4.0 起):「已安装」→ 点开
dsh-llm-auto卡片 → 表单在描述与行之间, 只暴露compactWindow一个字段,保存才写入(见下节「插件页里的 compactWindow 表单」); - 包内
cordis.patch.yml的注册行(= 本包自带、随组合包加载的那一行):结构性配置routes/retry只能在这里改;profile 层要覆盖就按id: llm-auto写覆写行(profile 层在包层之后应用)。
标了「热改」的键同时是 schemastery 的 .volatile() 字段,经宿主的设置服务可改、改完即时生效
(效果边界见本节末尾)。
- insert:
- id: llm-auto
name: 'dsh-llm-auto'
config:
routes:
- { provider: opencode-go, model: deepseek-v4.1-flash }
- { provider: commandcode, model: deepseek/deepseek-v4.1-flash }
- { provider: deepseek-official, model: deepseek-flash }
# 压缩点(默认就是 500000,写出来只是显式化)
compactWindow: 500000
# retry 不写 = 官方默认(每路由 5 次重试);要关掉或改参数再放开:
# retry:
# maxRetries: 5
# retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
# backoff: { initialDelayMs: 500, maxDelayMs: 10000, jitterRatio: 0.1 }
| 键 | 必填 | 默认 | 热改 | 说明 |
|---|---|---|---|---|
routes |
✅ | — | ✗ | 有序回退链,第一项即首选。每项 { provider, model };model 要写全(settings.yaml 里的真实 id,如 deepseek-v4.1-flash、deepseek/deepseek-v4.1-flash,别省前缀)。改它要改包内 cordis.patch.yml(包层 patch:不需要重启,但改完要有一次触发才被读入 —— 见 §4 第 2 条;插件会重新 apply) |
compactWindow |
500000 |
✅ | 让自动压缩发生在这个 token 数附近(正整数)。对外宣称的窗口由它反算(见下) | |
name |
Auto |
✅ | 模型显示名(选择器里的分组名恒为 Auto) |
|
contextWindow |
见下 | ✅ | 【旧键】直接声明对外宣称的上下文窗口(正整数)。与 compactWindow 同时给出时以 compactWindow 为准并被忽略(warn 会点名两者) |
|
logLimit |
50 |
✅ | 路由日志环形缓冲条数(仅内存) | |
retry.maxRetries |
5 |
✗ | 每路由重试上限(不含首次);0 = 关闭重试,恢复"一次败就切" |
|
retry.retryableCodes |
EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT |
✗ | 可重试的错误码白名单;永久错误(不在表里的)一次败就切,不白烧请求 | |
retry.backoff.initialDelayMs |
500 |
✗ | 指数退避起步(毫秒) | |
retry.backoff.maxDelayMs |
10000 |
✗ | 退避封顶;上游 Retry-After 超过它则不等了,直接切下一条 |
|
retry.backoff.jitterRatio |
0.1 |
✗ | ±10% 对称抖动 |
「热改」= 该键是 schemastery .volatile() 字段 ⇒ 由宿主设置服务写入后原地更新引用,
插件下次用到时就是新值(不需要重启、也不需要重新 apply);✗ 的键是结构性配置,
由 apply() 一次性消费,只能改包内 cordis.patch.yml。
retry 块的形状与默认值全部复用官方 resolveRetryPolicy(@deepseek-ai/dsh-llm,
即自带 dsh-llm-retry 用的那个):选 auto 与选普通模型的重试语义一致。只支持 mode: 'normal'
(always = 无上限重试,单请求可能无上限计费,挂载时 warn 并回落默认);坏值只 warn 不阻止注册。
compactWindow:压缩点 → 对外宣称的窗口
DSH 的自动压缩引擎 @deepseek-ai/dsh-compaction-basic 不看真实上游窗口,只用"该请求声明的
窗口"算阈值(该包 lib/index.js:124-147):
threshold = floor(min( cw × 0.8, cw − reserved − 65536 )) # 0.8 / 65536 是引擎默认值
reserved = 请求头 maxTokens ?? 适配器声明的 defaultMaxTokens ?? 0
本插件既不声明 defaultMaxTokens、调用方也不给 maxTokens ⇒ reserved = 0,
于是 threshold = floor(min(cw × 0.8, cw − 65536))。反过来解就能让压缩点落在你要的位置:
cw = max(ceil(T × 1.25), T + 65536) # T = compactWindow;T = 500000 ⇒ cw = 625000
验算(T = 500000):0.8 × 625000 = 500000;625000 − 65536 = 559464 > 500000(min 取前者);
保留尾部 floor(625000 × 0.16) = 100000 < 500000 ✅。挂载日志会把这一步连同假设打出来:
auto: 压缩点 500000 → 声明窗口 625000(假设 compaction-basic thresholdRatio 0.8 / headroomTokens 65536)
行为变化(升级到 0.3.0 就会看到):本机 auto 链的窗口从"第一条可解析路由的 884000"
变成"按默认压缩点反算的 625000",压缩触发点相应从 707200 提前到 500000 ——
后者正好等于本机全链最紧那一跳的可用输入预算(opencode-go 884000 − 384000 = 500000)。
这是有意的:声明窗口在这里只是"给压缩引擎定位用的刻度",不是对上游窗口的承诺。
优先级(纯函数 planDeclaredWindow(),实现在 lib/compact.js):
compactWindow(正整数) → 反算声明窗口 默认 500000 ⇒ 625000
contextWindow(正整数) → 直接声明(旧键) 不写 compactWindow 时才轮到它
逐跳解析 → 第一条可解析路由的窗口
兜底 65536
⚠ 宿主里 schema 总会给
compactWindow补上默认值 500000 ⇒ 默认口径就是反算;contextWindow只在"compactWindow完全没出现"时才生效(直接调apply()的集成方)。 两者同时给出时会打一条 warn 说明用了哪个、忽略了哪个。 ⚠compactWindow低于最小可用值 12484(见minimumUsableCompactWindow())会另打一条 warn:此时引擎的"保留尾部 < 阈值"校验过不去 (floor(0.16 × (T + 65536)) ≥ T),@deepseek-ai/dsh-compaction-basic每轮抛TargetPressureConfigError;该错误被引擎捕获(agent/pre-step那层):第一次 warn、之后对同一目标 静默跳过压缩、回合照常继续 —— 净效果是压缩从不发生(不会报错,但上下文会一直涨)。 这个下界是算出来的,不是拍脑袋的常量(见minimumUsableCompactWindow())。 ⚠compactWindow/contextWindow非法(非正整数)只 warn 并回落默认值,不会让宿主起不来。 ⚠contextWindow ≤ 65536时引擎的 pressure budget ≤ 0,连阈值都算不出来(同样每轮抛错)—— 这也是引入compactWindow的原因之一;反算路径下T ≥ 12484一定是安全的。 ⚠ 该反算依赖上面三个引擎默认常量。引擎换版本、或你把compaction-basic的thresholdRatio/headroomTokens改成别的值,映射就不再精确 —— 挂载日志里那行假设就是为 这种时候留的证据(可用/api/llm-auto/routes的compactWindow/declaredContextWindow复核)。
插件页里的 compactWindow 表单(0.4.0 起)
0.4.0 起本包自带浏览器半侧(lib/client.js),注册进插件页为组合包预留的 plugins.bundle.config
slot(键 = 本包包名 dsh-llm-auto)⇒ 插件页 →「已安装」→ 点开 dsh-llm-auto 卡片,
官方 SettingsForm 的表单就渲染在卡片描述与行列表之间,不必再点进任何二级页。
位置沿革(两版同为 2026-09-25):初版注册的是
plugins.row.config(键<包名>#<行 id>), 表单只能从「行llm-auto→「配置」」的二级页进入 —— 要钻四层。现按用户要求提到卡片上, 行上的「配置」控件随之消失(本插件不再占用plugins.row.config)。 两个槽位的契约都在dsh-client-ui-plugin-manager的lib/types/client/slot-contract.d.ts; 该页只在 ledger 收录了这个键时才渲染这一段(其client.js:2879的ledger.bundles.has(pkg.name)), 而 ledger 直接读槽位注册的 key(其client.js:48的keysOf)⇒ 键必须恰好是包名。
- 只有一个字段「用于压缩的上下文窗口」(=
compactWindow),与官方四个配置页 (ui-settings-shell/agent-loop/subagent/web-search)同构:用SettingsFormModel/SettingsForm/SettingsValueField暂存草稿,点保存才写入,自带「已覆盖」标记与「恢复默认」; - 写入经宿主 settings 服务,只落
compactWindow这一个键,routes原样不动 (routes/retry不是.volatile()字段,写了也不会原地生效 ⇒ 仍改包内cordis.patch.yml); - 命名空间就是 loader 行 id
llm-auto(dsh-settings按entry.options.id投影)—— 与启停开关、覆写行的寻址键一致; - 前提:这个半侧是 0.4.0 新增的,首次启用必须重启一次 dsh 才会被收录(依据见 §1); 此后改这个文件本身也要重启(或至少刷新页面)才看得到新表单,见 §4 第 2 条;
- 备选改法不变:手编 profile 的
cordis.patch.yml追加按 id 覆写的顶层行 (- id: llm-auto+name: 'dsh-llm-auto'+config: { routes: [...], compactWindow: N }, profile 层在包层之后应用 ⇒ 遮蔽包内那行 insert),或直接改包内cordis.patch.yml。routes这类结构性配置没有表单,只能走这两条。
回退链面板(0.5.0 起,只读)
同一张卡片上、compactWindow 表单下方多出一块「回退链」面板:
回退链 [刷新]
按上面的顺序依次尝试;某条重试耗尽、或错误码不允许重试时,才静默切下一条。这里是只读视图。
当前链(第一项即首选)
1. opencode-go/deepseek-v4.1-flash
2. commandcode/deepseek/deepseek-v4.1-flash
3. deepseek-official/deepseek-flash
每路由最多重试 5 次,退避 500ms→10000ms
最近请求 ............................................ 共 12 条记录,容量 50
● 14:23:11 4.2s 成功
1. opencode-go/deepseek-v4.1-flash ✕ RATE_LIMIT 试了 5 次 → 切换至 2
2. commandcode/deepseek/deepseek-v4.1-flash ✓ 成功
● 14:18:02 1.1s 成功
1. commandcode/deepseek/deepseek-v4.1-flash ✓ 成功
- 上半是配置链:直接取端点的
chain与retry,即当前生效值 ——routes只能改 包内cordis.patch.yml(没有表单),这块面板就是它在界面上的唯一出口;重点是 "第几条是首选""重试开不开、退避多少"。 - 下半是运行时的真实回退:端点的
calls字段,按一次 auto 请求分组(adapter 给 每条日志写上本次stream()的序号call,宿主侧lib/calls.js的groupCalls()还原)。 同一条路由的多次尝试合并成一行(试了 N 次),错误码取该路由的最终失败,→ 切换至 …指向下一条候选。最近结束的请求排最上面。 - 只读:面板不写任何配置 —— 没有 routes 表单,也不把日志写进任何持久存储。
- 刷新时机:组件挂载(进这张卡片)时读一次端点,点「刷新」再读一次;不轮询。
- 失败有提示而不是空白:端点非 200 / 网络错误 ⇒ 面板尾部一行"读取失败:原因" (插件没加载、端点不可达时正是这个);链与记录都为空时给空态文案。
- 路由日志是进程内内存,重启即清空 ⇒ 面板读的就是它,重启后"最近请求"从空开始。
- 备选仍是 curl:
curl http://127.0.0.1:3080/api/llm-auto/routes(完整字段见 §5)。 - 面板样式只用主题
--dsw-*token(浅色/深色两套随外壳),状态用官方StateDot/Tag; 文案走本插件的字典命名空间llmAutoSettings(中英双语,与 compactWindow 表单同一份)。
导出 Config(= 成为可编辑配置条目),以及它的代价与效果边界
0.3.0 起本插件导出一个 schemastery Config(lib/index.js)。效果是配置成为宿主的可编辑条目:
dsh-settings 的 SettingsForms.describe() 会为带 schema 的活动条目生成描述符
(lib/index.js:413-452;schema 取 entry.fiber.runtime.Config,同文件 :538-541),
settings.describe() / settings.mutate() 这条远端通道因此能看到并写入它的字段。
name / compactWindow / contextWindow / logLimit 标了 .volatile(),插件不缓存这些值
(每次用到时重新读引用),所以值一改就生效、不需要重启;routes / retry 是结构性配置,
仍走包内 cordis.patch.yml。
⚠ 效果边界(如实说明):导出
Config这一步本身不生成任何表单 —— 随包发布的 Web 客户端 没有"按 schema 自动生成表单"的页面(@deepseek-ai/dsh-settings的 README 自己写着Each form reports autoGenerate … for clients that build pages from the schema; no shipped client does so yet),插件页(Plugins)只渲染经 slot 注册的表单页(dsh-client-ui-plugin-manager的plugins.item/plugins.bundle.config/plugins.row.config)。0.4.0 起本包自带浏览器半侧 (lib/client.js)注册进plugins.bundle.config,才把compactWindow那一个字段变成插件页里可点的 表单(见上一节);宿主侧 schema 的作用是让settings.describe()/settings.mutate()这条远端 通道能看到并写入这些字段。 本节的结论来自源码阅读 + 单测(test/compact.test.mjs里"真 schema 校验 ⇒ 解包 ⇒ 反算 625000")。
代价与取舍(原作者当初"有意不导出 Config"的理由依然成立,只是被权衡掉了):
- schema 是加载期校验,校验失败 = 整行插件加载失败(不再是本插件那条"打 error 但不注册"
的软失败)。所以
routes/retry用z.any():形状校验继续留在normalizeRoutes()/normalizeRetry()里,坏值依旧只 warn/error;数值字段用z.number()但不加.min()/.step(), 范围与整数性仍由插件自己判并 warn 回落。唯一会硬失败的是类型错误(例如把字符串写进compactWindow这种数字字段)。 - 表单只覆盖标了
.volatile()的字段(volatileForm(),dsh-settingslib/index.js:122-131; 一个 volatile 字段都没有时整条目被跳过),所以routes/retry不在可写字段里 —— 这是有意的: 它们由apply()一次性消费,标成 volatile 等于承诺一个做不到的"改了即时生效"。 .volatile()字段在插件里拿到的是 cosmokit 的 Volatile 引用(不是值本身), 读之前必须unwrapVolatile()(lib/compact.js)。
⚠ 宿主侧这条能力("配置成为可编辑条目")的依据是上面引用的源码位置 + 单测里 "真 schema 校验 → 解包 → 反算 625000"这条用例(见 §7);插件页里的表单入口见上面那节 (它走的是同一套 schema:
ctx.configForms只投影标了.volatile()的字段)。 重启后请先看/api/llm-auto/routes的compactWindow/declaredContextWindow是否符合预期。
contextWindow 不写且 compactWindow 也没给时:逐个试路由,取第一条能给出正整数窗口的那条的窗口;
全都拿不到就用保守值 65536(宁可让压缩早触发,也不要谎报一个大窗口导致请求必撞上游上限)。
解析结果缓存 30 秒,目录被反复重建时不会反复去问上游适配器。
⚠
provider不要写auto(自递归):挂载时会 warn 并跳过该条。 ⚠routes为空/缺失/解析后一条不剩:打一条error并拒绝注册(不抛错,宿主照常启动)。 ⚠ 写成auto的整条目的链要避开两个坑:①provider: auto是自递归(挂载时 warn 并跳过该条); ② 别写本机不可解析的通道或模型 id,否则那一跳每次都以NO_ADAPTER/UNKNOWN_MODEL/MISSING_CREDENTIAL白撞一次(点开/api/llm-auto/routes能看到真实 code)。 ⚠ 本文档里的链是"当时的实例",权威定义在包内cordis.patch.yml(profile 层按id: llm-auto的覆写行优先):那份改了而本文没同步时, 以文件为准(curl http://127.0.0.1:3080/api/llm-auto/routes的chain字段永远反映当前生效值)。
3. 路由行为细则
回退判定
DSH 的适配器失败不是抛异常,而是终止分片
finish{ kind:'error'|'aborted', failure:{ code, message, status? } }(由 LlmRuntime.adapterStream
把适配器的一切异常归一而成)。本插件据此判定,口径是「黑名单 + 默认回退」:
- 绝不回退:
ABORTED(调用方取消)、CONTEXT_WINDOW_EXCEEDED(请求本身超窗)、IMAGE_OFFLOAD_REQUIRED(官方约定:按offloadImages卸图后重试同一条); - 其余一律回退,包括未知码/没有码。
之所以不做白名单:真机实测的上游错误码空间是开放的 —— 不存在的 provider 是 NO_ADAPTER、
错误 model id 是 UNKNOWN_MODEL、缺凭据是 MISSING_CREDENTIAL、上游 502 是 SERVER
(而 SERVER 并不在 @deepseek-ai/dsh-llm 的错误码常量里,是 pi-ai 自己带的)。
白名单会把真实错误漏成"不回退"。
注意这是换路由的口径。原地重试是另一套、相反的口径(白名单,见下节): 重试比切换贵(同一路由重复计费),永久错误不值得白撞 N 次。
硬要求:已经吐给调用方内容之后不重试也不回退
一旦向调用方交出了内容分片(text-delta / reasoning-delta / tool-call-delta / block-end),
后续失败原样上报,绝不换路由、绝不在该路由内重试 —— 否则用户会看到"半截回答 + 重新回答"的拼接。
实现上还有一条来自源码的硬约束:重试/回退只能发生在"一个分片都没交出去"的时候。
@deepseek-ai/dsh-llm/lib/invariant.js 给每一次 llm/stream 套了流语法校验器,它会拒绝
「同一个流里 block-start 重复 index」和「usage 出现两次」。所以 block-start 与 usage
会被暂存,直到第一条内容分片到达才一起放行。这不是洁癖:真机观测到
ww/gpt-6-astra 先发一个全零 usage 再报 502(2026-09-23 探针实测,该条已在同日改链时移出),
暂存 usage 正好让这种情形仍能安全重试/回退。
路由内重试(v0.2.0 起,默认开启)
某条路由失败时,若错误码在瞬时白名单(EMPTY_RESPONSE / RATE_LIMIT / SERVER /
TIMEOUT / TRANSPORT)且该路由还有尝试预算,就带退避原地重试,重试耗尽才切下一条。
策略形状/默认值/校验复用官方 resolveRetryPolicy(@deepseek-ai/dsh-llm),
与不选 auto 时的普通模型完全一致:
- 默认每路由最多重试 5 次(共 6 次尝试),
retry.maxRetries: 0关闭; - 退避 500ms 起步、10s 封顶的指数退避,±10% jitter(第 1/2/3… 次重试前约等 0.5s / 1s / 2s / 4s / 8s);
- 上游
Retry-After在 10s 界内优先;超过上限则不等了直接切(与官方 normal 模式一致 —— 一条"等 120s"的指令等满了大概率还是限流,不如换一条健康的路); - 白名单外的错误码(永久错误)不重试,一次败就切;
- 调用方取消(含退避等待中被取消)立即收尾,不再打上游;
- 与自带
@deepseek-ai/dsh-llm-retry不叠加:那个挂在 agent loop 瀑布上管"整步重跑", 管不到本插件的嵌套调用;本插件的最终错误码AUTO_ROUTES_EXHAUSTED也不在它的默认可重试 集合里。同一次上游失败只会被一层消费,不会双重计费。
历史回放:嵌套调用前把 source 改回真实路由(2026-09-24 修复)
症状:经 auto 的会话回放历史时,模型自己每一轮的思考被当成普通正文发给上游,
思考通道被填成空串;直连同一路由(opencode-go)则两个通道严格分离。
会话持久化记录本身是完整的(思考块、正文块、replayState 都在),坏的只是出站负载。
成因(五环,每一环都读过源码):
会话里的历史助手消息:source.provider = "auto"(外层请求的 provider)
source.replayState.response.provider = "opencode-go"(嵌套路由真正用的)
↓ ① dsh-agent-loop 把"当次请求的 provider"写进 source.provider ⇒ 经 auto 的每一轮都写着 auto
↓ ② LlmRuntime.forAdapter 只保留「历史 provider 的适配器 === 本次适配器」的 replay 状态
嵌套调用里本次适配器是 pi-ai、历史 provider 是 auto(归本插件)⇒ replay 被剥掉
↓ ③ dsh-llm-pi-ai 的 toPiAssistant 见不到 replay ⇒ 退到 foreignAssistant
(打上 provider=auto / api="dsh-foreign" / model=auto)
↓ ④ pi-ai 的 transformMessages 算 isSameModel(provider+api+model 全等)⇒ 假
⇒ 思考块被降级成普通文本块
↓ ⑤ pi-ai 的 OpenAI 序列化把文本块拼成一个字符串当 content;
思考通道没人写,又因 requiresReasoningContentOnAssistantMessages 补成 ""
修法:在换 provider 的那一刻(adapter.js 的 #nestedOptions,即发起嵌套调用之前),
把每条历史助手消息的 source.provider / source.model 换成它 replayState.response 里记着的
真实路由值(纯函数 restoreReplaySources,见 lib/replay.js)。于是环 ② 的判定成立 ⇒
replay 状态保留 ⇒ pi-ai 走 replayedAssistant(它正好校验 response.provider === source.provider
且 response.model === source.model,改写后逐字通过)⇒ 思考回 reasoning_content、正文回 content。
为什么不能改成包一层 LlmRuntime.forAdapter:外层请求的适配器是本插件的 AutoAdapter,
外层那次 forAdapter 对 source.provider === 'auto' 的消息本来就是原样保留 replay 的
(实测:AutoAdapter.stream() 收到的历史助手消息 source 键为 ['kind','provider','model','replayState']);
剥掉 replay 的是嵌套调用那一次。若在 forAdapter 外面统一改写,外层那次会变成
「历史 provider 是 opencode-go、适配器却是 AutoAdapter」⇒ 反而把 replay 剥掉,修法失效。
纪律:restoreReplaySources 是纯函数 —— 只读入参、返回新对象、绝不改 content、
逐条按各自的 replay 路由取值(会话中途切过路由时不能统一成"当前路由")、
replayState 缺失或形状不对就原样放行(不抛错,交给下游适配器自己校验/降级)。
字节级验收(无头 profile + 抓包代理,同一份历史两跑):
| 修复前 | 修复后 | |
|---|---|---|
出站 content |
62 字符 = 思考 57 + 正文 5(拼接,无分隔符) | 5 字符,与会话正文块 SHA256 一致 |
出站 reasoning_content |
空串(长度 0) | 57 字符,与会话思考块 SHA256 一致 |
跨路由历史思考摘除:跨路由的思考摘掉,而不是被上游摊成正文(2026-09-28 修复;0.5.2 修正 DeepSeek 原生路由例外)
症状:会话中途换过路由时,那批跨路由的历史助手消息里,模型自己的思考被当成普通正文 发给上游 —— 与上一节同款的现象,但成因不同,上一节的修法①对它无效(修法①只救得回同路由的历史)。
成因:上一节的修法①让历史走 pi-ai 的 replayedAssistant;而 dsh-llm-pi-ai 只会为
同一条路由的历史走这条路,跨路由的历史走另一条分支 —— pi-ai 的 transform-messages.js:66-90
对"历史消息的模型 ≠ 本次请求模型"的助手消息执行 return { type:'text', text: block.thinking }
(:87-90),思考块被降级成正文。判据是 provider + api + model 三者全等
(transform-messages.js:68-70),而当前链上 3 条路由的 provider 互不相同 ⇒
任何中途切换都必然走降级分支(把某条路由移出链只是换个受害者)。
抓包实测(同一份历史两跑):跨路由臂那条历史消息出站是
keys=[role,content]、content 长 145 = 思考 124 + 真答案 21(零分隔符)、没有任何独立思考字段;
同路由对照臂 content 只有真答案 17 字符、思考 124 字符在 reasoning_content 里。
后果不是"少一段上下文",而是模型把内心独白学成正文格式,整场会话此后思考全进正文且不可自愈。
修法:在换 provider 的那一刻(adapter.js 的 #nestedOptions)把跨路由历史助手消息的
reasoning 块从 content 与 replayState.blocks 两侧同位同步摘掉(lib/replay.js 的
restoreMessageSource / stripReasoning):
跨路由的历史助手消息
content: [reasoning, text, tool-call] ─┐ 同位摘掉 reasoning
replayState.blocks: [reasoning, text, tool-call] ─┘ (两侧必须同步)
↓
content: [text, tool-call]
replayState.blocks: [text, tool-call] ← dsh-llm-pi-ai 的等长校验仍然通过
为什么必须两侧同步摘:dsh-llm-pi-ai 的 replayedAssistant 有四条校验
(lib/index.js:185/:186/:187/:192),其中 :187 比的是 replayState.blocks.length 与
message.content.length 逐条等长、:192 比逐条同型。只摘 content 一侧 ⇒ :187 抛
INVALID_REPLAY_STATE ⇒ toPiAssistant(:240-252)把整条降级成 foreignAssistant,
而那条路径会把残留的 reasoning 原样映射成 thinking(:154-158)、并把 api 打成
"dsh-foreign" ⇒ pi-ai 的 isSameModel 必为假 ⇒ 思考照样被摊成正文。
也就是说只摘一侧 = 缺陷原样复发 + 多一条 degrade 日志(日志文案见 §8);
同位摘 k 项 ⇒ 两侧各减 k ⇒ 等长与逐条同型都仍然成立。
边界(哪些动、哪些不动):
| 情形 | 行为 |
|---|---|
| 跨路由的历史助手消息 | content 与 replayState.blocks 同位摘 reasoning |
| 同路由的历史 | 一个字都不动 —— 那是唯一能让 pi-ai 带签名原样回放的路径(transform-messages.js:80-81 要求 isSameModel && thinkingSignature),摘了等于把 2026-09-24 的收益还回去 |
| 摘完全空的消息(这条消息本来只有思考) | 整条消息从请求里去掉 —— 不给上游一个空 content 的助手消息(dsh-llm-deepseek 的序列化只跳过空 user 消息,空助手消息会原样发出去;该条路由对空 content 助手消息的真实反应未测,本机实施记录里列为"未验证项") |
replayState.blocks 与 content 对不齐(缺 blocks、长度或类型已错位) |
摘 content 并丢掉整个 replayState(不交半截信封);与宿主自己的做法一致 —— BlockAssembler 在 blocks 对不上时就是 replay: undefined(@deepseek-ai/dsh-llm/lib/index.js:1060-1063) |
非助手消息、source 缺失、路由值不是非空字符串 |
原样放行,不抛错(交给下游适配器自己校验/降级) |
| 目标路由 keepThinking: true | 跨路由思考不摘除;用于 DeepSeek thinking 模式的工具循环。未设置/false 仍照常摘除 |
| 目标路由 breakToolLoop: true | 出站以工具结果结尾、且历史里存在"带工具调用却没有思考块"的助手消息时,末尾追加一条用户提示,把请求改成"以用户消息结尾" —— 绕开 DeepSeek thinking 模式的工具循环校验。未设置/false 不追加(见下一节) |
| DeepSeek Messages 信封缺 provider | 通知与摘除按 model 判同异;source 不伪造 provider,通知标签只显示 model |
调用 restoreReplaySources(options)(不传 route) |
退化为旧行为:只改 source,不摘思考 |
代价(有意的取舍):跨路由的思考从此"看不见"了。现状是"看得见但被误导",改后是"看不见"。
跨路由时思考的签名已经无效(thoughtSignature 跨模型即删),保真价值接近零;
丢弃只损失一点上下文,而污染会改掉整场会话的格式。这一层与 pi-ai 自己的先例同向:
transform-messages.js:72-77 对跨模型的 redacted 思考就是直接丢弃。
复发路径与兜底:唯一现实的复发路径是上游把 readReplayState 加严、或把 version 升到 3
—— 那时同路由的消息也会走 foreignAssistant,而本插件"同路由 ⇒ 保留思考"的判断就成了帮凶。
兜底做法是在"保留"分支上加一个自证可用的前置检查(version === 2 && blocks 与 content 同位同型);
本次有意不加(它把上游校验复制进插件,且现有用例里没有对应失败场景)。
日后升级 DSH/pi-ai 时,先跑设计阶段的用例 1/2/11 再决定。
工具循环收尾:历史缺思考时,别让请求以工具结果结尾(0.5.3 新增)
症状:兜底到 DeepSeek 原生路由时,工具循环第二轮必失败,整条请求被拒:
The content[].thinking in the thinking mode must be passed back to the API(HTTP 400)。
根因与"摘思考"不是一回事:那一回的思考不是被摘掉的、是从来没有 —— 会话前段由别的路由
(现场是 opencode-go)产出,那些回合的上游响应里本就没有 reasoning 分片,keepThinking: true
无从保留。DeepSeek 在 thinking 模式下对工具循环有硬校验:请求以 tool 结果结尾时,
历史里带工具调用的助手消息必须把思考一并回传。
修法:目标路由声明 breakToolLoop: true 后,满足下面两个附加条件时,在出站消息末尾追加一条
用户角色的提示(正文 [tool loop notice: …]),把请求形态从"以工具结果结尾"改成"以用户消息结尾"。
| 条件 | 为什么 |
|---|---|
目标路由 breakToolLoop: true |
显式开关,不猜 provider 语义 |
出站消息以 tool 结果结尾 |
不处在这个形态就没有校验点 |
| 历史里存在"带工具调用却没有思考块"的助手消息 | 否则纯属打扰 |
与路由切换通知互斥:两者都追加在末尾、都是用户角色。切换通知先落地时末尾已不是工具结果, 本提示自然不追加 —— 这正好解释了 2026-10-01 现场"第 113 步侥幸成功、第 114 步才炸"的现象 (那一步的切换通知替它挡了一次)。
代价:提示只存在于出站副本里(不落盘),所以每轮都会重新追加一条;模型会看到一句 "上面有些回合没有思考记录,请从工具结果接着做"。这是有意的取舍 —— 相比整条请求被 400 拒绝, 多一句话是更小的代价。
路由切换通知:静默切换也告诉模型"上面那些回合是别的模型生成的"(0.5.1 起;0.5.2 支持 DeepSeek 信封)
DSH 自带的那条 [model changed: …] 只在用户手动换模型时追加
(@deepseek-ai/dsh-agent/lib/index.js:133-147 的 modelSwitchNotice,由 agent/pre-step
瀑布注入,且会落进会话记录)。auto 的切换是静默的,模型在毫无提示的情况下看到一堆
"内心独白式正文",更容易把坏格式学下去 ⇒ 本插件在本次请求真的发生了切换时,
在出站消息序列末尾追加一条同款通知。
措辞与标签规则(逐字复刻自带通知,routeLabel = dsh-agent/lib/index.js:130-132):
[model changed: assistant turns above this point were generated by <来源>; the session continues with <目标>]
| 标签 | 规则 |
|---|---|
<来源> / <目标> |
provider 相同就只写 model,否则写 provider/model |
| 注意 | 两侧各按对方判一次(routeLabel(旧, 新) / routeLabel(新, 旧)),所以 provider 不同时两个标签都会带 provider 前缀 |
追加在末尾而不是插在那批回合之后:措辞是 "assistant turns above this point",放在末尾时
"上面"正好包含全部历史回合,措辞仍然准确;插进历史中间会让 messages 的下标与其它改写逻辑
(以及宿主自己"内容块与 replay 块逐条同位"的不变式)纠缠。
触发条件(三条全中才追加):
- 有历史助手消息,且它的
replayState.response给出可用路由 (取不到时不发通知 —— 判不出"上面那些回合是哪个模型生成的"就不能声称发生了切换; 宁可不发,也不发一条每回合都出现的假通知。这不是常态:会话里每条经auto产出的助手消息 都带 replay 状态 ——dsh-llm-pi-ai的toPiReplayState与dsh-llm-deepseek的replayState()都是每次产出必写,而外层forAdapter对source.provider === 'auto'的消息本来就原样保留 replay); - 本次候选路由与它不同(比
provider+ 重建后的model(信封无 provider 时仅比 model),与摘思考同一套口径 —— 见上节;api === 'anthropic-messages'时 pi-ai 用的是responseModel, 重建规则见dsh-llm-pi-ai/lib/index.js:218); - 历史里没有已覆盖本次切换的通知(见下)。
候选路由在 adapter.js 的链循环里定,#nestedOptions(options, route) 每次尝试都重算,
且每次尝试都从原始 options 重新派生(不是从上一次尝试的结果接着改)⇒ 失败路由的尝试
不会把摘除结果或通知带进下一次尝试的负载;只有真正成功那条的负载会被上游看到,
所以"逐次尝试各自判定"与"链上第一个成功的路由 ≠ 上一条助手消息的路由"等价。
只改出站负载:通知只加在交给嵌套调用的请求副本里,绝不写进会话记录
(与上节"只改出站、不改落盘"同一口径);会话记录由 DSH 自己的机制管。
通知消息本身是用户角色、深冻结,source 带同款标记(kind: 'model-selection' /
form: 'notice');不带 id —— 出站请求消息不需要稳定身份,省略也让出站负载可预测、可测
(自带那条由 createUserMessage 造,会带一个随机 uuid)。
去重规则(hasCoveringNotice):历史里若已有一条"覆盖本次切换"的通知就不再追加。
两条判据同时成立才算覆盖:
| # | 判据 | 为什么 |
|---|---|---|
| 1 | 位置:它出现在最后一条助手消息之后 | 自带通知里的 "assistant turns above this point" 是位置相关的 —— 只有紧跟在我们要标注的那批回合之后,它说的才是同一批回合;更早的那条说的是更早的回合,不能拿它顶账 |
| 2 | 来源:它的措辞里 "generated by <来源标签>;" 这一段与本次要写的来源路由一致 |
即模型已经被明确告知"上面那些回合是 <来源> 生成的" |
只比这两条、不比目标标签:自带通知的目标写的是选择(auto),我们写的是真实路由,
两者天然不同字;而"上面那些回合是别的模型生成的"这层意思,来源标签就是它的全部信息量。
比前缀而不比整句,是为了对 boundContextSummary 的截断(自带那条的 summary 会被截到
120 字符)保持稳健 —— 正文本身不截断,但只依赖前缀更不容易被上游改坏。
空响应也算失败
上游"正常结束但一条内容都没有"(finish{kind:'stop'} 且零内容)被当作 EMPTY_RESPONSE 处理:
能换路由就换;全都这样则报聚合错误,而不是静默交出一个空回合。
全部失败
抛 LlmError(code AUTO_ROUTES_EXHAUSTED,cause 是逐条失败的 AggregateError),
消息形如:
auto: 全部 2 条路由均失败
1) probe-no-such-provider/whatever → NO_ADAPTER: no adapter registered for provider "probe-no-such-provider"(1 ms)
2) commandcode/xiaomi/definitely-not-a-model → UNKNOWN_MODEL: pi-ai provider "commandcode" has no configured model "xiaomi/definitely-not-a-model"(0 ms)
重试到耗尽的路由会多带一个次数后缀(每条路由在聚合错误里只有一行,次数 = 该路由的总尝试数):
2) commandcode/deepseek-v4.1-flash → SERVER(HTTP 502): 502 status code(8231 ms,共 6 次尝试)
行里的 elapsedMs 是该路由最后一次尝试的耗时(不是 6 次的累计);逐次耗时看
/api/llm-auto/routes 的 ring 记录(每条 attempt 一条)。
用 LlmError 而不是裸 AggregateError:后者的码会被归一成 UNKNOWN,丢掉可路由性。
不改默认模型
本插件不碰 settings.yaml 与 agent-default-model:用户不主动选 auto 时一切照旧。
4. 生效条件(踩过)
file:依赖在 pnpm 下默认建硬链接,但形态要逐文件实测**(本包 12/12 共享文件实测同 inode)。 真正被加载的是~\.dsh\profiles\<profile>\node_modules\dsh-llm-auto\⇒ 原地改已有文件两侧同生效,**不需要** remove + add;但write/edit这类"写临时文件再改名"的换文件式写入会**当场打断硬链接**,改完必须核 两侧fileId。**只有新增文件**才要重跑 link(remove+add,或pnpm install;只add可能报Already up to date而跳过同步):node <工作区>\dsh-plugin-manager\dshpm.mjs remove dsh-llm-auto --profile web node <工作区>\dsh-plugin-manager\dshpm.mjs add ` file:%USERPROFILE%\.dsh\profiles\<profile>\plugins\dsh-llm-auto --profile <profile>dshpm是本机工作区里的插件装卸 CLI(dsh-plugin-manager,即上面那个脚本),公开环境没有它; 可用官方dsh plugin add/dsh plugin remove代替,只是官方命令在部分版本会超时并丢dsh.profile.bundles更新,所以本机一直用dshpm。改完用 SHA256 比对两份
lib/index.js一致。不想经历remove造成的空窗时也可以直接改运行副本 侧那份(原地改写,别用会换文件的工具 —— 那会断链),再照上面核对 SHA256。最快的一条是只给改动过的那个文件重建硬链接(2026-09-25 搬
compactWindow表单时用的就是这条, 之后 8 个lib/*.js两侧全部共 inode):$src="$env:USERPROFILE\.dsh\profiles\web\plugins\dsh-llm-auto\lib\client.js" $dst="$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-llm-auto\lib\client.js" Remove-Item $dst -Force New-Item -ItemType HardLink -Path $dst -Target $src | Out-Null (Get-FileHash $src).Hash -eq (Get-FileHash $dst).Hash # 必须 True fsutil hardlink list $dst # 必须列出两条改插件代码要重启 dsh —— loader 按 URL 缓存已 import 的模块实例。 浏览器半侧理论上另有一条免重启路径:宿主
dsh-client-hmr每 500ms stat 一遍各 client bundle, 一有变化就clientModules.rebuilt(id)并经/plugins/eventsSSE 让页面modules.reload换掉旧模块 (dsh-client-hmr/lib/index.js:79-92、其lib/client.js:59)。但 2026-09-25 实测这条没生效: 改完lib/client.js(原地改即两侧同变;stat 的 mtime/size 确实变了)后,线上plugins.row.config的占用者 仍是旧注册 —— 用cordis_inspect_query(clientSlots,plugins.bundle.config/plugins.row.config) 复核可重现。所以按老规矩办:改完重启 dsh(至少要刷新页面再看),别指望它自己换。 改包内cordis.patch.yml(包层 patch)不需要重启,但它不会自己触发重组合:dsh-hmr 只监视 profile 的cordis.patch.yml、home 层cordis.patch.yml与 profile 的package.json三个输入 (dsh-hmr/lib/index.js:353-376),包内 patch 不在其中;重组合时会重读全部 bundle 层,所以改完要有 一次触发才被读入 —— 在插件页点一下本卡(或任意行级)开关,或保存 profile 的cordis.patch.yml里任意一处。 所以顺序是:先改代码(原地改即两侧生效)→ 改配置(可选)→ 按上面触发一次重组合;代码改动本身仍要重启。本插件是组合包(bundle):包里有
dsh.bundle.patch(指向包内cordis.patch.yml), 包名在dsh.profile.bundles里 ⇒ 由 profile 的 bundles 装载,不需要再往 profile 的cordis.patch.yml里贴 insert 行(启停与卸载见 §1)。
5. 可观测
HTTP 端点
GET /api/llm-auto/routes?limit=N # limit 省略/非法 = 不限(以容量为上限)
{
"provider": "auto", "model": "auto", "name": "Auto",
"retry": { "mode": "normal", "maxRetries": 5, "retryableCodes": ["EMPTY_RESPONSE", "RATE_LIMIT", "SERVER", "TIMEOUT", "TRANSPORT"], "initialDelayMs": 500, "maxDelayMs": 10000, "jitterRatio": 0.1 },
"compactWindow": 500000, "declaredContextWindow": 625000,
"chain": ["commandcode/deepseek/deepseek-v4.1-flash", "stepfun/step-5-preview", "deepseek-official/deepseek-flash"],
"calls": [
{ "call": 12, "at": "2026-09-23T13:03:51.518Z", "elapsedMs": 3100, "outcome": "ok",
"routes": [
{ "attempt": 1, "provider": "commandcode", "model": "deepseek/deepseek-v4.1-flash", "tries": 5, "ok": false, "code": "SERVER", "reason": "SERVER(502): 502 status code", "elapsedMs": 1200, "switched": true, "switchedTo": "stepfun/step-5-preview" },
{ "attempt": 2, "provider": "stepfun", "model": "step-5-preview", "tries": 1, "ok": true, "code": null, "reason": null, "elapsedMs": 1900, "switched": false, "switchedTo": null }
] }
],
"capacity": 50, "total": 2,
"routes": [
{ "at": "2026-09-23T13:03:49.516Z", "attempt": 1, "try": 1, "provider": "probe-no-such-provider",
"model": "whatever", "ok": false, "switched": true, "elapsedMs": 1,
"code": "NO_ADAPTER", "reason": "NO_ADAPTER: no adapter registered for provider \"probe-no-such-provider\"" },
{ "at": "2026-09-23T13:03:50.516Z", "attempt": 1, "try": 2, "willRetry": true, "provider": "commandcode",
"model": "deepseek/deepseek-v4.1-flash", "ok": false, "switched": false, "elapsedMs": 1200,
"code": "SERVER", "reason": "SERVER(502): 502 status code" },
{ "at": "2026-09-23T13:03:51.518Z", "attempt": 3, "provider": "commandcode",
"model": "deepseek/deepseek-v4.1-flash", "ok": true, "switched": false, "elapsedMs": 1900 }
]
}
字段:at(时间)、attempt(第几条路由,1 起)、try(该路由第几次尝试,1 起)、
willRetry(存在且为 true 表示这条失败后还会重试,不是终态)、provider/model(命中的路由)、
ok(是否成功)、switched(这次失败是否触发了切换)、elapsedMs(耗时)、code/reason(失败原因摘要)。
顶层 retry 是当前生效的重试策略(排查"它为什么重试/为什么不重试"先看这个)。
顶层 calls 是 0.5.0 加的分组视图(插件页面板读的就是它):同一份记录按一次
stream() 调用(call 字段)归组,outcome 为 ok / failed / aborted,
routes 里同一条候选路由的多次尝试合并为一行(tries 次尝试、code/reason 取最终失败、
elapsedMs 为该路由内的耗时之和),switchedTo 指向同组下一条候选。?limit=N 只切
记录条数 ⇒ 分组后最旧那一组可能被截断(同一次请求的前半段已被环形缓冲淘汰)。
顶层 compactWindow / declaredContextWindow 是窗口口径的事后复核字段:前者是生效的压缩点
(没启用映射时为 null),后者是当前对外声明的窗口(逐跳解析口径下,要等第一次目录解析才有值,
否则为 null)。两个值都是现场重算的,设置页刚改完就能在这里看到。
⚠ 该端点不经过浏览器鉴权:它是 exact 路由,优先于
dsh-client-connection注册的/api前缀(前缀表只在 exact 未命中时才查),因此不检查鉴权 cookie。 仅因为 webServer 绑在回环地址才可接受;不要把 webServer 改成0.0.0.0后继续留用它。
日志
用插件 logger(llm-auto 子系统):
- 挂载时一条
info:auto: 已注册路由 auto/auto(Auto)→ commandcode/… → ww/…;重试: 每路由最多 5 次(瞬时码 …),退避 500→10000ms jitter 0.1; - 挂载时再一条
info(窗口口径):auto: 压缩点 500000 → 声明窗口 625000(假设 compaction-basic thresholdRatio 0.8 / headroomTokens 65536)(contextWindow 口径下会写明"未启用压缩点映射"并报出实际压缩点;逐跳解析口径下写明兜底值); - 配置有问题时各一条
warn:compactWindow非法(回落默认)、与contextWindow同时给出(说明用哪个忽略哪个)、 压缩点过小(点名TargetPressureConfigError); - 每次重试一条
warn:auto: 第 1 条路由 x/y 第 2 次尝试失败(SERVER(502): …),500 ms 后重试(剩余重试 3 次); - 每次切换一条
warn:auto: 第 1 条路由 x/y 失败(SERVER(502): …,共尝试 6 次),静默切换 → a/b; - 已经产出内容后失败、以及错误码不允许回退时各一条
warn; - 全部失败一条
error。
不刷屏:每次重试/切换才一条,正常请求零日志。
6. 边界与已知限制
- 只"失败时切换",不挑路由:不做额度记账、不按价格/能力/内容挑路由。顺序完全由
routes决定; 失败时先在该路由内重试(见上节),重试耗尽或码不可重试才按顺序切下一条。 - 重试的计费/时延上限(默认参数下):单次
auto请求最坏 = 链长 × 6 次上游调用、约 15.5s×链长 的退避(0.5+1+2+4+8+10s);想省就把retry.maxRetries调小或设0。每次重试都是新的上游请求, 与自带dsh-llm-retry一样可能重复计费 input token。连带效应:ring 缓冲(默认 50 条)消耗也快 约 6 倍 —— 一条全瞬时失败的链一个请求就占 20+ 条,想多留历史就调大logLimit。 - 只支持
mode: 'normal':always(无上限重试)在单请求内可能无上限计费,挂载时 warn 并回落默认。 - 插件卸载不 drain 在飞退避:cordis 卸载本插件时,正在进行的退避(≤10s)会自然完成,不像官方
dsh-llm-retry有 lifetime abort + drain(它挂在 agent loop 上,拿得到 session 生命周期)。 - 不做视觉/长上下文分流:上游同类插件按"含图 / 超长"分流,本插件按用户明确要求只做失败重试/回退。
- 声明
inputModalities: ['text','image'](2026-09-27 起;此前有意留空):留空等于对所有调用方 宣称"不支持图片"——read_image(dsh-tool-fs)与 MCP 图像回传(dsh-mcp-client)的能力门禁都是 "未声明即拒",于是auto路由下连截图都读不了。曾担心的投影副作用不成立:投影发生在每次llm.stream()的适配器边界,嵌套调用会按内层真实路由再判一次 ⇒ 链路里若有纯文本模型, 图片仍会在那一层被换成占位文本。前提是链路全部候选都收图。 reasoningEffort一律用该路由可用的最高强度(2026-09-23 用户指定):每跳前问一次该路由resolveModelInfo的reasoning.efforts,取最后一项(DSH 的档位强度序固定为off→minimal→low→medium→high→xhigh→max,适配器只保留该模型支持的档位 ⇒ "最后一项"就是它 能给的最高强度),忽略调用方带来的档位;该路由完全不声明档位(不思考的模型)时才原样透传, 让分发给出准确错误。这样既不因为"档位不匹配"让兜底路由白失败一次,也不需要调用方为每跳手动调档。 本插件自己不声明 reasoning 能力,所以模型选择器不会给auto提供档位选项。- 跨路由的 replay 元数据:DSH 只在"历史 provider 与目标 provider 属于同一个适配器实例"时保留
replay 状态。
auto自己产出的消息(source.provider =auto)在嵌套调用里会被自动剥掉 replay —— 这正是上面「历史回放」一节修的缺陷:剥掉后 pi-ai 会把思考摊平进正文、把reasoning_content填成空串。本插件在嵌套调用前把 source 改回 replay 记录的真实路由来保住它;历史消息若本来就来自commandcode,回退到同属 pi-ai 的ww时 replay 会被保留 —— 那是上游既有行为(手动切模型时 同样发生)。跨模型的历史(replay 路由 ≠ 本次路由)本来也会被 pi-ai 摊平(它的策略是"思考签名跨模型 不可信",与直连时一致);本插件 2026-09-28 起改为主动把跨路由的思考块摘掉(见 §3「跨路由历史思考摘除」), 不再让它以正文形态出现 —— 这是有意的取舍:跨路由的思考不再可见,换掉的是"整场会话的格式被污染"。 - 路由日志是进程内内存,重启即清空,不适合当审计账本。
- 回退链面板的数据来源就是这份内存缓冲(0.5.0):进程内没有对应路由活动时"最近请求"为空; 换一次浏览器/刷新页面不会丢(数据在宿主侧),但重启会。端点非 200 或插件未加载时面板显示 "读取失败:原因",不是空列表。
- 压缩点靠"声明窗口"间接控制,依赖引擎默认常量:
compactWindow的反算写死了thresholdRatio = 0.8/headroomTokens = 65536,并按reserved = 0推算。若把@deepseek-ai/dsh-compaction-basic的thresholdRatio/headroomTokens改成别的值、 或给本插件补上defaultMaxTokens声明,压缩点就会偏离compactWindow—— 挂载日志那行假设与/api/llm-auto/routes的两个复核字段是排查这类偏离的入口。 - 声明窗口是"压缩刻度"而不是上游承诺:默认 625000 大于本机某些跳的真实窗口(如
stepfun的可用输入 958464 没问题,但换成更小的通道就会超)—— 请求真正撞上游窗口时由那一条路由 如实报CONTEXT_WINDOW_EXCEEDED(该码不回退)。要"声明真实窗口"就把compactWindow调成你想要的压缩点,或用旧键contextWindow直接声明。 - 失败原因摘要可能含上游返回的文本(如报文片段),但不含凭据:各适配器按设计不把 key 写进消息。
- 未实测覆盖:①"已产出内容后失败"只有单测(含真实
LlmRuntime+ 真实流语法不变式)覆盖, 没有对真上游稳定复现过(需要一条"吐一半再断"的路由);②路由内重试同样只有单测/真实LlmRuntime覆盖,没有对真上游的瞬时抖动复现过(需要一条"抖几下再好"的路由);③非回环绑定下的 鉴权影响未评估(见 §5);④导出Config之后的设置描述符只有源码依据 + 单测(见 §2「效果边界」), 没有在重启后的真机上查过settings.describe()(0.4.0 的插件页表单走的是同一套 schema, 注册在plugins.bundle.config,见 §2)。 - 第三方同类插件:
zhanghao3693/dsh-llm-router功能相近(按内容分流 + 回退链)。本插件是 本机自建、只做失败回退,不依赖也不需要它。
7. 测试
cd dsh-llm-auto # 本仓库根目录
node --test "test/*.test.mjs" # 注意:Node 24 起 `node --test test/` 不再展开目录
| 文件 | 覆盖 |
|---|---|
test/routes.test.mjs |
normalizeRoutes(含 keepThinking 布尔透传、坏类型 warn 回落;空/非数组/自递归/重复/单条坏条目)、describeChain、createRing(定长 + 取值函数容量) |
test/compact.test.mjs |
压缩点反算:500000→625000、边界值表(12484 / 262143 / 262144 / 327680 / 884000 / 1000000…)、1~300 万抽样"阈值处处精确等于 T"(独立复刻一遍引擎的 resolveCompactSpec 来验算)、最小可用值 12484 的推导;planDeclaredWindow 的优先级/同时给出/非法回落/过小警告/null 与坏类型;unwrapVolatile;describeWindowPlan 四种文案;导出的 Config(六键中文 description、compactWindow 默认 500000 且 volatile、routes/retry 坏值不失败、真 schema 走一遍⇒解包后仍是 500000⇒625000) |
test/retry.test.mjs |
normalizeRetry 全部分支(缺省/布尔/对象/always/坏值回落)、computeRetryDelay(官方口径序列与 jitter 边界)、describeRetryPolicy |
test/adapter.test.mjs |
0.5.2 新增 15 条 pi-ai 助手历史 + 工具结果结尾时,DeepSeek keepThinking 开/关的链级差异;首次成功、首条瞬时失败后先重试再回退、全部失败聚合(带尝试次数)、已产出内容后失败不重试不回退、暂存分片、空响应(可重试)、不可回退码、取消、退避中取消、上游抛异常、按路由取最高推理档位、窗口解析、modelName 取值函数;重试块另覆盖:第 N 次成功、白名单外不重试、maxRetries: 0 旧行为、Retry-After 界内优先/超界直切、退避序列 500/1000/2000/4000/8000 |
test/replay.test.mjs |
restoreReplaySources:路由不同 ⇒ 改写且 content 逐字未变、路由相同/无 replayState/形状不对 ⇒ 原样放行不抛、非助手消息不动、多条各按自己的 replay 路由改写、冻结输入不被破坏;外加一条接线用例:经 AutoAdapter.stream() 的嵌套请求确实拿到了改写后的 source。2026-09-28 起同文件再覆盖跨路由思考摘除(两侧同步摘、只摘一侧 ⇒ 等长校验失败的反例、摘空 ⇒ 整条去掉、信封对不齐 ⇒ 丢 replayState、同路由零改动、anthropic 的 responseModel 重建规则)与路由切换通知(切换才追加、已有覆盖通知不重复、标签规则、只进嵌套请求),共 49 例;2026-10-01 起再覆盖工具循环收尾(三判据全中才追加、开关未开/末尾非工具结果/历史无断链消息各自不追加、坏形状不抛、与切换通知互斥、只在出站副本里);全套 194 例(改动前 144 例) |
test/runtime-integration.test.mjs |
用真实 LlmRuntime + 真实 @deepseek-ai/dsh-llm/invariant 跑端到端:目录校验、回退后的流语法零违规、重试后成功的流语法零违规、maxRetries: 0 旧行为、聚合错误的终止分片、注销后路由立刻消失 |
test/endpoint.test.mjs |
apply() 的注册/拒绝注册分支、provider: auto 跳过、HTTP 端点响应、retry 默认值/关闭/坏值回落;compactWindow 的映射/宿主形态/与 contextWindow 同时给出/非法回落/过小警告、volatile 引用改值后不重启即生效(name / compactWindow / logLimit)、端点复核字段 |
test/calls.test.mjs |
groupCalls:坏输入、按 call 分组、同 attempt 合并(tries/最终失败/耗时求和/switchedTo)、三种 outcome、order 与 maxCalls、desc 按请求结束排序、没有 call 的旧记录降级分组 |
test/client.test.mjs |
浏览器半侧的纯函数(桩 window.__ModuleLoader__ 后手动调 factory(require)):模块契约、fill/tr(对 t 的插值实现不敏感)、retrySummary、formatClock/formatDuration、结局→状态点/标签。不覆盖渲染 —— 那部分靠真机(见 §7 末尾) |
runtime-integration 是这套测试里最值钱的一个:它把"我的输出能不能被宿主接受"也钉住了 ——
尤其是"回退时不能出现重复 block-start / 重复 usage"这条,只有跑真实校验器才测得出来。
真机端到端(探针 profile,任务内容"只回复两个字:收到"):
| 场景 | 观察到的路由序列 | 结果 |
|---|---|---|
| 首选可用 | auto → commandcode/deepseek/deepseek-v4.1-flash |
成功,exit 0 |
| 两条真实失败后回退 | auto → probe-no-such-provider/whatever(NO_ADAPTER) → commandcode/xiaomi/definitely-not-a-model(UNKNOWN_MODEL) → commandcode/deepseek/deepseek-v4.1-flash |
成功,exit 0 |
| 全部失败 | 同上但砍掉第三条 | exit 1,消息含 AUTO_ROUTES_EXHAUSTED 与逐条原因 |
8. 故障排查
| 现象 | 多半是 |
|---|---|
选择器里没有 Auto 分组 |
插件没装进 node_modules(只改了 plugins\)、或宿主没重启、或包名不在 dsh.profile.bundles 里(用 dshpm sync --check --profile web 复查,--dry-run 可预演) |
有分组但选 auto 就报 NO_ADAPTER |
宿主还在跑旧代码,或包层那行被 profile 层的覆写行遮蔽/停用了 |
| 每次第一条必失败 | routes[0] 那条路由本机不可用(例如 deepseek-official 无凭据);/api/llm-auto/routes 会直接告诉你 code |
| 一条路由要试 6 次才切/切得慢 | 重试默认开启(每路由 5 次 + 退避累计约 15.5s);这是 v0.2.0 起的预期行为,想关:retry.maxRetries: 0 |
| 期望"失败立即切"但它等了 | 失败码在瞬时白名单(RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT/EMPTY_RESPONSE);不在白名单的码(NO_ADAPTER/UNKNOWN_MODEL/MISSING_CREDENTIAL…)本来就是一次败就切 |
| 改了源码没反应 | 形态逐文件实测(本包 12/12 同 inode)⇒ 硬链接文件原地改即生效,断链或新增文件才需重装 + 重启;改完核 fileId/SHA256(见 §4) |
压缩点不在 compactWindow 上 |
先看挂载日志那行"压缩点 X → 声明窗口 Y(假设 …)";X 对不上说明 compactWindow 非法/被回落(有 warn),Y 算得出而压缩仍不按 Y 走 ⇒ 多半是有人改了 compaction-basic 的 thresholdRatio/headroomTokens(见 §6);/api/llm-auto/routes 的 compactWindow/declaredContextWindow 用来复核 |
日志出现 TargetPressureConfigError(retainTokens ... must be less than threshold tokens) |
compactWindow 太小(< 12484,挂载时已有 warn)或旧键 contextWindow ≤ 65536;把 compactWindow 调到 ≥ 12484 即可 |
设置了 contextWindow 但窗口没变 |
预设里 compactWindow 已有值(默认 500000)⇒ 按优先级以 compactWindow 为准,挂载日志有一条 warn 点名两者;要用旧键就先把 compactWindow 从配置里删掉 |
| 插件页看不到本插件的配置表单 | 本包 0.4.0 起自带浏览器半侧、注册进 plugins.bundle.config(键 = 包名 dsh-llm-auto,渲染在卡片描述与行之间)。没看到先分清两种原因:①宿主还在跑 0.4.0 之前的代码 —— 半侧新增后必须重启一次 dsh(见 §1),HMR 不会重新扫描(dsh-client-modules 把"本包不是客户端包"的否定结论按 specifier 缓存在 pkgMeta,其 lib/index.js:510/703);②只是刚改过 lib/client.js —— 这条 HMR 路径实测不生效,同样要重启(见 §4 第 2 条)。另:本插件故意不再注册 plugins.row.config,所以行 llm-auto 上没有「配置」控件是正常的;routes/retry 本来就没有表单,故意留在包内 cordis.patch.yml 里 |
| 插件页看不到「回退链」面板 / 上面写着"读取失败" | 面板与 compactWindow 表单是同一个客户端半侧(0.5.0 新增 ⇒ 同样要重启一次 dsh 才被收录);若是"读取失败:…",先用 curl http://127.0.0.1:3080/api/llm-auto/routes 复核端点本身 —— 插件没加载、被停机或 bind 到非回环地址时就是这个文案 |
| 「最近请求」是空的 | 路由日志是进程内内存(重启清零,见 §6);也可能还没有请求走过 auto:模型选择器选一次 Auto 再发一条消息 |
经 auto 的历史思考跑到正文里、思考通道是空的 |
插件是修复前的版本(宿主还在跑旧代码);修复见 §3「历史回放」与「跨路由历史思考摘除」,改完两份并重启后消失。若已确认代码是修好的、这个现象还在 ⇒ 多半是宿主没重启(loader 按 URL 缓存已 import 的模块) |
日志出现 llm-pi-ai: unusable replay state on assistant history |
那条历史消息的 replay 状态与本次路由不匹配(例如跨模型回放),pi-ai 主动降级成 provider-neutral 内容;这是上游既有行为,不是本插件的错误。0.5.1 起还有一条专属成因:只摘了 content 一侧的思考而没有同步摘 replayState.blocks ⇒ replayedAssistant 的等长校验不过 ⇒ 整条降级(§3「跨路由历史思考摘除」的"只摘一侧 = 缺陷原样复发") |
| 跨路由的历史思考"不见了" | 默认(keepThinking 缺省/false)是有意的:跨路由的思考块被同位摘掉,不再被 pi-ai 摊进正文(见 §3)。想复核是不是真发生了:抓一次出站请求,跨路由臂的历史助手消息应当既没有 reasoning_content、content 里也不再混着思考文本(与 §3 那对实测数字同口径) |
DeepSeek 目标路由报 content[].thinking 必须回传 |
检查目标 routes[i] 是否显式设 keepThinking: true;该选项只对该条目标路由生效,默认仍照摘 |
| 想知道静默切换有没有通知模型 | 通知只加在出站请求里、不落盘,所以会话记录里看不到它属正常。抓包复核:跨路由臂的 messages 末尾多一条 role: "user"、正文是 [model changed: assistant turns above this point were generated by …];同路由臂不该有这条(§3「路由切换通知」) |
| 上下文窗口明显偏小 | contextWindow 没配且首选路由解析不到窗口,回落到了保守值 65536;显式配一个即可 |
License
MIT
No comments yet. Be the first to write one.