ARTICLE · 1127891
DeepSeek-Harness 源码深读(15):你取消了工具调用,日志里反而多了一条记录
摘要:教程篇把主循环的正路走完了,这篇专拆歪路。流式输出中途按停止,半截回答带 interrupted 标记入账;工具池里按停止,没启动的调用补一条合成结果;调度器自己崩掉,反而一字不补;进程被 kill -9,账留给 repair 补、invariant 查。五种死法守的是同一条约束:重放必须还能算出同一份请求。
一、把一个 turn 弄死的五种方式
教程篇《主循环不拼消息、不带重试,ReAct 凭什么转起来》把一个 turn 的正路走了一遍:followup 进队列、turn/start 落账、认领、推导消息、发请求、跑工具、turn/end 收口。正路之外全是岔路。这篇我们换个玩法:同一个 turn,在五个时间点上分别弄死它,看每次日志里多出什么、少掉什么。
先把主线案例立起来。你对 agent 说:跑一下 A、B、C 三个目录的测试,结果汇总写进 report.md。模型一步回了四个 tool-call,三个 test 加一个 writeReport。调度器 executeToolCalls(tool-calls.ts:59)按模型顺序扫描,每遇到一个调用就现场问一次注册表:这个工具能并行吗?切组规则长这样:
这段是外层调度循环的切组逻辑。
while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition const first = planned[next]! const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first](tool-calls.ts:84-89)注意看 slice(next):test 是 parallel,切组时直接吞掉剩余全部四个调用;writeReport 的互斥身份要等到 fillPool 里每次启动前重读模式才被拦下(tool-calls.ts:203-204),它排在三个测试后面,等池子排空自己单独成组。池子的并行上限默认 10(constants.ts:6),运行期可以热调,走的正是 dd02 拆过的那套 settings 基建:命名空间 agent-loop(index.ts:237)、installSettingsSection(:335)、校验挂载在 :339,调度器每个组开工时现读一次(tool-calls.ts:131),改完下一个组就生效。
舞台搭好了。五个时间点:T1,模型正在流式输出这四个调用的中途;T2,三个 test 已进池、writeReport 还没轮到;T3,你按完停止又反悔,立刻补了句话;T4,没人碰停止键,调度器自己炸了;T5,进程整个被 kill -9。全篇的结论先钉在这:五种死法共享同一条约束,任何时刻把循环弄死,session.deriveMessages() 必须还能算出一份模型 API 愿意接受的请求。半截输出要入账、没跑的工具要补结果、故障却一个字不能编,全部从这条约束推出来。

