青雲的博客
深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 第 16 章

并行工具调用:顺序和屏障

executionMode() fail-closed 分类;group 取连续 parallel calls,exclusive 形成包含 post-execute 的全屏障;commitReady() head-of-line 光标保序提交;parallel 组内 pre-execute/guards/post-execute 仍按提交顺序串行;run_code 内部有镜像调度器实现相同规则;abort 时 started calls drain,remaining 记录 synthetic error。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

“并行工具调用”很容易被理解成 Promise.all:所有工具一起跑,谁先结束谁先回调。DeepSeek Harness 的并行比这精细得多,也保守得多。

它不是跑一组,而是跑一串分组。连续的 parallel 工具进一个有界池,遇到一个 exclusive 工具就停,这个 exclusive 自己成为一组,等前面的池彻底空了(包括所有 post-execute)才开始跑;它跑完(包括它自己的 post-execute)后,后面的工具才重新开始分组。而且,就算在同一个 parallel 组里,pre-execute hooks、guards、post-execute finalizers 也还是按模型原始顺序串行——并发的只有工具主体。

这一章容易看花,因为它同时在讲”能不能并行”、“怎么分组”、“结果按什么顺序提交”。但骨头就一根:DeepSeek Harness 真正并发的是 tool body,死守的是提交顺序和副作用边界。

所以别被“parallel”这个词带跑。这里不是一声令下大家一起 Promise.all。它的做法更像一层一层卡闸:默认不确定就 exclusive;连续 parallel 只是候选队列,撞上 exclusive 当场起屏障;哪怕 body 已经并发跑起来,真正写回 session、进入模型上下文,顺序也不能乱。

带着这个前提再去看 executionMode()fillPool()commitReady(),会清楚很多:它们不是三套散着的机制,而是在看住同一件事。

executionMode():fail-closed 分类

