DeepSeek Harness 会话永久失败:崩溃的 tool_use 让每轮都报 INVALID_REQUEST

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHtool_useINVALID_REQUESTTOOL_RUNTIME_SCHEDULERSymbol.for会话恢复session.v3.jsonl
DSH 一轮工具调用进行中崩溃后,历史会留下没有结果的 tool_use;此后每轮都在真正发起新调用前瞬时失败,报 INVALID_REQUEST,会话再也无法恢复。根因是 TOOL_RUNTIME_SCHEDULER 用 Symbol() 定义、跨模块图查到 undefined。

DSH 会话「崩溃一次、永久失灵」是一条能被完整解释、也能被完整恢复的链路:一轮在工具调用进行中崩溃,在历史里留下没有结果的 tool_use;而 DeepSeek Messages 序列化器要求「工具调用必须立刻拿到结果」,于是此后每一轮都在请求发出之前就被本地校验拦下,报 DeepSeek Messages tool calls need immediate results(INVALID_REQUEST)。崩溃的根因是 TOOL_RUNTIME_SCHEDULER 用 Symbol() 而不是 Symbol.for() 定义,跨模块图查到了 undefined,一行即可修复;而「日志为什么不能自愈」是第二个独立问题——interruptedTurnClosers 只在日志中途结束时运行,本例里 turn/end 已经写下,日志被视为平衡,修复流程永不触发。已中毒的会话无法靠升级救回,只能修复日志或新开。

先分诊:一次崩溃会留下三层问题

结论先行:把「崩溃」「中毒」「永久失败」当成三层独立的故障来看,你才能判断自己卡在哪一层,以及每一层分别该做什么。 这是本文最重要的一步分诊——它们症状相似(都是「会话用不了」),但处置方式完全不同,混在一起修只会白费力气。

