🎁 新用戶 免費註冊,送 10 次呼叫,最高 $1,免綁卡。
你的 LLM 閘道有沒有謊報快取?5 分鐘完成稽核

你的 LLM 閘道有沒有謊報快取?5 分鐘完成稽核

目錄
  1. 閘道謊報快取的四種方式
  2. 兩種快取機制,一套稽核方法
  3. 檢查 1:快取是否啟用?
  4. 檢查 2:成本是否反映折扣?
  5. 檢查 3:token 數量能否對上?
  6. 檢查 4:串流是否保留中繼資料?
  7. 檢查 5:負向對照
  8. 如何解讀檢查結果
  9. 結語
  10. 常見問題

閘道位於你的程式碼與模型供應商之間。回應裡的 cached_tokens 數字變小了,你便相信確實省到了錢。但你看不到送往上游的請求。閘道可能回報快取命中,卻仍按完整輸入費率計費。也可能根本沒有快取,只是回應看起來一切正常。甚至可能在串流時移除 usage 中繼資料,而大多數正式環境流量走的正是這條路,讓你無從判斷快取到底有沒有生效。

TL;DR

  • Hacker News 上的一則 PSA 指出,透過某個熱門閘道呼叫 DeepSeek V4,回傳的快取 token 比直接呼叫 DeepSeek 少了 2-3 倍。
  • 一支可直接執行的腳本會做五項檢查:快取是否啟用、成本是否真的下降、token 數量能否對上、串流是否保留 usage 中繼資料,以及負向對照是否維持未命中。
  • 經 Synthorai 閘道稽核,deepseek-v4-flash 的暖快取命中率為 96%,單次呼叫成本降低 72.3%;claude-opus-4-8 則分別為 99.9% 與 90.6%。
  • cached_tokens > 0,但冷快取與暖快取成本完全相同,代表閘道雖回報命中,計費卻沒有套用快取價格。

這不是假設情境。Hacker News 上的一則 PSA 指出,透過某個熱門閘道呼叫 DeepSeek V4,回傳的快取 token 比直接呼叫 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))

接下來會逐項說明:實作各項檢查的程式碼、兩條路徑各自回傳的結果,以及結果該如何解讀。


檢查 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
冷快取 cached暖快取 cached結果
deepseek-v4-flash07,552 / 7,870(96%)通過
claude-opus-4-8012,446 / 12,454(99.9%)通過

使用唯一前綴的冷呼叫不應命中任何快取;再次呼叫則必須命中。最常見的誤判是只做一次暖呼叫,就認定「沒有快取」,因為快取不一定能立刻讀取。這段迴圈每隔 1 秒輪詢一次,可避免結果不穩定。如果 prompt 已超過最低快取大小(多數供應商約為 1,024 個 token;DeepSeek 能以更細的 64 個 token 為單位比對),連續幾次暖呼叫後仍得到 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
冷呼叫成本暖呼叫成本折扣結果
deepseek-v4-flash$0.00107$0.0003072.3%通過
claude-opus-4-8$0.07112$0.0067290.6%通過

這項檢查用來抓出假快取。暖呼叫的成本必須確實下降。DeepSeek 的單次呼叫總成本降低約 72%(快取輸入的折扣更大,但輸出與未快取部分會稀釋整體降幅)。Claude 的快取讀取折扣約為 90%。失敗訊號非常明確:若 cached_tokens > 0,但冷呼叫與暖呼叫的成本完全相同,代表閘道雖然回報命中,計費時卻沒有套用快取價格。紙面上的快取看似「有效」,你付的仍是原價。


檢查 3:token 數量能否對上?

r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
cachedprompt 總數結果
deepseek-v4-flash7,5527,870通過
claude-opus-4-812,44612,454通過

cached 必須包含在 prompt 總數內,其餘部分按未快取輸入計費。兩條路徑的數字都能對上。如果 cached_tokens 超過 prompt_tokens,或穩定前綴的未快取部分大得不合理,代表閘道的計數有誤:可能在格式轉換過程中重新 tokenization,或某處重複計數。


