八週以來,我們對 Claude Code 子代理並行數的 10 個限制,僅僅是 CLAUDE.md 文件中的一句話,但某次會話卻達到了 15 個。現在,Agent 工具上的 PreToolUse 鉤子會計算會話中仍在運行的子代理數量,加上同一個回應中已安排的啟動數,並拒絕第 11 個子代理的啟動。自此之後的 676 次啟動中,峰值精準地維持在 10 個。
最初的四次拒絕是錯誤的,且 Claude Code 自身的子代理限制也未能捕捉到我們達到 15 個的情況。這個上限最初只是一句話。在 2026 年 8 月 3 日,我們的代理在一次每週審查中同時啟動了 56 個子代理,導致運行它的 Mac 電腦記憶體不足。當天,擁有者將並行子代理的上限設定為 30 個,並在隔天將上限降至 10 個。
我們的 CLAUDE.md 文件將其記錄為一個硬性上限:任何時候不得超過 10 個,所有類型的代理都計算在內;更大的計畫會分批運行,且在每次啟動前,代理會將正在運行的數量加上即將啟動的數量。接下來的八週,這句話是唯一的強制措施,大多數時候也足夠了。我們仍然保留了鉤子啟用前的 16 個會話的本地紀錄。
其中 15 個會話從未有超過 10 個子代理同時運行。第 16 個會話在 2026 年 9 月 26 日世界標準時間 01:28 達到了 15 個。沒有人決定要運行 15 個。當時該會話有六個代理團隊成員,每個成員研究一個主題。
其中兩名成員啟動了自己的輔助代理,其中一個輔助代理又將其批次任務拆分為四個閱讀器。兩個回應完成了大部分的數量攀升。一名成員在已有七個代理運行時,將四個 Agent 呼叫放入一個回應中,使其達到 11 個。
大約九十秒後,其中一個輔助代理又將四個 Agent 呼叫放入一個單一回應中,使其達到 15 個。從每個步驟來看,當時都沒問題。規則要求啟動者先計算整個會話的數量,但這兩個回應仍然啟動了。在 2026 年 9 月 30 日,我們將此寫入待自動化清單,並在同一天透過 commit 9d5b284b 添加了鉤子。
為什麼必須是 PreToolUse 鉤子?Claude Code 鉤子參考文件提供了兩個候選事件,但只有一個可以拒絕。PreToolUse 鉤子傳回的結束代碼 2 會阻止工具呼叫,且 Claude 會收到鉤子的標準錯誤輸出作為工具錯誤。SubagentStart 傳回的結束代碼 2 不會阻止任何事情:標準錯誤輸出會顯示為鉤子錯誤通知,Claude 看不到,且子代理會繼續執行。
因此,上限必須在 Agent 呼叫運行前生效,所以它位於 PreToolUse。以下是從 .claude/settings.json 截取的註冊資訊,僅保留一個群組。匹配器同時命名了 Agent 和 Task,因為文件指出 Task 工具在版本 2.1.63 中已更名為 Agent。該群組還運行了第二個不相關的鉤子。
```json
{ "hooks" : { "PreToolUse" : [ { "matcher" : "Agent|Task" , "hooks" : [ { "type" : "command" , "command" : "bash \" $CLAUDE_PROJECT_DIR/scripts/hooks/require-cross-model-reviewer.sh \" " }, { "type" : "command" , "command" : "bash \" $CLAUDE_PROJECT_DIR/scripts/hooks/limit-concurrent-subagents.sh \" " } ] } ] } }
```
這個 shell 腳本是一個輕量級的包裝器。它將鉤子的標準輸入傳遞給一個 TypeScript CLI,即 src/commands/subagent-concurrency-gate.ts。它僅在 CLI 傳回結束代碼 2 時傳回 2,在所有其他情況下(包括 CLI 缺失或崩潰時)傳回 0。當達到上限時,CLI 會列印拒絕訊息。
從日文翻譯並截取後,它會列印:「此會話同時運行 10 個子代理(10 個運行中 + 0 個等待啟動)。上限為 10 個。請等待正在運行的子代理完成,然後再啟動(將較大的計畫分批執行)。」
鉤子如何定義「運行中」?鉤子輸入包含 session_id、transcript_path 和 tool_use_id。文件指出子代理自己的紀錄檔儲存在巢狀的 subagents/ 資料夾中。在我們的磁碟上,該資料夾位於主紀錄檔旁邊,在以會話命名的目錄中,每個子代理會在其中留下兩個檔案:一個紀錄檔 agent-<id>.jsonl 和一個 meta.json,其 toolUseId 是啟動它的 Agent 呼叫的 ID。
這兩個檔案的格式都沒有文件說明,因此以下所有內容都是我們觀察到的,而非規範。顯而易見的捷徑是計算父紀錄檔中尚未傳回的 Agent 呼叫。這個計數會接近零。在我們分析的 1,125 次子代理啟動中(分析視窗如下所述),有 1,106 個 meta.json 記錄了啟動為背景運行。
在鉤子啟用後的 637 次背景啟動中,有 625 次父代理的 Agent 呼叫在 meta.json 出現後一秒內就取得了結果。然後子代理繼續工作,中位數為 328 秒,第 90 個百分位數為 2,477 秒。文件指出背景子代理的結果會在稍後的輪次中作為完成通知傳達給 Claude。因此,Agent 呼叫幾乎立即關閉,而工作在其後很長時間仍在進行。
因此,鉤子會讀取每個子代理自己的紀錄檔,並且只查看其最後一行。如果該行是以下三種情況之一,則子代理被視為已完成:它可能是停止原因為 end_turn、stop_sequence 或 refusal 的助理行。它也可能是 [Request interrupted by user] 標記。
或者它可能是一個包含 "Report delivered to your caller." 的工具結果,這是後來添加的,並將在下面解釋。
除非紀錄檔在 30 分鐘內沒有寫入,否則任何其他情況都算作運行中。這個逾時是針對在我們無法識別的行上崩潰的子代理的應急措施。當鉤子建立時,規則是根據建立它的會話的 136 個子代理紀錄檔進行檢查的:130 個以 end_turn 結束,4 個以 stop_sequence 結束,1 個以中斷標記結束,還有 1 個以純文字行結束,沒有停止原因,這必須由 30 分鐘規則處理。
從 15 個子代理的事件中得出兩個選擇。如果鉤子收到的紀錄檔路徑本身位於 subagents/ 資料夾內,表示正在啟動一個子代理,則鉤子會計算該資料夾。因此,輔助代理自己的啟動也會計入會話總數。隊友的紀錄檔位於同一個資料夾中,所以隊友也會計入,因為原始規則將所有類型的代理一起計算。
```typescript
export function countConcurrentSubagents ({ subagents , reservations , now }) { const staleBefore = now . getTime () - RUNNING_SUBAGENT_STALE_MINUTES * 60 * 1000 const runningSubagentCount = subagents . filter ( ( subagent ) => ! subagent . finished && ( subagent . transcriptUpdatedAt ?? subagent . startedAt ). getTime () >= staleBefore , ). length const materializedToolUseIds = new Set ( subagents . map (( subagent ) => subagent . toolUseId ). filter (( id ) => id !== null ), ) const reservationExpiredBefore = now . getTime () - SPAWN_RESERVATION_TTL_SECONDS * 1000 const pendingReservationCount = new Set ( reservations . filter ( ( reservation ) => reservation . reservedAt . getTime () >= reservationExpiredBefore && ! materializedToolUseIds . has ( reservation . toolUseId ), ) . map (( reservation ) => reservation . toolUseId ), ). size return { runningSubagentCount , pendingReservationCount , total : runningSubagentCount + pendingReservationCount , } }
```
(從 src/ops/subagent-concurrency.ts 截取,已移除類型。)
為什麼必須計算同一個回應中的啟動?計算磁碟上的檔案會留下一個空隙。當一個回應包含多個 Agent 呼叫時,該回應中較早的子代理在鉤子檢查較晚的呼叫時,尚未在磁碟上存在。以下是 2026 年 10 月 1 日的一個回應,它啟動了六個審閱者:
這種模式在我們的紀錄檔中普遍存在。鉤子啟用後,有 197 個回應發出了兩個或更多 Agent 呼叫,總計 521 次呼叫。我們查看了 295 對連續的呼叫,其中較早的呼叫確實產生了一個子代理。在所有這些情況下,較早子代理的 meta.json 出現在較晚呼叫已經寫入之後,中位數為 1.11 秒。
在所有八個有六個或更多啟動的回應中,第一個 meta.json 出現在最後一個呼叫之後 0.58 到 1.85 秒。
鉤子的預留檔案(接下來會說明)只保留最近的條目,所以我們只有來自鉤子側的少量讀數,它們顯示了兩種順序。在一個回應中,四個鉤子在最後一個呼叫寫入後 0.64 秒內連續運行,而第一個子代理的 meta.json 出現在第二個和第三個鉤子之間。在另一個回應中,每個鉤子在自己的呼叫後 0.5 到 1.8 秒運行,而模型仍在寫入下一個。
因此,每個允許啟動通過的鉤子也會將一個預留(tool_use_id 加上時間戳)寫入 temp 目錄中的一個小型 JSONL 檔案。一個目錄鎖保護該檔案:最多等待 3 秒,且超過 10 秒的鎖被視為已放棄。一個預留最多算作待處理 120 秒,直到出現具有相同 toolUseId 的 meta.json。它透過 ID 匹配,因此已出現的啟動只計算一次,而不是兩次。
老實說:預留路徑從未是真正拒絕的原因。2026 年 9 月 26 日的攀升事件確實包含了它存在的案例,即七個運行中加上一個回應中的四個。自鉤子啟用以來,我們的代理沒有再次產生這種情況。在所有十次真正的拒絕中,待處理計數都是零。
前四次拒絕是錯誤的。在 2026 年 10 月 1 日世界標準時間 16:12,代理在一個回應中發出了四個審閱者啟動,鉤子拒絕了所有四個:「10 個運行中 + 0 個等待中。」我們的紀錄檔間隔顯示實際數量為兩個。其他八個已透過呼叫 SubagentHandback 完成,SubagentHandback 是子代理用來將報告交給啟動它的代理的工具。
以這種方式結束的紀錄檔不會以 end_turn 結束。它的最後一行是工具的結果:「Report delivered to your caller.」我們已經根據 136 個紀錄檔檢查了規則,但沒有一個以這種方式結束,所以鉤子會將這八個計為運行中 30 分鐘。
代理讀取了鉤子的原始碼,添加了第三個完成標記,並在 16:14:44 再次啟動了相同的四個審閱者,距離第一次拒絕兩分半鐘。該修復在 16:19:03 以 290035eb 提交。
規則故意傾向於這種方式。無法識別的最後一行計為運行中,因為多計數只會導致幾分鐘的等待,而少計數則會破壞上限。在這裡,代價是四次審查晚了兩分半鐘才開始。
六次正確的拒絕。在 2026 年 10 月 12 日世界標準時間 07:46,一個會話正在運行十個正在編寫測試的子代理。其中一個在任務進行到一半時,將六個 Agent 呼叫放入一個回應中,以拆分其餘的工作。鉤子拒絕了所有六個:「10 個運行中 + 0 個等待中。」該子代理自己編寫了其任務。如果沒有鉤子,該會話將達到 16 個,就像九月份的 15 個一樣。
這些拒絕每次花費 0.96 到 3.22 秒,在一台忙於十個測試編寫代理的機器上。2026 年 10 月 1 日的四次拒絕花費 0.43 到 0.46 秒。
前後比較。為了比較,我們掃描了所有我們本地仍有的子代理紀錄檔:從 2026 年 9 月 13 日到 2026 年 10 月 13 日世界標準時間 18:00,共 20 個會話中的 1,125 次啟動。每個子代理的生命週期從其紀錄檔的第一個時間戳到最後一個時間戳。在每次啟動時,我們計算了同一會話中有多少生命週期覆蓋了該時刻。
「之前」指的是在鉤子提交(2026 年 9 月 30 日世界標準時間 09:32)之前的啟動。有兩件事阻礙了這成為一個更乾淨的勝利。首先,「之前」的一方是一個單一事件,而不是習慣。所有五次超過十個運行中的啟動都來自 2026 年 9 月 26 日的那次單一攀升,如果你不考慮隊友,鉤子之前的峰值是 10,而不是 15。
其次,鉤子不會規劃批次。代理會這樣做,因為規則告訴它這樣做。鉤子啟用後,有 55 次啟動精準地以十個運行中結束(包括它們自己),其中 47 次來自一個回應,該回應在九個已運行中的子代理上又添加了一個。鉤子是該規劃的後盾。在 676 次啟動中,它拒絕了兩次,其中一次是錯誤。
Claude Code 自身的限制。Claude Code 有自己的並行限制。根據子代理文件,一旦一個會話中有 20 個子代理運行,進一步的 Agent 呼叫將因 "Concurrent subagent limit reached" 而失敗。CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS 可以更改此數字,該限制需要版本 2.1.217 或更高版本。