← 回到 infra 主页

DeepSeek Harness · Upstream · Issue Drafts

DSH 上游本体 6 个可复现缺陷:issue 草案(含源码行号、正负对照测试与建议修复)

本页把我们在生产多用户 DSH Web(deepseek-v4-flash-vision-exp-dev0,1M 上下文)里实测到的上游本体问题整理成可直接提交的 issue。每条都能独立提交到 github.com/deepseek-ai/deepseek-harness。结论先行:这 6 条都是 DSH 官方包的实现问题,不是我们部署脚本的问题;我们自己的 patchReload 崩溃和 gateway 崩溃是另一类,已单独立项修复。所有数字采集于 2026-09-05(+08:00),测试脚本保留在 /tmp/dsh-fix/、/tmp/dsh-perf-fix/、/tmp/dsh_plan/。

提交前请复核版本:下列行号基于安装包 @deepseek-ai/dsh 0.1.2-rc.1(bundle)与上游 HEAD d347e70 / 0.1.3-alpha.1。B 席确认上游 HEAD 仍含缺陷 1–3。提交前请再跑一次文末复现脚本确认行号未漂移。

缺陷总览与优先级

按“用户可感知程度 × 修复性价比”排序。P0 = 建议优先。

#缺陷类型体感修复成本/风险优先级
1断线退避终档永久 disconnected,不再自动重连正确性“连接中”久不恢复、需手动刷新小 / 低P0
2generationReadyTimeout 是假超时,握手不 ready 永久卡正确性无限“连接中”小 / 低P0
3退避/重连参数只有类型、无运行时 schema,不可配置可配置性无法按部署调优小 / 低P1
4长回复最终化丢弃增量状态,full parse + 同步 KaTeX/Shiki 阻塞主线程性能完成瞬间按钮迟滞、页面顿中 / 中P0
5超长会话冷恢复 O(n) + fork 全量继承父 history性能打开/派生子代理秒级等待大 / 中-高P1
6模型不可路由时 composer 锁死,catalog 出错后不自动解锁正确性发送框点了没反应、必须刷新小 / 低P0
Issue 1 · P0 · bug

[client-connection] 指数退避到达终档后永久停留在 disconnected,不再自动重连

组件
@deepseek-ai/dsh-client-connection(客户端连接)
版本
0.1.2-rc.1;上游 HEAD d347e70(0.1.3-alpha.1)仍可复现
严重度
高:一次持续数秒的网络中断即可让页面永久“已断开”,只能手动重连/刷新或触发一次真实的 offline→online。
证据
lib/client.js:107-110、:139-146;等待点 :27-34

复现步骤

  1. 让 generation source 连续失败(拔网/停后端/代理返回失败)持续到第 6 档退避。
  2. 默认 backoffBaseMs=500、backoffFactor=2、backoffMaxMs=10000,6 档累计约 12.75–25.5 秒。
  3. 观察 attempt 到达终档后进入只监听 abort 的 waitForAbort,不再有下一次 source()。
  4. 即使后端随后恢复,页面也不会自动重连,除非用户点“重连”或浏览器触发一次真实 offline→online。

期望 vs 实际

期望:退避封顶后应以最大间隔(如每 5–10 秒抖动)继续重试,直到成功或被显式停止。实际:isFinalBackoffTier 判定为真后进入无定时器的永久等待,连接状态卡在 disconnected。

正负对照测试(真实 bundle)

PASS NEG-A  original stalls at final tier           attempts=4
PASS NEG-B  original hangs forever on ready timeout  attempts=1 aborts=0
PASS POS-A  patched retries past final tier           attempts≈27-29
PASS POS-B  patched abort+retry after timeout         attempts=11 aborts=10-11
PASS POS-C  attempt resets to 0 after a success       connected=1 retryNums=[1,2,1,2,...]
JUDGE: ALL PASS

建议修复(最小面)

删除 isFinalBackoffTier 的永久 waitForAbort 分支;退避本身已在 backoffMaxMs 封顶,删后终档按 5–10s 抖动持续重试。保留成功归零、手动 reconnect() 立即恢复、offline 暂停/online 重置语义。风险:稳态重试无风暴;需更新上游两个把“终态”当预期的旧测试。