调度器判断一个工具能不能并行,靠的是 executionMode(exec) 方法。它的逻辑简单到近乎保守:

  1. 看工具定义有没有 `isConcurrencySafe 方法
  2. 没有?返回 { kind: 'exclusive' }——默认不能并行
  3. 有?调用 tool.isConcurrencySafe(args)
  4. 调用抛错?返回 { kind: 'exclusive' }——分类器出错就保守处理
  5. 返回 true?parallel;false?exclusive

这就是 fail-closed 设计。任何不确定——没有分类器、分类器抛错、分类器说 false——都当作不能并行。只有分类器明确说”这个参数组合下我可以并行”,才放进 parallel 组。

这个设计不是保守过头——它是在保护你。如果一个工具本来不该并行(比如写同一个文件)但默认并行,会产生竞态条件、数据损坏,这种 bug 极难复现和调试。默认 exclusive,需要并行时你显式声明 isConcurrencySafe,出问题你自己负责。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "executionMode" "$repo/packages/core/tools/src/index.ts" | head -10

你可以 grep 看 executionMode 的实现,确认 fail-closed 逻辑。

分组规则:连续 parallel 为一组,遇 exclusive 全屏障

分组不是提前一次性分好的。外层 while 循环每次只处理从 next 开始的一组:

  • 如果 next 位置的 call 是 exclusive,group 就是 [first],一个单元素组
  • 如果 next 位置的 call 是 parallel,group 是 planned.slice(next)——从这里到末尾所有 call 都作为候选组

但注意:候选组不等于实际并行跑的组。fillPool 在填充时会对 nextToStart > 0 的 call 重新检查 mode。如果在填充过程中,下一个 call 因为 registry 变化、或者因为前面的 pre-execute 改变了什么状态,mode 变成 exclusive,fillPool 立刻 break,不启动它。

这时候 runGroup 跑完当前已启动的所有 call,返回 consumed = started 数量(不是整个 group 长度),外层循环 next += consumed,下一轮循环从那个变成 exclusive 的 call 开始新分组。

exclusive 形成的屏障是全屏障——它不仅要等前面 parallel 组的所有 body 执行完,还要等它们的 post-execute 全部 commit 完;它自己执行的时候,前面的必须全部结束(inFlight.size === 0),它自己执行期间不启动任何其他 call;它的 post-execute 也必须完成后,屏障才解除,后面的 call 才能开始。

你看 run_code 内部调度器的代码,注释写得很明白:“The barrier covers post-execute: later starts wait for the exclusive call’s full pipeline, as under the native loop.”

flowchart LR
    accTitle: 工具分组与屏障
    accDescr: 连续 parallel call 进 bounded pool 组成一组,exclusive call 单元素组形成全屏障,包含 post-execute;run_code 内部调度遵循相同规则。
    subgraph "Group 1 (parallel)"
        direction TB
        P1["call 0: parallel<br/>pre → body → post"]
        P2["call 1: parallel<br/>pre → body → post"]
        P3["call 2: parallel<br/>pre → body → post"]
    end
    subgraph "Group 2 (exclusive barrier)"
        direction TB
        E1["call 3: exclusive<br/>pre → BODY → post<br/>(等前面全部 post 完才开始)<br/>(自己 post 完才解屏障)"]
    end
    subgraph "Group 3 (parallel)"
        direction TB
        P4["call 4: parallel<br/>pre → body → post"]
        P5["call 5: parallel<br/>pre → body → post"]
    end
    
    P1 -->|body concurrent| P2
    P2 -->|body concurrent| P3
    P3 -->|all posts committed| E1
    E1 -->|post committed| P4
    P4 -->|body concurrent| P5
    
    Note1["注:pre 严格按 0→1→2 顺序串行<br/>post 严格按 0→1→2 顺序串行<br/>只有 body 阶段时间重叠"]
    Note2["注:call 3 期间 inFlight.size 必须 == 0<br/>它跑的时候不启动任何其他 call"]

commitReady():head-of-line 光标保序提交

为什么需要 head-of-line 光标?因为并行 body 完成的顺序是不确定的。你启动了 call 0、1、2,call 2 可能最先返回,call 0 可能最后返回。

但模型看到的工具结果必须严格按模型原始调用顺序——call 0 的 result 必须在 call 1 前面,call 1 在 call 2 前面。如果乱序返回,模型的上下文窗口里结果顺序和调用顺序对不上,模型会困惑。

slots 数组每个位置对应 group 内一个 call,初始都是 undefined。某个 call 的 body resolve 了,就把它的 slots[index] 填上结果。

commitReady() 的 while 循环是这样的:

  1. 看 slots[committed](最前面还没提交的位置)是不是 undefined
  2. 如果不是 undefined:执行 post-execute(finalize 或 finish),appendToolResult 到 session,accept context,committed++,继续循环
  3. 如果是 undefined:立刻 break

这就是 head-of-line blocking:call 2 先跑完了没用,只要 call 0 还没跑完,slots[0] 是 undefined,commitReady 就 break,call 2 的结果只能在 slots[2] 等着,不能提交到 session。等 call 0 跑完了,commitReady 先处理 0,committed 变成 1,再看 slots[1],如果 call 1 也跑完了处理 1,committed 变成 2,然后处理早就等着的 call 2。

结果 append 到 session 的顺序永远是 0,1,2,3,4… 严格递增,不管 body 完成顺序如何。

run_code 内部:镜像调度器,规则一模一样

run_code 看起来是一个工具,但它内部执行模型写的代码时,代码里可能又调用多个工具(通过 SDK binding)。这些子调用怎么调度?不是随便并发——run_code 内部实现了一个和原生调度器逻辑一模一样的镜像调度器。

看 code-mode.ts 里 createRunCodeTool 的 execute 函数,它自己实现了一个驱动循环 drive(),规则和原生调度器完全对应:

  • pendingQueue:提交的子调用排队
  • inFlight: Set<Promise<void>>:正在跑的 body,大小不超过 maxParallel(来自 maxParallelSubCaps 配置)
  • commitQueue:等提交的 settled 调用
  • exclusiveActive:标记是否有 exclusive 子调用在跑
  • head-of-line 提交:commitQueue 也是按提交顺序,队头 settled 才 commit
  • 重新分类:启动每个子调用前重新查 executionMode()
  • exclusive 屏障包含 post-execute:注释明确说 “The barrier covers post-execute: later starts wait for the exclusive call’s full pipeline”

甚至 maxParallelSubCalls 的默认值和原生 maxParallelToolCalls 默认值一样,都是 10。为什么重复造一个而不是复用外层调度器?因为 run_code 的子调用发生在一个工具 body 的内部——外层调度器已经把 run_code 当成一个单独的 call 在处理了,不知道 run_code 内部发生的子调用。run_code 需要自己管理内部的并发,而且要遵守和外层一样的顺序/屏障规则,保证语义一致。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '406-409p' "$repo/packages/core/tools/src/code-mode.ts"

看这几行注释:“The barrier covers post-execute: later starts wait for the exclusive call’s full pipeline, as under the native loop.” 明确说和原生循环一样。

Abort 处理:drain started,remaining synthetic error

abort 信号来了怎么处理?不是 SIGKILL 一切。调度器的策略是:

  1. fillPool 停止启动新的 call(aborted 变量设为 true)
  2. 已经 inFlight 的 call 让它们继续跑完(drain)——信号传给它们了,它们可以选择响应,但调度器不等它们也不杀它们,只是等它们 settle
  3. 每个 settle 的 call 正常走 commitReady,按顺序提交结果
  4. 所有 inFlight 都 settle 完、commit 完之后,还没 started 的 call(group.slice(started),以及外层循环剩余的),每个调用 appendSkippedToolCall()

appendSkippedToolCall 做什么?先 append 一个 ‘tool/call’ 事件,然后 append 一个 ‘tool/result’ 事件,内容是:

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 } }

为什么要给没启动的 call 也写 result?因为 session log 的不变量:每个 ‘tool/call’ 事件后面必须有对应的 ‘tool/result’ 事件,不能只有 call 没有 result,否则 replay 到那个位置会卡住。给 skipped call 写一个 synthetic error result,保证 replay 能完整跑下去。

注意:schedulerFailure(调度器内部错误,比如 prepare 抛了非 abort 错)是另一种处理——它不写 synthetic result,因为 started 的 call 已经有真实 result 了,没 started 的不需要伪造,直接抛错让上层处理。只有 abort 才给未启动的 call 写 synthetic result。

容易踩的坑

坑一:以为 parallel 组里 hooks 也并发。 pre-execute hooks、guards、approval ask、post-execute finalizers 全部严格按模型顺序串行。只有 dispatch 之后的工具 body 并发。如果你在 pre-execute 里做了耗时的异步操作(比如弹审批对话框等用户输入),后面的 parallel call 连 pre-execute 都进不去,在 fillPool 里等着。

坑二:以为 exclusive 屏障只等 body 完成。 不是。屏障一直持续到 exclusive call 的 post-execute 也提交完。这就是为什么 commitReady 是 runGroup 循环的一部分——一个 parallel 组的最后一个 post 没提交完,下一个 exclusive 不会开始;exclusive 自己的 post 没提交完,后面的 parallel 不会启动。

坑三:在工具 body 里不响应 AbortSignal。 调度器会把信号传给你,但不会强制终止你的 Promise。如果你写了个死循环不检查 signal,abort 了它还会继续跑,占着 inFlight 位置,导致整个组永远没法完成,turn 卡住。body 里应该定期检查 exec.signal.aborted,或者把 signal 传给底层的异步操作(fetch、spawn 等)。

坑四:以为 run_code 里的子调用可以无限并发。 不是。run_code 内部有自己的 maxParallelSubCaps 限制,默认也是 10,和外层一样。而且它也有 exclusive 屏障、head-of-line 提交,和外层规则完全一致。你在 run_code 里用 SDK 调用 100 个工具,它们会按 10 个一批跑,exclusive 工具照样形成屏障。

坑五:abort 后假设所有工具都立刻失败。 已经 started 的工具还是会跑完,它们的结果(成功或失败)还是会按顺序提交到 session log。用户看到的是:abort 前已经开始执行的操作(比如已经发出去的 bash 命令、已经发出去的 HTTP 请求)该怎么完成怎么完成,它们的结果正常显示;只有还没开始的操作显示”aborted before dispatch”。

并行和顺序解决了多个工具怎么协调执行的问题。但工具执行前还有一道关:权限审批。什么时候问用户?什么时候直接拒绝?为什么 bash/fs 不用”每次执行前先问”?Permission、Approval、Ask User 这三个东西有什么区别?下一章讲审批三件套。