|
| 1 | +# 并行 Tool 执行 |
| 2 | + |
| 3 | +**Date:** 2026-03-18 |
| 4 | + |
| 5 | +## Context |
| 6 | + |
| 7 | +当前 `loop.ts` 中,当 LLM 返回一条包含多个 `tool_use` 的 assistant 消息时,所有工具调用是通过 `for...of` 循环**顺序执行**的。这意味着即使 LLM 在同一条消息中返回了多个 task(subagent)调用,第二个 task 也必须等待第一个完全执行完毕才能开始。 |
| 8 | + |
| 9 | +参考 Claude Code 的实现(通过逆向分析 `versions/2.1.29/cli.js` 得到),其核心机制是使用 `Promise.all()` 并行执行同一条消息中的所有 `tool_use` 块,从而实现真正的并行 subagent 执行以及所有只读工具的并行加速。 |
| 10 | + |
| 11 | +目标:将 Neovate Code 的工具执行模型从顺序改为并行,同时 UI 能同时展示多个 subagent 的实时进度。 |
| 12 | + |
| 13 | +## Discussion |
| 14 | + |
| 15 | +### 探讨过的方案 |
| 16 | + |
| 17 | +**方案 A: 分组并行** — 将 task 类工具并行执行,其他工具顺序执行。改动集中在 `loop.ts`,风险可控,但灵活性有限。 |
| 18 | + |
| 19 | +**方案 B: 仅连续 task 并行** — 最小改动,识别连续的 task tool calls 并行执行,其他不变。UI 零改动。风险最低但收益有限。 |
| 20 | + |
| 21 | +**方案 C: 通用并行框架** — 所有只读工具都可并行,改动范围大,测试成本高。 |
| 22 | + |
| 23 | +**最终选择:全部 tool 并行执行**(参考 Claude Code 实现)— 在看到 Claude Code 的逆向分析后,决定直接采用 `Promise.all` 并行所有 tool_use 的做法,获得最佳体验。 |
| 24 | + |
| 25 | +### 关键决策 |
| 26 | + |
| 27 | +- **审批流程**:沿用现有逻辑,顺序进行审批(UI 一次只能展示一个 ApprovalModal)。审批完成后再并行执行。 |
| 28 | +- **denial 处理**:被拒绝的 tool 只跳过该工具,其他已批准的工具继续并行执行。无 denyReason 的拒绝保持现有行为(终止所有后续工具)。 |
| 29 | +- **UI 层**:零改动。`agentProgressMap` 按 `toolUseId` 索引,`Messages.tsx` 逐个渲染每个 tool pair,天然支持多 agent 并行展示。 |
| 30 | + |
| 31 | +### 风险评估 |
| 32 | + |
| 33 | +| 风险 | 严重程度 | 缓解措施 | |
| 34 | +|------|----------|---------| |
| 35 | +| 多个 bash 并行写文件冲突 | 高 | LLM 通常不会在同一批返回多个写操作 | |
| 36 | +| onToolResult 回调竞争 | 中 | 需检查实现确保无共享可变状态 | |
| 37 | +| 多个 write/edit 并行操作同一文件 | 中 | LLM 自行规避 | |
| 38 | +| 内存压力(多 agent 同时运行) | 低 | subagent 本身就是独立 session | |
| 39 | + |
| 40 | +## Approach |
| 41 | + |
| 42 | +将 `loop.ts` 中的顺序 `for...of` 工具执行循环改为两阶段模型: |
| 43 | + |
| 44 | +1. **Phase 1 顺序审批**:逐个对 toolCalls 进行 approval 检查,收集 approved 和 denied 列表 |
| 45 | +2. **Phase 2 并行执行**:对所有 approved 的 toolCalls 使用 `Promise.allSettled()` 并行执行 |
| 46 | +3. **Phase 3 结果收集**:合并 approved 执行结果和 denied 错误结果,按原始顺序排列后写入 history |
| 47 | + |
| 48 | +核心改动仅在 `src/loop.ts` 一个文件,约 50 行代码。 |
| 49 | + |
| 50 | +## Architecture |
| 51 | + |
| 52 | +### 改动文件 |
| 53 | + |
| 54 | +| 文件 | 改动范围 | 说明 | |
| 55 | +|------|---------|------| |
| 56 | +| `src/loop.ts` | ~50 行 | 替换顺序循环为并行执行 | |
| 57 | + |
| 58 | +### 不需要改动的文件 |
| 59 | + |
| 60 | +- `src/agent/executor.ts` — 每个 agent 已是独立 session,天然支持并行 |
| 61 | +- `src/agent/agentManager.ts` — `executeTask` 是无状态的,直接并行调用即可 |
| 62 | +- `src/tools/task.ts` — task tool 的 `execute` 函数本身不需要改 |
| 63 | +- `src/ui/store.ts` — `agentProgressMap` 按 `toolUseId` 索引,天然支持多 agent 并行更新 |
| 64 | +- `src/ui/AgentProgress/` — 天然支持多 agent 渲染 |
| 65 | +- `src/messageBus.ts` — 事件系统异步解耦 |
| 66 | + |
| 67 | +### 执行流程 |
| 68 | + |
| 69 | +``` |
| 70 | +LLM 返回: [text, task1, task2, read1, bash1] |
| 71 | +
|
| 72 | +Phase 1: 顺序审批 |
| 73 | + approval(task1) → approved ✓ |
| 74 | + approval(task2) → denied (with reason) → 跳过,继续 |
| 75 | + approval(read1) → approved ✓ |
| 76 | + approval(bash1) → approved ✓ |
| 77 | +
|
| 78 | +Phase 2: 并行执行 |
| 79 | + Promise.allSettled([ |
| 80 | + execute(task1), // → agentProgressMap['task1-id'] 实时更新 |
| 81 | + execute(read1), // → 读文件 |
| 82 | + execute(bash1), // → 执行命令 |
| 83 | + ]) |
| 84 | +
|
| 85 | +Phase 3: 结果收集 |
| 86 | + toolResults = [ |
| 87 | + task1 result (fulfilled), |
| 88 | + task2 result (denied error), |
| 89 | + read1 result (fulfilled), |
| 90 | + bash1 result (fulfilled), |
| 91 | + ] |
| 92 | + → 按原始顺序写入 history |
| 93 | +``` |
| 94 | + |
| 95 | +### 核心伪代码 |
| 96 | + |
| 97 | +```typescript |
| 98 | +// Phase 1: 顺序审批 |
| 99 | +const approvedCalls: { toolCall, toolUse }[] = []; |
| 100 | +const deniedResults: ToolCallResult[] = []; |
| 101 | +let shouldBreak = false; |
| 102 | + |
| 103 | +for (const toolCall of toolCalls) { |
| 104 | + let toolUse = buildToolUse(toolCall); |
| 105 | + if (opts.onToolUse) toolUse = await opts.onToolUse(toolUse); |
| 106 | + |
| 107 | + const { approved, updatedParams, denyReason } = await doApproval(toolUse); |
| 108 | + |
| 109 | + if (approved) { |
| 110 | + if (updatedParams) toolUse.params = { ...toolUse.params, ...updatedParams }; |
| 111 | + approvedCalls.push({ toolCall, toolUse }); |
| 112 | + } else { |
| 113 | + deniedResults.push(buildDeniedResult(toolUse, denyReason)); |
| 114 | + if (!denyReason) { |
| 115 | + addDeniedResultsForRemainingTools(); |
| 116 | + shouldBreak = true; |
| 117 | + break; |
| 118 | + } |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +// Phase 2: 并行执行所有已批准的工具 |
| 123 | +if (!shouldBreak && approvedCalls.length > 0) { |
| 124 | + toolCallsCount += approvedCalls.length; |
| 125 | + const results = await Promise.allSettled( |
| 126 | + approvedCalls.map(async ({ toolUse }) => { |
| 127 | + let toolResult = await opts.tools.invoke( |
| 128 | + toolUse.name, JSON.stringify(toolUse.params), toolUse.callId, |
| 129 | + ); |
| 130 | + if (opts.onToolResult) { |
| 131 | + toolResult = await opts.onToolResult(toolUse, toolResult, true); |
| 132 | + } |
| 133 | + return { toolCallId: toolUse.callId, toolName: toolUse.name, |
| 134 | + input: toolUse.params, result: toolResult }; |
| 135 | + }) |
| 136 | + ); |
| 137 | + |
| 138 | + for (const result of results) { |
| 139 | + if (result.status === 'fulfilled') { |
| 140 | + toolResults.push(result.value); |
| 141 | + } else { |
| 142 | + toolResults.push(buildErrorResult(result.reason)); |
| 143 | + } |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// Phase 3: 合并 denied 结果,按原始 toolCalls 顺序排列 |
| 148 | +// turnsCount 一次性减去 approvedCalls.length |
| 149 | +turnsCount -= approvedCalls.length; |
| 150 | +``` |
| 151 | + |
| 152 | +### 边界情况 |
| 153 | + |
| 154 | +| 场景 | 处理方式 | |
| 155 | +|------|---------| |
| 156 | +| 单个 tool_use | 退化为单个 Promise,行为完全一致 | |
| 157 | +| 所有 tool 被拒绝 | 每个返回 denied error,无并行执行 | |
| 158 | +| 并行执行中 signal abort | 所有工具共享同一个 signal,同时中止 | |
| 159 | +| 一个工具失败另一个成功 | `allSettled` 不互相影响,各自返回 | |
| 160 | +| subagent 内部请求 tool approval | 通过 messageBus 独立请求,不影响其他并行工具 | |
| 161 | + |
| 162 | +### 测试策略 |
| 163 | + |
| 164 | +- **单元测试**:mock tools,验证多个 toolCalls 是并行执行的(总执行时间应约等于最长那个工具的时间,而非总和) |
| 165 | +- **集成测试**:手动验证多个 Explore agent 并行执行时 UI 正确显示多个进度 |
0 commit comments