Workaround(当前生产) 页面卡住时手动刷新或点重连;本仓已有补丁草稿(见文末)。

Issue 2 · P0 · bug

[client-connection] generationReadyTimeout 到期只告警,不取消/不重试,握手不 ready 时无限“连接中”

组件
@deepseek-ai/dsh-client-connection / @deepseek-ai/dsh-api-gateway
版本
0.1.2-rc.1;上游 HEAD d347e70 仍在
严重度
高:Host 接受连接但迟迟不发 opening ready 帧(半死代理/异常上游)时,UI 永久停在 connecting。
证据
client.js:231-255(setTimeout 仅 console.warn);loop 依赖 Promise.race([waitForReady, sourceLost]) :200-209;ready 由首条 stream 值触发 dsh-api-gateway/lib/client.js:591-620

复现步骤

  1. 构造一个“能完成 WebSocket/传输握手、但从不发第一条 ready 事件、也从不主动断”的 Host(或在 ready 前挂起代理)。
  2. 等待超过 generationReadyTimeoutMs=3000。
  3. 观察只有一条 warn 日志,状态仍是 connecting,没有任何 abort 或重试。

期望 vs 实际

期望:ready 超时应 abort 当前 generation 并按退避重试(或明确置错),而不是无限等待。实际:timeout 回调不 resolve/reject/abort,UI 假阳性“连接中”。

建议修复

把 timeout 回调从 console.warn 改为 finish(error);在 loop catch 中 ac.abort(error);将 await failed 改为 Promise.race([failed, waitForAbort(ac.signal)]),防止坏 Host 无视 abort 继续挂死。sourceLost/stop/network 语义保持不变。

与 Issue 1 的关系:两条都在同一退避循环里,建议同一个 PR 一起修,并补 timeout 用例。
Issue 3 · P1 · configurability

[client-connection] 退避/重连/ready 超时参数只有 TS interface,无运行时 schema,profile 无法配置

组件
@deepseek-ai/dsh-client-connection(Config)与 @deepseek-ai/dsh-api-gateway client
版本
0.1.2-rc.1
影响
运维无法按部署形态调整重连行为;想改只能改 bundle 或等上游。
证据
Host Config 只有 trustedHosts/cookieMaxAgeDays/maxRequestBodyBytes lib/index.js:682-697;四个退避参数仅在 types/client/connection.d.ts;gateway 调 connection.start 未传 config dsh-api-gateway/lib/client.js:1415-1422

现状与问题

ConnectionController 构造时支持 {backoffBaseMs, backoffFactor, backoffMaxMs, generationReadyTimeoutMs},但 client 插件的 apply(ctx) 不读 ctx.config 暴露 schema,gateway client 构造也不消费 ctx.config。因此 Caddy/DSH profile 都无法设置这些值,运行时永远用硬编码默认。

建议修复

  1. 给 client-connection 增加运行时 schema(四个参数 + 校验范围),并从 gateway/客户端引导处显式传入。
  2. 或在 gateway Service Config 暴露并在创建 carrier 时传入。
  3. 提供 profile patch 可设置的字段文档与默认值。

Workaround 当前可用引导脚本在 boot 前挂全局配置(需打补丁读取),长期仍应上游提供 schema。

Issue 4 · P0 · performance

[client-ui-chat] 流式回复最终化丢弃增量解析,full parse + 同步 KaTeX/Shiki 造成主线程长任务

组件
@deepseek-ai/dsh-client-ui-chat(Markdown/代码/公式渲染)
版本
部署 bundle index-Df-65__b.js,对应源码 0.1.2-rc.1
体感
长回复“输出中”还好,点完成的一瞬页面顿、发送/插话按钮 disabled 更新被排队到长任务结束。
证据
finalize 置空增量状态 MarkdownText.tsx:169-178(bundle ic@412368)→ 全量 Mp@410735;同步 KaTeX katex.tsx:66-89(bundle Bl@403592);Shiki CodeBlock.tsx:136-141(bundle Oh@383284,已有视口门控/流式缓存复用)

