第九章:分叉代理(Fork Agent)與提示快取(Prompt Cache)
百分之九十五的洞見
當父代理平行產生五個子代理時,每個子代理的 API 請求中絕大部分都是相同的。系統提示相同。工具定義相同。對話歷史相同。觸發產生的助理訊息相同。唯一不同的是最後的指令:「你負責資料庫遷移」、「你寫測試」、「你更新文件」。
在典型的分叉場景中,若對話已經暖起來,共享前綴可能有 80,000 個 token。每個子代理的指令可能只有 200 個 token。那是 99.75% 的重疊。Anthropic 的提示快取(Prompt Cache)對已快取的輸入 token 提供九折優惠。如果你能讓那 80,000 個 token 在第二到第五個子代理中命中快取,你就為那四個請求省下了 90% 的輸入成本。對父代理來說,這就是在同一次平行派遣中花 $4 還是花 $0.50 的差別。
問題在於提示快取是位元組精確(byte-exact)的。不是「夠相似」。不是「語義等價」。位元組必須完全匹配,從系統提示的第一個位元組到每個子代理內容開始分歧前的最後一個位元組,逐字元比對。多一個空格、工具定義排序不同、某個過期的功能旗標改變了系統提示的片段——快取就會失效。整個前綴都會以全價重新處理。
分叉代理(Fork Agent)是 Claude Code 對此限制的解答。它們不只是「帶上下文產生子代理」的便利功能——它們是偽裝成編排功能的提示快取利用機制。分叉系統中的每一個設計決策都回歸到一個問題:如何保證平行子代理之間擁有位元組相同(byte-identical)的前綴?
分叉子代理繼承什麼
分叉代理從父代理繼承四項東西,而且是透過引用或位元組精確複製來繼承,而非重新計算。
1. 系統提示。 不是重新產生——而是穿透傳遞(threading)。父代理已經渲染好的系統提示位元組透過 override.systemPrompt 傳遞,從 toolUseContext.renderedSystemPrompt 中取出。這是父代理最近一次 API 呼叫中實際發送的確切字串。
2. 工具定義。 分叉代理定義宣告了 tools: ['*'],但搭配 useExactTools 旗標設為 true,子代理直接接收父代理已組裝好的工具陣列。不做篩選、不做重排、不做重新序列化。
3. 對話歷史。 父代理與 API 交換的每一則訊息——使用者回合、助理回合、工具呼叫、工具結果——都透過 forkContextMessages 複製到子代理的上下文中。
4. 思考配置與模型。 分叉定義指定 model: 'inherit',解析為父代理的確切模型。相同模型意味著相同的分詞器、相同的上下文視窗、相同的快取命名空間。
分叉代理的定義本身是極簡的——幾乎是一個空操作:
分叉代理的定義刻意極簡——它從父代理繼承一切。它指定所有工具('*')、繼承父代理的模型、使用氣泡模式(bubble mode)處理權限(讓提示浮現在父代理的終端機中),並提供一個永遠不會被呼叫的空操作系統提示函式——真正的提示透過覆寫通道送達,已經渲染好且位元組穩定。
位元組相同前綴技巧
傳送給 Claude 的 API 請求有特定結構:系統提示、然後是工具、然後是訊息。要讓提示快取命中,從請求開頭到某個前綴邊界的每一個位元組在各請求之間都必須相同。
分叉代理透過確保三個層級被凍結來達成這一點:
第一層:透過穿透傳遞系統提示,而非重新計算。
當父代理的系統提示在其最後一次 API 呼叫中被渲染時,結果被捕獲在 toolUseContext.renderedSystemPrompt 中。這是經過所有動態插值之後的字串——GrowthBook 功能旗標、環境詳情、MCP 伺服器描述、技能內容、CLAUDE.md 檔案。分叉子代理收到的正是這個確切字串。
為什麼不直接再次呼叫 getSystemPrompt() 呢?因為系統提示的產生不是純函式。GrowthBook 旗標在 SDK 取得遠端配置後會從冷狀態轉為暖狀態。在父代理第一個回合中回傳 false 的旗標,到分叉子代理啟動時可能回傳 true。如果系統提示包含一個由該旗標控制的條件區塊,重新渲染的提示就會產生哪怕是一個字元的差異。快取爆掉。80,000 個 token 以全價重新處理,乘以五個子代理。
穿透傳遞已渲染的位元組消除了這整類分歧。
第二層:工具定義透過精確傳遞。
一般的子代理(Sub-Agent)會經過 resolveAgentTools(),它根據代理定義的 tools 和 disallowedTools 陣列篩選工具池、套用權限模式差異,並可能重新排序工具。產生的序列化工具陣列會與父代理的不同——不同的子集、不同的順序、不同的權限標註。
分叉代理完全跳過這些:
const resolvedTools = useExactTools
? availableTools // 父代理的精確陣列
: resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
useExactTools 旗標只在分叉路徑上被設為 true。子代理原封不動地取得父代理的工具池。相同的工具、相同的順序、相同的序列化。這包括在子代理的工具池中保留 Agent 工具本身,即使子代理被禁止使用它——移除它會改變工具陣列並破壞快取。
第三層:訊息陣列建構。
這是 buildForkedMessages() 精心處理的部分。此函式建構位於共享歷史和每個子代理指令之間的最後兩則訊息:
buildForkedMessages() 函式建構位於共享歷史和每個子代理指令之間的最後兩則訊息。演算法:
- 複製父代理的助理訊息(保留所有
tool_use區塊及其原始 ID)。 - 對每個
tool_use區塊,建立一個帶有固定佔位字串的tool_result(所有子代理完全相同)。 - 建構一則使用者訊息,包含所有佔位結果,後面接著用樣板標籤包裝的每個子代理指令。
- 回傳
[clonedAssistantMessage, userMessageWithPlaceholdersAndDirective]。
// 虛擬碼——示意訊息建構過程
function buildChildMessages(directive, parentAssistant) {
const cloned = cloneMessage(parentAssistant)
const placeholders = parentAssistant.toolUseBlocks.map(b =>
toolResult(b.id, CONSTANT_PLACEHOLDER) // 所有子代理位元組相同
)
const userMsg = createUserMessage([...placeholders, wrapDirective(directive)])
return [cloned, userMsg]
}
每個子代理產生的訊息陣列看起來像:
[...共享歷史, assistant(所有tool_use), user(佔位結果..., 指令)]
指令之前的每個元素在所有子代理之間都是相同的。FORK_PLACEHOLDER_RESULT——一個固定字串 'Fork started -- processing in background'——確保即使是工具結果區塊也是位元組相同的。tool_use_id 的值是相同的,因為它們引用同一則助理訊息。只有最後的文字區塊——包含每個子代理的指令——是不同的。
快取邊界恰好落在那個最後的文字區塊之前。它上方的一切——可能數萬個 token 的系統提示、工具定義、對話歷史和佔位結果——在第一個子代理之後的每個子代理都以九折優惠命中快取。
分叉樣板標籤
每個子代理的指令被包裝在一個樣板 XML 標籤中,它有兩個用途:指示子代理應如何行為,以及作為遞迴分叉偵測的標記。
樣板包含大約 10 條規則。關鍵的幾條:
- 覆寫父代理的分叉指令。 父代理的系統提示說「預設使用分叉」——樣板明確告訴子代理:「那條指令是給父代理的。你就是分叉。不要產生子代理。」
- 靜默執行,報告一次。 工具呼叫之間不產生對話文字。直接使用工具,然後產出結構化摘要。
- 保持在範圍內。 子代理不得超出其指令範圍。
- 結構化輸出格式。 回應必須遵循 Scope/Result/Key files/Files changed/Issues 的範本,當多個子代理同時回報時,讓父代理容易解析結果。
規則 1 特別有趣。父代理的系統提示——分叉子代理為了快取原因而逐字繼承的——包含類似「當有平行工作時預設使用分叉」的指令。如果子代理遵循那條指令,它會嘗試分叉自己的子代理,造成代理的無限遞迴。樣板明確覆寫:「那條指令是給父代理的。你就是分叉。」
結構化輸出格式(Scope/Result/Key files/Files changed/Issues)不是裝飾性的。它將子代理的輸出限制在事實性報告中,這讓父代理在五個子代理同時回報時更容易解析和彙整結果。
遞迴分叉防護
分叉子代理在其工具池中保留了 Agent 工具。它必須這樣做——移除它會改變序列化的工具陣列並破壞提示快取。但如果子代理實際上在沒有指定 subagent_type 的情況下呼叫了 Agent 工具,分叉路徑會再次觸發,產生一個孫代理分叉。這個孫代理會繼承更大的上下文(父代理 + 子代理的對話),產生自己的分叉,如此反覆。
兩道防護機制阻止了這個情況:
主要防護:querySource 檢查。 當分叉子代理被產生時,其 context.options.querySource 被設為 'agent:builtin:fork'。call() 方法在允許分叉路徑之前會檢查這個值:
// 在 AgentTool.call() 中:
if (effectiveType === undefined) {
// 分叉路徑——但我們已經在分叉中了嗎?
if (querySource === 'agent:builtin:fork') {
// 拒絕:已經是分叉子代理
}
}
這是快速路徑。它只檢查選項物件中的一個字串。
備援防護:訊息掃描。 分叉防護使用兩道防線:在產生時設定的 querySource 標記(快速路徑——單次字串比對),以及掃描訊息歷史中是否有樣板 XML 標籤的備援機制。備援機制存在是因為 querySource 能存活過自動壓縮(autocompact),但在某些它未被正確傳遞的邊界情況下,訊息掃描備援能捕捉到遞迴。這是一種雙重保險的做法,其中檢查的成本(掃描訊息)相較於意外遞迴分叉的成本(失控的 API 開銷)微乎其微。
為什麼需要備援?因為 Claude Code 有一個自動壓縮(autocompact)功能,當上下文過長時會重寫訊息陣列。自動壓縮能重寫訊息內容但會保留選項中的 querySource。理論上,querySource 本身就足夠了。實務上,訊息掃描備援捕捉了 querySource 未被正確傳遞的邊界情況——這是一種雙重保險的做法,其中檢查的成本(掃描訊息)相較於意外遞迴分叉的成本(失控的 API 開銷)微乎其微。
同步到非同步轉換
分叉子代理一開始在前景執行:它的訊息串流到父代理的終端機,父代理阻塞等待完成。但如果子代理花太長時間呢?Claude Code 允許執行中轉為背景——使用者(或自動逾時機制)可以將正在執行的前景代理推到背景,而不會遺失任何工作。
機制出乎意料地乾淨:
-
當前景代理透過
registerAgentForeground()註冊時,會建立一個背景信號 promise。 -
父代理的同步迴圈在代理的訊息串流和背景信號之間競爭:
while (true) {
const result = await Promise.race([
iterator.next(), // 代理的下一則訊息
backgroundSignal, // 「移到背景」觸發器
])
if (result === BACKGROUND_SIGNAL) break
// ... 處理訊息
}
-
當背景信號觸發時,前景迭代器透過
iterator.return()優雅地終止。這會觸發產生器的finally區塊來處理清理工作。 -
一個新的
runAgent()實例以isAsync: true產生,使用相同的代理 ID 和截至目前累積的訊息歷史。代理從中斷處繼續,現在在背景執行。 -
原本的同步
call()回傳{ status: 'async_launched' },父代理繼續其對話。
不會遺失任何工作,因為訊息歷史就是代理的狀態。磁碟上的側鏈轉錄本(sidechain transcript)記錄了代理產出的每一則訊息。新的非同步實例從這份轉錄本回放,從同步實例停止的地方繼續。
自動背景化
當 CLAUDE_AUTO_BACKGROUND_TASKS 環境變數或 tengu_auto_background_agents GrowthBook 旗標啟用時,前景代理會在 120 秒後自動被背景化:
當透過環境變數或功能旗標啟用時,前景代理會在 120 秒後自動被背景化。停用時,函式回傳 0(不自動背景化)。
這是一個帶有成本影響的使用者體驗決策。前景代理會阻塞父代理的終端機——使用者無法輸入、無法發出新指令、無法產生其他代理。兩分鐘足夠讓代理同步完成大部分快速任務(此時串流輸出是有用的回饋),但又短到長時間執行的任務不會綁架終端機。
在分叉實驗下,自動背景化問題是多餘的:所有分叉產生的代理從一開始就強制非同步。run_in_background 參數完全從 schema 中隱藏。每個分叉子代理都在背景執行,完成後透過 <task-notification> 回報,父代理永遠不會阻塞。
何時不使用分叉
分叉是數種編排模式之一,在三種情況下會被刻意排除:
協調者(Coordinator)模式。 協調者模式和分叉模式互斥。協調者有結構化的委派模型:它維護計畫、以明確的提示指派任務給工作者、並追蹤進度。分叉的「繼承一切」做法會破壞這個模型。被分叉的協調者會繼承父協調者的系統提示(上面寫著「你是協調者,委派工作」),子代理會嘗試編排而非執行。isForkSubagentEnabled() 函式會先檢查 isCoordinatorMode(),如果啟用則回傳 false。
非互動式工作階段。 SDK 和 API 消費者(--print 模式、Claude Agent SDK)在沒有終端機的情況下運作。分叉的 permissionMode: 'bubble' 會將權限提示浮現到父代理的終端機——在非互動式模式中不存在。與其建構一個獨立的權限流程,分叉路徑直接被停用。SDK 消費者改用明確的 subagent_type 選擇。
明確指定 subagent_type。 當模型指定了 subagent_type(例如 "Explore"、"Plan"、"general-purpose"),分叉路徑不會被觸發。分叉只在 subagent_type 被省略時觸發。這讓模型可以在「我想要一個有自己系統提示和工具集的專用代理」(明確指定型別)和「我想要一個繼承我上下文的自身複製品來平行處理這件事」(省略型別)之間做選擇。
經濟學
考慮一個具體場景。開發者要求 Claude Code 重構一個模組。父代理分析了程式碼庫、形成計畫,並平行派遣五個分叉子代理:一個更新資料庫 schema、一個重寫服務層、一個更新路由器、一個修復測試、一個更新型別。
在對話的這個時間點,共享上下文相當大:
- 系統提示:約 4,000 個 token
- 工具定義(40+ 個工具):約 12,000 個 token
- 對話歷史(分析 + 計畫):約 30,000 個 token
- 帶有五個 tool_use 區塊的助理訊息:約 2,000 個 token
- 佔位工具結果:約 500 個 token
共享前綴總計:約 48,500 個 token。每個子代理指令:約 200 個 token。
不使用分叉(五個獨立代理,各自有全新上下文和自己的系統提示):
- 每個子代理處理自己的系統提示 + 工具 + 任務提示
- 沒有快取共享(不同的系統提示、不同的工具集)
- 成本:5 x 全價輸入處理
使用分叉(位元組相同的前綴):
- 子代理 1:48,700 個 token 以全價處理(第一個請求快取未命中)
- 子代理 2-5:48,500 個 token 以 10% 價格處理(快取命中)+ 每個 200 個 token 以全價處理
- 子代理 2-5 的有效成本:約 4,850 + 200 = 每個約 5,050 個 token 等值
節省的金額隨上下文大小和子代理數量而增長。對於一個擁有 100K token 歷史的暖工作階段派生 8 個平行分叉,快取節省可以超過未共享時輸入 token 成本的 90%。
這就是為什麼分叉系統中的每一個設計決策——穿透傳遞而非重新計算、精確工具傳遞、佔位結果、甚至在子代理的工具池中保留被禁止使用的 Agent 工具——都在為一件事最佳化:位元組相同的前綴。每個決策都用少量的優雅或安全性來換取可衡量的 API 成本降低。
設計張力
分叉系統做出了值得理解的明確取捨:
隔離性 vs. 快取效率。 分叉子代理繼承一切,包括可能與其任務無關的對話歷史。一個重寫測試的子代理不需要父代理討論資料庫 schema 設計的那 15 則訊息。但包含那些訊息正是讓前綴相同的關鍵。剔除無關歷史可以節省上下文視窗空間,但代價是破壞快取。設計上的賭注是快取節省超過上下文開銷。
安全性 vs. 快取效率。 Agent 工具留在分叉子代理的工具池中,即使子代理不得使用它。移除它會更安全(子代理甚至無法嘗試分叉),但會改變工具陣列的序列化。樣板標籤和遞迴分叉防護是補償控制——用執行時期防護代替靜態移除。
簡潔性 vs. 快取效率。 佔位工具結果是一個謊言。子代理對於父代理助理訊息中的每個 tool_use 區塊都看到 'Fork started -- processing in background',不管那些工具呼叫實際上做了什麼。這沒問題,因為子代理的指令告訴它要做什麼——它不需要父代理派遣回合中準確的工具結果。但這意味著子代理的對話歷史在技術上是不連貫的。佔位文字的選擇是為了簡短和一致性,而非準確性。
這些取捨中的每一個都反映了同樣的優先級:當你在規模化使用 API 呼叫時按 token 付費,位元組相同的前綴值得你為此扭曲架構。
實踐應用:為提示快取效率而設計
分叉代理模式的適用範圍超越 Claude Code。任何從相同上下文派遣多個平行 LLM 呼叫的系統都能從快取感知的請求建構中獲益。原則如下:
1. 穿透傳遞已渲染的提示,不要重新計算。 如果你的系統提示包含任何動態內容——功能旗標、時間戳記、使用者偏好、A/B 測試變體——捕獲渲染結果並以值傳遞給子代理。重新計算有分歧的風險。
2. 凍結工具陣列。 如果你的子代理需要不同的工具集,你就放棄了工具區塊上的快取共享。考慮保留完整的工具集,使用執行時期防護(像分叉樣板中的「不要使用 Agent」)來代替編譯時期的移除。
3. 最大化共享前綴,最小化每個子代理的後綴。 結構化你的訊息陣列,讓所有共享內容排在前面,每個子代理的內容附加在末尾。交錯混合共享和每個子代理的內容會碎片化快取邊界。
4. 對可變內容使用固定佔位符。 當訊息結構需要回應先前的工具呼叫時,在所有子代理中使用相同的佔位字串,而非實際的(會分歧的)結果。
5. 衡量損益平衡點。 快取共享有其開銷:每個子代理更大的上下文視窗(它們攜帶無關歷史)、執行時期防護代替靜態安全性、架構複雜度。計算你的平行模式(多少個子代理、共享前綴多大)在扣除額外上下文 token 後是否確實省錢。