Lügt Ihr LLM-Gateway über den Cache? Ein 5-Minuten-Audit
Inhalt
- Vier Arten, wie ein Gateway über den Cache lügen kann
- Zwei Cache-Mechanismen, ein Audit
- Prüfung 1: Greift der Cache?
- Prüfung 2: Spiegelt der Preis den Rabatt wider?
- Prüfung 3: Stimmen die Token-Zahlen?
- Prüfung 4: Bewahrt das Streaming die Metadaten?
- Prüfung 5: Die Negativkontrolle
- Ihre Bewertungstabelle lesen
- Fazit
- FAQ
Ein Gateway sitzt zwischen Ihrem Code und dem Modellanbieter. Sie lesen cached_tokens aus der Antwort zurück, Sie sehen eine kleinere Zahl, und Sie vertrauen darauf, dass die eingesparten Dollar real sind. Aber Sie sehen den Upstream-Aufruf nie. Das Gateway könnte einen Cache-Treffer melden und trotzdem den vollen Eingabetarif berechnen. Es könnte hinter einer völlig sauberen Antwort überhaupt nichts cachen. Es könnte Nutzungs-Metadaten beim Streaming entfernen – dem Pfad, über den der Großteil Ihres Produktionsverkehrs läuft –, sodass Sie es so oder so nicht überprüfen können.
Das ist nicht hypothetisch. Ein Hacker-News-PSA berichtete, dass das Routing von DeepSeek V4 über ein beliebtes Gateway 2–3× weniger gecachte Tokens zurückgab als der direkte Aufruf von DeepSeek; ein Kommentator postete Rechnungen, die zeigten, dass die Caching-Statistiken über das Gateway überhaupt nicht gemeldet wurden. Das Team des Gateways antwortete, dass es das Problem nicht reproduzieren könne und ermittle. Genau diese Uneinigkeit ist der springende Punkt. Wenn sich zwei Parteien nicht einig werden können, ob Ihr Cache funktioniert, ist der einzige Schiedsrichter eine Messung, die Sie selbst durchgeführt haben.
In der Regel ist das keine Böswilligkeit. Es ist eine Übersetzungslücke oder ein unfertiger Codepfad. Die Auswirkung auf Ihre Rechnung ist in beiden Fällen dieselbe. Dieser Beitrag liefert ein einziges ausführbares Skript, das beide Stile des Prompt-Cachings auditiert – automatisch (DeepSeek) und markerbasiert (Claude) –, gegen jedes beliebige Gateway, einschließlich dieses hier. Es gibt in unter fünf Minuten eine direkte Vergleichstabelle aus.
Vier Arten, wie ein Gateway über den Cache lügen kann
| Fehlermodus | Was Sie sehen | Was tatsächlich passiert |
|---|---|---|
| Stilles Nicht-Caching | Eine saubere Antwort, kein Fehler | Nichts wurde gecacht; Sie zahlen bei jedem Aufruf den vollen Preis |
| Cache-Theater | cached_tokens > 0 in der Antwort | …aber die berechneten Kosten sind der volle Eingabetarif |
| Aufschlags-Schleichweg | Eine plausible Kostenzahl | Der Aufschlag des Gateways frisst still und heimlich den Rabatt |
| Metadaten-Blackout | Saubere Textausgabe | Nutzungsfelder entfernt (besonders beim Streaming), Sie können es nicht auditieren |
Die gefährlichen sind die ersten beiden: Die Antwort sieht aus, als würde das Caching funktionieren. Sie merken es am Monatsende.
Zwei Cache-Mechanismen, ein Audit
Anbieter stellen Caching in zwei Formen bereit, und ein echtes Gateway muss beide getreu durchreichen:
- Automatisch (DeepSeek, GPT, Gemini, Qwen): Der Anbieter cacht von sich aus jedes ausreichend lange Präfix. Keine Marker. Treffer erscheinen in
usage.prompt_tokens_details.cached_tokens. - Markerbasiert (Anthropic Claude): Sie markieren cachebare Abschnitte mit
cache_control. Treffer erscheinen alscache_read_input_tokens.
Das Skript verbirgt diesen Unterschied hinter einem schlanken Lane-Adapter und führt dann alle fünf Prüfungen gegen beide aus. Hier ist das Ganze: zwei Lanes und ein audit(), das jede Prüfung 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))
Der Rest des Beitrags geht jede Prüfung durch: die Zeilen, die sie implementieren, was beide Lanes zurückgaben und wie das Ergebnis zu lesen ist.
Prüfung 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
| cold cached | warm cached | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | PASS |
claude-opus-4-8 | 0 | 12,446 / 12,454 (99.9%) | PASS |
Ein Kaltaufruf auf ein eindeutiges Präfix darf nichts cachen; eine Wiederholung muss einen Treffer landen. Der mit Abstand häufigste Fehlalarm besteht darin, nach einem einzigen Warmaufruf „kein Cache” zu erklären, denn Caches werden nicht immer sofort lesbar. Die Schleife fragt mit einer Pause von einer Sekunde mehrmals ab, was die Unzuverlässigkeit beseitigt. Wenn Sie nach mehreren Warmaufrufen bei einem Prompt oberhalb der Mindestgröße (~1.024 Tokens bei den meisten Anbietern; DeepSeek arbeitet mit einer feineren Granularität von 64) immer noch 0 erhalten, greift der Cache tatsächlich nicht.
Prüfung 2: Spiegelt der Preis den Rabatt 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
| cold cost | warm cost | Rabatt | Ergebnis | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.6% | PASS |
Dies ist die Prüfung, die Cache-Theater aufdeckt. Die Kosten des Warmaufrufs müssen tatsächlich sinken. Die Gesamtkosten pro Aufruf von DeepSeek fielen um ~72 % (die gecachte Eingabe wird stärker rabattiert; die Ausgabe und der ungecachte Rest verwässern den Schlagzeilenwert). Der gecachte Read von Claude ist um ~90 % reduziert. Das Fehlersignal ist unmissverständlich: cached_tokens > 0 bei identischen Kalt- und Warmkosten bedeutet, dass das Gateway einen Treffer meldet, den es nicht bepreist. Sie zahlen den vollen Preis für einen Cache, der auf dem Papier „funktioniert”.
Prüfung 3: Stimmen die Token-Zahlen?
r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
| cached | prompt total | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
cached muss innerhalb der Prompt-Gesamtsumme liegen, wobei der Rest als ungecachte Eingabe berechnet wird. Beide gehen auf. Wenn cached_tokens prompt_tokens übersteigt oder der ungecachte Rest für ein stabiles Präfix unplausibel groß ist, rechnet das Gateway falsch: Es retokenisiert oder zählt irgendwo in der Übersetzung doppelt.
Prüfung 4: Bewahrt das Streaming die Metadaten?
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 | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | erhalten | erhalten | PASS |
claude-opus-4-8 | erhalten | erhalten | PASS |
Die meisten Produktions-Chats streamen, also ist dies der Pfad, der am meisten zählt. Auf beiden Lanes überleben sowohl das Cache-Treffer-Signal als auch die Kosten den Stream. cached_tokens und cost kommen im letzten Usage-Chunk an, sodass Ihr verkehrsstärkster Pfad auditierbar bleibt. Der Fehlermodus, auf den Sie achten müssen, ist ein Gateway, das die Nutzung beim Streaming verwirft: eine saubere Token-Ausgabe ohne cached_tokens oder cost bedeutet, dass Sie auf dem Pfad, den Sie am meisten nutzen, im Blindflug unterwegs sind. (Übergeben Sie stream_options={"include_usage": True}, damit der Usage-Chunk überhaupt ausgegeben wird.)
Prüfung 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
| eindeutiges Präfix A | eindeutiges Präfix B | Ergebnis | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
Senden Sie bei jedem Aufruf ein eindeutiges Präfix; es darf nie einen Treffer landen. Beide Lanes meldeten für unterschiedliche Präfixe korrekt cached=0 zum vollen Preis. Ein „Treffer” hier würde das Cache-Reporting zu einem False Positive machen, dem Sie nie vertrauen könnten. Die saubere Negativkontrolle ist es, die die positiven Ergebnisse in den Prüfungen 1–2 überhaupt erst aussagekräftig macht.
Ihre Bewertungstabelle lesen
| Prüfung | Gesundes Ergebnis | Warnsignal |
|---|---|---|
| 1. Cache greift | 0 kalt, >0 warm (nach Abfrage) | 0 nach mehreren Warmaufrufen, oberhalb der Mindestgröße |
| 2. Preis spiegelt Rabatt | Warmkosten ≪ Kaltkosten | cached > 0, aber gleiche Kosten |
| 3. Token-Abrechnung | cached ≤ prompt_total, geht auf | Zahlen stimmen nicht |
| 4. Streaming-Metadaten | Cache + Kosten überleben den Stream | Nutzung bei gestreamten Aufrufen fehlt |
| 5. Negativkontrolle | eindeutiges Präfix verfehlt immer | ein unterschiedliches Präfix „trifft” |
Die beiden, die still und heimlich Geld kosten, sind 2 (voller Preis für einen gemeldeten Treffer) und 1 (kein Caching hinter einer sauberen Antwort). Führen Sie beide für jedes Modell aus, das Sie abrechnen.
Fazit
Caching ist der wirkungsvollste Kostenhebel in einer LLM-App, weshalb genau „der Cache funktioniert” einen Test verdient, keine Annahme. Verdrahten Sie Prüfung 1 + Prüfung 2 in der CI gegen jedes Modell, das Sie abrechnen, alarmieren Sie, wenn der Rabatt unter Ihren erwarteten Bereich abdriftet, und Sie erwischen eine stille Regression an dem Tag, an dem ein Gateway oder ein Upstream-Anbieter sein Verhalten ändert – statt erst am Ende des Abrechnungszyklus. Und was auch immer Ihr Audit tut: fragen Sie den Warm-Read ab, bevor Sie einen Cache für kaputt erklären.
Für die Mechanik hinter diesen Zahlen (Prefill, KV-Cache, TTLs) beginnen Sie mit Wie KV-Cache und TTL funktionieren. Für funktionierende Caching-Patterns je Anbieter siehe das Tutorial.
FAQ
Meine Prüfung 1 zeigt 0 beim Warmaufruf. Lügt mein Gateway?
Prüfen Sie zuerst drei Dinge. (1) Überschreitet Ihr Prompt die cachebare Mindestgröße des Anbieters (~1.024 Tokens bei den meisten; DeepSeek arbeitet mit einer feineren Granularität von 64 Tokens)? (2) Haben Sie den Warm-Read mehrmals abgefragt? Caches werden nicht immer beim unmittelbar nächsten Aufruf lesbar. (3) Ist das Präfix zwischen den Aufrufen byte-identisch, ohne Zeitstempel oder Pro-Request-IDs am Anfang? Erst nach diesen drei Punkten sollten Sie das Gateway verdächtigen.
Was kostet mich „Cache-Theater” in der Praxis? Sie zahlen bei jedem Aufruf den vollen Eingabetarif, während Sie glauben, nur einen Bruchteil zu zahlen. Auf einem Endpoint mit hohem Volumen und einem großen stabilen Präfix bedeutet das, dass Ihre Rechnung ein Vielfaches dessen ist, was Sie modelliert haben. Prüfung 2 ist diejenige, auf die Sie alarmieren sollten.
Warum ist der Rabatt von DeepSeek hier niedriger als der von Claude? Es werden unterschiedliche Dinge gemessen. Claudes ~90 % ist der Read-Rabatt auf die gecachte Eingabe. DeepSeeks ~72 % ist die Reduktion der Gesamtkosten pro Aufruf, bei der die Ausgabe und der ungecachte Rest zum vollen Tarif berechnet werden und den Schlagzeilenwert verwässern. Vergleichen Sie Gleiches mit Gleichem für Ihre eigene Prompt-Form.
Funktioniert das auch für GPT, Gemini, Qwen?
Ja. Sie sind alle automatisch, also verwenden sie AutoLane unverändert mit einem anderen model. Nur Claude benötigt die MarkerLane. In beiden Fällen dieselben fünf Prüfungen.
Sollte das in der CI leben? Ja. Führen Sie Prüfung 1 + Prüfung 2 gegen jedes Modell, das Sie abrechnen, nach Zeitplan aus und alarmieren Sie, wenn der beobachtete Rabatt außerhalb Ihres erwarteten Bereichs abdriftet. Ein dauerhaftes Audit verwandelt eine stille Regression in eine Benachrichtigung.