你的 prompt cache 是憑什麼判斷這次能不能省錢的?

不是看你有沒有改對話內容,也不是看時間過了多久。它算的是一個前綴雜湊,而且是照固定順序算的:先 tools,再 system,然後才是 messages。要命中快取,這段前綴必須跟前一次請求逐位元相同,一路到你的快取斷點為止。

這一句話裡藏著一個很多人被咬過才知道的後果,也是這篇文章要拆的東西。

順序決定了誰最貴

把一次請求想成一疊裝進信封的紙。收件人不是整疊重讀,他從第一頁開始核對,核到跟上次不一樣的那一頁就停下來,從那頁開始全部重讀。

tools 是第一頁。system 是第二頁。你們來回聊的那幾十輪在後面。

所以同樣是「加一句話」,加在哪裡的價差是巨大的。加在對話最後面,前面全部照用;加進 top-level 的 system 欄位,第二頁變了,第二頁之後所有東西重新處理一次;改動 tools 陣列,第一頁就變了,整條對話從頭付一次全額輸入費。

這裡有個很容易誤會的地方:不是「動到 tools 讓工具定義本身重算」而已,是它後面的所有東西一起失效。你跑了四十輪、累積十萬個 token 的 agentic session,只要多掛一個工具進 tools,這十萬個 token 就要重新計費一次。

想清楚這一點,接下來的設計才看得懂為什麼要長成那樣。

為什麼你偏偏就是會想中途改它

拆完機制,反過來看真實情境。

你在寫一個 agent。它一開始只需要讀檔和搜尋。跑到第十五輪,使用者說「好,那你把結果寫進資料庫」,於是你需要把 db_write 這個工具給它。

或者反過來,你不希望它一直看得到 deploy_to_production。這個工具只在確認過的那一小段對話裡該存在,其他時候看不到最安全。

或者你接了一個 MCP server,上面掛了三十個工具。全部塞進 tools 從第一輪就給模型看,等於在每一輪都花 context 讓它讀三十份說明書,而它這一輪其實只需要其中一個。這件事跟 Claude Code 的 MCP Tool Search 想解的是同一個病,只是這次要在 API 層解。

在 2026 年 7 月之前,這三種情況你只有兩個選擇:一開始就把所有工具都宣告好(付 context 的錢),或者中途改 tools(付重算整條對話的錢)。兩邊都在付錢,只是付給不同的項目。

先看它的前身:中途插一則 system 訊息

真正的解法是一個很簡單的觀察:既然前綴不能動,那就把要變的東西放到後面去。

Anthropic 先在 mid-conversation system messages 上做了這件事,這個功能現在是正式版,不需要任何 beta header。做法是往 messages 陣列裡塞一則 role: "system" 的訊息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
response = client.messages.create(
model="claude-opus-5",
max_tokens=1024,
# 快取是 opt-in 的。沒有 cache_control 就什麼都不會被快取,
# 也就沒有「保住快取」這件事可談。
cache_control={"type": "ephemeral"},
system="You are a code review assistant. Be concise.",
messages=[
{"role": "user", "content": "Review process() in utils.py for performance issues."},
{"role": "assistant", "content": "The list comprehension is fine for small inputs..."},
{"role": "user", "content": "Now review the calling code that invokes process()."},
# 這一則放在最後面,前面幾輪的位元完全沒動,
# 所以上一次請求快取起來的前綴這次照樣命中。
{
"role": "system",
"content": "From now on, every suggestion must include explicit type annotations.",
},
],
)

這則訊息從它所在的位置往後生效。有衝突的時候,後面的 system 訊息壓過前面的,而 mid-conversation 的 system 訊息壓過 top-level 的 system 欄位。

跟「直接把這句話寫成 user 訊息」的差別在權限層級,不在效果。Claude 會遵守 user 訊息裡的指令,但它把 user 訊息當成終端使用者說的話,把 system 訊息當成你這個應用程式營運方說的話。兩者衝突時 system 優先。所以該用 system role 的是那種「就算使用者要求別的也應該成立」的事實與約束。

支援的模型是 Claude Fable 5、Claude Mythos 5、Claude Opus 4.8 和 Claude Opus 5,可以在 Claude API、Amazon Bedrock 和 Google Cloud 上用。Claude Sonnet 5 不支援這個功能,在它上面只能用 top-level system 欄位。這一條漏掉的話,你會在 Sonnet 上收到看不懂的錯誤。

