DSH plugin 会话打开一片空白?重复 tool call ID 致汇编失败排查与修复

故障排查发布于 2026-09-12作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH plugin会话空白重复 callIdreceived more than one start Match
DeepSeek Harness 历史会话要么完全打不开、要么打开后对话整段空白?同一 callId 在一步内被通告两次会让 v0→v1 迁移整体拒读,或让前端汇编器抛 received more than one start Match。写入侧去重与 ID 重命名修复工具是两条处置路径。

历史会话出现「打不开」或「能打开但对话整段空白」这两种截然不同的症状时,很可能是同一个原因:同一个 callId 在一(turn, step)内被通告了两次。 落到 v0 文件上会让 v0→v1 迁移在加载阶段整体拒读(模式 A),落到已是 v2 的文件上则让主机侧恢复成功、前端汇编器却抛 received more than one start Match(模式 B)。根因是「provider 产出的 ID」与「harness 内部调用身份」共用 callId 一个字段;写入侧从未强制唯一、读取侧却当硬性不变量。

DeepSeek Harness:Mode A 打不开与 Mode B 空白两种表现

同一份脏数据、两条读取路径、两种失效面——先确认自己处在哪一面,再选处置手段。 实测记录:

  1. 模式 A:会话完全打不开。v0→v1 迁移复用了已发布的校验器 assertReleasedArtifactRelationships,当它在 assistant/message 里遇到已经通告过tool-call 块 ID 时直接抛出,整个迁移——因此整个会话加载——失败。抛出点在 packages/session/session-format-v0-to-v1/src/relationships.ts:140SessionFormatError('assistant/message repeats advertised tool call ${callId}')#5909)。
  2. 模式 B:能打开但一片空白。已是 v2 的会话(样本 81570fca:1236 事件、45 组重复 ID)不走迁移边,生产读取路径用 validation: 'transformed'packages/session/session-persistence-jsonl/src/index.ts:276-280)对重复 ID 宽容,主机侧恢复成功(约 22 ms);但客户端 ConversationNodeAssemblerassembler.ts:513:569 抛出同一个错误,结果是对话整段空白、只剩「Load earlier」,点它走 prepend 再抛一次,按钮复位,内容永远不出现
  3. 前端控制台的原始报错[session-controller] event feed subscriber failed: Error: conversation Context 20:trajectory-tool-callpwsh:0 received more than one start Match——订阅整条事件流的 feed subscriber 因此中断(#5247)。
  4. 脏数据可以完全潜伏:一旦落盘,不需要任何新的模型输出——对 v2 会话,仅重新打开即会触发失败;对 v0 会话则连迁移都进不去。可排除文件损坏嫌疑:检查过的日志 7360 帧全部解压成功、8907 事件、seq 连续至 37098、JSONL 全部合法(#5247)。
  5. 严重度有版本差异46196d6f95(含于 0.1.3-alpha.2)移除了 'target' 校验模式,assertReleasedArtifactRelationships 现在也会在 'current' 恢复时执行,因此迁移输入与严格 current 校验都会拒绝此类日志;生产日常读取仍走 validation: 'transformed',会话本身还能打开(#5909)。

DeepSeek Harness 汇编器以 (kind, callId) 为键的机制

两侧的机制都很短,但合起来正好解释了「写端能产出读端必拒的数据」这一不对称。 逐层拆解:

  1. 前端 Context 键不含回合维度acceptMatchconversationContextKey(definition.kind, id) 作为键,id 取事件的 callId;只要 role === 'start' 且该 Context 已有 start 就抛错。测试套件把它固化为设计行为(rejects a duplicate start before mutating the existing Context)。
  2. 旧写入端铸造短索引 ID:当前 agent 每次工具调用都是唯一 uuid,所以 (kind, id) 天然不撞;但旧日志由铸造 pwsh:0grep:0read:0 这类只在单回合内唯一的写入端产生。一条 16 轮会话里实测撞车:pwsh:0×45、grep:0×11、read:0×8、grep:1×5、pwsh:1×7、write:0×4、edit:0×3、todo_write:0×4(#5247)。
  3. 跨回合复用是合法的,同回合重复才是畸形:判别轴是 (turn, step)——重复 start 落在同一(turn, step)内属真正畸形;短 ID 在跨回合复用属合法历史数据。这也是「跨回合不同 step 各自完成生命周期后复用同一 ID」能通过校验的原因。
  4. ID 的产生链没有去重:本地 OpenAI 兼容 Responses 服务器在同一个 assistant 响应内为多个输出项重复发出相同的 call_id+id;pi-ai 组合为 `${call_id}|${id}` 时不做唯一性检查,packages/llm/llm-pi-ai/src/stream.ts:188,200 更是原样透传 event.toolCall.id / known?.id
  5. 写入侧无约束、读取侧硬校验:v0 格式在写入时对工具调用 ID 不强制唯一,而读取侧的已发布校验器把「重复的已通告工具调用」当成不变量违例(relationships.ts:140,相邻不变量还有 :156:171assertNoUnresolvedTools)。这种不对称正是脏数据能产生、之后又被拒绝的根本原因
  6. 数据形态固定可辨:工具调用 ID 形如 call_<token>|fc_<token>;每组重复恰好出现 2 次且落在同一个 step/turn 内;一次执行会同时出现在四个表面——assistant/message 的 content 块、tool/calltool/resultmessage.source.callIdmessage.content[0].toolCallId)以及 assistant/chunk 的 delta 与 block-end。重命名时必须四个表面同步,否则仍会不一致。
  7. 它还会在别的面爆:同一根因在严格 provider 回放时表现为 400(#5732),在前端表现为 received more than one start Match(#5884 / #4501 / #5296)与 received an update before its start Match(#5692),在服务端表现为 BlockAssembler 把两次不同调用按索引合并(#4427)——同一份数据,多个表现面

DSH plugin 修复工具与写入侧去重进展

处置分三半:写入侧去重防新增、读取侧宽容救存量、客户端优雅降级避免整段空白——这也是当前 DSH插件 与其它 DeepSeek插件 处理会话类损坏的通用次序。 具体手段:

  1. 写入侧去重(治本):在 pi-ai 适配层或会话写入器里,对同一步骤内重复的工具调用 ID 做去重/重映射,让脏数据无法落盘。更彻底的方向是把 providerCallId(协议往返与诊断用)与 harness 生成的 invocationId(会话配对与汇编身份用)拆成两个字段——这样新写入永不冲突,读取侧的兄弟分叉就退化为纯遗留兼容路径(#5268)。
  2. 读取侧宽容:社区补丁把「重复 start 抛错」改成兄弟分叉——用 families 映射记录基础业务键到兄弟 Context 键;重复 start 派生 base#2base#3 等兄弟;非 start 事件路由到 startSeq <= event.seq 的最新兄弟;prepend 批处理按 start 分段,让 append / 窗口重放 / prepend 三条路径都正确落位(ui-conversation 全套 337 测试通过)。一个实现细节:兄弟后缀不要假设 # 不会出现在 provider ID 里,改用 NUL 字符之类的分隔符或按族单调计数器,只要求唯一、不要求可解析。
  3. 客户端优雅降级ConversationNodeAssembler 遇到「一个上下文多个 start」时应记录并跳过问题块,而不是抛错导致整段会话空白;等价的降级思路也适用于 received an update before its start Match(#5692)。
  4. 磁盘侧 ID 重命名dsh-session-surgeoninspect 会标出 duplicate-tool-call-id;当本机 SESSION_FORMAT_VERSION >= 1(0.1.3 起会走迁移)时,--apply后出现的 id 追加 #n 后缀,并按出现顺序重映射 tool/call.callIdtool/resultsource.callId,首个 id 原样保留,空 callId 不编。重命名必须保持纯粹——只改名、不增删事件——这样 seq 保持稠密、所有 sourceEventSeqs / surfaceOp 引用继续有效(#5909)。
  5. 用修复工具的正确姿势:先停掉写入方、先 --dry-run、不要对已裁过的文件连续 --apply;该工具明确不代做 v0→v1→v2 迁移(那是官方 persistence 的职责),也不修模式 B 的前端空转。安装后需重启:
sh
dsh plugin --profile web add "github:xiaoshenming/dsh-session-surgeon#main"
  1. 插件装卸走插件市场:在 DSH Plugin Hub 的「设置 → 插件市场」安装与更新这类修复插件,比手工改 profile 更安全(失败会回滚 manifest)。会话内容类的其它损坏形态(枚举失败、格式拒读)参见 会话损坏排查

DSH plugin 排查注意事项

先分清模式 A 还是模式 B——完全打不开多半是 v0 被迁移拒读,能打开但空白是 v2 撞上前端汇编器抛错,两者处置不同。 六条要点:

  1. 先分清 A/B:完全打不开多半是 v0 被迁移拒读;能打开但空白是 v2 撞上前端汇编器抛错,两者处置不同。
  2. 四表面必须同步重命名assistant/messagetool/calltool/resultassistant/chunk 缺一即不一致。
  3. 重命名保持纯净:不增删事件,避免打乱 seq 与引用关系。
  4. transformed 不是万能钥匙:它能放过已 v2 的文件,但放不过 v0 迁移路径上的关系校验。
  5. llama.cpp 类本地服务是高发源:单次响应内并行工具调用共用 ID,因此「运行中一切正常、之后无法继续」是典型时序。
  6. 不要把放宽读取校验当成完整修复:写端去重、存量显式恢复、客户端显示需要分别验证。
DSH Plugin Hub 插件市场:安装会话修复类插件、查看版本与更新

来源:Discussion #5247Discussion #5909dsh-session-surgeon

常见问题

为什么 DeepSeek Harness 里有的历史会话完全打不开,有的却打开后对话整段空白?

DeepSeek Harness 里这两种症状的分歧来自同一份重复 ID 脏数据落在两条不同的读取路径上:**模式 A** 是 v0 文件,v0→v1 迁移在加载时就把整个会话拒掉(assistant/message repeats advertised tool call <id>),会话根本打不开;**模式 B** 是已是 v2 的文件,不走迁移边,主机侧用宽配置恢复成功,但 Web 客户端的 ConversationNodeAssembler 在同一个上下文收到两个 start 时抛错,于是对话整段空白、连「Load earlier」也卡死(来源:Discussion #5909)。

在 DeepSeek Harness 里,重复的 callId 是怎么被写进会话日志的?

DeepSeek Harness 的重复 callId 来源是本地 OpenAI 兼容的 Responses 服务器(llama.cpp 一类):它会在**同一个 assistant 响应**里为多个输出项重新发出**相同的** call_idid。pi-ai 把工具调用 ID 组合为 ${call_id}|${id} (形如 call_<token>|fc_<token>)且不做去重,本仓库的适配层(packages/llm/llm-pi-ai/src/stream.ts:188,200)原样透传;而 v0 格式在写入侧**不强制**工具调用 ID 唯一,脏数据于是顺利落盘,读取侧却把它当不变量违例(来源:Discussion #5909)。

DeepSeek Harness 的前端汇编器为什么对重复 start 直接抛错,而不是跳过它?

DeepSeek Harness 前端汇编器对重复 start 直接抛错,是因为 ConversationNodeAssembler(definition.kind, callId) 作为 Context 键:acceptMatch 里只要 role === 'start' 且该 Context 已有 start 就抛 received more than one start Match,测试套件甚至把这条拒绝当作**设计行为**固化下来。当前 agent 每次工具调用都用唯一 uuid,所以新写入不会撞车;但旧写入端会铸造**短索引 ID**(pwsh:0grep:0),它们只在单个回合内唯一——跨回合的 pwsh:0 其实指代不同调用,汇编器没有回合维度,于是把第二个回合的 pwsh:0 误判成重复 start(来源:Discussion #5247)。

已经被重复 callId 写坏的历史会话,在 DeepSeek Harness 里还能救回来吗?

已经被写坏的会话在 DeepSeek Harness 里有两条救援路径。① **读取侧宽容**:社区补丁把「重复 start 直接抛错」改成**兄弟分叉**——重复 start 会派生一个带后缀的兄弟 Context,非 start 事件路由到 startSeq <= event.seq 的最新兄弟,append / 窗口重放 / prepend 三条路径都覆盖(ui-conversation 全套 337 测试通过)。② **磁盘侧 ID 重命名**:dsh-session-surgeoninspect 会标出 duplicate-tool-call-id,在 SESSION_FORMAT_VERSION >= 1--apply 给后出现的 id 追加 #n 后缀并按顺序重映射引用,首个 id 原样保留。注意它不代做 v0→v1→v2 迁移,也不修前端空白那一半(来源:Discussion #5909)。

相关术语

Mode A / Mode B
Mode A / Mode B 是同一份重复 ID 脏数据落在两条读取路径上的两种失效面:Mode A 指 v0 文件被 v0→v1 迁移在加载时整体拒读(会话打不开);Mode B 指 v2 文件主机侧恢复成功、但 Web 客户端汇编器抛错导致对话整段空白。https://github.com/deepseek-ai/deepseek-harness/discussions/5909
Context key (kind, callId)(Context 键)
Context key (kind, callId) 是前端会话汇编器把一次工具调用归组成一个 Context 所用的键,由节点的 kind 与事件的 callId 拼成。它不含回合/步维度,因此旧写入端的短索引 ID 跨回合复用时会撞键。https://github.com/deepseek-ai/deepseek-harness/discussions/5247
sibling forking(兄弟分叉)
sibling forking 是读取侧对重复 start 的宽容实现:用一个 families 映射记录基础业务键到兄弟 Context 键的对应,重复 start 派生 `base#2`、`base#3` 等兄弟,非 start 事件路由到 startSeq 不晚于该事件的最新兄弟。https://github.com/deepseek-ai/deepseek-harness/discussions/5247

来源