错误路径全景
二、T1:输出中途按停止,半截回答也入账
我们先看最早的杀法。模型的回答正在一个 chunk 一个 chunk 地流回来,你按了停止。cancel(agent.ts:134-140)默认清空收件箱、复位唤醒记录、abort 当前信号。此刻循环停在哪?在这个 for await 里:
这段是流式消费的主循环,每个 chunk 先入账再装配。
for await (const chunk of stream) { signal.throwIfAborted() chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) assembler.push(chunk) }(agent.ts:348-352)注意看顺序:chunk 先 append 成 assistant/chunk 事件、拿到 seq 号,随后才喂给 BlockAssembler。停止键落下,下一个 chunk 还没到,throwIfAborted 先抛了。已入账的 chunk 全部留在日志里。它们是事实。
抛出的异常被外层 catch 接住(agent.ts:354),里面干的这件事有点反直觉:
这段是中止路径的收尾,把半截输出装配成一条带标记的消息。
if (signal.aborted) { const content = assembler.interruptedBlocks() if (content.length > 0) { this.session.append('assistant/message', { turn, step, message: createAssistantMessage({ content, source: { provider: request.provider, model: request.model }, }), interrupted: true, ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }) } }(agent.ts:355-369)注意看 interrupted: true 和 sourceEventSeqs:半截回答被装配成一条正式的 assistant/message 入账,回链到它全部 chunk 的序号,然后异常原样上抛(:370)。半截也要写,理由很直接:下一轮请求里模型要看自己上次说过什么,半句也算说过。更有意思的是 interruptedBlocks 的取舍(assembler.ts:164-165 注释):装配只保留 text 和 reasoning 块,流里已经冒头的 tool-call 块被剔除,注释原话是 retaining one would require a fabricated result,保留一个没派发过的调用就得给它伪造结果。消息侧的原则,和第三节调度器侧一模一样。先记下这个伏笔。
模型的话记完了。它伸出去的那只手呢?
三、T2:工具池里按停止,没启动的调用补一条结果
时间推到 T2。三个 test 在池子里跑,writeReport 在屏障后面排队,你按了停止。fillPool 的补给循环看到 signal.aborted 就停下(tool-calls.ts:199-211),主循环继续 race,把已启动的调用一个个等完,commitReady 按模型序把三个 test 的真实结果落进日志(:146-160)。随后 runGroup 走到这段:
这段是组内中止的收尾,给没轮到的调用补一对事件。
if (aborted) { // Started calls and accepted context settle first; every remaining model // call then receives an ordered synthetic result before the turn aborts. for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) return { consumed: group.length, aborted: true, concluded } }(tool-calls.ts:237-242)注意看 group.slice(started):started 是 3,slice 出来的只有 writeReport。它从头到尾没执行过一行工具代码,却要在日志里领一对事件。补的内容长这样:
这段是补账函数的全貌,一条调用事件配一条合成结果。
const callSeq = appendToolCall(session, turn, step, block) appendToolResult(session, turn, step, block, { content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, error: { message: 'tool call aborted before dispatch', info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }, }, callSeq)(tool-calls.ts:250-258)注意看那条 text:取消一个没跑过的工具,多出来的记录内容是 Error: tool call aborted before dispatch,错误码 TOOL_ABORTED_BEFORE_DISPATCH。标题说的那条多出来的记录,就是它。文件头注释把理由写死了:Abort records synthetic error results for skipped calls so replay stays valid(tool-calls.ts:8)。tool/call 和 tool/result 必须配对,悬空的调用会让 deriveMessages 产出孤儿工具消息,多数模型 API 直接拒收整个请求。
两处不显眼的善后也在这条路上。已启动调用的 additionalContexts 仍经接纳器进 next-step 队列(agent.ts:416),取消不吞工具攒下的情报,它们等下一个驱动的步边界被认领。外层循环还有兜底(tool-calls.ts:96):组中止后,后面所有组的调用全部补齐合成结果,一个不漏。
问题来了。同样是没跑完,下面这种死法一条结果都不补。
四、T4:调度器自己崩,一条结果都不补
没人按停止。某个 test 的 dispatch 自己 reject 了,比如工具运行时内部炸出一个 TypeError。startCall 的 reject 分支记下 schedulerFailure(tool-calls.ts:178-181),fillPool 和主循环在每个 await 后检查它,第一个检查点就把错误抛进 catch:
这段是调度器失败的收容方式,排空在飞的调用后原样重抛。
} catch (error: unknown) { schedulerFailure ??= { error } await Promise.allSettled(inFlight.values()) throw schedulerFailure.error }(tool-calls.ts:231-235)注意看 allSettled:在飞的调用全部落定后,错误被原样抛出。没有 appendSkippedToolCall,没有合成结果,已提交的 tool/call 事件原样留着。
同样是没跑完就结束,用户中止要补合成结果,调度器故障一个字不补。差别在记账资格:中止是一个语义完整的结局,这个调用确实永远不执行了,用户意志把话说清楚就行;故障是程序 bug,此刻补一条结果等于把 bug 伪装成正常收场,日志从此撒谎。事实走 throwError 变成 agent/error 事件(agent.ts:203-208),真相留给错误报告,账本里只留 bug 的现场。
两条路都走完了。它们最后汇进同一个 finally。
五、殊途同归的 finally:turn/end 无条件成对
不管哪种死法,异常一路抛到 turn() 的最外层,catch 先分类:
这段是 turn 级的异常分类,中止和故障各拿各的结局。
} catch (error: unknown) { if (signal.aborted) { turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause } throw error } // Every failure is structured: an `LlmError` keeps its facts, anything // else flattens to `errorChain` text under the `UNKNOWN` code. turnEnds = { kind: 'error', error: error instanceof LlmError ? error.failure : { message: errorChain(error), code: 'UNKNOWN' }, } this.throwError(error)(agent.ts:302-315)注意看两种结局的分野:abort 记成 aborted、带上取消原因后继续上抛;其余错误结构化成 error,LlmError 保留原始 failure,杂牌异常展平成 errorChain 文本配 UNKNOWN 码。然后是本节的主角:
这段是 turn 的收口,任何出口都要经过它。
} finally { try { // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { this.throwError(error) } }(agent.ts:316-323)注意看这个 try 里只有一件事:追加 turn/end。正常结束、pre-step 拒绝、中止、崩溃,全部从这里过。要是不写这个无条件收口,一次崩溃就留下一个有 start 无 end 的回合,重放时状态机卡在半开的 turn 里,下一个输入永远进不来。空 turn 也是同等待遇(agent.ts:272-277 的注释:still owns the initial turn boundary, but it spends no model call),唤醒消息被人移走导致的首步空转,一个模型调用不花,边界照写。
turn 关上了。你反悔的那句话呢?
六、T3:停止之后你反悔,唤醒记在谁的账上
你按完停止,半秒后又发:等等,先别写报告。此刻老驱动还在收尾,turn/end 刚落账,kick 的 finally 还没跑到。新消息进 send:
这段是所有输入的唯一入口,反悔消息的第一站。
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) if (wakeup) this.wakeDriver(wakingAfterAbort) }(agent.ts:113-120)注意看 wakingAfterAbort 的求值时机:在插队之前。目标驱动已经 abort,唤醒类输入不能加盟一个垂死的活动,改投 next-turn 队列留给下一个驱动。注释特意说明这个分类在插入前捕获,splice 观察者里重入的 cancel 改变不了它。接着 wakeDriver(true) 进来:
这段是唤醒的三分法,只有部分情况记闩。
const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { this.phase.wakeRequested = true } return(agent.ts:177-181)注意看两个条件:disposed 不记闩,销毁流程不必等任何模型轮次;已中止的驱动记下唤醒,留给收尾时重放。重放发生在这里:
这段是驱动的收尾,相位回落 idle 后兑现记下的唤醒。
if (this.phase.kind === 'running') { const { turn, wakeRequested } = this.phase this.setPhase({ kind: 'idle', lastTurn: turn }) if (wakeRequested && this.inbox.hasPending) this.wakeDriver() }(agent.ts:217-221)注意看条件里的 hasPending:闩和队列同时在,新驱动才启动。反悔消息就这样从垂死驱动的账上转到新驱动头上。正常续跑还有个配套细节(agent.ts:325-327):turn 结束时队列若还有货,同一个驱动换一个全新的 AbortController 继续跑,旧控制器上的闩随之作废,注释原话是 A fresh controller makes a latch set on the old one stale。活着的驱动自己认领队列,不吃陈旧唤醒。
运行期的账补齐了。还剩最狠的一种死法。
七、T5:kill -9 之后,repair 补账,invariant 查账
进程直接被杀,什么都来不及写:chunk 停在半空,工具停在中途,turn/end 没有。重启加载日志时,session 包的 repair 接手(interruptedTurnClosers,repair.ts:27),它扫出开着的 turn 和悬空的调用,补合成事件把账关上。dd08 拆 checkpoint 时问过「崩溃那一刻,凭什么敢说这条命令没执行过」,repair 就是那个答案的落账侧。给悬空调用补的结果文本分两种,原文如下:
这段是崩溃恢复时写给模型看的合成结果,措辞即策略。
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.' : 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',(repair.ts:104-105)注意看第一段的措辞:不确定的结果不说谎,它明说 outcome is unknown,接着教模型按只读或幂等来决定重试。补完调用补 step/end,最后补一条 reason 为 interrupted 的 turn/end(repair.ts:131)。运行中取消写 TOOL_ABORTED_BEFORE_DISPATCH,崩溃后恢复写 TOOL_OUTCOME_UNKNOWN(repair.ts:17),同一条配对约束,两种措辞对应两种确定性。
补好的账谁验收?invariant。这个伴生插件在 llm/stream 上前置注册守卫(invariant.ts:21,{global: true, prepend: true} 挂在 :54,prepend 是防短路型监听器把检查吞掉),对每个循环构建的请求做核心断言:
这段是每次模型调用前的验收,逐字节比对。
const expected = session.deriveMessages() if (JSON.stringify(options.messages) !== JSON.stringify(expected)) { fail(`llm request for session 「${String(session.id)}」 diverges from the dispatch-time durable derivation (log-reconstruction desync)`) }(invariant.ts:39-42)注意看比对方式:JSON.stringify 逐字节。请求里的 messages 和派发时刻从日志推导的结果差一个字符,当场报 log-reconstruction desync。我们前六节的所有补账动作,验收标准就是这两行。dd06 拆 session 内核时说过「守不变量的不是测试,是编译器和 Object.freeze」,这条断言是同一哲学在请求层的落点。
八、对账与代价
我们把五个时间点对个账。T1 半截回答带着 interrupted: true 入账,冒头的 tool-call 块被剔除;T2 三个真实结果落账,writeReport 领一条 Error: tool call aborted before dispatch;T4 没有合成结果,只有 agent/error 和原样保留的 tool/call;T3 反悔消息先记闩、收尾时重放给新驱动;T5 repair 补不确定措辞的结果和 interrupted 边界,invariant 在下一次请求逐字节验收。
代价也该摆到明处,这是我的评价:每 chunk 一个事件,长输出下写入量翻着倍涨;invariant 的全量 stringify 比对是每次请求的固定开销,它本身是独立伴生插件,更像开发期护栏,生产可以不装;状态机里那些「插入前捕获」「换新控制器作废旧闩」的顺序全靠注释锚着,tests 目录 18 个 spec 文件(cancel.spec、request-reconstruction.spec、tool-order.spec 这些)就是源码作者给自己上的保险。账本不撒谎的背面,是 tool-calls.ts 里那两处 appendSkippedToolCall 调用点:组内一处(:240),外层兜底一处(:96),头顶的文件注释写着 so replay stays valid。
下一篇是深读线的特殊一站。dd16 拆 agent-spine-demo:官方把这两篇讲的所有机制,循环、工具、会话,写成一份能跑的主线示例。正路的终点站,去看看教科书答案长什么样。
你的框架里,取消一个执行到一半的工具调用,日志里留下的是什么?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。