Dev

Finde, was deinen Prompt-Cache invalidiert hat

Cache Diagnostics nennt die erste Stelle, an der ein Request nicht mehr zum vorherigen passt. Nützlich wird das erst, wenn du das Urteil neben dem Cache-Read-Zähler liest und die Parameteränderungen, die Diagnostics nicht sieht, selbst abdeckst. Misses durch solche Änderungen findest du mit bloßem Auge am schwersten.

Ein Prompt-Cache-Miss ist still. Wenn der Anfang deines Prompts nicht mehr Byte für Byte zum vorherigen Request passt, beschwert sich die API nicht. Sie antwortet, berechnet dir das erneute Schreiben des Präfixes und macht weiter. (Bei Opus 5.5 gibt es eine laute Variante davon: Wenn du Thinking-Blöcke zurückschickst, nachdem du den Inhalt vor ihnen bearbeitet hast, kann das auf neueren Accounts ein 400 liefern. Ein gewöhnlicher Miss tut das nie.) Die einzige Spur ist usage.cache_read_input_tokens, das auf null steht, ohne zu sagen, ob sich das Modell geändert hat, der system-Text oder die Historie.

Cache Diagnostics, seit Mai als öffentliche Beta und seit dem 23. September auf der Claude API aus der Beta heraus, sagt dir, was davon es war, soweit es den Prompt betrifft. Für die Parameter rund um den Prompt sagt es dir das nicht, und genau die Misses, die dort entstehen, findest du mit bloßem Auge nicht.

Was sagt dir Cache Diagnostics eigentlich?

Es vergleicht zwei aufeinanderfolgende Requests und nennt die erste Stelle, an der sie auseinanderlaufen. Du übergibst die id der vorherigen Antwort, die API bildet einen Fingerprint des neuen Requests, vergleicht ihn mit dem gespeicherten und hängt ein diagnostics-Objekt an die Antwort. Die möglichen Ursachen sind model_changed, system_changed, tools_changed und messages_changed, jeweils mit cache_missed_input_tokens, einer Schätzung, wie viel Input hinter dem Bruch lag. Die Doku nennt diese Schätzung eine Größenordnung, keine Abrechnungszahl, und sie kann sogar über input_tokens liegen.

Das wichtige Wort ist erste. Die Doku ordnet das Präfix als tools, dann system, dann messages, und eine Änderung auf einer Ebene invalidiert diese Ebene und alles danach. Die Antwort meldet nur die früheste Abweichung. Wenn dein system-Text einen Zeitstempel enthält und deine Tool-Schemas zusätzlich instabil serialisiert werden, siehst du tools_changed, behebst das und triffst erst danach auf den Zeitstempel.

Es vergleicht Requests, keine Cache-Ergebnisse. Ein sauberes Urteil heißt, dass sich dein Request nicht geändert hat. Ob der Cache getroffen wurde, ist eine andere Zahl, und du brauchst beide.

Wie schaltest du es ein?

Setz bei jedem Request ein diagnostics-Objekt. Der erste Turn übergibt previous_message_id: null, damit bist du dabei, ohne etwas zum Vergleichen zu haben. Jeder weitere Turn übergibt die id der Antwort davor. Der Beta-Header cache-diagnosis-2026-04-07 ist nicht mehr nötig, aber im Python-SDK sitzt der Parameter diagnostics auf client.beta.messages.create, nicht auf der stabilen Methode, und jedes SDK-Beispiel in der Doku läuft über den Beta-Namespace.

Über reines HTTP sind es zwei ganz normale Messages-Calls mit einem zusätzlichen Feld. Das Dokument im Prompt muss echt sein: Opus 5.5 cacht kein Präfix unter 512 Tokens, ein Platzhalter wird also nie geschrieben, und dann gibt es nichts, was verfehlt werden könnte.

bash
DOC=$(cat big-document.txt)   # well over 512 tokens
SYSTEM=$(jq -n --arg d "$DOC" '"You are analyzing this document. <document>" + $d + "</document>"')
 
call() {
  curl -sS --fail-with-body https://api.anthropic.com/v1/messages \
    -H "x-api-key: $ANTHROPIC_API_KEY" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d "$1"
}
 