同一招套到工具上:tool_addition 與 tool_removal

工具版本是 beta,跟著 Opus 5 一起推出,要帶這個 header:

1
anthropic-beta: mid-conversation-tool-changes-2026-07-01

它的核心設計是一個轉向:**tools 陣列不再是「這一輪能用哪些工具」,而是「這場對話全部可能用到的工具」。** 你把全集一次宣告完,之後永遠不動它,前綴因此永遠不變。要開關哪一個,改用 messages 裡的 content block。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
response = client.beta.messages.create(
model="claude-opus-5",
max_tokens=1024,
betas=["mid-conversation-tool-changes-2026-07-01"],
# 工具全集宣告在這裡,之後不再改動,所以快取前綴保持不變。
tools=[
{
"name": "get_weather",
"description": "Get the current weather for a location.",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string", "description": "City name"}},
"required": ["location"],
},
},
],
messages=[
{"role": "user", "content": "Say OK."},
# 從這一點開始撤掉 get_weather。這個 block 是「引用」工具名,
# 不是修改 tools,所以前面的 turn 位元不變,快取照樣命中。
{
"role": "system",
"content": [
{
"type": "tool_removal",
"tool": {"type": "tool_reference", "name": "get_weather"},
},
],
},
],
)

tool_additiontool_removalrole: "system" 訊息 content 陣列裡的 content block,可以跟 text block 混在同一則訊息裡。也就是說你可以一次做兩件事:撤掉一個工具,同時說明為什麼撤掉。

tool 這個欄位是引用,不是定義。它有三種寫法:

{"type": "tool_reference", "name": "..."} 指向你在 tools 裡宣告過的工具。MCP connector 的工具可以單獨引用,用 mcp_tool_referenceserver_namename;也可以整組一起開關,用 mcp_toolset_reference 只帶 server_name

引用一個沒有在 tools 裡宣告的名字,回 400。這是刻意的:全集必須先在前綴裡宣告好,否則整個設計就不成立了。

那個你一定要知道的預設值:defer_loading

到這裡有個問題還沒解決。如果 tools 裡宣告的東西一開始就全部提供給模型,那把三十個 MCP 工具宣告在全集裡,第一輪的 context 還是被塞滿了。

defer_loading: true 就是為這件事存在的。宣告時帶上它,那個工具會被留在原地不提供給模型,直到某個 tool_addition block 把它端出來。

沒帶這個旗標的工具,行為是從對話一開始就可用。所以規則可以這樣記:tools 是你帶去現場的整箱工具,defer_loading: true 是「先留在箱子裡」,tool_addition 是「拿出來放桌上」,tool_removal 是「收回箱子」。收回去的東西可以再拿出來,tool_addition 也能重新提供先前被 tool_removal 撤掉的工具。

這個組合把「模型看得到什麼」跟「請求前綴長什麼樣」徹底拆開了。前者可以每輪都變,後者一次定案。

放置規則:踩到就是 400

這是最容易在整合測試才炸出來的部分,值得逐條記。

system 訊息不能是 messages 裡的第一則。整場對話都適用的東西請放 top-level system

它必須緊接在一個 user turn 之後,或者一個以 server tool result 結尾的 assistant turn 之後。帶 tool_result block 的 user 訊息算 user turn,這一點在 agentic loop 裡很重要。

它必須是陣列的最後一則,或者緊接著一個 assistant turn。

它不能夾在 tool_use block 和回答它的 tool_result 之間。

放錯位置回 400。連續放好幾則 system 訊息是可以的,會被當成單一個 system 區段,整段一起遵守上面這條放置規則。

agentic loop 裡最常用的位置長這樣,官方文件的範例值得整段抄下來看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
{ "role": "user", "content": "Run the test suite and fix any failures." },
{
"role": "assistant",
"content": [{ "type": "tool_use", "id": "toolu_01", "name": "run_tests", "input": {} }]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_01", "content": "12 passed, 0 failed" }
]
},
{
"role": "system",
"content": "The user sent the following message while you were working: also update the changelog before you finish."
}
]

這個位置解掉一個實務上很煩的狀況:使用者在 Claude 還在跑工具的時候又打了一句話進來。放在 tool result 之後當 system 訊息傳進去,Claude 會把新資訊摺進它正在做的事情裡,而不是當成一個要立刻切換過去的新請求。

