LLM Prompt Caching #5: LangChain-Setups, die wirklich treffen
Inhalt
- Zuerst: welches “Caching” suchst du überhaupt?
- Die Lösung: Content-Blöcke statt Strings
- Wo deine Template-Variablen stehen, entscheidet über die Hit-Rate
- Tool-Definitionen werden ebenfalls gecacht
- Multi-Turn: den Marker auf die letzte Message setzen
- Zähler ablesen und ihre Namen kennen
- Implizite Caches: falsche Reihenfolge scheitert lautlos, also hier am genauesten hinschauen
- Checkliste
- Disclaimer
- Quellen
Hier ist ein LangChain-System-Prompt, der völlig vernünftig aussieht und nichts cacht:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
Wir haben das zweimal gegen claude-sonnet-5 laufen lassen, mit einem identischen System-Prompt von 1.800 Token, und die Usage-Felder ausgelesen. Beide Aufrufe: Cache-Writes 0, Cache-Reads 0. Kein Teiltreffer, kein fragmentierter Cache. Nichts. Der Grund: Anthropic cacht nur, was du mit cache_control markierst, und ein reiner String in einem ("system", ...)-Tupel hat keinen Platz für den Marker. Die bequemste Syntax in LangChain ist zugleich die, bei der der komplette Rabatt liegen bleibt – und keine Fehlermeldung weist dich darauf hin.
TL;DR
- LangChains
("system", "string")-Tupel kann keincache_controltransportieren, also cacht Claude nichts: Ein identischer System-Prompt mit 1.800 Tokens ergab 0 Cache-Schreibvorgänge und 0 Lesevorgänge aufclaude-sonnet-5. - Die Lösung ist eine
SystemMessagemit Content-Block-cache_control; ein Marker am System-Block deckt auch die mitbind_toolsgebundenen Tools ab. - Mit
langchain-anthropic1.4.8 bleibtinput_token_details.cache_creationselbst bei echten Schreibvorgängen 0; der wahre Wert steht inephemeral_5m_input_tokens. - Ein falsch geordneter RAG-Prompt (variierender Kontext vor stabilen Regeln) zahlt bei jedem Aufruf den Cache-Schreibaufschlag von etwa 1,25x: teurer, als gar nicht zu cachen.
Das ist Teil 5 der Caching-Serie. Teil 1 erklärt, wie Prefix-Caching funktioniert, Teil 3 bringt das Tutorial mit dem rohen SDK. In diesem Teil geht es darum, was sich ändert, wenn LangChain deine Prompts für dich zusammenbaut. Alles unten wurde am 2026-07-04 über das Synthorai-Gateway gemessen, mit langchain-core 1.4.8, langchain-anthropic 1.4.8 und langchain-openai 1.3.3.
Zuerst: welches “Caching” suchst du überhaupt?
Zwei völlig verschiedene Features teilen sich das Wort, und die LangChain-Doku-Seite, auf der du bei der Suche landest, ist meist die falsche.
Response-Caching (LangChains InMemoryCache) | Prompt-Caching (diese Serie) | |
|---|---|---|
| Was gespeichert wird | Die komplette Completion, in deiner App | Der KV-State des Prompt-Prefix, beim Provider |
| Wann es Geld spart | Exakt dieselbe Anfrage wiederholt sich | Verschiedene Anfragen teilen sich ein Prefix |
| Wo | set_llm_cache(InMemoryCache()), SQLite, Redis | cache_control-Marker oder automatisches Prefix-Matching |
| Agent-Loops, RAG, Chat | Praktisch nutzlos (jede Anfrage ist anders) | Der zentrale Hebel, da System + Tools sich in jedem Turn wiederholen |
Und “exakt dieselbe Anfrage” heißt exakt: Die eingebauten Caches keyen auf das Paar (serialisierter Prompt, Model-Config-String). Gemessen: Eine identische Wiederholung kam in 0 ms zurück, ohne API-Aufruf; ein einziges zusätzliches Leerzeichen im Prompt verfehlte den Cache; derselbe Prompt mit max_tokens um eins geändert verfehlte ebenfalls. (Der gecachte Replay gibt auch die Usage-Zahlen des ursprünglichen Aufrufs zurück, naive Token-Abrechnung zählt also doppelt.) Semantische Caches gibt es als Drittanbieter-Integrationen; die eingebauten funktionieren nur per Exact-Match.
set_llm_cache eignet sich also, um identische Aufrufe in Tests zu deduplizieren; der System-Prompt mit 2.000 Token, den du in jedem Agent-Turn erneut sendest, ist Sache des Prompt-Cachings – und dafür muss der Prompt richtig zusammengebaut sein.
Die Lösung: Content-Blöcke statt Strings
cache_control wird innerhalb eines Content-Blocks übertragen. Die System-Nachricht muss deshalb eine SystemMessage mit Block-Inhalt sein, kein reiner String:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
llm = ChatAnthropic(
model="claude-sonnet-5",
base_url="https://synthorai.io", # any Anthropic-compatible endpoint
)
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # a bare string has nowhere to put this
}]),
("human", "{question}"),
])
chain = prompt | llm
Derselbe System-Prompt mit 1.800 Token, gemessen über dasselbe Gateway:
| Call | String-Tupel-Syntax | Content-Block-Syntax |
|---|---|---|
| 1. (cold) | write 0 / read 0 | write 1.875 / read 0 |
| 2., andere Frage | write 0 / read 0 | write 0 / read 1.875 |
Ein Warm-Read wird mit etwa 10 % des Input-Preises abgerechnet. Bei Claude entscheidet diese eine strukturelle Änderung also darüber, ob du für immer den vollen Preis zahlst oder 90 % Rabatt auf die stabile Hälfte jedes Calls bekommst. Die Wirtschaftlichkeit steht in Teil 1; die Marker-Mechanik entspricht der Roh-SDK-Nutzung in den Docs zur LangChain-Anthropic-Integration und Anthropics Prompt-Caching-Guide.
Wo deine Template-Variablen stehen, entscheidet über die Hit-Rate
LangChain-Templates machen es mühelos, Variablen an beliebiger Stelle einzusetzen – und genau darin liegt die Gefahr. Der Cache-Key ist das byte-exakte Präfix. Wir haben ein Datum in den gecachten Block gesetzt und gemessen:
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| Call | Ergebnis |
|---|---|
| Tag A, Frage 1 | write 1.865 (cold für diesen Wert) |
| Tag A, Frage 2 | read 1.865 (gleicher Wert, Hit) |
| Tag B, Frage 1 | write 1.865 (neuer Wert, wieder cold) |
Der Cache ist nicht kaputt. Er wurde auf die Variable gekeyt. Ein Wert, der sich wiederholt, etwa ein Datum, kostet pro Wert einen Cache-Write und trifft danach. Ein Wert, der pro Call einmalig ist, etwa ein Timestamp oder eine Request-ID, macht jeden Call zu einem Cold-Write und die Hit-Rate ist exakt null.
Die teure Variante dieses Fehlers in der Praxis ist RAG. Das Template, zu dem viele Chains greifen, setzt den abgerufenen Kontext an den Anfang des System-Prompts, vor die statischen Anweisungen. Wir haben beide Reihenfolgen gemessen, mit einem abgerufenen Kontext von 800 Token, der pro Query wechselt, und einem markierten System-Block:
| Reihenfolge im Prompt | Call 1 | Call 2 (neue Query, neuer Kontext) |
|---|---|---|
| Erst Kontext, dann Regeln | write 3.133 | wieder write 3.133, read 0 |
| Erst Regeln (markiert), Kontext im Human-Turn | write 1.852 | read 1.852 |
Die falsche Zeile bedeutet nicht bloß „kein Rabatt”: jeder Call zahlt den Cache-Write-Aufschlag von etwa dem 1,25-fachen des normalen Input-Preises, und zwar auf alle 3.133 Token, und nichts wird je zurückgelesen. Ein falsch geordneter RAG-Prompt mit aktiviertem Caching kostet mehr, als gar nicht zu cachen. Der feste Inhalt steht nach dem variablen Inhalt und könnte genauso gut gar nicht existieren.
Die Regel, die aus den Messungen folgt:
- Statischer Text zuerst, innerhalb des markierten Blocks. System-Regeln, Tool-Definitionen, Few-Shot-Beispiele.
- Alles, was variiert, kommt hinter den Marker, idealerweise in den Human-Turn: abgerufener Kontext, Datum, Nutzerfragen.
- Eine Variable innerhalb des Blocks ist nur dann akzeptabel, wenn sie sich oft genug wiederholt, um ihren eigenen Cache-Write zu amortisieren.
Tool-Definitionen werden ebenfalls gecacht
Ein Agent schickt seine Tool-Schemas bei jedem Aufruf erneut mit, und im Request-Layout von Anthropic stehen die Tools vor dem System-Prompt. Da ein Marker bedeutet “cache alles vom Anfang des Requests bis hierher”, ergeben sich zwei praktische Fragen. Deckt ein Marker auf dem System-Block auch die davor liegenden Tools ab? Und erzeugt LangChains bind_tools bei jedem Aufruf exakt dieselben Bytes für deine Tools – denn wenn die Serialisierung schwankt, ändert sich das Prefix und jeder Aufruf verfehlt den Cache.
Für beides gibt es gemessene Antworten. Bei gleichem markiertem System-Prompt lag der Warm-Cache-Read ohne Tools bei 1.861 Token und mit zwei gebundenen Tools bei 2.389 Token: die zusätzlichen 528 Token sind die Tool-Schemas, die aus dem Cache zurückkommen. Und diese 2.389 wiederholten sich über drei aufeinanderfolgende Aufrufe exakt, das heißt bind_tools serialisiert jedes Mal identisch; das Framework schleust kein Rauschen ins Prefix ein. Also explizit: solange der System-Block den Marker trägt, brauchen die Tools selbst kein cache_control; der eine Marker dahinter erledigt die ganze Arbeit.
Die umgekehrte Anordnung gibt es für einen speziellen Fall: die Tools sind das größte stabile Element, das du schickst, und der System-Prompt ist dünn oder fehlt ganz. Dann braucht der Request trotzdem irgendwo einen Marker, und der kann auf einem Tool sitzen. Das funktioniert nur mit dem rohen Dict im Anthropic-Format, denn eine mit @tool dekorierte Funktion hat kein Feld dafür; bind_tools reicht das Dict unverändert durch:
# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
"name": "get_weather",
"description": LONG_TOOL_DESCRIPTION,
"input_schema": {...},
"cache_control": {"type": "ephemeral"}, # passes through bind_tools verbatim
}])
Gemessen: 3.002 cold geschrieben, 3.002 warm gelesen, ohne markierte System-Message im Request.
Multi-Turn: den Marker auf die letzte Message setzen
Eine Konversation sieht nach einem weiteren Ordering-Problem aus, ist aber der umgekehrte Fall: die Reihenfolge ist bereits perfekt, denn der Verlauf wird nur angehängt, das gesamte Transkript ist also ein stabiles Prefix. Das Problem hier ist die Abdeckung. Ein Marker auf dem System-Block cacht den System-Block und nichts danach: wächst der Verlauf, bleibt der Warm-Read flach auf der Größe des System-Blocks, während jeder angesammelte Turn als normaler Input abgerechnet wird.
Das Muster, das das behebt, ist dasselbe wie im rohen SDK: setze den Marker auf die letzte Message, damit der Breakpoint nach vorne wandert und die gesamte bisherige Konversation zum gecachten Prefix wird:
def marked(text):
return HumanMessage(content=[{
"type": "text", "text": text,
"cache_control": {"type": "ephemeral"},
}])
# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])
Gemessen über zwei Turns: Turn 1 schrieb 1.864; Turn 2 las 1.864 und schrieb nur das Delta von 15 Token (die vorige Antwort plus die neue Frage), das vorige Prefix wurde mit der Read-Rate von ≈10 % abgerechnet. Das ist genau die Form, die eine Agent-Loop braucht, und LangChain drückt sie mit einer gewöhnlichen Message-Liste aus. Anthropic erlaubt bis zu vier Marker pro Request, der gleitende Marker lässt sich also mit einem festen Marker auf dem System-Block oder auf den Tools kombinieren.
Zähler ablesen und ihre Namen kennen
LangChain vereinheitlicht die Verbrauchsdaten in usage_metadata, und hier ist die Falle, in die wir getappt sind: Mit langchain-anthropic 1.4.8 blieb bei jeder Antwort in unseren Läufen das Standardfeld input_token_details.cache_creation auf 0, selbst wenn ein Cache-Write stattfand. Der tatsächliche Write-Count landet unter einem nicht standardisierten Key:
r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"] # correct on hits (1875 above)
det["cache_creation"] # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"] # the actual write count (1875)
Der Provider meldete den Write korrekt (cache_creation_input_tokens: 1875 in der Rohantwort, sichtbar über r.response_metadata["usage"]); das standardisierte Mapping legt ihn nur unter dem Key für den TTL-Bucket ab. Ein Kostendashboard, das cache_creation beobachtet, meldet dir Caching als kostenlos, während der Write-Aufschlag still auflaufen. Verlass dich auf das Roh-Usage-Objekt oder kenn die Bucket-Keys. Das ist dieselbe Klasse von Problem wie fehlerhaft gemeldete Cache-Felder bei Gateways, die wir in Lügt dein LLM-Gateway über den Cache? durchleuchten.
Implizite Caches: falsche Reihenfolge scheitert lautlos, also hier am genauesten hinschauen
Claudes Cache ist explizit. GPT und die meisten Open-Weight-Provider cachen automatisch bei Prefix-Match, ohne Marker, und über LangChain funktioniert dieselbe Chain nach einer einzigen Änderung am Konstruktor:
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
Einfacher String als System-Prompt, keine Marker: GLM 5.2 las beim zweiten Aufruf 1.088 Tokens des rund 1.850 Tokens langen Prefix. (Nicht das gesamte: automatische Caches matchen in groben Blockschritten statt Byte für Byte bis zum Ende; OpenAI dokumentiert etwa eine Granularität von 128 Tokens.) So weit, so gut. Aber die Gefahr der falschen Reihenfolge aus der RAG-Tabelle oben greift hier mit voller Wucht, und mit einem gemeineren Fehlermodus. Wir haben dasselbe Reihenfolge-Experiment auf dem automatischen Pfad wiederholt, bei jedem Aufruf neu abgerufener Kontext:
| Reihenfolge (keine Marker, automatischer Cache) | Aufruf 1 | Aufruf 2 (neue Query, neuer Kontext) |
|---|---|---|
| Kontext zuerst, dann Regeln | read 0 | read 0 |
| Regeln zuerst, Kontext im Human-Turn | read 0 | read 1.088 |
Die falsche Reihenfolge ist eine unbedingte Null: der wechselnde Kontext sitzt vorne, keine zwei Aufrufe teilen sich einen Prefix, und der Rabatt kommt nie. Auf dem expliziten Pfad taucht derselbe Fehler wenigstens auf der Rechnung auf, als Cache-Write-Aufschlag bei jedem Aufruf; auf dem impliziten Pfad gibt es keinen Aufschlag, keinen Fehler und kein Signal. Der Prompt qualifiziert sich einfach still nie, während du annimmst, „automatisch” heiße „funktioniert”. Und weil es keinen Marker zu setzen gibt, ist die Prompt-Reihenfolge der einzige Hebel, den dir der implizite Pfad gibt.
Also verifiziere anhand der Zähler, in Produktion statt einmalig im Test: input_token_details.cache_read (LangChain) oder prompt_tokens_details.cached_tokens (raw). OpenAIs automatisches Caching dokumentiert zusätzlich einen Mindest-Prefix von 1.024 Tokens, und TTL sowie Eignung unterscheiden sich je nach Provider — das ist das Terrain von Teil 2.
Checkliste
- Bei Claude hat ein String-Tupel wie
("system", "...")keine Möglichkeit,cache_controlmitzuführen: Es wird nichts gecacht und es gibt keine Warnung. Cachebare System-Prompts gehören in eineSystemMessagemit Content-Blöcken und dem Marker. - Der Cache-Key ist das byte-genaue Präfix: statischer Inhalt zuerst, Variablen nach dem Marker oder im Human-Turn. RAG-Kontext vor den Regeln verfehlt nicht nur den Cache, sondern zahlt bei jedem Call den Write-Aufschlag.
- Eine Variable innerhalb des gecachten Blocks erzeugt pro Wert einen eigenen Cache-Eintrag: wiederkehrende Werte amortisieren sich, pro Call einzigartige Werte (Timestamps, Request-IDs) treffen den Cache nie.
- Tools stehen im Präfix vor dem System-Prompt, also cacht der System-Marker auch die gebundenen Tools (
bind_toolsserialisiert deterministisch). Wenn Tools dein größter stabiler Block sind, kann der Marker stattdessen auf ein Tool-Dict im Anthropic-Format. - In Konversationen lässt ein fest am System-Block platzierter Marker die wachsende History zum vollen Preis liegen; setz den Marker auf die neueste Nachricht, damit jeder Turn das vorherige Präfix liest und nur das Delta schreibt.
- Überwache nicht
input_token_details.cache_creation: Der Wert bleibt selbst bei Writes auf 0, ein Dashboard schließt daraus, Caching sei kostenlos, während die Write-Aufschläge auflaufen. Der echte Wert steht inephemeral_5m_input_tokens, oder lies das roheresponse_metadata["usage"]. - Bei Modellen mit automatischem Cache (GPT, GLM, DeepSeek) ist die Prompt-Reihenfolge der einzige Hebel, und eine falsche Reihenfolge scheitert lautlos: kein Aufschlag, kein Fehler, nur ein Rabatt, der nie kommt. Prüfe die Hits über die Usage-Felder.
set_llm_cachespeichert ganze Responses mit dem exakten Prompt und der Modellkonfiguration als Key; das lohnt sich nur, wenn sich identische Requests wiederholen, nie in einer Agent-Loop.
Die Gewohnheiten sind klein: ein Content-Block statt eines Strings, statisch vor variabel, ein Marker, der mit der Konversation wandert, ein Usage-Feld korrekt gelesen. Der gemessene Unterschied war 90 % Rabatt auf jeden stabilen Token gegenüber nichts, und im Fall der falsch sortierten RAG gegenüber einem Aufpreis. LangChain steht Prompt Caching nicht im Weg; es macht nur die falsche Prompt-Form genauso leicht schreibbar wie die richtige.
Disclaimer
Gemessen am 04.07.2026 gegen https://synthorai.io/ mit langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3, den Modellen claude-sonnet-5 und glm-5.2, einem englischen System-Präfix von rund 1.800 Token, kleinen Stichproben und 1–2 Sekunden Abstand zwischen aufeinanderfolgenden Calls, damit die Cache-Writes Zeit haben zu greifen. Jedes Experiment startete mit einem frisch randomisierten Präfix, um einen kalten Cache zu garantieren; deshalb weichen die Baseline-Tokenzahlen zwischen den Tabellen leicht voneinander ab (1.852 bis 1.875). Die Feld-Mappings der Libraries und das Cache-Verhalten der Provider ändern sich zwischen Versionen; miss gegen deinen eigenen Stack nach, bevor du dich auf die Zahlen verlässt.