# Turn 1: opt in, write the cache
r1=$(call "$(jq -n --argjson s "$SYSTEM" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [{role: "user", content: "Summarize section 1."}],
  diagnostics: {previous_message_id: null}}')") || exit 1
jq '{id, usage}' <<< "$r1"   # expect cache_creation_input_tokens > 0
 
# Turn 2: same prefix, the assistant turn echoed back verbatim, one new question
r2=$(call "$(jq -n --argjson s "$SYSTEM" --argjson r "$r1" '{
  model: "claude-opus-5-5", max_tokens: 1024,
  cache_control: {type: "ephemeral"}, system: $s,
  messages: [
    {role: "user", content: "Summarize section 1."},
    {role: "assistant", content: $r.content},
    {role: "user", content: "Now section 2."}],
  diagnostics: {previous_message_id: $r.id}}')") || exit 1
jq '{usage, diagnostics}' <<< "$r2"

In einer Agent-Schleife ist das ganze Feature eine Variable, die du weiterreichst. Das Urteil hat vier Zustände, nicht drei, also logge sie getrennt:

python
import anthropic
 
client = anthropic.Anthropic()
SYSTEM = "You are analyzing this document. <document>" + open("big-document.txt").read() + "</document>"
 
def verdict(r, prev_id):
    d = r.diagnostics
    if prev_id is None:
        return "first-turn"
    if d is None:
        return "no-divergence"
    if d.cache_miss_reason is None:
        return "pending"
    return d.cache_miss_reason.type
 
messages, prev_id = [], None
for prompt in ["Summarize section 1.", "Now section 2.", "Now section 3."]:
    messages.append({"role": "user", "content": prompt})
    r = client.beta.messages.create(
        model="claude-opus-5-5",
        max_tokens=1024,
        cache_control={"type": "ephemeral"},
        system=SYSTEM,
        messages=messages,
        diagnostics={"previous_message_id": prev_id},
    )
    u = r.usage
    print(
        f"read={u.cache_read_input_tokens} write={u.cache_creation_input_tokens} "
        f"uncached={u.input_tokens} verdict={verdict(r, prev_id)}"
    )
    messages.append({"role": "assistant", "content": r.content})  # verbatim, never rebuilt
    prev_id = r.id

In einer Streaming-Antwort kommt dasselbe Objekt mit dem Event message_start, du kannst es also loggen, bevor das erste Token ankommt.

Loggst du die Zahl, die einen Miss zeigt?

Wahrscheinlich nicht vollständig. Der Usage-Block teilt den Input dreifach auf: aus dem Cache gelesene Tokens, in den Cache geschriebene Tokens und input_tokens, das nur zählt, was nach dem letzten Breakpoint kommt. Der gesamte Input ist die Summe der drei. Ein Logger, der nur input_tokens und output_tokens behält, verliert das gecachte Präfix komplett, bei Hits wie bei Misses. Bleiben Präfix und Frage gleich, sehen seine Zahlen identisch aus, egal ob das Präfix zu einem Zwanzigstel des Preises gelesen oder zu mehr als dem vollen Preis geschrieben wurde.

Mein agent-recall-Paket ist genau so gebaut. Sein Anthropic-Caller gibt input_tokens und output_tokens zurück und sonst nichts. Es setzt nie cache_control, also erzählen diese zwei Zahlen heute die ganze Geschichte. An dem Tag, an dem ein Breakpoint dazukommt, tun sie das nicht mehr, und nichts in der eigenen Abrechnung des Pakets würde es verraten.

Der Preis ist der Grund, diese Lücke zu schließen. Auf Opus 5.5 kostet ein Cache-Read das 0,05-Fache des Basis-Input-Preises, gegenüber 0,1 bei den meisten Modellen. Ein Miss zahlt stattdessen den Schreibpreis: das 1,25-Fache mit der Standard-TTL von fünf Minuten, das 2-Fache mit der einstündigen. Logge alle drei Felder, bevor du auch nur ein Urteil liest.

Wie liest du das Ergebnis?

