Also available in English.
TL;DR
問題:同一個 agent 裡,主對話的回合首通 cache 命中率 54.9%,另一條共用同一份 system prefix 的路徑卻是 0/24,一次都沒中。兩邊送出去的 system prefix 位元完全相同,56,454 字元。
原因:OpenAI 的 cache 存的是 entry —— 一段有頭有尾的 prefix。不標 breakpoint 的話,entry 的結尾由 API 自己決定,而它落在「這通請求最後一則訊息之前」。所以延伸前一通的請求對得上,從同一段 system prefix 分岔出去的請求對不上,而且是整通全價,不是少省一點。
解法:在共用的那段結尾標一個 explicit breakpoint。
// 掛在第 0 則訊息(system prefix)上
responses.ResponseInputTextParam{
Text: systemPrefix,
PromptCacheBreakpoint: responses.NewResponseInputTextPromptCacheBreakpointParam(),
}
params.PromptCacheOptions = responses.ResponseNewParamsPromptCacheOptions{Ttl: "30m"}
params.PromptCacheKey = openai.String(hashOf(systemPrefix, tools))
三件事要一起做:
prompt_cache_breakpoint標在共用 prefix 的結尾 —— 拆變因實測,就是它把分支從 0% 變成 96.7%prompt_cache_options.ttl取代prompt_cache_retention—— 後者在 GPT-5.6+ 已 deprecated,送了不報錯也不生效prompt_cache_key送 prefix 的 hash —— 文件說 GPT-5.6+ 必須設,但近距離的測試量不到它的效果
mode 留預設 implicit:implicit 會保留 API 自己那個 breakpoint(服務延伸)再加上你標的(服務分支);設成 explicit 會把前者砍掉,等於用延伸換分支。
另外兩件 GPT-5.6 才有的事:cache 寫入從免費變成 1.25 倍(讀取仍是 0.1 倍,0.28 次重用就回本);prompt_cache_retention 只吃 30m。
起點跟 cache 沒關係
我在做一個個人助理型的 agent,跑在自己機器上。那天要回答的問題是:從手機捷徑丟一句話進來,會不會污染我正在進行的那場對話的 context。
答案是不會,很快就查清楚了。但翻 log 的時候看到一個不該出現的東西:手機丟進來走的那條 path(以下叫 capture),每一次回合的第一通模型呼叫,cached_tokens 都是 0。
二十四次,沒有一次例外。
背景:為什麼會有好幾條 path
這個 agent 有四條路徑,各自解決不同的問題。
| path | 誰觸發 | 為什麼要獨立一條 |
|---|---|---|
| main | 在終端機或網頁打字 | 正常聊天,完整歷史、完整工具 |
| fast path | 同上,但先過一個分類器 | 省錢用的 |
| capture | 手機捷徑、對 Siri 講一句話 | 不能污染正在進行的討論 |
| task / wake | 時間到自己跑 | 沒人在場,規則不一樣 |
fast path 是這次排查的反面教材。大部分丟給助理的事情很小,「明天十點加個會」這種,動用主模型加全套工具加完整歷史又貴又慢。所以在 main 前面擺了一個便宜的分類器,判斷這句話是自足的小事還是需要翻舊帳的大事;小事丟給一條臨時 session:便宜模型、精選幾把工具、用完即丟的空白對話。
它上線時量測過,以短指令為主的工作量下成本接近砍半 —— 但省的是模型級價差(小事走便宜模型),不是 token 數。同一顆模型跑兩條路的對照組量起來其實大致打平:fast path 把小事切成一個個丟棄式的 context,吃不到主對話的深度 cache,全價 token 反而變多,2.5 倍。這件事後面會再回來找我。
capture 的動機不同。手機丟進來的東西如果直接進 main,你正在討論一個技術問題,中間突然插進來一則「睡眠回報:昨晚睡了六小時」,畫面亂、模型的 context 也髒。所以它也開一個用完即丟的空白對話,但工具給滿、模型用主模型,因為歸檔本身需要完整能力。
關鍵在這裡:capture 跟 main 共用同一份 system prefix。同一顆 builder 物件,同一個字串。這是刻意的,就是為了吃 cache。
所以它命中率 0,很奇怪。
第一次撈數字
log 是一行一個 JSON,每次模型呼叫記一筆,帶 prompt_tokens、cached_tokens、origin(哪條 path)、iter(回合裡的第幾通)。
先看整體:
import json, collections
tot = collections.defaultdict(lambda: [0,0,0]) # 通數, prompt, cached
for line in open('calls.jsonl'):
r = json.loads(line)
o = r.get('origin') or 'main'
t = tot[o]
t[0] += 1; t[1] += r['prompt_tokens']; t[2] += r.get('cached_tokens', 0)
for o, t in sorted(tot.items(), key=lambda x: -x[1][1]):
print(f'{o:12} {t[0]:5d} 通 命中率 {t[2]/t[1]*100:5.1f}%')
main 1715 通 命中率 76.6%
capture 65 通 命中率 61.4%
fastpath 579 通 命中率 54.3%
task 147 通 命中率 81.9%
wake 209 通 命中率 12.9%
gmail 129 通 命中率 1.3%
61% 不算低。但把 capture 按回合拆開,形狀就出來了:
2026-07-16T18:04 calls=3 cached: [0, 22073, 22112]
2026-07-17T08:22 calls=3 cached: [0, 20252, 20291]
2026-07-18T11:29 calls=5 cached: [0, 0, 20608, 23004, 23287]
2026-07-19T20:30 calls=6 cached: [0, 22703, 24431, 24470, 24546, 24706]
...
推估回合數: 24
第一通就 miss 的回合: 24 / 24
同回合後續通數: 41 其中 miss: 1
每個回合第一通全價,之後幾乎全中。那 61% 完全是後續通撐起來的。
第一個念頭是 cache 過期。手機丟東西進來的時間點 —— 早上七點、凌晨一點、每天十點的睡眠回報 —— 那些時候 main 往往幾小時沒動過。合理。
然後看到這兩筆:
2026-07-24T10:00 cached=0 前一通是 main,47 秒前
2026-07-24T13:25 cached=0 前一通是 main,84 秒前
四十七秒。過期解釋不了。
把「回合」切對,數字才有意義
這裡先出了一個錯。
要問「每個回合的第一通有沒有命中」,得先知道哪幾筆屬於同一個回合。一個回合是使用者講一句話、模型回說要叫工具、叫完再呼叫一次模型,最後才回話 —— 一句話通常對應三到六筆紀錄。
第一版腳本用時間差猜:兩筆間隔超過 120 秒就算換了一個回合。
時間 →
09:00:00 09:00:00 09:00:00 │ 09:01:20 09:01:20
通1 通2 通3 │ 通1 通2
└──── 真實回合 A ─────┘ │ └─ 真實回合 B ─┘
間隔 80 秒 → 腳本判定「還是同一回合」
回頭去看寫入紀錄那段程式怎麼蓋時間戳,才發現 main 的每一筆時間戳都是回合結束時蓋的,同一回合的三通共用同一個值。同回合內部間隔恆為 0 秒,歸得對;但相鄰兩個回合只要連著打第二句話(隔不到 120 秒,很常見)就被黏成一個,後面那個回合的首通被當成「前一個回合的後續通」算掉。
而後續通幾乎都命中。每黏一次,就有一筆真正的 miss 從「首通」這個桶子裡消失。
紀錄本身就有 iter 欄位,0 就是首通,不用猜:
m = [r for r in rows if r.get('origin') == 'main']
first = [r for r in m if r.get('iter') == 0]
hit = sum(1 for r in first if r.get('cached_tokens', 0) > 0)
print(f'回合首通 {hit}/{len(first)} = {hit/len(first)*100:.1f}%')
回合首通 190/346 = 54.9%
同回合後續通 474/491 = 96.5%
母體是 2026-07-11 至 07-24、origin=main 的紀錄。順帶拉出一個問題 —— 七百多筆沒有 origin 欄位的舊紀錄,欄位加上去以前的,一直被當成 main 算,剔掉之後才是這個數字。
再把首通按「距上一個 main 回合多久」分桶:
| 間隔 | 首通命中率 |
|---|---|
| < 2 分 | 95.5%(107/112) |
| 2–10 分 | 81.5%(44/54) |
| 10–60 分 | 41.1%(39/95) |
| 1–24 小時 | 0%(0/84) |
| > 24 小時 | 無樣本 |
112+54+95+84 = 345,等於 346 筆首通扣掉沒有前一回合可比的第一筆;命中合計 190,跟上面對得起來。
是衰減曲線沒錯。但程式裡設了 PromptCacheRetention: 24h,保留期一整天,實際看起來半衰期是十分鐘等級。
這裡要補一件事:側路徑的 iter 永遠是 0,只有 main 會遞增。所以下面側路徑的「回合首通」只能用時間分組數 —— 這一次可以,因為側路徑兩個回合之間隔很久,不會黏在一起。
再撈一次「非 main 的 path 回合首通,且十分鐘內 main 剛跑過同一個模型」的所有案例:
task 距 main 3 秒 cached=0
task 距 main 4 秒 cached=0
wake 距 main 2 秒 cached=0
wake 距 main 5 秒 cached=0
capture 距 main 47 秒 cached=0
...
小計: 命中 0 / miss 44
零比四十四,包含間隔兩秒的案例。同一時間點 main 對 main 是 95%。
看到這個我下了一個結論:跨 path 共用 cache 是結構性做不到的。這個結論是錯的。
七個假設,逐一排除
把所有「兩條 path 送出去的東西可能哪裡不一樣」的可能性一個一個殺掉。每一個都是丟給 Claude Code 去寫探針、跑、把原始輸出貼回來。
一:prefix 位元不同
把 provider 換成一個假的、只記下收到什麼的錄音機,跑一個 main 回合再跑一個 capture 回合,把兩邊真正送出去的訊息陣列印出來(內容以佔位符代替):
MAIN 第一通: 6 則訊息
[0] system 56454 chars <SYSTEM_PREFIX>
[1] system 78 chars <reminder: day_state>
[2] system 211 chars <reminder: toolbox>
[3] system 624 chars <reminder: tree_index>
[4] system 60 chars <reminder: desk>
[5] user 28 chars "Reply with just the word OK."
CAPTURE 第一通: 6 則訊息
[0] system 56454 chars <SYSTEM_PREFIX>
[1] system 2692 chars <reminder: capture>
[2] system 78 chars <reminder: day_state>
...
message[0] IDENTICAL = true
56,454 字元,一個不差。分歧從第 1 則才開始。死。
二:工具陣列不同
工具定義也算在 cache prefix 裡,而工具的可見性會隨 session 變動。把兩邊的工具清單做 hash:
advertised specs main=63 tools capture=63 tools
main 50b39fd4fd356606f0faa7a9
capture 50b39fd4fd356606f0faa7a9
一樣。死。
三:模型根本不認「部分 prefix」
當時最合理的猜測。main 存下來的是「prefix 加一整串歷史」,capture 要的只有前面那段。也許廠商只服務完整的延伸。
寫一支小腳本,跳過整個 app 直接打 API,四通:
1 LONG (cold, writes the entry) prompt= 18493 cached= 0 ( 0.0%)
2 SHORT (shares only the prefix head) prompt= 13036 cached= 13022 ( 99.9%)
3 LONG again (control) prompt= 18493 cached= 18490 (100.0%)
4 SHORT again (control) prompt= 13036 cached= 13033 (100.0%)
第二通 99.9%。部分 prefix 完全服務,三秒就生效。死,而且死得讓我更困惑。
四到六:工具、串流、推理強度
既然直接打 API 會中、真程式不會中,差別一定在真程式多做的事情上。逐一補進那支小腳本:
| 補上什麼 | 分支那通的命中 |
|---|---|
| 四十個工具定義 | 99.9% |
| 改走串流 | 100% |
| 送 reasoning effort | 99.9% |
全部維持。三個一起死。
七:capture 進來時 system prefix 剛好被重建
system prefix 會在兩個時機重建:清空對話、每天午夜換日。log 裡的 history_start 欄位在清空時會跳,查每一則 capture 前後那兩通 main 的值:
capture 前後 prefix 沒變的: 20 中間發生重建的: 4
二十四則裡二十則前後完全沒變,還是 0%。基本上也死。
然後做真正該做的那件事
繞了這麼多圈,才想到應該在真程式上做一個乾淨的重現。開一個臨時環境的完整 app,跑一個 main 回合,等三秒,再走真正的手機捷徑注入路徑跑一個 capture 回合:
origin prompt cached rate
main 23897 0 0.0% ← 冷開,符合預期
main 23916 23894 99.9% ← 對照組:第二個 main 回合是延伸,照中
capture 24474 0 0.0% ← 三秒後分岔,還是 0
capture 24707 24471 99.0% ← 只中自己上一通
重現了。同一個行程、間隔三秒、prefix 位元相同、工具相同。直接打 API 同樣的形狀給 99.9%,真程式給 0%。
能想到的變數全部對齊了,還是差這麼多。剩下的路只有一條:讓 Claude Code 去把官方文件整份翻過一遍。
文件裡的三句話
三句話全部在官方文件上,而我用了兩年都沒讀到。
第一句:這個欄位在新模型上是必要的
On GPT-5.6 models and later model families, you must set
prompt_cache_keyto use the more reliable matching for both implicit and explicit caching.
Set
prompt_cache_keyon requests that share long, common prompt prefixes. Reuse the same key for those requests to help route them to the same cache and improve cache hit rates.
我對 prompt cache 的印象一直是「它只看 prefix」。這個印象對,但少了一層。
cache 存在單一台機器的記憶體裡,不是一個大家都查得到的共用資料庫。所以命中要兩件事同時成立:內容 prefix 對得上,而且這次請求剛好落到上次那台機器。
┌─ 機器 A ──────────────┐
│ 記得 [prefix 13k] │
你的請求 ─▶ 分流 ──┼─ 機器 B ──────────────┤
│ 什麼都沒有 │
└─ 機器 C ──────────────┘
什麼都沒有
prefix 決定:如果去到 A,有沒有東西對得上
路由決定:這次到底會去 A 還是 B
prompt_cache_key 是一個你自己編的字串,唯一的作用是「同一個字串的請求盡量送同一台機器」。它不是 cache 的名字,你不能拿它去取出某份 cache;送一個亂編的 key 也不會讓內容不同的東西命中。官方措辭用的是 help route,不是 identify cache。
這很合理,而且當下看起來就是答案。後面拆變因實測把它推翻了 —— 在這個案例裡,路由不是原因。
第二句:真正解掉問題的那個
prompt_cache_breakpoint— Marks the exact end of a reusable prompt prefix. The breakpoint inherits its TTL from the request’sprompt_cache_options.ttl; the boundary is not rounded to a token block.
這是我完全不知道存在的東西,而且事後拆變因測證實,就是它修好的。
先把名詞定一下。cache 裡存的不是「一堆可以任意截斷的 token」,而是 entry:一段有頭有尾的 prefix。新請求要命中,得有某個 entry 的完整內容跟這次請求的開頭一模一樣。breakpoint 決定的就是「entry 在哪裡結束」。
預設情況下(文件叫 implicit mode),API 只放一個 breakpoint。它到底放在哪,文件沒寫;量出來的可命中邊界是「最後一則訊息之前」。
措辭得小心。實測能講的是「後來的請求只能在這個位置擷到東西」,不是「存進去的就只有這麼多」—— 同一批輸出裡的 cache_write_tokens 顯示寫入量跟整通 prompt 差不多(15,196 送出去、寫了 15,193),所以寫的應該是整串,只是只有到肯定不會再變的那一點為止可以被別人比對。下面用「entry 止於」這個講法是為了好讀,實際指的是可命中邊界。
把 system prefix 叫 Sp、後面每一塊內容叫 A B C:
第一通送 Sp + A + B + C (C 是最後一則)
↑ implicit breakpoint 在這
存下的 entry:一個,內容 = Sp+A+B (沒有 C)
之後送 Sp + A + B + C + E (延伸)
└─ 開頭 Sp+A+B 對得上 ─┘ → 命中
之後送 Sp + D (岔在 Sp)
沒有 entry 在 Sp 結束 → 整通全價
之後送 Sp + A + E (岔在 Sp+A)
也沒有 entry 在 Sp+A 結束 → 一樣整通全價
之後送 Sp + A + B + E (岔在 Sp+A+B)
正好是 entry 邊界 → 命中 Sp+A+B
這條規則是量出來的,不是文件寫的。一支探針三個案例:先送 [Sp][A][B][user C] 把 entry 寫進去,然後三個岔點各自用自己的冷 prefix 問一次(都不送 prompt_cache_key,量的就是預設行為):
| 查詢形狀 | 岔在哪 | 命中 |
|---|---|---|
[Sp][X] | Sp | 0 / 15,146(0%) |
[Sp][A][X] | Sp+A | 0 / 15,170(0%) |
[Sp][A][B][X] | Sp+A+B | 15,182 / 15,197(99.9%) |
寫入那通總共 15,196 token,命中的是 15,182 —— 差的 14 個就是最後那則 user 訊息。
誠實起見:這三個案例各只跑一次。單獨看它,Q1/Q2 的 0% 也可以是「這通剛好被路由到別台機器」。支持邊界這個解釋的是另兩組資料:後面的 A/B 把同一個岔點跑了兩輪都是 0,而且證明送不送 routing key 完全不影響結果。
為什麼這個位置這麼要命
因為 entry 的邊界不是你決定的,是「你這通請求剛好長什麼樣」決定的。同一段 Sp,在不同形狀的請求裡會被切在不同位置:
| 你送的 | entry 止於 | 別人想從 Sp 分岔時 |
|---|---|---|
[Sp][user] | Sp | 對得上 |
[Sp][A][user] | Sp+A | 對不上,差一格 |
[Sp][A][B][user] | Sp+A+B | 對不上,差兩格 |
主對話的形狀愈豐富(多幾張環境卡片、多一則提醒),邊界就往後推愈遠,側路徑愈接不到。而主對話的形狀本來就會隨功能長大。
延伸與分支
這也是為什麼同一套系統裡有些請求一直很健康、有些一直 0:
| 請求類型 | 跟前一通的關係 | 沒 breakpoint 時 |
|---|---|---|
| 一般對話的下一回合 | 嚴格延伸,蓋得住整個 entry | 中 |
| 清空對話後的第一回合 | 從 prefix 分岔 | 全價 |
| 側路徑(推播進來的、排程跑的) | 從 prefix 分岔 | 全價 |
| 另一個 agent 共用同一份 prefix | 從 prefix 分岔 | 全價 |
單一對話流的系統永遠不會發現這件事,因為它每一通都是延伸。一旦有第二條路徑從同一個頭岔出去,就整片踩到。
而且 miss 是整通 miss
不是「共用多少就省多少」。Sp+A+E 已經跟 entry 共用了 Sp+A,一萬五千多個 token,但邊界對不上,這通就從第一個 token 開始全部重算。差一格跟差一整串,帳單一樣。
標上 explicit breakpoint 之後
[工具定義 + Sp]▮[各 path 自己的東西]
↑ explicit breakpoint:entry 到這裡結束
存下的 entry:兩個 —— 一個在 breakpoint 結束,一個在最後一則訊息之前結束
Sp + D → 拿回 Sp
Sp + A + E → 拿回 Sp(A 那塊還是全價,除非 A 後面也標一個)
| 情況 | 結果 |
|---|---|
| breakpoint 掛在第 0 則訊息(Sp) | 每條 path 都撈得到那段 |
| breakpoint 掛在更後面 | 標到只有某一條 path 有的邊界,等於白標 |
| mode 設成 explicit | implicit 那個被取消,等於用延伸換分支 |
| 會不會每次寫兩份 | 不會,實測分支那通只多寫 7 個 token |
模式留在預設的 implicit。文件寫:
With
implicit, OpenAI creates one implicit breakpoint and writes up to the latest three explicit breakpoints in the request. Withexplicit, OpenAI does not create an implicit breakpoint and writes up to the latest four explicit breakpoints.
第三句:有個欄位我一直在送,但它不生效
For GPT-5.6 models and later model families, the only supported value is
30m, which is also the default.
程式裡寫死的 PromptCacheRetention: 24h 是舊模型的功能。新家族只吃 30m,而且這個欄位整個被標記為 deprecated,取代它的是 prompt_cache_options.ttl。
正式環境的請求一直沒因此失敗,所以它要麼被忽略、要麼被寬容。不管哪一種,它都沒有延長 cache 壽命。更麻煩的是我還照著它推理,一直以為 cache 活一整天,前面那個「衰減曲線跟設定不符」的困惑就是這樣來的。
順帶一提,社群論壇上有人回報一模一樣的形狀:多代理系統裡 orchestrator 從靜態 prefix 分岔出去,命中率 0;同一套設定下其他做延伸的 agent 命中 71–89%。他 SHA256 驗過 prefix 位元相同、cache key 也設了。那串沒有官方回覆。另外有一串標題直接叫「Prompt Caching Is a Core GPT-5.6 Feature. Why Are Customers Still Reverse-Engineering It?」。
改完之後
先升 SDK:當時用的版本這三個欄位一個都沒有,往上跳七個小版本才有(v3.39.0 → v3.46.0)。升級零編譯錯誤,只有兩個測試檔要補新參數。
改動三個地方:
// 路由鍵:對第 0 則訊息加工具名做 hash。
// 共用 prefix 的 path 自然算出同一把 key;有自己 prefix 的 path 自然分開。
params.PromptCacheKey = openai.String(promptCacheKey(msgs, tools))
// cache 政策,取代已 deprecated 的 retention
params.PromptCacheOptions = responses.ResponseNewParamsPromptCacheOptions{Ttl: "30m"}
// explicit breakpoint 掛在第 0 則訊息上
toResponsesInput(msgs, cacheBreakpointIndex(msgs))
breakpoint 那個要多做一步。原本組訊息用的是吃字串的建構子,掛不了屬性,得改成內容列表的形式:
func breakpointMessage(text string, role responses.EasyInputMessageRole) responses.ResponseInputItemUnionParam {
content := responses.ResponseInputMessageContentListParam{{
OfInputText: &responses.ResponseInputTextParam{
Text: text,
PromptCacheBreakpoint: responses.NewResponseInputTextPromptCacheBreakpointParam(),
},
}}
return responses.ResponseInputItemParamOfMessage(content, role)
}
驗證用前面那支重現測試,但加了一個關鍵細節:先寫一行帶奈秒時間戳的資料進去,強迫造出一份沒有任何先前執行可能弄熱過的冷 prefix。沒有這一步,測試會回報假的通過。
| 改前 | 改後 | |
|---|---|---|
| main 冷開 | 0% | 0%(本來就該是) |
| main 延伸 | 99.9% | 99.9%(沒退步) |
| capture 首通 | 0% | 96.5% |
但是哪一個修好的?
兩個東西一起上、0% → 96.5%,這句話沒有講出是誰的功勞。所以把正式環境組請求的那個函式複製一份,兩個 cache 機制改成參數,其他全部釘死:
func abParams(p *OpenAIProvider, msgs []Message, tools []ToolSpec,
useKey, useBreakpoint bool) responses.ResponseNewParams {
bp := -1
if useBreakpoint {
bp = cacheBreakpointIndex(msgs) // breakpoint 掛在第 0 則訊息
}
params := responses.ResponseNewParams{
Input: responses.ResponseNewParamsInputUnion{
OfInputItemList: toResponsesInput(msgs, bp),
},
// model / store / verbosity / reasoning / ttl 全部跟正式環境一樣
}
if useKey {
params.PromptCacheKey = openai.String(promptCacheKey(msgs, tools))
}
return params
}
四種組合,每種送三通:
MAIN = [Sp][短的 day_state][user] ← 冷開,寫進 cache
↓ 等 3 秒
CAPTURE = [Sp][2,704 字元的卡片][user] ← 岔在 Sp(正式環境的形狀)
↓
LATE = [Sp][同一個 day_state][別句 user] ← 岔在 Sp+A(額外加的對照)
LATE 不是正式環境的形狀,是拿來驗「共用得更多一格會不會有差」的。
兩個防污染的設計:每一格用自己的 prefix(尾巴接上帶奈秒 runID 的標記),四種設定讀不到彼此的 entry;整個矩陣跑兩輪,因為沒有 key 的時候路由是盡力而為,單次結果是噪音。
| 設定 | CAPTURE(岔在 Sp) | LATE(岔在 Sp+A) |
|---|---|---|
| 兩個都不開 | 0 / 15,650(0%) | 99.9% |
只開 prompt_cache_key | 0 / 15,646(0%) | 99.9% |
| 只開 breakpoint | 15,134(96.7%) | 99.9% |
| 兩個都開(出貨版) | 15,137(96.7%) | 99.9% |
兩輪數字完全一致。是 breakpoint 修好的;在這支 rig 裡,prompt_cache_key 一個 token 都沒貢獻。 而且命中的量停在 15,134,而 MAIN 整通是 15,171 —— 差的那 37 個是 MAIN 自己的 day_state 加 user 訊息,沒多吃到。
這也把前面那個「機器親和性」解釋推翻了:main 健康不是因為它一直黏在同一台,是因為它每一通都是延伸、剛好蓋得住 implicit entry。
prompt_cache_key 還是留著,但要誠實:這支 rig 對它太仁慈 —— 同一個行程、間隔三秒、當下流量很低,兩通本來就容易落在同一台機器。它要解的是隔幾小時、跨 path、中間夾著別人流量那種路由發散,得看正式環境的數字才知道。所以正確的講法是「在這個情境下量不到效果」,不是「它沒用」。
那個「矛盾」其實不是矛盾
假設三那支小腳本,沒有 breakpoint,分支照樣命中 99.9%;A/B 的「兩個都不開」那格,同樣沒有 breakpoint,分支卻是 0%。
差別不在有沒有 breakpoint,在訊息則數:
| 寫入的形狀 | 則數 | entry 止於 | 哪個岔點會中 |
|---|---|---|---|
[Sp][user] | 2 | Sp | 岔在 Sp |
[Sp][A][user] | 3 | Sp+A | 岔在 Sp+A |
[Sp][A][B][user] | 4 | Sp+A+B | 岔在 Sp+A+B |
那支小腳本只有兩則訊息,entry 止於 Sp 本身,而它的分支正好岔在 Sp,所以命中。真實的 capture 那邊 MAIN 有三則以上,entry 止於 Sp+A,capture 岔在 Sp,早了一格,所以 0。
我從頭到尾在比較兩種訊息則數不同的形狀。
這一切查得動,是因為每一通都記帳
上面所有數字都來自同一份 calls.jsonl。這套東西一年多前就在了,拆四層講。
收:provider 交出四個數字
每次呼叫結束,provider 層把 usage 原封不動往上交:prompt / cached / written / output。這一層不算錢、不猜、不四捨五入。上面任何一層想知道花了多少,都只能從這四個數字推。
這次排查順便發現 written 一直沒接 —— SDK 的 InputTokensDetails.CacheWriteTokens 有給,我們的 Usage struct 沒收。
存:一通一筆,append-only 的 JSON Lines
一次模型呼叫一筆,不是一個回合一筆。 這是整套設計最關鍵的決定。一個回合可能呼叫模型六次(工具來回幾輪),只記回合總和的話,「回合首通命中率」這個問題根本問不出來 —— 而那正好是整篇的切入點。
每一筆帶的欄位:
| 欄位 | 幹嘛的 |
|---|---|
prompt_tokens / cached_tokens / cache_write_tokens / completion_tokens | 四個原始數字 |
iter | 這是回合裡的第幾通,0 = 首通(只有 main 會遞增,這是個缺口) |
origin | 哪條 path |
model | 哪顆模型服務的 |
history_start | 對話從哪一則開始,清空時會跳 |
標籤必須在寫入當下打好,事後補不回來。 這一波排查同時碰到這條規則的兩面。
正面是 history_start。假設七(capture 進來時 system prefix 是不是剛被重建)能直接用資料回答,就是因為它當初有記。
反面有兩個。origin 是後來才加的,前面七百多筆沒有,那段資料現在只能整段丟掉。而 iter 只有 main 會遞增 —— 側路徑每一筆都寫 0,所以「這是那個回合的第幾通」這個問題,在側路徑上只能繼續用時間分組猜。這一次猜得安全(側路徑回合間隔很久),下一次不一定。
算:成本不存,讀的時候才算
每一筆只存 token,不存金額。花多少是讀取時拿一張 model → 費率的表算出來的:
type rates struct{ inputPerM, cachedPerM, outputPerM, writeMult float64 }
func cost(input, cached, written, output int, r rates) float64 {
return float64(input-cached)/1e6*r.inputPerM +
float64(cached)/1e6*r.cachedPerM +
float64(written)/1e6*r.inputPerM*(r.writeMult-1) +
float64(output)/1e6*r.outputPerM
}
好處是費率改了不用動資料:換模型就是在 map 加一行,過去的紀錄自動照新費率讀。
但這次有一個它救不到的地方,值得講清楚:written 這個欄位是這次才接的,舊紀錄裡沒有這個數字,讀回來是 0。所以兩週的歷史並不會因為補上 writeMult 就自動變貴 —— 下面那個重算後的月帳,是拿「未 cache 的輸入量」當寫入量估出來的,不是量出來的。真正準的帳要等新紀錄累積。
這就是上一段那句話的反面:沒收的欄位,事後只能估。
撈:一次掃描就能回答
因為是 append-only 的 JSON Lines 加齊全的標籤,每個問題都是十行以內的 Python:
rows = [json.loads(l) for l in open('calls.jsonl')]
# 1. 每條 path 的整體命中率 → 用 origin 分組
# 2. 回合首通 vs 後續通 → 用 iter == 0 切
# 3. 按「距上一回合多久」分桶 → 用相鄰 iter==0 的時間差
沒有 iter 就得用時間差猜回合邊界,而那正是前面出錯的地方。能問什麼問題,是寫入當下就決定的。
GPT-5.6 之後,算錢要多算一項
5.5 以前:cache 寫入免費,你只付「未 cache 的輸入 + 讀到 cache 的輸入 + 輸出」。
5.6 之後:
For GPT-5.6 models and later model families, cache writes cost 1.25× the uncached input token rate.
舉例。一段 10,000 token 的 prefix,未 cache 輸入價假設 $1 / 百萬 token:
| 5.5 以前 | 5.6 之後 | |
|---|---|---|
| 第一次送(寫入 cache) | $0.010 | $0.0125 |
| 第二次送(讀到 cache) | $0.001 | $0.001 |
| 兩次合計 | $0.011 | $0.0135 |
貴的只有第一次,多 25%;讀取一樣是 0.1 倍。所以那份 cache 只要被重用 0.28 次以上就回本 —— 換句話說,第一次被重用就已經賺了。
實測三種形狀:
| prompt | cached | written | 輸入成本 | 完全不用 cache | |
|---|---|---|---|---|---|
| 冷開 | 18,494 | 0 | 18,491 | $0.02312 | $0.01849 |
| 分支讀取 | 13,041 | 13,031 | 7 | $0.00131 | $0.01304 |
| 重複讀取 | 18,494 | 18,491 | 0 | $0.00185 | $0.01849 |
中間那列回答了一個擔心:加了 explicit breakpoint 會不會變成每次寫兩份?不會,分支那通只寫了 7 個 token。
計價函式有個坑:寫入的 token 已經在「未 cache 輸入」那一項按全價付過一次了,所以只能加差額 0.25 倍(程式裡的 r.writeMult-1),收滿 1.25 會重複計費。補了兩個方向的單元測試釘住。
補上這一項之後重算兩週:帳面 $23.18/月 → 實際約 $26.65/月,一直漏記 $3.47/月。如上一節所說,舊紀錄沒有寫入量,這個數字是用未 cache 輸入量估的上界。
最後的數字
把兩週的首通 miss 拆開,才知道這次改動實際能拿回多少:
| 類型 | 通數 | 兩週可省 | breakpoint 救得到嗎 |
|---|---|---|---|
| 距上次超過 30 分鐘 | 92 | $3.28 | 不確定,30 分鐘是保證下限 |
| 重設後第一回合(system prefix 本身重建過) | 55 | $1.33 | 救不到 |
| 同 prefix、30 分鐘內(純路由) | 9 | $0.22 | 救得到 |
合計 156 通、$4.83 兩週,換算約 $10.35/月,佔總帳 39%。這是上限 —— 三類裡只有第三類確定救得到。
結論
機制:cache 命中要三件事同時成立 —— prefix 位元對得上、請求送到存著那份 cache 的機器、你要的那段落在一個可命中的邊界上。不標 breakpoint 的話第三件由 API 決定,而那個邊界落在「最後一則訊息之前」。
誰會踩到:單一對話流的系統不會,它每一通都是延伸。有第二條路徑從同一段 prefix 分岔出去的系統會整片踩到 —— 清空後首通、側路徑、共用 prefix 的另一個 agent —— 而且是整通全價。
怎麼修:共用 prefix 的結尾標一個 explicit breakpoint,mode 留 implicit,prompt_cache_key 送 prefix 的 hash,別再用 prompt_cache_retention。
排查上的四條:
- 一次只改一個變因。兩個欄位一起上,0% → 96.5% 沒有指出功勞歸誰;拆開測才知道 routing key 在這個情境下一個 token 都沒貢獻。
- 簡化的測試會說謊。那支只有兩則訊息的腳本四種變體全綠,而它跟真實請求的唯一差別 —— 訊息則數 —— 正好是決定性的維度。
- 統計切法先確認再下結論。用時間間隔猜回合邊界,基準線整個歪掉;資料裡本來就有
iter。 - 測 cache 的 rig,每次執行要用不同的冷 prefix。固定標記會讀到上次執行留下的 entry,整張表 100% 假通過。驗收條件:寫入那通必須
cached=0且written>0。