复现步骤

  1. 发一条长 markdown,含大量行内/块级公式与多个代码块(可用文末 gen-benchmark-md.py 生成 200 公式 + 12 代码块)。
  2. 用 PerformanceObserver(longtask)(文末 longtask-probe.js)记录 maxLongTask 与发送按钮重新可用的 inputReadyMs。
  3. 观察最终化窗口一次性 full parse,所有公式首次同步 katex.renderToString。

根因澄清(避免误判)

  • KaTeX 是主峰:流式阶段故意不识别 math(让半截 TeX 显示字面量),导致完成一帧所有公式同时出现、同步渲染。
  • Shiki 是次峰且部分已缓解:有 IntersectionObserver 视口门控,且流式已高亮的 fence 会复用 previous.body;只有终结时新进入视口的代码块同步 codeToHtml。
  • 流式期间已有增量 parser 与三重 rAF 节流,问题集中在 finalize 主动丢弃增量状态——这是实现选择,非语义必需。

建议修复(分级)

级别做法收益风险
P0KaTeX 异步占位 + 分片:首帧输出 TeX 字面量占位,scheduler.unstable_scheduleCallback(~7ms/片)串行算,startTransition 替换;卸载取消。终结帧 0 次 renderToString,按钮先可用;最终 DOM 与现状一致。低
P1Shiki finalize 同样先 plain <pre> 占位,再入同一分片队列,保留视口门控与流式缓存。消除代码块尖峰。低-中
P2增量收尾:让带 math 的增量 parser 从冻结边界续解析,复用不含 $ 的 frozen 块 React 节点,只重渲染含 math/尾部/跨边界引用。去掉全量 parse/React 重建。中-高(上游核心 PR)
验证判定:修复后 maxLongTask 应从数百 ms~秒级降到 <100ms,且最终 .katex 节点数、DOM parity 与修复前一致。finalizeWindow 变长不算回归,关键看首帧长任务和按钮可用时间。
Issue 5 · P1 · performance

[session/subagent] 超长会话恢复 O(事件数),fork 子代理每次全量继承父 history 重复冷恢复

组件
@deepseek-ai/dsh-session · dsh-session-persistence-jsonl · dsh-subagent-fork-in-process · dsh-subagent-in-process-driver
版本
0.1.2-rc.1
体感
打开超大旧会话、或大量 fork 子代理时秒级等待;单次普通消息本地开销其实只有 10–40ms。
证据
fork 取父 completedTurnPrefix dsh-subagent-fork-in-process/lib/index.js:23-27,49-50,seed 建子 Session dsh-subagent-in-process-driver/lib/index.js:167-184;compaction 阈值 dsh-compaction-basic/lib/index.js:15-16,76

实测(真实最大会话)

操作结果
最大会话文件解压 38.7MB;事件 1,206,409;其中 assistant/chunk 1,191,956(98.8%);surface 仅 1,473
冷恢复 Session.fromRestore~938–1,049 ms
JSON 解析 + chunk 展开另 ~1,228 ms
deriveMessages(warm)~0.009 ms
把 chunk 换极小占位(不改 seq/surface)restore 仍 ~952ms ⇒ 瓶颈是 120 万事件对象数,不是字节
500 chunk append 开销~7.6–10 µs/chunk

关键反证:若删除 chunk 并重排序号,surface 由 1,473 变 5,546、deriveMessages 不一致(FAIL)。因此禁止 strip+renumber,会破坏 compaction 的 replace 语义;正确做法是保留 seq、按需展开(懒加载)。

建议修复(按性价比)