第 1 层,崩溃发生的那一轮:工具调度在第一次调用就抛错,一轮以 error 结束。上报的环境是 DSH 0.1.6-alpha.2、macOS arm64,provider deepseek-official、模型 deepseek-flash、reasoning effort high,工具模式 native。崩溃信息是 Cannot read properties of undefined (reading 'prepare'),错误码 UNKNOWN(#7318)。

第 2 层,日志里留下的断点:模型在一条助手消息里声明了 N 个工具调用,宿主只为第一个写下了 tool/call,然后就死了。剩下那些既没有 tool/call、也没有 tool/result;已经写下 tool/call 的那个也没有 tool/result。它们全部成为悬空的 tool_use。

第 3 层,此后每一轮瞬时失败:序列化器在把历史转成 DeepSeek Messages 时拒绝任何 pending 工具调用,报 DeepSeek Messages tool calls need immediate results,外层包成 INVALID_REQUEST。注意失败发生在发起任何新工具调用之前,所以你换提示词、换模型、换工具都没用。

三层的时间线在日志里是这样长出来的:

assistant/message   content: [ …, {type:'tool-call', id:'call_00_…'}, {type:'tool-call', id:'call_01_…'} ]
tool/call           { callId: 'call_00_…', name: 'grep' }     <-- 只有第一个调用拿到 tool/call
step/end            { turn: 6, step: 1 }
turn/end            { reason: { kind: 'error', error: { message: "Cannot read properties of undefined (reading 'prepare')", code: 'UNKNOWN' } } }
… 下一条用户消息 …
assistant/attempt   finish.reason.failure = { message: "DeepSeek Messages tool calls need immediate results", code: 'INVALID_REQUEST' }
turn/end            { reason: { kind: 'error', error: { …INVALID_REQUEST… } } }

要判断你的会话是否属于这一类,按下面四步做:

  1. 先看这一轮的起始:失败是否发生在「没有任何新 tool/call 产生」的情况下?如果是,说明请求在本地就被拦了,问题在历史而不在运行时。

  2. 再数工具调用的账:取崩溃前最后一条 assistant/message,数其中 type: 'tool-call' 的块数;再数整条日志的 tool/call 与 tool/result 条数。

  3. 对比差值:块数 − tool/result 数,就是悬空 tool_use 的条数。上报的三份样本如下表(#7318)。

    会话tool/calltool/result崩溃消息里的 tool-call 块悬空 tool_use
    session-69a110fd38938822
    session-c607c71a1022
    session-40158bc71011
  4. 确认它与工具无关:三份样本分别崩在 grep、pwsh、subagent 三个完全不同的工具上,而且都是该轮的第一个工具调用;session-69a110fd 在崩溃前 5 轮里 388 次调用全部正常。这三点合起来说明:问题不是某个工具坏了、也不是某个配置错了,而是随时间变化的环境性故障——这正是下一节那个模块身份问题的典型特征。

根因与一行修复:Symbol() 的模块实例身份

根因已确认:TOOL_RUNTIME_SCHEDULER 用 Symbol() 定义,它的身份是「按模块实例」的;当 dsh-tools 这个包同时落在两个 bundle / 两份模块图里,注册用一个 symbol、查找用另一个,查到的就是 undefined,于是第一个工具调用抛 reading 'prepare'。把 Symbol() 换成 Symbol.for() 即可修复,一行改动。

崩溃点的读取位置在 native 路径上是 packages/core/agent-loop/src/tool-calls.ts:170:

ts
callSeqs[index] = appendToolCall(session, turn, step, call.block)   // :168  durable tool/call 已写入
started++                                                          // :169
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec)  // :170  这一行抛错

注意 :168 已经先把 tool/call 落进了持久日志,:169 把 started 加了 1,然后 :170 才抛错。这就是为什么悬空的 tool_use 一定会被写进日志——崩溃发生在「记账之后、执行之前」。同样的符号查找也出现在 packages/core/tools/src/ptc.ts:615,两条调度路径共享同一个根因(#7318)。

定义的原始形态在 packages/core/tools/src/index.ts:463:

ts
export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')

修复只有一行(社区 fork 的单提交 4dcf2d1):

ts
export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol.for('@deepseek-ai/dsh-tools.scheduler')

为什么这一行就够:Symbol() 每次求值都产生全新的、绝不相等的身份,而 Symbol.for(key) 走全局符号注册表,同一个 key 在任何模块实例、任何 bundle 里都解析到同一个 symbol。这样一来,注册方与查找方用到的键必然一致,registry[TOOL_RUNTIME_SCHEDULER] 不再可能是 undefined(fork diff)。

这也不是社区发明的写法——仓库里其它跨包符号(例如 dsh.subagent.*、dsh.typert.owned-value)本来就使用 Symbol.for() 这一约定,只有这一处漏掉了。上报表述很谨慎:最初报告者只是「顺手提一句」,怀疑它可能是自建副本的产物;随后维护者在 Linux 源码构建上复现了同样的失败,才把根因坐实。

如果你要自己验证修复效果,可以按下面四步做:

  1. 在源码 checkout 里把 index.ts:463 的 Symbol( 改成 Symbol.for(,保留参数不变。
  2. 重新构建(社区实测环境为 macOS arm64、0.1.6-alpha.2 源码构建)。
  3. 开一条新会话,让它执行一个会触发工具调用的请求,确认第一次调用不再抛 reading 'prepare'。
  4. 注意:这一步只证明「不再产生新的中毒会话」。对已经中毒的旧会话无效——那是下一个问题。

如果你不打算改源码,那么能做的至少是:不要再指望升级能救回中毒会话。有报告把构建回滚到 0.1.6-alpha.1 之后,该会话在第 3 轮仍然失败;构建回滚和新构建都不能改变已写入日志的事实(#7318)。

为什么日志不能自愈:interruptedTurnClosers 的触发条件缺口

第二个独立问题:DSH 本来有一个「会话中断自修复」机制 interruptedTurnClosers,会给未完成的工具调用补上合成的结果事件;但它的触发条件是「日志在同一轮内戛然而止」。本例里 turn/end 已经带着 error reason 写下了,日志在结构上算平衡,于是修复永远不运行——而助手消息里仍然挂着未解析的调用,没人清理。

先看「失败路径为什么写不出合成结果」。调度失败的处理在 tool-calls.ts 里是这样组织的(#7318):

ts
// 调度失败时
catch (error) {
    // …
    throw error;                          // :235 直接 rethrow
}

// 唯一的合成结果恢复路径(永远不会被执行到)
if (aborted) {
    appendSkippedToolCall(…)              // :238
}

两个缺口叠加:

  1. catch 在 :235 直接 rethrow,appendSkippedToolCall 那条分支永远够不到;
  2. 即便够到,循环也是从 group.slice(started) 开始,会跳过那条已经在 :168 追加过 tool/call 的调用。

再看「持久层为什么不管」。packages/core/session/src/repair.ts 的 interruptedTurnClosers 只在日志中途结束时追加合成的 TOOL_NOT_STARTED / TOOL_OUTCOME_UNKNOWN。本例的日志是:

tool/call   { callId: 'call_00_…' }     ← 已写入
step/end    { turn: 6, step: 1 }        ← step 收尾了
turn/end    { reason: { kind: 'error', … } }   ← 这一轮也收尾了(带 error reason)

有 turn/end ⇒ 这一轮「闭合」⇒ 日志被视为 balanced ⇒ repair 永不运行。这就是「从应用视角看会话永久不可恢复」的直接原因:不是没有修复能力,而是修复能力的触发条件比故障的形态窄(#7318)。

社区给出的方向是一致的:把崩溃修复这一遍扩展到「带 pending 调用、以 error 结束的轮次」即可。支撑这个方向的证据是——有人手工把 interruptedTurnClosers 本该写的事件补进去后,v3 格式校验器和运行时不变式检查都接受了,会话随即恢复正常。也就是说,缺的只是「谁来触发」,而不是「这些事件的合法性」。

手工恢复中毒会话:四步,含 zstd 分帧

在官方修复落地之前,已中毒的会话是可以手工救回的:停进程 → 备份 → 解压并给每个未解析的 tool-call 插入一条合成 tool/result → 按「header 行自成第一个 zstd 帧」的规则重新压缩。 下面这套步骤来自社区实际恢复记录,本质是「手工重放 interruptedTurnClosers 本会写入的内容」。

第 1 步,停掉所有持有会话锁的 dsh 进程。会话日志有锁,进程活着的时候改文件既可能被覆盖、也可能损坏。

第 2 步,备份原始日志。文件在 ~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd:

bash
cp ~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd \
   ~/.dsh/sessions/<workspace-key>/<session-id>/session.v3.jsonl.zstd.bak

第 3 步,解压并插入合成结果:

bash
zstd -d session.v3.jsonl.zstd -o log.jsonl

然后,对最后一条 assistant/message 里每一个「没有匹配 tool/result」的工具调用块,处理规则如下:

  1. 在它的 tool/call 事件之后插入合成 tool/result;如果这个调用从未启动(没有 tool/call),则插入在助手消息之后。
  2. 合成事件使用当前 open step 的 turn / step,surfaceOp: "append",seq 顺延,并把后续事件重新编号。
  3. 已启动的调用:error: { name: "ToolOutcomeUnknownError", code: "TOOL_OUTCOME_UNKNOWN" },并带 sourceEventSeqs: [<tool/call 的 seq>]。
  4. 从未启动的调用:error: { name: "ToolNotStartedError", code: "TOOL_NOT_STARTED" },message id 必须是 interrupted-tool-result-<callId>-<seq>——这是 v3 格式校验器认可的规范修复身份。
  5. 消息内容放一个 tool-result 块,isError: true,说明文字沿用 repair.ts 里的原话,保持一致。

第 4 步,重新压缩时,header 行必须自成第一个 zstd 帧。持久层会断言 first frame is not exactly one header line,所以不能把整份 JSONL 一次性压成一个帧,否则在它看来文件从头就是坏的(#7318):

bash
head -1 log.jsonl | zstd > out.zst && tail -n +2 log.jsonl | zstd >> out.zst

做完这四步,会话即可恢复,新的轮次能正常完成。

必须强调两点:这是对持久日志的外科手术,不是受支持的路径;同时它也顺带证明了修复事件的合法性——v3 校验器与运行时不变式检查都接受这些合成事件,所以「把崩溃修复扩展到 error 结束的轮次」应当是一个安全的修复方向。如果你不确定自己改的是否正确,更稳妥的选择是:把关键上下文复制到一条新会话里继续,而不是冒损坏日志的风险。

排查与预防注意事项

  • 别把「模型报错」和「本地序列化拦住」混为一谈。 DeepSeek Messages tool calls need immediate results 不是模型返回的,它在 serialize.ts:117 / :121 抛出,请求根本没发出去。判断方法:失败那一轮没有任何新的 tool/call 产生。
  • 别指望换模型、换 provider、换工具能绕过。 历史是会话级的,校验发生在请求构造阶段,与模型无关。
  • 别指望升级或回滚能救回中毒会话。 已在日志里的悬空 tool_use 不会被新构建清理;session-40158bc7 回滚到 0.1.6-alpha.1 后第 3 轮仍然失败就是直接证据。
  • 判断「是不是这个故障」,最快的一步是数账。 崩溃前最后一条 assistant/message 里的 tool-call 块数,减去 tool/result 数,差值大于 0 就是。
  • 一行修复只解决「新崩溃」,不解决「旧中毒」。 改 Symbol.for() 之后不再产生新的悬空调用;但修复路径的缺口(interruptedTurnClosers 的触发条件、appendSkippedToolCall 够不到)是另一个独立问题,需要另修。
  • 预防性的操作习惯:崩溃一旦发生,先不要继续往这条会话里发消息。多发一条消息只是在同一个坏历史上反复失败,还会在日志里堆积更多 INVALID_REQUEST 的 assistant/attempt,让后面的手工修复更难辨认「最后一条正常消息」在哪。正确的顺序是:先确认崩溃、再决定是修日志还是复制上下文开新会话。

来源:


会话被一条悬空调用锁死的时候,最烦的不是修日志本身,而是你连「现在到底坏在哪一层」都要先猜半天。上面这些步骤能救回数据,但如果你还想顺手把环境里其它插件的重复坑一并排掉,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用来浏览、安装、卸载和更新插件,自带更新检测、系统诊断与系统日志面板,通知中心会集中记录安装/卸载/更新的历史、进行中任务的进度和待重启提醒:

DSH Plugin Hub 通知中心:集中查看安装/卸载/更新历史、进行中任务进度与待重启提醒

排查这类会话级故障时,把插件生态的变动和宿主日志放在一起看会省很多事。

常见问题

为什么我的会话崩溃后再发任何消息都立刻失败,可新开会话却完全正常?

因为坏掉的是这条会话的**持久日志**,不是运行时。崩溃那一轮在历史里留下了没有结果的 tool_use,而序列化器在把历史转成 DeepSeek Messages 时,遇到任何未完成的工具调用都会直接抛错,并且要求历史不能以未解析的工具结尾。于是从那一刻起,这条会话的每一轮都在「真正调用模型之前」就失败,与模型、网络、你的新提示词都无关。新会话历史是干净的,自然正常。

报错 `DeepSeek Messages tool calls need immediate results` 到底是谁抛出来的?

不是模型返回的,也不是网络错误。它是 DSH 自己的 DeepSeek Messages 协议序列化器抛的:packages/llm/llm-deepseek/src/protocols/messages/serialize.ts:117 在序列化到任何 pending 的工具调用时抛错,:121 在历史以未解析的工具结尾时抛错。外层再把它包成 INVALID_REQUEST 写进 assistant/attempt 的 finish.reason.failure。换句话说,这是「本地历史自校验没通过」,请求根本没发出去。

我只升级到新版本,能不能把已经中毒的会话救回来?

不能。已中毒的会话把故障**固化在日志里**了:有报告显示,把构建回滚到 0.1.6-alpha.1 之后,该会话在第 3 轮仍然失败。新构建只能修掉「再次崩溃」的可能,救不了已经写进日志的那条悬空 tool_use。要恢复只能按本文第四节对日志做一次修复(或直接新开会话)。

怎么确认这条会话里到底有几条未解析的 `tool_use`?

不用解压也可以先做一次粗判:在会话目录里找到 session.v3.jsonl.zstd,用 zstd -d 解出 JSONL 后,统计最后一条 assistant/message 里 type: 'tool-call' 的块数,与 tool/call 和 tool/result 事件的条数对比。典型形状是:模型一条消息里发出 2 个 tool-call,但只有第 1 个写下了 tool/call,且没有任何 tool/result —— 于是 2 条未解析。三份上报样本分别是 389/388/2、1/0/2、1/0/1(tool/call 数 / tool/result 数 / 崩溃消息里的 tool-call 块数)。

社区的一行修复能救回已中毒的会话吗?

不能,它修的是**崩溃**而不是**修复路径**。把 TOOL_RUNTIME_SCHEDULER 从 Symbol() 改成 Symbol.for(),可以让「一轮的第一个工具调用不再抛 reading 'prepare'」,即不再产生新的中毒会话;但已经写进日志的悬空 tool_use 仍然没人清理,interruptedTurnClosers 也不会被触发。这一点在社区讨论里被明确区分为「两个独立问题」。

相关术语

未解析的 tool_use
助手的某条 `assistant/message` 里声明了一个工具调用块(`type: 'tool-call'`),但对应的事件流里再也找不到匹配的 `tool/result`。DeepSeek Messages 协议要求工具调用必须紧跟结果,因此一条悬空的 `tool_use` 会让整条历史无法序列化——它不是「模型犯错」,而是「持久日志出现了不会自愈的断点」。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318
TOOL_RUNTIME_SCHEDULER
工具运行时调度器在注册表里的键,定义于 `packages/core/tools/src/index.ts:463`。它原本写成 `Symbol('@deepseek-ai/dsh-tools.scheduler')`:`Symbol()` 的身份是**按模块实例**的,同一个包被加载成两份(两个 bundle / 两份模块图)时,注册用的是一个 symbol、查找用的是另一个,于是查到 `undefined`,第一次工具调用就抛 `Cannot read properties of undefined (reading 'prepare')`。— https://github.com/sheldonzhang312-hub/deepseek-harness/commit/4dcf2d1
interruptedTurnClosers
`packages/core/session/src/repair.ts` 里的合成收尾逻辑:当会话日志**在中途(同一轮内)戛然而止**时,它会给未完成的工具调用补上合成的结果事件,使日志重新平衡。触发条件是「日志以未闭合的一轮结束」;如果 `turn/end` 已经写下(哪怕带着 error reason),日志就算平衡,这段修复永远不会运行——这正是本问题里那个「自修复缺口」。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318
TOOL_OUTCOME_UNKNOWN 与 TOOL_NOT_STARTED
两个规范化的「工具结果未知」错误码,用于合成收尾。工具**已经启动**(已有 `tool/call`)但结果丢失时用 `ToolOutcomeUnknownError` / `TOOL_OUTCOME_UNKNOWN`;工具**根本没来得及启动**时用 `ToolNotStartedError` / `TOOL_NOT_STARTED`。未启动那条的 message id 必须形如 `interrupted-tool-result-<callId>-<seq>`,这是 v3 格式校验器认可的规范身份。— https://github.com/deepseek-ai/deepseek-harness/discussions/7318

来源