Lies zuerst das Urteil, dann lies es gegen cache_read_input_tokens. Das Feld diagnostics ist null, wenn du nicht eingeschaltet hast, im ersten Turn oder wenn ein Vergleich lief und nichts gefunden hat. Es ist {"cache_miss_reason": null}, wenn der Vergleich noch lief, als die Antwort rausging; das ist nicht aussagekräftig, schau auf den nächsten Turn. Ansonsten steckt der Grund darin, in der Form, die die Doku zeigt:

json
"diagnostics": {
  "cache_miss_reason": {
    "type": "system_changed",
    "cache_missed_input_tokens": 41850
  }
}

previous_message_not_found und unavailable kommen in derselben Hülle, heißen aber, dass kein Vergleich stattgefunden hat. Für die Turns, die einen bekommen haben, legt die Doku eine Vier-Felder-Matrix fest, und die solltest du dir merken:

DiagnosticsCache-ReadWas es bedeutet
nullhochFunktioniert. Das Präfix ist stabil und der Cache hat getroffen.
nullniedrig oder nullDein Request hat sich nicht geändert, aber es war kein nutzbarer Eintrag da. Meist die TTL: Abstände zwischen Turns verkürzen oder den 1-Stunden-Cache nehmen.
ein *_changed-Typniedrig oder nullDein Bug. Behebe, was der Typ nennt.
ein *_changed-TyphochEine späte Änderung, aber ein früherer Breakpoint hat trotzdem getroffen. Lohnt sich zu beheben, geringe Wirkung.

Die zweite Zeile ist der Grund, warum es die Matrix gibt. Ein Urteil null mit null Reads ist kein Gesundheitszeugnis, und kein Suchen im Prompt wird es erklären. Bevor du der TTL die Schuld gibst, prüf, ob Turn eins überhaupt etwas geschrieben hat: Stand cache_creation_input_tokens dort ebenfalls auf null, war das Präfix nie cachebar, zu kurz oder ohne Breakpoint.

Welcher Fix gehört zu welchem Grund?

Jeder Typ zeigt auf eine Ebene des Präfixes. Die Fixes sind meist langweilig, und das ist die gute Nachricht.

system_changed. Etwas Request-Spezifisches wurde ins system-Feld eingesetzt: ein Zeitstempel, eine Request-ID, das heutige Datum. Mach den system-Text zur Konstante und verschieb den dynamischen Teil in die erste User-Nachricht nach dem Cache-Breakpoint.

tools_changed. Die Tool-Liste wurde erweitert, umsortiert oder anders serialisiert. Eine Registry, die aus einem Plugin-Scan oder einem ungeordneten Set zusammengebaut wird, schafft das, ohne dass jemand ein Tool anfasst. Schick jeden Turn dieselben Tools in derselben Reihenfolge und serialisiere Schemas deterministisch:

python
import json
 
def stable_tools(tools):
    ordered = sorted(tools, key=lambda t: t["name"])
    # round-trip through sorted JSON so key order never depends on how the dict was built
    return json.loads(json.dumps(ordered, sort_keys=True))

Das sortiert auch die properties in jedem Schema, was die Feldreihenfolge, die das Modell liest, einmal ändert. Danach ist sie stabil, also wende es ab dem ersten Request an, nicht mitten in einer Session. Wenn ein Tool wirklich mitten in der Session auftauchen muss, lass das tools-Array auf oberster Ebene in Ruhe und lass es im Gespräch ankommen, was Deferred Tool Loading tut. Dasselbe Argument habe ich schon zu Deferred Loading und dem gecachten Präfix angeführt: Was vorn im Prompt steht, ist der Teil, den der Cache schützt, also ist das der Teil, den du nicht mehr anfasst.

messages_changed. Modell, system und Tools stimmen überein, aber eine frühere Nachricht wurde bearbeitet, umsortiert oder entfernt, statt dass etwas angehängt wurde. Das Kürzen der Historie macht das, und ebenso das Neuaufbauen von Assistant-Turns aus deinem eigenen Datenmodell, statt content wörtlich zurückzuschicken. Die Doku warnt außerdem, dass manche Sprachen die Reihenfolge der Keys zufällig machen, wenn sie Objekte in JSON umwandeln, was den Cache über die tool_use-Blöcke bricht, die du zurückschickst. Hänge an die Historie nur an, ändere nie etwas darin.

