你的 LLM 閘道在快取上說謊了嗎?5 分鐘稽核
目錄
閘道位於你的程式碼與模型供應商之間。你從回應裡讀回 cached_tokens,看到一個較小的數字,於是相信省下的錢是真實的。但你永遠看不到上游那次呼叫。閘道可以報告一次快取命中,卻仍按全額輸入費率計費。它可能在一個看起來完美乾淨的回應背後根本沒有快取。它可能在串流傳輸(也就是你大部分正式環境流量所走的路徑)上剝離 usage 中繼資料,讓你兩頭都無從判斷。
這不是假設。一篇 Hacker News PSA 報告稱,把 DeepSeek V4 透過一個熱門的閘道路由,回傳的快取權杖比直接呼叫 DeepSeek 少 2–3 倍;一位留言者貼出帳單,顯示快取統計根本沒有透過閘道回報。該閘道團隊回覆說他們無法重現,正在調查。這種分歧正是關鍵所在。當兩方對你的快取是否生效都無法達成一致時,唯一的仲裁就是你自己跑過的一次測量。
通常這不是惡意,而是一處轉譯落差或一條未完成的程式碼路徑。但無論哪種情況,對你帳單的影響都是一樣的。本文給出一個可直接執行的指令稿,用它來對任意閘道(包括本閘道)稽核兩種風格的提示快取:自動快取(DeepSeek)和以標記為基礎的快取(Claude)。它會在五分鐘內印出一張並排的計分卡。
閘道在快取上說謊的四種方式
| 失敗模式 | 你看到的 | 實際發生的 |
|---|---|---|
| 靜默無快取 | 乾淨的回應,沒有錯誤 | 什麼都沒快取;你每次呼叫都付全價 |
| 快取表演 | 回應中 cached_tokens > 0 | ……但計費成本是全額輸入費率 |
| 加價蠶食 | 一個看似合理的成本數字 | 閘道的加價悄悄吃掉了折扣 |
| 中繼資料黑屏 | 乾淨的文字輸出 | usage 欄位被剝離(尤其在串流上),你無法稽核 |
最危險的是前兩種:回應看起來像快取在正常運作。你直到月底才會發現真相。
兩種快取機制,一次稽核
供應商以兩種形態揭露快取,而一個真正的閘道必須忠實地把兩者都透傳:
- 自動(DeepSeek、GPT、Gemini、Qwen):供應商會自行快取任何足夠長的前綴。沒有標記。命中出現在
usage.prompt_tokens_details.cached_tokens中。 - 以標記為基礎(Anthropic Claude):你用
cache_control標記可快取的片段。命中出現為cache_read_input_tokens。
指令稿用一個輕量的 Lane 配接器把這種差異藏起來,然後對兩者跑全部五項檢查。下面就是全部內容:兩條 lane 和一個執行每項檢查的 audit()。
import os, time, uuid
from openai import OpenAI
from anthropic import Anthropic
KEY = os.environ["GATEWAY_KEY"]
oai = OpenAI(api_key=KEY, base_url="https://synthorai.io/v1") # auto lane
anth = Anthropic(api_key=KEY, base_url="https://synthorai.io/") # marker lane
class AutoLane: # DeepSeek / GPT / Gemini / Qwen: provider caches automatically
mode = "auto"
def __init__(self, model): self.model = model
def call(self, sys, q, stream=False):
if stream:
cached = cost = None
s = oai.chat.completions.create(model=self.model, max_tokens=48, stream=True,
stream_options={"include_usage": True},
messages=[{"role":"system","content":sys},{"role":"user","content":q}])
for ev in s:
if ev.usage:
d = ev.usage.prompt_tokens_details
cached, cost = (d.cached_tokens if d else None), getattr(ev.usage,"cost",None)
return {"cached": cached or 0, "cost": cost, "prompt_total": None}
u = oai.chat.completions.create(model=self.model, max_tokens=48,
messages=[{"role":"system","content":sys},{"role":"user","content":q}]).usage
cached = u.prompt_tokens_details.cached_tokens if u.prompt_tokens_details else 0
return {"cached": cached or 0, "cost": u.cost, "prompt_total": u.prompt_tokens}
class MarkerLane: # Anthropic Claude: explicit cache_control markers
mode = "marker"
def __init__(self, model): self.model = model
def call(self, sys, q, stream=False):
block = {"type":"text","text":sys,"cache_control":{"type":"ephemeral"}}
if stream:
with anth.messages.stream(model=self.model, max_tokens=48, system=[block],
messages=[{"role":"user","content":q}]) as s:
for _ in s.text_stream: pass
u = s.get_final_message().usage.model_dump()
return {"cached": u.get("cache_read_input_tokens") or 0,
"cost": u.get("cost"), "prompt_total": None}
u = anth.messages.create(model=self.model, max_tokens=48, system=[block],
messages=[{"role":"user","content":q}]).usage.model_dump()
read, created = u.get("cache_read_input_tokens",0), u.get("cache_creation_input_tokens",0)
return {"cached": read, "cost": u.get("cost"),
"prompt_total": u.get("input_tokens",0) + read + created}
def audit(lane, long_prompt):
SYS = f"[audit {uuid.uuid4().hex}]\n\n" + long_prompt # unique => guaranteed cold start
r = {"lane": lane.model, "mode": lane.mode}
# CHECK 1: cache engages. Cold misses; a repeat should hit. A cache can
# take a moment to become readable, so poll the warm read (sleep 1s between
# attempts) before concluding "no cache".
cold = lane.call(SYS, "Q1")
warm = cold
for i in range(4):
warm = lane.call(SYS, f"warm {i}")
if warm["cached"] > 0: break
time.sleep(1.0)
r["cold"], r["warm"] = cold, warm
r["check1"] = cold["cached"] == 0 and warm["cached"] > 0
# CHECK 2: cost reflects the discount (catches "cache theater").
disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["discount"], r["check2"] = disc, (disc is not None and disc > 30)
# CHECK 3: token accounting. cached fits inside the prompt total.
r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
# CHECK 4: streaming preserves usage metadata (cache count AND cost).
st = lane.call(SYS, "stream", stream=True)
r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
r["check4"] = r["stream_cached"] and r["stream_cost"]
# CHECK 5: negative control. a unique prefix must always miss.
n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
return r
# Any long, STABLE text works as the cacheable prefix: a system prompt, tool
# schemas, or a retrieved document. It only needs to clear the provider's
# minimum cacheable size (see Check 1). Load yours however you like.
LONG_SYSTEM_PROMPT = open("system_prompt.txt").read() # ~8K+ tokens
for lane in [AutoLane("deepseek-v4-flash"), MarkerLane("claude-opus-4-8")]:
print(audit(lane, LONG_SYSTEM_PROMPT))
本文餘下部分逐一講解每項檢查:實作它的程式碼行、兩條 lane 各自回傳了什麼,以及如何解讀結果。
檢查 1:快取是否生效?
cold = lane.call(SYS, "Q1")
warm = cold
for i in range(4): # poll: a cache may take a beat to be readable
warm = lane.call(SYS, f"warm {i}")
if warm["cached"] > 0: break
time.sleep(1.0)
r["check1"] = cold["cached"] == 0 and warm["cached"] > 0
| cold cached | warm cached | 結果 | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | PASS |
claude-opus-4-8 | 0 | 12,446 / 12,454 (99.9%) | PASS |
對一個唯一前綴的冷呼叫必須什麼都不快取;重複呼叫則必須命中。最常見的單一誤報是在一次溫呼叫之後就宣稱「無快取」,因為快取並不總是立即可讀。這個迴圈帶 1 秒間隔輪詢幾次,消除了這種偶發性。如果你的提示已經超過尺寸下限(大多數供應商約 1,024 個權杖;DeepSeek 以更細的 64 權杖粒度對齊),卻在幾次溫呼叫後仍然得到 0,那麼快取確實沒有生效。
檢查 2:成本是否反映了折扣?
disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["check2"] = disc is not None and disc > 30
| cold cost | warm cost | 折扣 | 結果 | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.6% | PASS |
這是抓出快取表演的那項檢查。溫呼叫的成本必須真的下降。DeepSeek 的每次呼叫總額下降了約 72%(快取輸入的折扣更陡峭;輸出和未快取的剩餘部分稀釋了這個表面數字)。Claude 的快取讀取打了約 1 折。失敗訊號一目了然:cached_tokens > 0 卻伴隨完全相同的冷、溫成本,意味著閘道報告了一次它並未據此定價的命中。你正在為一個紙面上「能用」的快取付全價。
檢查 3:權杖計數對得上嗎?
r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
| cached | prompt total | 結果 | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
cached 必須落在 prompt total 之內,剩餘部分作為未快取輸入計費。兩者都對得上。如果 cached_tokens 超過了 prompt_tokens,或者對一個穩定前綴來說未快取的剩餘部分大得不合常理,那麼閘道在記帳上出了錯:在轉譯過程的某處重新斷詞或重複計數了。
檢查 4:串流是否保留了中繼資料?
st = lane.call(SYS, "stream", stream=True)
r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
r["check4"] = r["stream_cached"] and r["stream_cost"]
| stream cached | stream cost | 結果 | |
|---|---|---|---|
deepseek-v4-flash | 保留 | 保留 | PASS |
claude-opus-4-8 | 保留 | 保留 | PASS |
大多數正式環境的聊天都是串流的,所以這是最要緊的路徑。在兩條 lane 上,快取命中訊號和成本都挺過了串流傳輸。cached_tokens 和 cost 都在最後一個 usage 區塊裡回傳,所以你流量最大的路徑仍然可稽核。要警惕的失敗模式是閘道在串流上丟棄 usage:乾淨的權杖輸出卻沒有 cached_tokens 或 cost,意味著你在跑得最多的路徑上兩眼一抹黑。(要傳 stream_options={"include_usage": True},usage 區塊才會被發出。)
檢查 5:陰性對照
n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
| 唯一前綴 A | 唯一前綴 B | 結果 | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
每次呼叫都傳送一個唯一前綴;它絕不能命中。兩條 lane 都正確地對不同前綴報告了 cached=0 並按全價計費。這裡若出現「命中」,就會讓快取報告成為一個你永遠無法信任的偽陽性。正是這個乾淨的陰性對照,才讓檢查 1–2 中的陽性結果在一開始就具有意義。
解讀你的計分卡
| 檢查 | 健康的結果 | 危險訊號 |
|---|---|---|
| 1. 快取生效 | 冷為 0,溫(輪詢後)>0 | 超過尺寸下限後,幾次溫呼叫仍為 0 |
| 2. 成本反映折扣 | 溫成本 ≪ 冷成本 | cached > 0 但成本相等 |
| 3. 權杖記帳 | cached ≤ prompt_total,對得上 | 計數對不上 |
| 4. 串流中繼資料 | 快取 + 成本挺過串流 | 串流呼叫上 usage 缺失 |
| 5. 陰性對照 | 唯一前綴總是未命中 | 一個不同前綴「命中」了 |
會悄悄花錢的是 2(為一次被報告的命中付全價)和 1(乾淨回應背後毫無快取)。對你每個要計費的模型都把這兩項跑一遍。
結語
快取是 LLM 應用裡槓桿率最高的成本調節項,這恰恰是「快取在運作」值得測試、而不該想當然的原因。把檢查 1 + 檢查 2 接入 CI,對你每個要計費的模型都跑,並在折扣偏離你預期區間時告警,那麼當閘道或上游供應商改變行為的當天,你就能抓到一次靜默回歸,而不是等到帳單週期結束。而無論你的稽核怎麼做,在你判定快取壞掉之前,先輪詢溫讀取。
想了解這些數字背後的機制(預填、KV 快取、TTL),從 KV 快取與 TTL 如何運作 開始。想看各供應商可用的快取模式,請參閱教學。
常見問題
我的檢查 1 在溫呼叫上顯示 0。是我的閘道在說謊嗎?
先檢查三件事。(1) 你的提示是否超過供應商的最小可快取尺寸(大多數約 1,024 個權杖;DeepSeek 以更細的 64 權杖粒度對齊)?(2) 你輪詢了溫讀取幾次嗎?快取並不總是在緊接著的下一次呼叫就變得可讀。(3) 各次呼叫之間前綴是否逐位元組相同,前面沒有時間戳或每請求 ID?只有在這三點都滿足之後,你才應該懷疑閘道。
「快取表演」在實際中會讓我付出什麼代價? 你在每次呼叫上都付全額輸入費率,卻以為只付了一個零頭。在一個帶大型穩定前綴的高流量端點上,那就意味著你的帳單是你模型估算的好幾倍。檢查 2 是值得告警的那一項。
為什麼這裡 DeepSeek 的折扣比 Claude 低? 測量的東西不一樣。Claude 的約 90% 是快取輸入上的讀取折扣。DeepSeek 的約 72% 是每次呼叫總額的下降,其中輸出和未快取的剩餘部分按全價計費,稀釋了表面數字。請按你自己的提示形態拿同類比較同類。
這對 GPT、Gemini、Qwen 也適用嗎?
適用。它們全都是自動快取,所以原封不動地使用 AutoLane,只換一個不同的 model。只有 Claude 需要 MarkerLane。兩種方式都跑同樣的五項檢查。
這該放進 CI 嗎? 該。對你每個要計費的模型,定期跑檢查 1 + 檢查 2,並在觀測到的折扣偏出你的預期區間時告警。一個常駐的稽核能把一次靜默回歸變成一條通知。