方案做法收益/风险
P0-配置compaction 阈值下沉:thresholdRatio 0.4、retainRatio 0.12、maxTokens 8192(1M 上下文 ⇒ 40 万 token 触发,原 80 万)。零代码、治本;maxTokens 降低会影响长回复,需产品确认。
P0-止血fork 暴露 inheritContext:false,等价 spawn,不再继承父全史。确定止血;牺牲子代理上下文。
P1UI 先用 projection-cache 展示,后台再开 Session。风险低,可独立发布。
P2Session 懒解析:scanner 保留 PackedSegment,同 seq 占位,eventAt/snapshotEvents 按需 expandRowAt,目标 restore <150ms。终极修复;改动大,必须保 seq 不变。
已被否决的方案(重要):“fork 只取最近 N turn(限窗)”在 3 个真实会话上做等价性验证,2 个 FAIL。原因是窗口之前的 compaction checkpoint 摘要被切掉,子代理丢失压缩背景,derive 出的 history 反而不完整。在该等价性 oracle 对含 compaction/team/steering 的会话全绿前,不要上线 fork 限窗。详见 /tmp/dsh_plan/validate_fork_equiv.mjs。
Issue 6 · P0 · bug

[client-ui-model-selection] 当前模型“不可路由”时锁死 composer;catalog 进入 error/loading 后旧 routable:false 不清除,发送按钮只能靠刷新恢复

组件
@deepseek-ai/dsh-client-ui-model-selection(每个会话的模型目录 / composer 锁定)
版本
0.1.2-rc.1(安装 bundle,2026-09-05 复核;上游 HEAD 待确认)
严重度
高:发送框被禁用且点击无反应,用户必须刷新或重选模型才能继续;子代理(subagent)越多、模型 catalog 刷新越频繁时越容易命中。
证据
dsh-client-ui-model-selection/lib/client.js:202-234(syncInputs)、:295-325(directoryFor + composer block 发布);消费端 dsh-client-ui-conversation/lib/client.js:15365,15368,15683

用户可感知现象

  • 输入框还在,但发送按钮恒灰、点不动;按 Enter 也不发(机器进入/保持 busy 或 input 被 block)。
  • 底部模型下拉仍然可以点(代码刻意让“消息输入”失效但保留模型选择)。输入框占位可能显示「当前模型不可用,请先选择模型」。
  • 刷新整页或重选一次模型后立刻恢复;在飞 subagent 多、网络/凭据/catalog 抖动时反复出现。

一句话根因(先给结论)

每个会话都有一个“当前模型是否可路由”的状态字段 routable:false 表示选中的模型不可用,此时输入被锁;null 表示“还没算出来”,按设计不锁。问题出在:一旦某会话算过一次 routable:false,之后模型目录(catalog)进入 loading/error 时,syncInputs 直接 return,不把旧的 routable:false 重置为 null。于是 composer block 永久残留,直到刷新重建整个 directory。

源码走查(最小充分证据)

// dsh-client-ui-model-selection/lib/client.js  (0.1.2-rc.1 反混淆,行号对应源)
private syncInputs() {
  if (this.disposed) return
  const catalog = this.catalog.store.getSnapshot()
  const projected = modelSelectionProjection(this.projected.getSnapshot())
  if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
    if (this.resolved) {
      if (catalog.status === 'error') this.store.update((state) => {
        state.status = 'error'
        state.error = catalog.error
      })
      return                       // ← BUG:曾 resolved,保留旧的 routable:false,block 不清
    }
    this.store.set({ current: null, routable: null /* not blocked */, groups: [], failures: [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error })
    return
  }
  const current = projected.next ?? catalog.value.default
  this.resolved = true
  this.store.set({
    current,
    routable: catalog.value.routableProviders.includes(current.provider), // false 时下面会 block
    groups: catalog.value.groups, failures: catalog.value.failures,
    status: this.store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready', error: null
  })
}

// 同一文件 directoryFor():routable === false 才注册 composer block
const publish = () => {
  conversation.blocks.set(sessionId,
    directory.store.getSnapshot().routable === false ? { reason: this.blockReason() } : void 0)
}
publish()
actx.effect(() => { const stop = directory.store.subscribe(publish); ... })

// 消费端 dsh-client-ui-conversation/lib/client.js
const disabled = removed || inert || !live || blocked !== void 0 || parentOffline   // :15365
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' // :15368
// 发送按钮 disabled: empty || disabled || machineBusy                              // :15683

