DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

liuyun847 /

liuyun847/dsh-llm-auto

Verified

DSH 宿主插件:注册 auto 模型,按有序回退链在多条 provider+model 间静默切换 (DeepSeek Harness)

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@c2582905

dsh-llm-auto

License: MIT Version DSH Plugin

给 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-settings lib/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 块逐条同位"的不变式)纠缠。

触发条件(三条全中才追加):

  1. 有历史助手消息,且它的 replayState.response 给出可用路由 (取不到时不发通知 —— 判不出"上面那些回合是哪个模型生成的"就不能声称发生了切换; 宁可不发,也不发一条每回合都出现的假通知。这不是常态:会话里每条经 auto 产出的助手消息 都带 replay 状态 —— dsh-llm-pi-ai 的 toPiReplayState 与 dsh-llm-deepseek 的 replayState() 都是每次产出必写,而外层 forAdapter 对 source.provider === 'auto' 的消息本来就原样保留 replay);
  2. 本次候选路由与它不同(比 provider + 重建后的 model(信封无 provider 时仅比 model),与摘思考同一套口径 —— 见上节;api === 'anthropic-messages' 时 pi-ai 用的是 responseModel, 重建规则见 dsh-llm-pi-ai/lib/index.js:218);
  3. 历史里没有已覆盖本次切换的通知(见下)。

候选路由在 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. 生效条件(踩过)

  1. 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                               # 必须列出两条
    
  2. 改插件代码要重启 dsh —— loader 按 URL 缓存已 import 的模块实例。 浏览器半侧理论上另有一条免重启路径:宿主 dsh-client-hmr 每 500ms stat 一遍各 client bundle, 一有变化就 clientModules.rebuilt(id) 并经 /plugins/events SSE 让页面 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(client Slots,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 里任意一处。 所以顺序是:先改代码(原地改即两侧生效)→ 改配置(可选)→ 按上面触发一次重组合;代码改动本身仍要重启。

  3. 本插件是组合包(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

—/ 5

No ratings yet

Verified DSH bundle

Commit c2582905c7ac

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout