Lügt dein LLM-Gateway beim Cache? Ein 5-Minuten-Audit
Inhalt
- Vier Arten, wie ein Gateway beim Cache falsche Angaben machen kann
- Zwei Cache-Mechanismen, ein Audit
- Check 1: Greift der Cache?
- Check 2: Spiegelt sich der Rabatt in den Kosten wider?
- Check 3: Gehen die Token-Zahlen auf?
- Check 4: Bleiben die Metadaten beim Streaming erhalten?
- Check 5: Die Negativkontrolle
- So liest du die Auswertung
- Fazit
- FAQ
Ein Gateway sitzt zwischen deinem Code und dem Modellanbieter. In der Response steht bei cached_tokens eine kleinere Zahl, und du gehst davon aus, dass die ausgewiesene Ersparnis stimmt. Den Upstream-Call siehst du aber nie. Das Gateway könnte einen Cache-Treffer melden und trotzdem den vollen Input-Preis abrechnen. Es könnte auch überhaupt nichts cachen und dennoch eine fehlerfreie Response liefern. Oder es entfernt beim Streaming die Usage-Metadaten. Genau darüber läuft meist der Großteil des Production-Traffics, sodass du den Cache nicht mehr prüfen kannst.
TL;DR
- Laut einem Hinweis auf Hacker News lieferte ein verbreitetes Gateway bei DeepSeek V4 2-3x weniger gecachte Tokens als ein direkter Aufruf von DeepSeek.
- Ein ausführbares Skript prüft fünf Punkte: Der Cache greift, die Kosten sinken tatsächlich, die Token-Zahlen gehen auf, beim Streaming bleiben die Usage-Metadaten erhalten und eine Negativkontrolle bleibt kalt.
- Beim Audit über das Synthorai-Gateway erreichte deepseek-v4-flash im warmen Zustand eine Cache-Trefferrate von 96% und 72.3% niedrigere Kosten pro Call; bei claude-opus-4-8 waren es 99.9% und 90.6%.
- Wenn bei
cached_tokens > 0der kalte und der warme Call gleich viel kosten, meldet das Gateway einen Treffer, den es bei der Abrechnung nicht berücksichtigt.
Das ist kein theoretisches Problem. Laut einem Hinweis auf Hacker News lieferte DeepSeek V4 über ein verbreitetes Gateway 2–3× weniger gecachte Tokens als bei einem direkten Aufruf von DeepSeek. Ein Kommentator veröffentlichte Abrechnungen, aus denen hervorging, dass die Cache-Statistiken über das Gateway überhaupt nicht gemeldet wurden. Das Gateway-Team konnte das Problem nach eigenen Angaben nicht reproduzieren und untersuchte es weiter. Genau deshalb braucht es einen eigenen Audit: Wenn sich zwei Parteien nicht darüber einig sind, ob dein Cache funktioniert, entscheidet nur eine Messung, die du selbst durchgeführt hast.
Meist steckt keine Absicht dahinter, sondern eine Lücke bei der Übersetzung zwischen APIs oder ein noch nicht vollständig implementierter Codepfad. Auf der Rechnung macht das keinen Unterschied. Dieser Beitrag enthält ein ausführbares Skript, das beide Varianten des Prompt-Cachings gegen beliebige Gateways prüft, auch gegen dieses: automatisches Caching bei DeepSeek und markerbasiertes Caching bei Claude. In weniger als fünf Minuten erhältst du eine direkte Gegenüberstellung der Ergebnisse.
Vier Arten, wie ein Gateway beim Cache falsche Angaben machen kann
| Fehlerbild | Was du siehst | Was tatsächlich passiert |
|---|---|---|
| Cache bleibt unbemerkt aus | Eine fehlerfreie Response | Nichts wurde gecacht; jeder Call kostet den vollen Preis |
| Schein-Caching | cached_tokens > 0 in der Response | …aber abgerechnet wird der volle Input-Preis |
| Schleichender Aufschlag | Plausibel wirkende Kosten | Der Aufschlag des Gateways frisst unbemerkt den Rabatt auf |
| Fehlende Metadaten | Fehlerfreie Textausgabe | Usage-Felder fehlen, besonders beim Streaming; der Cache lässt sich nicht prüfen |
Am gefährlichsten sind die ersten beiden Varianten: In der Response sieht es so aus, als würde der Cache funktionieren. Den Fehler bemerkst du erst am Monatsende.
Zwei Cache-Mechanismen, ein Audit
Anbieter stellen Caching in zwei Varianten bereit. Ein zuverlässiges Gateway muss beide unverändert durchreichen:
- Automatisch (DeepSeek, GPT, Gemini, Qwen): Der Anbieter cacht selbstständig jeden ausreichend langen Präfix. Marker sind nicht nötig. Treffer erscheinen unter
usage.prompt_tokens_details.cached_tokens. - Markerbasiert (Anthropic Claude): Cachebare Abschnitte werden mit
cache_controlmarkiert. Treffer erscheinen alscache_read_input_tokens.
Das Skript kapselt diesen Unterschied in einem schlanken Lane-Adapter und führt anschließend alle fünf Checks für beide Varianten aus. Hier ist das vollständige Skript: zwei Lanes und eine zentrale Funktion audit(), die sämtliche Checks durchführt.
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))
Im weiteren Verlauf gehen wir jeden Check einzeln durch: die relevanten Codezeilen, die Ergebnisse beider Lanes und deren Auswertung.
Check 1: Greift der Cache?
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
| kalt gecacht | warm gecacht | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | BESTANDEN |
claude-opus-4-8 | 0 | 12,446 / 12,454 (99.9%) | BESTANDEN |
Ein kalter Call mit einem eindeutigen Präfix darf nichts aus dem Cache lesen. Ein wiederholter Call muss einen Treffer liefern. Der häufigste Fehlalarm entsteht, wenn nach einem einzigen warmen Call vorschnell „kein Cache“ diagnostiziert wird. Caches sind nicht immer sofort lesbar. Die Schleife fragt den Cache mehrmals im Abstand von 1 Sekunde ab und verhindert damit solche instabilen Ergebnisse. Bleibt der Wert trotz mehrerer warmer Calls bei 0, obwohl der Prompt die Mindestgröße überschreitet (~1,024 Tokens bei den meisten Anbietern; DeepSeek gleicht feiner in Blöcken von 64 ab), greift der Cache tatsächlich nicht.
Check 2: Spiegelt sich der Rabatt in den Kosten wider?
disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["check2"] = disc is not None and disc > 30
| Kosten kalt | Kosten warm | Rabatt | Ergebnis | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | BESTANDEN |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.6% | BESTANDEN |
Dieser Check erkennt Schein-Caching. Der warme Call muss tatsächlich weniger kosten. Bei DeepSeek sanken die Gesamtkosten pro Call um ~72%. Der gecachte Input wird zwar stärker rabattiert, doch Output und der nicht gecachte Rest schwächen die Gesamtersparnis ab. Bei Claude kostet das Lesen aus dem Cache ~90% weniger. Das Fehlersignal ist eindeutig: cached_tokens > 0 bei identischen Kosten für den kalten und warmen Call bedeutet, dass das Gateway zwar einen Treffer meldet, ihn aber nicht bei der Abrechnung berücksichtigt. Du zahlst den vollen Preis für einen Cache, der nur auf dem Papier „funktioniert“.
Check 3: Gehen die Token-Zahlen auf?
r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
| gecacht | Prompt gesamt | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | BESTANDEN |
claude-opus-4-8 | 12,446 | 12,454 | BESTANDEN |
cached muss Teil der gesamten Prompt-Tokens sein. Der Rest wird als nicht gecachter Input abgerechnet. Bei beiden Modellen gehen die Zahlen auf. Wenn cached_tokens größer als prompt_tokens ist oder der nicht gecachte Rest bei einem stabilen Präfix unplausibel groß ausfällt, rechnet das Gateway die Tokens falsch ab. Dann wird bei der Übersetzung zwischen APIs wahrscheinlich neu tokenisiert oder doppelt gezählt.
Check 4: Bleiben die Metadaten beim Streaming erhalten?
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"]
| Cache beim Streaming | Kosten beim Streaming | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | erhalten | erhalten | BESTANDEN |
claude-opus-4-8 | erhalten | erhalten | BESTANDEN |
Die meisten Production-Chats laufen als Stream, daher ist dieser Pfad besonders relevant. Bei beiden Lanes bleiben sowohl das Signal für den Cache-Treffer als auch die Kosten im Stream erhalten. cached_tokens und cost werden im abschließenden Usage-Chunk übertragen. Damit lässt sich auch der Pfad mit dem höchsten Traffic prüfen. Problematisch sind Gateways, die beim Streaming die Usage-Daten verwerfen: Eine fehlerfreie Token-Ausgabe ohne cached_tokens oder cost nimmt dir ausgerechnet auf dem wichtigsten Pfad jede Kontrollmöglichkeit. (Damit der Usage-Chunk überhaupt ausgegeben wird, musst du stream_options={"include_usage": True} setzen.)
Check 5: Die Negativkontrolle
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
| eindeutiger Präfix A | eindeutiger Präfix B | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | gecacht 0 | gecacht 0 | BESTANDEN |
claude-opus-4-8 | gecacht 0 | gecacht 0 | BESTANDEN |
Jeder Call erhält einen eindeutigen Präfix und darf deshalb nie einen Treffer liefern. Beide Lanes meldeten für unterschiedliche Präfixe erwartungsgemäß cached=0 zum vollen Preis. Ein „Treffer“ an dieser Stelle wäre ein falsch positives Ergebnis und würde die gesamte Cache-Meldung unbrauchbar machen. Erst die saubere Negativkontrolle macht die positiven Ergebnisse aus Check 1–2 belastbar.
So liest du die Auswertung
| Check | Gesundes Ergebnis | Warnsignal |
|---|---|---|
| 1. Cache greift | kalt 0, warm >0 (nach wiederholter Abfrage) | nach mehreren warmen Calls weiter 0, obwohl die Mindestgröße überschritten ist |
| 2. Kosten enthalten Rabatt | Kosten warm ≪ Kosten kalt | cached > 0, aber identische Kosten |
| 3. Token-Abrechnung | cached ≤ prompt_total, Zahlen gehen auf | Zahlen gehen nicht auf |
| 4. Streaming-Metadaten | Cache + Kosten bleiben im Stream erhalten | Usage-Daten fehlen bei Streaming-Calls |
| 5. Negativkontrolle | eindeutiger Präfix liefert immer einen Miss | ein anderer Präfix liefert einen „Treffer“ |
Unbemerkt teuer werden vor allem 2 (voller Preis trotz gemeldetem Treffer) und 1 (kein Caching trotz fehlerfreier Response). Führe beide Checks für jedes Modell aus, das du abrechnest.
Fazit
Caching ist einer der wirksamsten Hebel für die Kosten einer LLM-Anwendung. Deshalb sollte die Aussage „Der Cache funktioniert“ durch einen Test belegt sein. Integriere Check 1 + Check 2 für jedes abgerechnete Modell in deine CI. Löse einen Alert aus, sobald der Rabatt den erwarteten Bereich verlässt. So bemerkst du eine unauffällige Regression noch am selben Tag, wenn ein Gateway oder Upstream-Anbieter sein Verhalten ändert, statt erst am Ende des Abrechnungszeitraums. Und unabhängig von der konkreten Umsetzung deines Audits: Frage den warmen Cache mehrmals ab, bevor du ihn für defekt erklärst.
Die technischen Grundlagen hinter diesen Zahlen, darunter Prefill, KV Cache und TTLs, erklärt So funktionieren KV Cache und TTL. Konkrete Caching-Muster für die einzelnen Anbieter findest du im Tutorial.
FAQ
Bei Check 1 zeigt der warme Call 0. Lügt mein Gateway?
Prüfe zuerst drei Punkte. (1) Überschreitet dein Prompt die Mindestgröße des Anbieters für cachebare Inhalte (~1,024 Tokens bei den meisten Anbietern; DeepSeek gleicht mit einer feineren Granularität von 64 Tokens ab)? (2) Hast du den warmen Cache mehrmals abgefragt? Caches sind nicht immer schon beim direkt folgenden Call lesbar. (3) Ist der Präfix in jedem Call byte-identisch, ohne Zeitstempel oder Request-IDs am Anfang? Erst wenn alle drei Bedingungen erfüllt sind, solltest du das Gateway verdächtigen.
Was kostet mich Schein-Caching in der Praxis? Du zahlst bei jedem Call den vollen Input-Preis, obwohl du davon ausgehst, nur einen Bruchteil davon zu zahlen. Bei einem Endpoint mit hohem Volumen und einem großen stabilen Präfix kann die Rechnung dadurch ein Vielfaches deiner Kalkulation betragen. Für Check 2 solltest du einen Alert einrichten.
Warum ist der Rabatt bei DeepSeek hier niedriger als bei Claude? Die Zahlen messen unterschiedliche Dinge. Die ~90% bei Claude beziehen sich auf den Rabatt beim Lesen von gecachtem Input. Die ~72% bei DeepSeek bezeichnen die Reduktion der Gesamtkosten pro Call. Output und der nicht gecachte Rest werden dort zum vollen Preis berechnet und verringern die Gesamtersparnis. Vergleiche für deine eigene Prompt-Struktur nur gleichartige Werte.
Funktioniert das auch mit GPT, Gemini und Qwen?
Ja. Sie verwenden alle automatisches Caching und können daher mit unverändertem AutoLane und einem anderen model geprüft werden. Nur Claude benötigt MarkerLane. Die fünf Checks bleiben in beiden Fällen gleich.
Gehört das in die CI? Ja. Führe Check 1 + Check 2 regelmäßig für jedes abgerechnete Modell aus. Löse einen Alert aus, wenn der gemessene Rabatt den erwarteten Bereich verlässt. Ein dauerhaft laufender Audit macht aus einer stillen Regression eine Benachrichtigung.