🎁 Neu Kostenlos registrieren, 10 Aufrufe gratis. Bis zu 1 $, ohne Karte.
Lügt dein LLM-Gateway beim Cache? Ein 5-Minuten-Audit

Lügt dein LLM-Gateway beim Cache? Ein 5-Minuten-Audit

Inhalt
  1. Vier Arten, wie ein Gateway beim Cache falsche Angaben machen kann
  2. Zwei Cache-Mechanismen, ein Audit
  3. Check 1: Greift der Cache?
  4. Check 2: Spiegelt sich der Rabatt in den Kosten wider?
  5. Check 3: Gehen die Token-Zahlen auf?
  6. Check 4: Bleiben die Metadaten beim Streaming erhalten?
  7. Check 5: Die Negativkontrolle
  8. So liest du die Auswertung
  9. Fazit
  10. 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 > 0 der 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

FehlerbildWas du siehstWas tatsächlich passiert
Cache bleibt unbemerkt ausEine fehlerfreie ResponseNichts wurde gecacht; jeder Call kostet den vollen Preis
Schein-Cachingcached_tokens > 0 in der Response…aber abgerechnet wird der volle Input-Preis
Schleichender AufschlagPlausibel wirkende KostenDer Aufschlag des Gateways frisst unbemerkt den Rabatt auf
Fehlende MetadatenFehlerfreie TextausgabeUsage-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_control markiert. Treffer erscheinen als cache_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 gecachtwarm gecachtErgebnis
deepseek-v4-flash07,552 / 7,870 (96%)BESTANDEN
claude-opus-4-8012,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 kaltKosten warmRabattErgebnis
deepseek-v4-flash$0.00107$0.0003072.3%BESTANDEN
claude-opus-4-8$0.07112$0.0067290.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"]
gecachtPrompt gesamtErgebnis
deepseek-v4-flash7,5527,870BESTANDEN
claude-opus-4-812,44612,454BESTANDEN

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 StreamingKosten beim StreamingErgebnis
deepseek-v4-flasherhaltenerhaltenBESTANDEN
claude-opus-4-8erhaltenerhaltenBESTANDEN

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 Aeindeutiger Präfix BErgebnis
deepseek-v4-flashgecacht 0gecacht 0BESTANDEN
claude-opus-4-8gecacht 0gecacht 0BESTANDEN

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

CheckGesundes ErgebnisWarnsignal
1. Cache greiftkalt 0, warm >0 (nach wiederholter Abfrage)nach mehreren warmen Calls weiter 0, obwohl die Mindestgröße überschritten ist
2. Kosten enthalten RabattKosten warm ≪ Kosten kaltcached > 0, aber identische Kosten
3. Token-Abrechnungcached ≤ prompt_total, Zahlen gehen aufZahlen gehen nicht auf
4. Streaming-MetadatenCache + Kosten bleiben im Stream erhaltenUsage-Daten fehlen bei Streaming-Calls
5. Negativkontrolleeindeutiger Präfix liefert immer einen Missein 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.

← Zurück zum Blog