那天我想偷懶。一個中型重構,要動 api 層、service 層,順便把一個命名很爛的函式全專案改名。我手上有能並行派子智能體(sub-agent)的工具,腦袋一熱就想:三件事互不相干,派三個 agent 同時做,理論上三分之一時間搞定。

結果跑完一看,service 層的修改不見了。不是壞掉,是憑空消失,像我從來沒改過。

這篇就是那次的紀錄。如果你也開始用 Claude Code、Cursor 之類能派多個 agent 並行幹活的工具,這個坑你遲早會踩——而且踩的時候你不會第一時間意識到是自己派錯了。

本來以為會發生的事

我的盤算很單純:

  • Agent A:改 api/ 底下的 controller,調整回傳格式
  • Agent B:改 service/ 底下的業務邏輯,補一段快取
  • Agent C:把 getUserData 這個函式全專案改名成 fetchUserProfile

三個任務,三個 agent,同時開跑。我甚至在每個 agent 的指令最後都加了一句「請小心,不要動到不屬於你任務範圍的檔案」。自我感覺良好。

第一個坑:它們看不到彼此

跑完之後,我打開 service/ 想看 B 加的快取,發現整段不見了。git diff 一比,B 那段程式碼根本不在工作目錄裡——像沒寫過。

我第一個反應是「Claude Code 出 bug 了吧」,差點就要去開 issue。冷靜下來看檔案的修改時間和 diff,才發現根本不是工具的錯,是我自己的問題。

關鍵在這裡:並行的子智能體之間,context 是完全隔離的。A 不知道 B 在幹嘛,B 也不知道 C 存在。它們不是一個團隊,是三個各自關在小房間、起點都是磁碟上同一份程式碼的人。

那句「請不要動到別人的檔案」完全沒用——你沒辦法叫一個看不到同事的人去配合同事。

真正出事的是 Agent C。改名 getUserData 這個任務天生橫跨整個專案,service/ 裡也有好幾處呼叫到它。C 為了改名,把 service/ 的檔案讀進自己的 context、改完寫回去——但 C 讀進去的是重構開始那一刻的版本,裡面還沒有 B 加的快取。C 寫回去的瞬間,就用這份舊版本把 B 辛苦加的東西整個蓋掉了。

這就是並行寫入最典型的死法:last-write-wins,最後寫的人贏。誰最後完成,誰的版本覆蓋全部。B 沒有比較弱,B 只是比較早寫完而已。

而且因為 B 那段從沒 commit 過,被蓋掉就是真的沒了——git reflog 救不回它,reflog 記的是 commit 歷史,工作目錄裡還沒提交的修改根本不在它管轄範圍。我最後是對著 B agent 當初的回報、手動把那段快取重打了一遍。沒 commit 的並行寫入翻車,沒有 Ctrl+Z。

第二個坑:任務的切法錯了

冷靜分析之後我才意識到,問題不在「並行」本身,在我切任務的方式。

A 和 B 是按目錄邊界切的——一個管 api/,一個管 service/,井水不犯河水。這種切法沒問題。

C 不一樣。「全專案改名」是一個橫切(cross-cutting)任務,它的本質就是要碰每一個用到那個函式的檔案,跟 A、B 的領地天然重疊。把橫切任務跟縱切任務丟進同一批並行,等於在賭誰先寫完,這不是並行,是 race condition。

我事後想了個判準,蠻好用的:

如果兩個任務「碰到的檔案集合」有交集,它們就不該並行。