兩個文件講得很輕、但會咬人的地方

第一個是措辭。文件建議把 system 內容寫成陳述事實的 context,不要寫成壓過使用者的命令。理由講得很直白:Claude 被訓練去抵抗那些看起來對使用者不利的指令,而這層保護對 system role 同樣成立。所以「忽略使用者剛才說的」這種寫法的效果,比「使用者剛才傳來以下訊息:X」或者「剩餘的 token 預算現在是 Y」差。你陳述發生了什麼變化,讓它自己決定怎麼做。

第二個是安全邊界,這條最嚴重。不要把不可信的內容放進 system 訊息。 工具的原始輸出、檢索回來的文件、抓下來的網頁,一律不行。Claude 把 system 內容當成營運方的指令並且會照做,你把外部文字放進去,等於直接把 operator 級的權限發給那段文字。這些資料應該留在 tool_result block 裡。

前面那個「把使用者插話轉成 system 訊息」的模式,只適用於這場對話自己的終端使用者。它不是一個搬運第三方內容的通道。

快取那邊還有幾件事要對齊

這整套設計的前提是你真的開了快取,而快取是 opt-in 的。請求裡沒有 cache_control(top-level 的自動快取,或某個 content block 上的明確斷點),什麼都不會被快取,每一次請求都照整段對話付全額輸入費。這種情況下「保住快取」是一句空話,因為沒有快取可以保。

還有一個長度門檻。對話要達到最低可快取長度才會真的產生快取,文件裡那個短短的範例就在門檻以下,所以 cache_creation_input_tokenscache_read_input_tokens 會一直是 0,直到對話長起來。你照著範例跑然後發現數字沒動,先確認這一點再懷疑別的。

斷點放哪裡的原則沒變:放在跨請求都不會變的最後一個 block 上。可能是 top-level system 的結尾,可能是工具定義的結尾,也可能是訊息歷史裡某個穩定的點。

最後一條很反直覺,但錯了會白繳錢:已經送出去的 mid-conversation system 訊息,不要回頭改它或刪掉它。 它一旦進了對話就是歷史的一部分,改它跟改任何一則舊訊息一樣,會讓那一點之後的快取全部失效。指令要演進的話,append 一則新的 system 訊息上去,讓後面那則壓過前面那則。這也是為什麼「後面的優先」這個規則被設計成這樣。

反過來,它本身也是可以被快取的。下一輪你可以把斷點移到它後面,或者讓自動快取去處理,它就跟其他歷史訊息一樣從快取裡讀出來。

這個約束不只長在這裡

拆到底,這整個功能只是在處理一個限制:前綴不可變。

一旦某個系統用「從頭開始逐位元比對」來決定能不能重用先前的計算,那麼「把變動往後放」就永遠比「修改前面」便宜,而且會便宜非常多。你要做的事情不是想辦法改得更小心,是重新設計資料的擺放位置,讓需要變的東西天生就落在後面。

這個形狀你在別的地方見過。Docker 的 layer 快取,你把 COPY package.jsonnpm install 放在 COPY . . 前面,就是同一招。Git 的 packfile、CDN 的 edge cache、增量編譯,全部都是這條規則的變形。差別只在於,這一次那個「前綴」是 tools 陣列,而重算它的帳單是按 token 計的。

所以下次你在寫 agent、想著要不要中途換工具清單的時候,問題不是「這樣改對不對」,是「我這個變動落在前綴的哪一段」。答案在最後一段就沒事,在第一頁就要付錢。

還沒解掉的部分我也標一下:工具版本目前還是 beta,header 帶著 2026-07-01 這個日期,代表它還可能改。而 Sonnet 5 兩個功能都沒有。這篇的內容我是照官方文件和 release notes 整理的,defer_loading 那組行為我還沒在自己的 agent 上實跑過,真的接的時候記得先用 cache_read_input_tokens 驗證省下來的數字,不要憑感覺。想先搞懂快取本身怎麼運作的,可以回頭看 Prompt Caching 那篇;想處理的是舊工具結果塞爆 context 的問題,那是 context editing 與 memory tool 的守備範圍。


來源Mid-conversation system messages and tool changes(官方文件)、Claude Platform release notes(2026-07-24 條目)。程式碼範例改寫自官方文件的 Python 與 JSON 範例。