类型定义本身写明了正确语义(lib/types/client/directory.d.ts):routable: null “是还没首次加载,不等于被 blocked;请以 === false 判断”。也就是说,catalog 不健康时把 routable 归 null 解锁,完全符合原设计。

复现步骤

  1. 在一个会话把当前模型切成一个可能不在 routableProviders 里的模型(例如实验模型 deepseek-v4-flash-vision-exp-dev0),让目录算得 routable:false、composer 被 block。
  2. 让全局模型 catalog 下一次刷新失败或进入 loading(断一下凭据/远端/网络,或压测制造 catalog 抖动)。观察会话的状态变成 status:error,但 routable 仍是 false。
  3. 回到该会话:发送框禁用;模型下拉可点;选别的模型或刷新后恢复。
  4. 在大量并发 subagent 场景重复:每个子会话各持一份 directory,catalog 抖动窗口内很容易有会话被永久锁住。

期望 vs 实际

期望:模型可用性“未知”不是“不可用”。当 catalog 进入 loading/error、或映射(projection)暂时缺失时,应清除旧的 block(routable = null),让用户仍能输入/排队;不可用状态应只在 catalog ready 且明确 false 时成立。实际:早期 return 保留了陈旧的 routable:false,block 粘死到整页刷新。

建议补丁(最小、低风险)

把 syncInputs 里“已 resolved 且 catalog 非 ready”的早退分支,改成把 routable 重置为 null(同时保留 error/loading 展示)。用 store.update 只动可用性三字段,不动已加载的 current/groups/failures,避免闪空模型列表:

--- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/packages/client/ui-model-selection/src/client/directory.ts
@@ -202,13 +202,17 @@ export class ModelDirectory {
       const catalog = this.catalog.store.getSnapshot()
       const projected = modelSelectionProjection(this.projected.getSnapshot())
       if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
         if (this.resolved) {
-          if (catalog.status === 'error') this.store.update((state) => {
-            state.status = 'error'
-            state.error = catalog.error
-          })
+          // Availability is unknown again: null is "not blocked", never false.
+          // Keep last-good current/groups/failures so the selector does not flash empty.
+          this.store.update((state) => {
+            state.routable = null
+            state.status = catalog.status === 'error'
+              ? 'error'
+              : 'loading'
+            state.error = catalog.status === 'error' ? (catalog.error ?? null) : null
+          })
           return
         }
         this.store.set({
           current: null,
           routable: null,
为什么安全:composer block 的发布函数只认 routable === false(见上);改成 null 会触发订阅、publish 把 block 清成 void 0。catalog 稍后回到 ready 时会重新计算真实的 routable 并再次按需 block。没有放开真正不可用的模型:catalog.status === 'ready' 且 provider 不在白名单时仍为 false,照锁不误。

可选的第二处加固(同目录,非必须)

directoryFor() 里先做一次 publish() 再注册 store.subscribe(publish),两步之间理论上存在一次竞态;订阅注册放在初始发布之前,或让初始发布也走幂等封装,可消除该窗口(blocks.set 已按 reason 去重,所以影响小于主 bug)。

验证 / 回归测试建议

  1. 正面(负例对照):构造目录:先 ready 算出 routable:false(断言 composer 被 block),再把 catalog 置为 error/loading,断言 store routable === null 且 block 被清除。
  2. 真不可用仍锁:catalog ready 且 provider 不在 routableProviders,断言 routable === false 且 composer 仍 block。
  3. 恢复:catalog 重新 ready 且 provider 进入白名单,断言解锁;provider 移出则重新锁。
  4. 浏览器侧:在输入框有文字时复现,修复后发送按钮应立即可用;可用 DevTools 看输入框占位是否仍为「当前模型不可用」。

Workaround(当前生产) 复现时先在底部模型下拉重选一次当前模型;若模型菜单因 catalog error 打不开,则只能刷新页面。这是本 issue 的直接止损,不替代上游修复。

与 approval “ask→never” 无关:曾怀疑是审批/问题面板的 composer 接管残留,但实测安装包中接管只在有 pending interaction 时生效,且审批改成 never 后没有 pending。根因在模型可用性字段的陈旧值,不在审批链路。

附:统一复现环境、脚本与建议的提交方式

建议的 GitHub issue 提交方式

  1. 建议把 #1 与 #2 合成一个 PR/issue,因为同属 ConnectionController.loop,一起改并补 timeout 测试。
  2. #3 可作为 #1/#2 的配套(暴露运行时 schema),在修复 PR 中一并落地,避免“修好默认值但仍不可配”。
  3. #4 单独提,按 P0→P1→P2 拆 3 个小 PR,先上 KaTeX 异步分片(收益最大、风险最低)。
  4. #5 先上纯配置的 compaction 阈值下沉与 inheritContext:false,懒加载 P2 单独立项;明确写出 fork 限窗被等价性测试否决,防止别人重复踩坑。
  5. #6 单独提,附最小 unified diff;建议同时补一个“catalog error 后 composer 必须解锁”的状态机单测,防止回归。补丁只认 routable === false,null 代表“未知、不锁”。

复现/对照脚本(保留在 /tmp 与仓库,可向 issue 附链接或复制)

  • ops/dsh/upstream-issue-composer-lock/selftest.mjs —— #6 无依赖状态机 oracle:BUGGY 保留陈旧 routable:false、PATCHED 清为 null,真不可用仍锁;退出 0 = 全过。
  • ops/dsh/upstream-issue-composer-lock/upstream-model-routable-unlock.patch —— #6 最小上游 unified diff(相对 deepseek-harness 仓库根)。
  • /tmp/dsh-fix/test_connection.mjs —— 退避终档/假超时正负对照,真实 bundle,已 ALL PASS。
  • /tmp/dsh-fix/dsh-connection-retry-fix.patch —— 部署补丁(-p0),及 upstream-connection.ts.patch / upstream-gateway.ts.patch / upstream-connection-tests.patch 上游补丁草稿。
  • /tmp/dsh-perf-fix/longtask-probe.js —— 浏览器 PerformanceObserver(longtask) + Event Timing,量 maxLongTask 与按钮可用时间。
  • /tmp/dsh-perf-fix/gen-benchmark-md.py —— 生成 200 公式 + 12 代码块的长 markdown(benchmark.md 38KB)。
  • /tmp/dsh-perf-fix/patches/01-P0-deferred-math.md 等 4 份前端补丁草稿。
  • /tmp/dsh_plan/validate_lazy_equiv.mjs —— 证明 chunk 不影响 surface/derive,并复现 ~938ms。
  • /tmp/dsh_plan/validate_fork_equiv.mjs —— 暴露 fork 限窗丢失 compaction 摘要的等价性测试(负面结果)。

复现环境

DSH
@deepseek-ai/dsh 0.1.2-rc.1(上游 HEAD d347e70 / 0.1.3-alpha.1 复核 #1–#3 仍在;#6 行号基于安装包 dsh-client-ui-model-selection/lib/client.js,上游源路径为 packages/client/ui-model-selection/src/client/directory.ts,提交前请重新核对行号)
模型/上下文
deepseek-v4-flash-vision-exp-dev0,defaultContextWindow: 1000000,maxTokens: 32000,reasoningEffort: off
前端 bundle
index-Df-65__b.js(423KB / gz 144KB),vendor-CCJJTK99.js(740KB / gz 179KB)
#6 对照结果
selftest.mjs 实测 10/10 断言通过:BUGGY 在 catalog error 后仍 block 且 routable:false;PATCHED 清为 null 解锁,catalog 真 ready 但 provider 不在白名单时重新 block。
采集时间
2026-09-05(+08:00)
说明
所有 DSH 本体测试均未改动生产 node_modules、未 commit、未重启服务;补丁只落在 /tmp 与 ops/dsh/upstream-issue-composer-lock/。生产环境另有两个部署层缺陷(patchReload=live EACCES 崩溃、自有 gateway WS error 崩溃与 zombie 自愈)已单独修复。

最后修改:2026-10-01 19:51:07 +08:00