你的 LLM 閘道在快取上說謊了嗎?5 分鐘稽核

目錄
  1. 閘道在快取上說謊的四種方式
  2. 兩種快取機制,一次稽核
  3. 檢查 1:快取是否生效?
  4. 檢查 2:成本是否反映了折扣?
  5. 檢查 3:權杖計數對得上嗎?
  6. 檢查 4:串流是否保留了中繼資料?
  7. 檢查 5:陰性對照
  8. 解讀你的計分卡
  9. 結語
  10. 常見問題

閘道位於你的程式碼與模型供應商之間。你從回應裡讀回 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 cachedwarm cached結果
deepseek-v4-flash07,552 / 7,870 (96%)PASS
claude-opus-4-8012,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 costwarm cost折扣結果
deepseek-v4-flash$0.00107$0.0003072.3%PASS
claude-opus-4-8$0.07112$0.0067290.6%PASS

這是抓出快取表演的那項檢查。溫呼叫的成本必須真的下降。DeepSeek 的每次呼叫總額下降了約 72%(快取輸入的折扣更陡峭;輸出和未快取的剩餘部分稀釋了這個表面數字)。Claude 的快取讀取打了約 1 折。失敗訊號一目了然:cached_tokens > 0 卻伴隨完全相同的冷、溫成本,意味著閘道報告了一次它並未據此定價的命中。你正在為一個紙面上「能用」的快取付全價。


檢查 3:權杖計數對得上嗎?

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

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 cachedstream cost結果
deepseek-v4-flash保留保留PASS
claude-opus-4-8保留保留PASS

大多數正式環境的聊天都是串流的,所以這是最要緊的路徑。在兩條 lane 上,快取命中訊號和成本都挺過了串流傳輸。cached_tokenscost 都在最後一個 usage 區塊裡回傳,所以你流量最大的路徑仍然可稽核。要警惕的失敗模式是閘道在串流上丟棄 usage:乾淨的權杖輸出卻沒有 cached_tokenscost,意味著你在跑得最多的路徑上兩眼一抹黑。(要傳 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-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

每次呼叫都傳送一個唯一前綴;它絕不能命中。兩條 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,並在觀測到的折扣偏出你的預期區間時告警。一個常駐的稽核能把一次靜默回歸變成一條通知。

← 返回部落格