Claude Prompt Caching griff nie: Ein Timestamp legte 1.412 Calls lahm
Nach dem Aktivieren von Claude prompt caching zeigten die Usage-Logs bei allen 1.412 Aufrufen cache_read_input_tokens: 0, ohne dass die API einen Fehler meldete. Die cache_control-Blöcke sahen korrekt aus, jeder Request zahlte trotzdem still den vollen Preis.

Ich hatte Claude Prompt Caching aktiviert, alles ausgeliefert und mich dem nächsten Projekt zugewandt. Eine Woche später öffnete ich die Nutzungsprotokolle, um mich an meinen Ersparnissen zu erfreuen.
cache_read_input_tokens: 0. Bei jedem Aufruf. 1.412 Aufrufe in Folge.
Die API gab niemals einen Fehler zurück. Meine cache_control-Blöcke sahen korrekt aus. Jede Anfrage bezahlte stillschweigend den vollen Preis für denselben 6.000-Token-Präfix, immer und immer wieder, und die Antwort erzählte es mir in einem Feld, das ich nicht las. Dies ist die Obduktion: drei unabhängige Bugs, jeder für sich allein ausreichend, um den Cache zu killen, und das Logging, das sie am ersten Tag hätte auffinden lassen.
Bei dem System handelt es sich um Preterview, ein von mir entwickeltes und betriebenes Übungsprogramm für mündliche Interviews (vollständige Offenlegung: Ich habe es gebaut, es ist unter preterview.com zu finden). Jede Sitzung ist ein Hin und Her mit einem KI-Interviewer in einem von drei Stilen, und jede Runde sendet denselben großen Kontextblock: Interviewer-Persona, Bewertungskriterien und den geparsten Lebenslauf des Kandidaten.
Dieser Block umfasst etwa 6.000 Token und ändert sich während einer Sitzung nicht. Eine Sitzung hat im Durchschnitt 12 bis 14 Runden. Dieselben 6.000 Token vierzehnmal erneut zu senden ist der Lehrbuchfall für Prompt Caching, weshalb ich es in etwa zehn Minuten eingebaut hatte und annahm, dass es funktioniere.
So sah der Aufruf ungefähr aus:
resp = client. messages. create ( model = MODEL, max_tokens = 600, tools = build_tools ( session ), system = [ { " type ": " text ", " text ": build_system_prompt ( session ), " cache_control ": { " type ": " ephemeral " }, } ], messages = session. history, ) Enter fullscreen mode Exit fullscreen mode Sieht richtig aus. War es aber nicht.
Claude Prompt Caching basiert auf exakter Präfix-Übereinstimmung. Die Anfrage wird als eine geordnete Sequenz behandelt: zuerst die Tool-Definitionen, dann der Systemprompt, dann die Messages. Ein cache_control-Breakpoint bedeutet: "Cache alles bis hierher." Beim nächsten Aufruf erhältst du einen Cache Read, abgerechnet zu einem Zehntel des normalen Input-Preises, wenn jeder Token bis zu diesem Breakpoint identisch ist. Weicht auch nur etwas ab, zahlst du den vollen Preis plus einen Write-Aufschlag (das 1,25-Fache bei der Standard-TTL von 5 Minuten), um einen neuen Eintrag anzulegen.
Ein "der Systemprompt ist im Wesentlichen gleich" zählt also nicht. Entweder er ist byte-identisch, oder es ist ein Miss.
Und zwar, weil es die erste Zeile des Systemprompts war. Monats zuvor hatte ich dies ergänzt, damit der Interviewer zur richtigen Tageszeit "guten Morgen" sagen konnte:
def build_system_prompt ( session ): return ( f " Current time: { datetime. now (): % H: % M: % S } \n " f " { PERSONAS [ session. style ] } \n " f " { RUBRIC } \n " f " Candidate resume: \n { session. resume_text } " ) Enter fullscreen mode Exit fullscreen mode Sekundengenaue Präzision. Jede Runde hatte eine andere erste Zeile, also hatte jede Runde einen anderen Präfix, also schrieb jede Runde einen frischen Cache-Eintrag, den niemand je las. Ich zahlte den 1,25fachen Write-Aufschlag auf 1.412 Aufrufe, um Prompts zu cachen, die ungenutzt verfielen. Caching war teurer als gar kein Caching.
Die Lösung: alles Volatile hinter den Breakpoint verschieben. Die Tageszeit wanderte in einen kurzen zweiten Systemblock ohne cache_control:
system = [ { " type ": " text ", " text ": stable_prefix ( session ), " cache_control ": { " type ": " ephemeral " }}, { " type ": " text ", " text ": f " Local time: { now: % H: % M } " }, ] Enter fullscreen mode Exit fullscreen mode Ich habe neu ausgerollt und die Logs geprüft. Trefferquote: 66 Prozent. Besser. Aber nicht irgendwas mit 90, was die Rechnung hätte ergeben müssen.
Denn Python randomisiert das String-Hashing pro Prozess, sodass die Iteration über eine Menge von Strings in jedem Prozess eine andere Reihenfolge ergibt. Meine Tool-Liste stammte aus Folgendem:
enabled = { " score_answer ", " ask_followup ", " end_interview " } tools = [ TOOL_DEFS [ name ] for name in enabled ] Enter fullscreen mode Exit fullscreen mode Innerhalb eines Prozesses ist die Reihenfolge stabil. Aber ich betreibe 4 uvicorn-Worker, und jeder Worker erhielt seinen eigenen Hash-Seed. Tools stehen am Anfang des Cache-Präfix, sodass vier Reihenfolgen vier verschiedene Präfixe für dieselbe Sitzung bedeuteten.
Das erklärt auch die 66 Prozent exakt. Die Runden einer Sitzung landen zufällig auf den Workern. Jeder Worker hat pro Sitzung einmal einen Miss (beim ersten Mal, wenn er die Sitzung sieht), danach Treffer. Bei 4 Workern und 12 Runden liegt die erwartete Anzahl berührter unterschiedlicher Worker bei 4 × (1 − 0,75^12) ≈ 3,87. Das ergibt etwa 3,9 Misses auf 12 Runden, etwa 8,1 Treffer, also rund 68 Prozent. Meine gemessenen 66 Prozent lagen exakt auf der Vorhersage, was das befriedigendste war, was ein Bug in diesem Jahr für mich getan hat.
tools = [ TOOL_DEFS [ name ] for name in sorted ( enabled )] Enter fullscreen mode Exit fullscreen mode Zusätzlich serialisiere ich die Tool-Schemas inzwischen aus einer eingefrorenen JSON-Datei, statt sie zur Laufzeit aus Dictionaries zu bauen, damit auch nichts vorgelagertes die Schlüssel umordnen kann.
Denn es gibt eine minimale cachefähige Länge pro Modell, und darunter überspringt die API das Caching stillschweigend. Bei dem von mir verwendeten Modell liegt sie bei 1.024 Token; kleinere Modelle brauchen mehr, also konsultiere die Dokumentation deines Modells.
Zwei meiner drei Interviewer-Stile hatten lange Persona-Blöcke. Der dritte, ein bewusst knapper "rapid-fire"-Stil, kam auf etwa 900 Token, bevor der Lebenslauf angehängt wurde. Bei Kandidaten, die einen einseitigen Lebenslauf hochluden, blieb der gesamte stabile Präfix unter der Schwelle. Diese Aufrufe zeigten cache_creation_input_tokens: 0 und cache_read_input_tokens: 0. Kein Fehler. Der Breakpoint tat schlicht nichts.
Ich habe die gemeinsamen Bewertungskriterien über die Persona verschoben, sodass der Präfix jedes Stils mit denselben mehr als 2.000 Token beginnt. Das brachte jede Variante über das Minimum, und zusätzlich wird der Bereich der Bewertungskriterien nun auch stileübergreifend geteilt.
Finale Trefferquote: 94 Prozent. Die verbleibenden 6 Prozent sind die erste Runde jeder Sitzung, die den Cache schreiben muss, sowie Sitzungen, in denen der Kandidat länger als 5 Minuten pausierte und der Eintrag verfiel.
Die Input-Token-Kosten pro Sitzung sanken um 58 Prozent. Hier die Rechnung für eine durchschnittliche Sitzung mit 14 Runden, gezählt in "Token-Äquivalenten zum Vollpreis":
Zwei Dinge fallen auf. Erstens war die defekte Variante 18 Prozent schlechter als gar kein Caching, wegen des Write-Aufschlags. Zweitens ist der Verlauf nun der größte Posten, daher ist der nächste Schritt ein zweiter Breakpoint auf der Konversation selbst. Man bekommt bis zu 4 Breakpoints pro Anfrage; ich habe einen benutzt.
Die Latenz hat sich ebenfalls verbessert. Die Zeit bis zum ersten Token sank bei gecachten Runden deutlich, was bei einem Voice-Produkt sehr wichtig ist, wo wenige hundert Millisekunden sich wie eine unangenehme Stille anfühlen. Eine saubere Vorher-Nachher-Analyse habe ich dazu nicht, weil in derselben Woche weitere Änderungen ausgeliefert wurden, deshalb nenne ich keine Zahl.
Protokolliere die drei Usage-Felder bei jeder Antwort und alarmiere, wenn Reads bei null bleiben. Das ist der Code, den ich am ersten Tag hätte schreiben sollen:
u = resp. usage log. info ( " cache ", read = u. cache_read_input_tokens, write = u. cache_creation_input_tokens, uncached = u. input_tokens, prefix_hash = hashlib. sha256 ( prefix_bytes ). hexdigest ()[: 12 ], ) Enter fullscreen mode Exit fullscreen mode Die prefix_hash-Zeile hat Bug 2 entlarvt. Ich hashe die serialisierten Tools plus den stabilen Systemblock lokal und logge das Ergebnis. Als zwei Runden derselben Sitzung unterschiedliche Hashes zeigten, diffete ich die beiden Request-Bodies, und dort stand die umgeordnete Tool-Liste in den ersten 40 Bytes.
Eine Checkliste, die ich inzwischen bei jedem gecachten Prompt durchgehe:
Claude Prompt Caching erfordert einen byte-identischen Präfix bis zum cache_control-Breakpoint, und mein Präfix veränderte sich bei jedem einzelnen Aufruf, weil die erste Zeile des Systemprompts ein sekundengenaues Zeitstempel war. Die Behebung legte zwei weitere stille Fehler offen: Pythons prozessbasierte Hash-Randomisierung ordnete meine Tool-Definitionen über 4 Worker hinweg neu (wodurch die Trefferquote bei 66 Prozent gedeckelt wurde, exakt wie von der Rechnung vorhergesagt), und eine Prompt-Variante fiel unter die minimale cachefähige Länge. Das Verschieben von volatilen Texten hinter den Breakpoint, das Sortieren der Tools und das Auffüllen des gemeinsamen Präfix erhöhten die Trefferquote von 0 auf 94 Prozent und senkten die Input-Kosten pro Sitzung um 58 Prozent.
Keines davon warf einen Fehler. Das einzige Signal war cache_read_input_tokens, also logge es.
Geschrieben von dem Entwickler hinter Preterview, einer Plattform zur Interviewvorbereitung.
Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.
Original (Englisch) anzeigen
I turned on Claude prompt caching, shipped it, and moved on to the next thing. A week later I opened the usage logs to admire my savings.
cache_read_input_tokens: 0. On every call. 1,412 calls in a row.
The API never returned an error. My cache_control blocks looked fine. Every request quietly paid full price for the same 6,000-token prefix, over and over, and the response told me so in a field I wasn't reading. This is the autopsy: three separate bugs, each enough to kill the cache by itself, and the logging that would have caught them on day one.
The system in question is Preterview, a voice interview practice tool I built and run (full disclosure: I built it, it's at preterview.com ). Each session is a back-and-forth with an AI interviewer in one of three styles, and every turn sends the same big chunk of context: interviewer persona, scoring rubric, and the candidate's parsed resume.
That chunk is about 6,000 tokens and it doesn't change during a session. A session averages 12 to 14 turns. Re-sending 6,000 identical tokens 14 times is the textbook case for prompt caching, which is why I added it in about ten minutes and assumed it worked.
Here's roughly what the call looked like:
resp = client. messages. create ( model = MODEL, max_tokens = 600, tools = build_tools ( session ), system = [ { " type ": " text ", " text ": build_system_prompt ( session ), " cache_control ": { " type ": " ephemeral " }, } ], messages = session. history, ) Enter fullscreen mode Exit fullscreen mode Looks right. It was not right.
Claude prompt caching matches on an exact prefix. The request is treated as one ordered sequence: tool definitions first, then the system prompt, then messages. A cache_control breakpoint says "cache everything up to here." On the next call, if every token up to that breakpoint is identical, you get a cache read billed at a tenth of the normal input rate. If anything differs, you pay full price plus a write premium (1.25x on the default 5-minute TTL) to create a new entry.
So "the system prompt is basically the same" counts for nothing. It's either byte-identical or it's a miss.
Because it was the first line of the system prompt. Months earlier I'd added this so the interviewer could say "good morning" at the right time of day:
def build_system_prompt ( session ): return ( f " Current time: { datetime. now (): % H: % M: % S } \n " f " { PERSONAS [ session. style ] } \n " f " { RUBRIC } \n " f " Candidate resume: \n { session. resume_text } " ) Enter fullscreen mode Exit fullscreen mode Seconds precision. Every turn had a different first line, so every turn had a different prefix, so every turn wrote a fresh cache entry that nobody ever read. I was paying the 1.25x write premium on 1,412 calls to cache prompts that expired unused. Caching made it more expensive than not caching.
The fix: move anything volatile after the breakpoint. The time of day went into a short second system block with no cache_control:
system = [ { " type ": " text ", " text ": stable_prefix ( session ), " cache_control ": { " type ": " ephemeral " }}, { " type ": " text ", " text ": f " Local time: { now: % H: % M } " }, ] Enter fullscreen mode Exit fullscreen mode I redeployed and checked the logs. Hit rate: 66%. Better. Not 90-something, which is what the math said it should be.
Because Python randomizes string hashing per process, so iterating a set of strings gives a different order in each process. My tool list came from this:
enabled = { " score_answer ", " ask_followup ", " end_interview " } tools = [ TOOL_DEFS [ name ] for name in enabled ] Enter fullscreen mode Exit fullscreen mode Inside one process the order is stable. But I run 4 uvicorn workers, and each worker got its own hash seed. Tools come first in the cache prefix, so four orderings meant four different prefixes for the same session.
This also explains 66% exactly. A session's turns land on workers at random. Each worker misses once per session (the first time it sees that session), then hits. With 4 workers and 12 turns, the expected number of distinct workers touched is 4 × (1 − 0.75^12) ≈ 3.87. So about 3.9 misses per 12 turns, about 8.1 hits, about 68%. My measured 66% sat right on top of the prediction, which is the most satisfying thing a bug has done for me all year.
tools = [ TOOL_DEFS [ name ] for name in sorted ( enabled )] Enter fullscreen mode Exit fullscreen mode I also started serializing tool schemas from a frozen JSON file instead of building them from dicts at runtime, so nothing upstream could reorder keys either.
Because there's a minimum cacheable length per model, and below it the API silently skips caching. On the model I use it's 1,024 tokens; smaller models need more, so check the docs for yours.
Two of my three interviewer styles had long persona blocks. The third, a deliberately terse "rapid-fire" style, came to about 900 tokens before the resume got appended. For candidates who uploaded a one-page resume, the whole stable prefix stayed under the threshold. Those calls showed cache_creation_input_tokens: 0 and cache_read_input_tokens: 0. No error. The breakpoint simply did nothing.
I moved the shared rubric above the persona so every style's prefix starts with the same 2,000+ tokens. That pushed every variant over the minimum, and it means the rubric portion is shared across styles too.
Final hit rate: 94%. The remaining 6% is the first turn of each session, which has to write the cache, plus sessions where the candidate paused longer than 5 minutes and the entry expired.
Input token cost per session fell 58%. Here's the math on an average 14-turn session, counted in "full-price token equivalents":
Two things jump out. First, the broken version was 18% worse than having no caching at all, because of the write premium. Second, history is now the biggest line item, so the next step is a second breakpoint on the conversation itself. You get up to 4 breakpoints per request; I was using one.
Latency improved too. Time to first token on cached turns dropped noticeably, which matters a lot in a voice product where every few hundred milliseconds feels like an awkward silence. I don't have a clean before/after on that because other changes shipped the same week, so I won't put a number on it.
Log the three usage fields on every response and alert when reads stay at zero. This is the code I should have written on day one:
u = resp. usage log. info ( " cache ", read = u. cache_read_input_tokens, write = u. cache_creation_input_tokens, uncached = u. input_tokens, prefix_hash = hashlib. sha256 ( prefix_bytes ). hexdigest ()[: 12 ], ) Enter fullscreen mode Exit fullscreen mode The prefix_hash line is the one that cracked bug 2. I hash the serialized tools plus the stable system block locally and log it. When two turns of the same session showed different hashes, I diffed the two request bodies and the reordered tool list was right there in the first 40 bytes.
A checklist I now run on any cached prompt:
Claude prompt caching requires a byte-identical prefix up to the cache_control breakpoint, and my prefix changed on every single call because the first line of the system prompt was a seconds-precision timestamp. Fixing that exposed two more silent failures: Python's per-process hash randomization reordered my tool definitions across 4 workers (capping hits at 66%, exactly what the math predicted), and one prompt variant fell under the minimum cacheable length. Moving volatile text after the breakpoint, sorting the tools and padding the shared prefix took the hit rate from 0% to 94% and cut input cost per session by 58%. None of it threw an error. The only signal was cache_read_input_tokens, so log it.
Written by the developer behind Preterview, an interview prep platform.