A 碰 api/*,B 碰 service/*,交集是空的,可以並行。C 碰「所有檔案」,跟誰都有交集,它必須單獨跑,要嘛排在最前面(先改完名,後續 agent 拿到的就是新名字),要嘛排在最後面(等 A、B 都寫完,C 再統一掃一遍)。

(順帶一提,我也試過更笨的辦法:把三個任務都縮小範圍,叫 C 只改 api/service/ 以外的地方。結果是改名改一半,剩下的呼叫點還是舊名字,編譯直接掛。橫切任務不能硬切成幾塊塞進不同 agent,會切出一個誰都跑不起來的中間狀態。)

第三個坑:就算切對了,還是可能撞

把 C 抽出來單獨跑之後,A 和 B 並行確實沒事了。但我又想得更遠一點:如果哪天 A 和 B 的領地有一小塊模糊地帶,比方說它們都需要改一個共用的 type 定義檔,就算我以為切乾淨了,還是會撞。

人是會判斷錯的。與其每次都祈禱自己切得夠乾淨,不如讓環境物理上不可能撞。

這就是 git worktree 派上用場的地方。worktree 讓你從同一個 repo 開出多個獨立的工作目錄,各自有自己的檔案,共用底層的 git 物件庫:

1
2
3
4
# 從目前的 repo 開三個獨立工作目錄,各自一個分支
git worktree add ../wt-agent-a -b agent-a
git worktree add ../wt-agent-b -b agent-b
git worktree add ../wt-agent-c -b agent-c

接著派 agent 的時候,在指令裡明確告訴每個 agent 它的工作目錄是哪一個——「你的工作目錄是 ../wt-agent-a,所有讀寫都只在這裡面」。A 寫 ../wt-agent-a 的檔案,怎麼樣都碰不到 B 的 ../wt-agent-b。它們從根本上沒有共享的可寫狀態,last-write-wins 這種事情不可能發生——因為沒有「同一個檔案」讓它們搶。

跑完之後,衝突被推遲到你能控制的那一刻,也就是 merge:

1
2
3
git merge agent-a
git merge agent-b
git merge agent-c # 真有衝突,git 會明明白白告訴你哪幾行

並行直接寫同一個目錄,衝突是靜默的,B 的東西消失了你完全不會收到通知;走 worktree + merge,衝突是顯式的,git 會停下來、把撞到的那幾行標給你看。把一個你看不見的問題,換成一個你看得見的問題——這個換法穩賺不賠。

事後收完的工作目錄記得清掉,順手把合併完的分支也刪一刪:

1
2
3
git worktree remove ../wt-agent-a
git worktree prune
git branch -d agent-a # merge 進主線後就可以刪了

那到底什麼時候該並行?

踩完這輪我的結論是,並行子智能體不是「能用就用」的加速器,它有明確的適用邊界。

適合並行的情況:任務之間檔案集合不重疊。典型的是「每個 agent 負責一個獨立模組」「分頭去讀不同子系統然後各自回報」這種扇出(fan-out)型工作。讀取類的任務尤其安全——大家都只讀不寫,怎麼撞都沒事。

不適合並行的情況:橫切任務(全域改名、改一個被到處引用的介面、調整共用設定)、有先後依賴的任務(B 要用 A 的產出)、以及任何兩個 agent 會寫到同一個檔案的情況。這些老老實實串行,或用 worktree 隔離。

判斷其實就一句話:先問「這幾個任務碰的檔案會不會重疊」,會就別並行。我現在派 agent 之前都先在腦袋裡跑一遍這個檢查,比事後撈回消失的程式碼快多了。

踩過的坑,列一下

  • 以為加一句「別動別人的檔案」就能讓 agent 互相讓路——它們根本看不到彼此,這句話是寫給空氣的
  • 把橫切任務(全域改名)跟縱切任務(改某個模組)丟同一批並行——橫切任務碰所有檔案,必撞
  • 衝突是靜默的——被覆蓋的修改不會報錯,它只是安靜地消失,而且沒 commit 的話 reflog 也救不回
  • 想靠「縮小橫切任務的範圍」硬塞進並行——會切出一個編譯不過的中間狀態

最後那次重構,我把 C 排到最前面單獨跑,跑完再讓 A、B 在各自的 worktree 裡並行,merge 的時候乾乾淨淨。總時間沒有比一開始幻想的「三分之一」多多少,但至少東西不會再憑空消失。

下次再想偷懶派一堆 agent 之前,我會先花十秒鐘想清楚它們會不會搶同一個檔案。這十秒,比事後對著 diff 一行行找哪裡被吃掉值錢。