model_changed. Ein Router, ein A/B-Split oder ein Fallback hat mitten im Gespräch ein anderes Modell gewählt, und der Cache gilt pro Modell. Der Turn mit dem Fallback zahlt das ganze Präfix neu, und der Turn zurück ebenso, falls der ursprüngliche Eintrag bis dahin abgelaufen ist. Wenn du auf ein Fallback wechselst, bleib dort für den Rest des Gesprächs. Das ist kein Argument dagegen, Worker und Reviewer auf verschiedenen Modellen laufen zu lassen: Getrennte Rollen sind getrennte Gespräche und haben sich nie einen Cache geteilt.

previous_message_not_found. Kein Beleg dafür, dass sich etwas geändert hat. Der vorherige Request hat das diagnostics-Objekt ausgelassen, lief in einem anderen Workspace oder ist zu alt; Fingerprints werden nur kurz aufbewahrt. Wenn du die Beta früh übernommen hast, prüf deinen ersten Turn: Seit dem 9. September speichert ein Request, der nur den Beta-Header schickt, ohne das diagnostics-Objekt, keinen Fingerprint, also meldet der Turn danach jedes Mal diesen Typ.

Wo wird Cache Diagnostics blind?

An drei Stellen: bei Parametern außerhalb des Prompts, bei sehr langen Gesprächen und auf jeder Plattform außer der Claude API. Die erste ist die unangenehme.

Parameter außerhalb des Prompts. Wenn model, system und tools übereinstimmen, aber tool_choice, thinking, context_management, output_config, output_format oder die Menge der anthropic-beta-Header abweicht, lautet das Urteil unavailable. Lies jetzt die Invalidierungstabelle auf der Caching-Seite: Eine Änderung an tool_choice lässt die Caches für Tools und system gültig und invalidiert die Message-Blöcke, und eine Änderung an der Thinking-Konfiguration oder an output_config.effort auf oberster Ebene invalidiert immer die Message-Blöcke, bei manchen Modellen mehr. Genau in dem Fall, der mit bloßem Auge am schwersten zu erkennen ist (Tools intakt, system intakt, die Reads auf den Messages brechen ein), nennt Diagnostics also keine Ursache.

Über Effort stolpert man leicht. Ihn explizit auf den Standard des Modells zu setzen, ist dasselbe, wie ihn wegzulassen, aber ein Agent, der den Effort auf oberster Ebene für einen schweren Schritt hochsetzt, zahlt in diesem Turn mit seinem Message-Cache. Auf Modellen, die Effort pro Nachricht unterstützen, kann die Änderung in einer role: "system"-Nachricht innerhalb von messages mitfahren und das gecachte Präfix intakt lassen. Der Standard selbst hat sich bei Opus 5.5 verschoben, wie ich in den Notizen zur Migration auf Opus 5.5 beschrieben habe.

Diagnostics hört beim Prompt auf. Die Parameter musst du selbst beobachten, und ein kurzer Hash pro Turn, neben dem Urteil geloggt, reicht:

python
import hashlib, json
 
PROMPT_PARAMS = ("tool_choice", "thinking", "context_management", "output_config", "output_format")
 
def param_fingerprint(request: dict, betas: list[str]) -> str:
    snapshot = {k: request.get(k) for k in PROMPT_PARAMS}
    snapshot["betas"] = sorted(betas)
    blob = json.dumps(snapshot, sort_keys=True, default=str)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]

Wenn unavailable mit eingebrochenen Reads kommt, nennt ein Hash, der sich seit dem letzten Turn geändert hat, die Ebene, und ein Diff der beiden Snapshots nennt das Feld.

Sehr lange Gespräche. Liegt die einzige Änderung tief in einer sehr langen Nachrichtenliste, bekommst du womöglich unavailable statt einer Position. Die Doku nennt keine Schwelle. Die Sessions, in denen ein Miss am meisten kostet, sind genau die, die am ehesten darauf stoßen.