檢查 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"]
串流 cached串流成本結果
deepseek-v4-flash已保留已保留通過
claude-opus-4-8已保留已保留通過

大多數正式環境中的聊天功能都採用串流,因此這條路徑最關鍵。兩條路徑在串流時都保留了快取命中訊號與成本。cached_tokenscost 會出現在最後一個 usage chunk,因此流量最高的路徑仍可稽核。需要留意的是閘道在串流時丟棄 usage:如果 token 輸出正常,卻沒有 cached_tokenscost,就無法掌握最常用路徑的快取情況。(請傳入 stream_options={"include_usage": True},否則 usage chunk 根本不會送出。)


檢查 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-flashcached 0cached 0通過
claude-opus-4-8cached 0cached 0通過

每次呼叫都傳入不同的唯一前綴,結果絕不能命中。兩條路徑面對不同前綴時,都正確回報 cached=0,並按原價計費。如果這裡出現「命中」,代表快取回報有偽陽性,完全不可信。先確認負向對照結果正確,檢查 1–2 的正向結果才有意義。


如何解讀檢查結果

檢查正常結果警訊
1. 快取啟用冷呼叫為 0,暖呼叫為 >0(經過輪詢)超過最低大小,連續多次暖呼叫後仍為 0
2. 成本反映折扣暖呼叫成本 ≪ 冷呼叫成本cached > 0,但成本相同
3. token 計數cached ≤ prompt_total,數量能對上計數無法對上
4. 串流中繼資料快取與成本都能通過串流保留下來串流呼叫缺少 usage
5. 負向對照唯一前綴永遠不命中不同前綴卻「命中」

會默默增加成本的是 2(回報命中卻按原價收費)和 1(回應正常但完全沒有快取)。每個會產生費用的模型都應執行這兩項檢查。


結語

在 LLM 應用程式中,快取是降低成本最有效的手段之一。正因如此,「快取有正常運作」必須經過測試,不能只靠假設。把檢查 1 和檢查 2 納入 CI,對每個會產生費用的模型執行;如果折扣低於預期區間,就發出警示。這樣一來,無論是閘道或上游供應商變更行為,都能在發生無聲退化的當天抓到,不必等到帳單週期結束。無論稽核如何實作,在判定快取故障前,務必輪詢暖快取讀取結果

若想了解這些數字背後的機制(prefill、KV 快取、TTL),可先閱讀 KV 快取與 TTL 的運作原理。各供應商可直接使用的快取模式,請參考這份教學


常見問題

檢查 1 的暖呼叫顯示 0。我的閘道在謊報嗎? 先確認三件事。(1)你的 prompt 是否超過供應商的最低快取大小(多數約為 1,024 個 token;DeepSeek 能以更細的 64-token 粒度比對)?(2)你是否對暖快取讀取結果輪詢了幾次?快取不一定在下一次呼叫時就能讀取。(3)每次呼叫的前綴是否逐位元組完全相同,開頭沒有時間戳記或單次請求 ID?三項都確認過後,才應懷疑閘道有問題。

「假快取」實際上會讓我多付多少錢? 你以為只需支付一小部分,實際上每次呼叫都按完整輸入費率計費。對於流量高、穩定前綴又大的端點,帳單可能是預估值的好幾倍。應針對檢查 2 設定警示。

為什麼這裡 DeepSeek 的折扣比 Claude 低? 兩者量測的項目不同。Claude 的約 90% 指的是快取輸入的讀取折扣。DeepSeek 的約 72% 則是單次呼叫總成本的降幅,其中輸出與未快取部分仍按原價計費,因此會稀釋整體降幅。請依自己的 prompt 結構,在相同基準下比較。

這套方法也適用於 GPT、Gemini、Qwen 嗎? 適用。它們都採用自動快取,只要替換 model,不必修改 AutoLane。只有 Claude 需要使用 MarkerLane。無論是哪種機制,五項檢查都相同。

應該把這套稽核放進 CI 嗎? 應該。定期對每個會產生費用的模型執行檢查 1 和檢查 2;觀測到的折扣超出預期區間時,就發出警示。持續稽核能把無聲退化變成即時通知。

← 返回部落格