Repository navigation
Replies: 2 comments
|
这条和 8 月的 #4387 / #4385 是同一族(空 我在 0.1.7-rc.2 与桌面端 0.2.0-rc.2 上逐条核过(两份
关于手工救活,有一个容易踩的坑:这三个地方的 id 必须同时改,只改一个会从「空 id 拒收尾」变成「不一致整份拒读」(我在修 #5909 重复 id 时逐个消融量过):
改完这三处, 你自己已经给出的主修复( 我们这边的工具(dsh-session-surgeon)能把这条定位出来( |
|
同款问题在最新版本(0.2.1-rc.2,2026-10-20,web 端)上仍然复现,补一份和本楼第 ① 条不完全一样的现场。 环境
现场本轮报错 之后每次提问都在同一个 seq 上失败,物理日志停在 seq 15850。和本楼 ① 的差别在同一条 assistant/message 里有两个 tool-call 块,空块在前。 stream 里能看到的顺序 落盘的 tool/call 只有 index 1 那个空块,callId 和 name 都是空串,执行时报 也就是说,模型这一刻同时吐出了一个空身份块和一个合法块,空块先被当成待执行的调用,合法块被丢掉。这条路径比“模型只吐一个空块”更隐蔽,空块看起来来自流式拼装,模型真正想发起的调用在它后面。 根因链和本楼一致,没有新的分支。BlockAssembler 放行空 id,block-end 带 block 时直接返回 同类报告还有 #9215、#9731 和更早的 #6152、#6300、#4387,属于同一族。对照 #9215 的时间线,0.1.7-rc.2 到 0.2.1-alpha.1 之间没修,到今天 0.2.1-rc.2 仍然没修。 自救过程应用里没有截断、回滚或修复入口,只能动日志文件。
第 5 步不能省。运行中的进程缓存着这个会话,还持有写租约,改完磁盘上的文件它不会自己重读,下一次提问仍然撞同一行旧记录。会话级没有 reload 或 repair 的入口。 建议写入侧仍然最该修,本楼第 ⑥ 条的写读不对称是根本。在 append 之前拒绝空 id 和空 name 的 tool call,让这一轮停在还能重试的位置,不要把日志推进不可闭合的状态。想保留兼容性的话,可以把空 id 归一成稳定 id,跟 assembler 里已有的 恢复侧也值得补一层。interruptedTurnClosers 碰到空 callId 的历史记录时,至少给一条可诊断的路径,别让整个会话永远自锁。错误文案把“id 为空”和“id 与来源不匹配”分开报,排障会省很多时间。 社区已经有在 llm/stream 出口拦空身份块的插件(@argszero 的 phantom-tool-call-guard),它能挡住新产生的脏数据,挡不住已经在日志里的坏记录,写入侧的校验还是要有。 需要脱敏的事件结构片段(只保留字段和序号,不含路径、正文和 id 真值)我可以补上来。 |
Uh oh!
There was an error while loading. Please reload this page.
严重程度:高 —— 会话永久不可用,应用内无任何恢复手段,只能手工修改日志文件。
环境
DSH(npx 安装),session format v4(session.v4.jsonl.zstd)
触发模型:qwen3.8-27b-sglang(provider dxgspark,openai-completions API)—— 任何会输出畸形 tool_use 块的 provider/模型都可能触发
症状
会话运行中某轮报错:本轮运行失败 format v4 tool/result at seq requires toolCallId matching its tool source
此后该会话的每一次提问都报同样的错误(seq 不变),会话永久卡死,无法继续。
根因(完整故障链,已逐层验证)
① 模型输出了空 id 的 tool-call。 该轮 assistant 消息的嵌入流为:
② @deepseek-ai/dsh-llm 的 BlockAssembler 放行了空 id(lib/index.js):
push() 的 tool-call-delta 分支:partial.toolCallId = chunk.id; 无条件接受空字符串(同分支的 name 却有 if (chunk.name) 守卫,id 没有);
该类本有兜底逻辑 id: partial.toolCallId ?? brandString(call-${index})(assemble() 内),但 ?? 只对 null/undefined 生效,"" 不触发;
更关键的是 assemble() 开头 if (partial.block) return partial.block; —— provider 在 block-end 里直接组装好的 block 原样返回,完全绕过兜底。
结果:assistant/message 以 {"type":"tool-call","id":"","name":"","arguments":"{}"} 落盘。
③ @deepseek-ai/dsh-agent-loop 直接透传空 id(lib/index.js):
appendToolCall():callId: block.id → tool/call 事件以 callId: "" 持久化;
appendToolResult():createToolResultMessage({ callId: block.id }) → 结果消息 toolCallId: ""、source.callId: ""。
④ 持久化层 v4 校验拒绝空 toolCallId,轮次中断。 @deepseek-ai/dsh-session-persistence-jsonl(worker.cjs,assertV4ToolResultMessage):
⑤ 空 id 使日志"不可闭合",每次 resume 重复同一失败(永久卡死的本质)。
每次新提问触发 AgentLoop.resumeWith()(@deepseek-ai/dsh-agent-loop):读日志 → interruptedTurnClosers()(@deepseek-ai/dsh-session repair)→ ToolCallRecovery.results() 为悬空调用合成 tool/result 收尾,toolCallId = callId = "" → 同一校验再次拒绝(同一 seq)。
v4 格式要求 toolCallId 非空,而收尾结果的 toolCallId 必须等于悬空 tool/call 的 callId("")——逻辑上不存在合法的收尾事件,日志永远无法前进。
应用内没有截断/修复/回滚 API,用户唯一出路是手工编辑 zstd 日志文件。
⑥ 校验层不对称(缺陷得以落盘的原因)。 全量 restore 路径的 lifecycle fold(worker.cjs Relationships.tool())会用非空校验检查 assistant 消息里的 tool-call id(text(block["id"], "tool call id")),但冷读/增量 append 路径只执行逐行 admission(assertV4RowAdmission → assertV4ToolResultMessage),不检查 assistant 消息的 tool-call id。空 id 因此能写入日志,随后 tool-result 校验器拒绝闭合它。
最小复现
用 mock provider(openai-completions)按上面 ① 的 chunk 序列流式返回(或让模型自然产生空 tool_use 块);
该轮结束时报 format v4 tool/result at seq requires toolCallId matching its tool source;
再发任意消息,错误原样复现,会话不可用。
单元测试级复现:向 BlockAssembler push 上述 chunk 序列,修复前 blocks() 返回 id: "" 的 tool-call 块(即 bug 本身)。
建议修复
主修复(根因,@deepseek-ai/dsh-llm BlockAssembler,一处兜底覆盖两条路径):
效果:空 id 的 tool-call 获得持久身份(与既有 call-${index} 兜底约定一致),其结果(含"未知工具"错误结果)合法落盘;即使中途崩溃,恢复合成的结果也合法,会话不再卡死。
建议的纵深防御(可选):
ToolCallRecovery / openTurnClosers(@deepseek-ai/dsh-session):对已持久化的空 callId 做防御性处理(跳过并为这类日志给出明确诊断),避免"不可闭合"状态静默产生;
提供会话级修复手段(如"回滚到最后闭合 step"或截断损坏尾部),让用户在应用内即可解救卡死会话;
错误信息区分"toolCallId 为空"与"toolCallId 与 source 不匹配"两种情况(当前合并为一条消息,排障时极具误导性)。
已验证
修复补丁已在本机 npx 安装目录应用并用真实数据验证:空 id 的 block-end 流 → 兜底为 call-;纯 delta 空 id 流 → 同样兜底;正常 provider id 原样保留(无回归)。
All reactions