Andere Plattformen. Diagnostics läuft nur auf der Claude API, nicht auf Amazon Bedrock, Google Cloud oder Microsoft Foundry. Ein Gespräch gegen die Claude API nachzuspielen, kann dir zeigen, ob sich deine Requests zwischen Turns ändern, aber nicht, was der Cache der anderen Plattform getan hat; dort sind die Usage-Felder alles, was du hast.

Solltest du es in Produktion eingeschaltet lassen?

Meiner Einschätzung nach ja, für Agent-Schleifen auf der Claude API. Einen deterministischen Miss kannst du bei Bedarf debuggen: Diagnostics mit null einschalten, einen Turn warten, das Urteil lesen. Die Misses, die Dauerbetrieb rechtfertigen, sind die sporadischen, etwa ein Plugin-Scan, der ab und zu Tools umsortiert, oder ein Fallback, der nur unter Last greift. Wenn du es erst dann einschaltest, hat der verfehlte Turn keinen Fingerprint zum Vergleichen, und er wiederholt sich vielleicht nicht, während du zuschaust. Dagegen steht, dass das Feature nie einen Request blockiert oder scheitern lässt, und was es speichert, sind Hashes und Schätzungen von Token-Zahlen, kein Prompt-Text, begrenzt auf deine Organisation und deinen Workspace.

Lös Alarme aber nicht über das Urteil aus, sondern über den Read-Zähler: Jeder Turn nach dem ersten mit Reads nahe null ist einen Blick wert, egal was Diagnostics sagt. Das Urteil sagt dir, wo du suchen musst. Ein *_changed-Typ ist dein Prompt, null ist die TTL oder ein nicht cachebares Präfix, und unavailable sind deine Parameter, wofür der Fingerprint da ist.

Was prüfst du zuerst, wenn die Cache-Reads auf null fallen?

In dieser Reihenfolge. Stell sicher, dass du alle drei Usage-Felder loggst, damit du einen Miss von einem Präfix unterscheiden kannst, das nie gecacht wurde. Lies den Diagnostics-Typ und behebe die Ebene, die er nennt, erste Abweichung zuerst, dann schau erneut. Ist das Urteil null, prüf, ob Turn eins den Cache geschrieben hat, dann den Abstand zwischen den Turns. Ist es unavailable, vergleiche die Parameter-Fingerprints. Und während du auf den nächsten Turn wartest, schau dir den system-Text an: Ob dort ein Zeitstempel steht, ist in Sekunden geprüft.

Diskussion

Hier gibt es keine Kommentarspalte. Diskussionen laufen auf X.

Max Nardit

Max Nardit

@mnardit

Weitere Artikel

Was die Settings-Datei eines geklonten Repos noch ausführen kann

Claude Code hat Repositories gerade verboten, den Telemetrie-Export einzuschalten. Der schmale Fix verdeckt eine breitere Tatsache: Eine eingecheckte Settings-Datei ist Code, der mit deinen Rechten läuft, und in einem Headless-Lauf fragt vorher niemand. Interaktiv fragt zwar ein Dialog, aber ich bestätige ihn, ohne ihn zu lesen.

Opus 5.5 ist günstiger, und die 400er sind der einfache Teil

Ein Request, der 400 zurückgibt, hat dir schon gesagt, was du reparieren musst. Die Änderungen, die dich bei diesem Upgrade etwas kosten, kommen als 200 zurück: eine Antwort, die nicht mehr mit Text beginnt, ein Effort-Level, das eine Stufe tiefer liegt, weil du es nie gesetzt hast, und ein Alias, der dich ohne Deploy auf ein neues Modell umgestellt hat.

Ein Preis, den man nicht ausrechnen kann

Die Rechnung steigt um $96 im Jahr, das ist nichts. Darunter hat sich etwas anderes verändert: Die Zahl lässt sich nicht mehr herleiten. Das im Plan enthaltene Kontingent hat keine veröffentlichte Größe, das Gratis-Kontingent an Credits auch nicht, und der Jahres-Credit kostet mehr als der Monats-Credit. Ein Preis, den du nicht aus veröffentlichten Zahlen ausrechnen kannst, ist eine Schätzung, und eine Schätzung ist eine andere Art von Abhängigkeit.