Prompt Caching LLM n°5 : des configs LangChain qui touchent vraiment le cache
Sommaire
- D’abord, quel « caching » cherchez-vous ?
- Le correctif : des blocs de contenu, pas des chaînes
- L’emplacement de vos variables de template détermine votre taux de hit
- Les définitions d’outils sont mises en cache aussi
- Multi-tour : déplacer le marqueur sur le dernier message
- Lisez les compteurs, et sachez comment ils s’appellent
- Caches implicites : un mauvais ordre échoue en silence, alors surveillez-le encore plus ici
- Checklist
- Disclaimer
- Sources
Voici un system prompt LangChain qui a l’air parfaitement raisonnable et qui ne met rien en cache :
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
On l’a exécuté deux fois contre claude-sonnet-5 avec un system prompt identique de 1 800 tokens, puis on a relu les champs d’usage. Les deux appels : 0 écriture en cache, 0 lecture en cache. Pas un hit partiel, pas un cache fragmenté. Rien. La raison : Anthropic ne met en cache que ce que vous marquez avec cache_control, et une simple chaîne dans un tuple ("system", ...) n’a nulle part où poser ce marqueur. La syntaxe la plus pratique de LangChain est aussi celle qui laisse toute la remise sur la table, et aucune erreur ne vous prévient.
TL;DR
- Le tuple
("system", "string")de LangChain ne peut pas transportercache_control, donc Claude ne met rien en cache : un prompt système identique de 1 800 tokens a mesuré 0 écriture et 0 lecture de cache surclaude-sonnet-5. - La solution est un
SystemMessageavec un bloc de contenucache_control; un seul marqueur sur le bloc système couvre aussi les outils liés avecbind_tools. - Avec
langchain-anthropic1.4.8,input_token_details.cache_creationreste à 0 même lors d’écritures réelles ; le vrai décompte se trouve dansephemeral_5m_input_tokens. - Un prompt RAG mal ordonné (contexte variable avant règles stables) paie la surprime d’écriture de cache, environ 1,25x, à chaque appel : plus coûteux que de ne pas mettre en cache du tout.
C’est la partie 5 de la série sur le caching. La partie 1 explique le fonctionnement du prefix caching, la partie 3 est le tutoriel avec le SDK brut. Cette partie porte sur ce qui change quand c’est LangChain qui assemble vos prompts. Tout ce qui suit a été mesuré le 2026-07-04 via le gateway Synthorai avec langchain-core 1.4.8, langchain-anthropic 1.4.8 et langchain-openai 1.3.3.
D’abord, quel « caching » cherchez-vous ?
Deux fonctionnalités sans rapport partagent le même mot, et la page de doc LangChain sur laquelle vous tombez en cherchant est en général la mauvaise.
Response caching (InMemoryCache de LangChain) | Prompt caching (cette série) | |
|---|---|---|
| Ce qu’il stocke | La complétion entière, dans votre app | L’état KV du préfixe du prompt, chez le provider |
| Quand il fait économiser | La requête est exactement identique | Des requêtes différentes partagent un préfixe |
| Où | set_llm_cache(InMemoryCache()), SQLite, Redis | Marqueurs cache_control ou matching automatique de préfixe |
| Boucles d’agent, RAG, chat | Quasi inutile (chaque requête diffère) | Le levier principal, puisque system + tools se répètent à chaque tour |
Et « requête exactement identique » veut dire exactement : les caches intégrés indexent sur la paire (prompt sérialisé, chaîne de config du modèle). Mesuré : une répétition identique renvoyée en 0 ms sans appel API ; ajouter un seul espace au prompt fait manquer le cache ; le même prompt avec max_tokens modifié de un manque aussi. (Le rejeu depuis le cache renvoie également les chiffres d’usage de l’appel d’origine, donc un comptage naïf des tokens compte double.) Des caches sémantiques existent en intégrations tierces ; les caches intégrés font uniquement du match exact.
Donc set_llm_cache convient pour dédupliquer des appels identiques dans les tests ; le system prompt de 2 000 tokens que vous renvoyez à chaque tour d’agent, c’est le boulot du prompt caching, et il faut que le prompt soit assemblé correctement.
Le correctif : des blocs de contenu, pas des chaînes
cache_control se transporte à l’intérieur d’un bloc de contenu. Le message système doit donc être un SystemMessage dont le contenu est un bloc, et non une simple chaîne :
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
Même prompt système de 1 800 tokens, mesuré via le même gateway :
| Appel | Syntaxe chaîne-tuple | Syntaxe bloc de contenu |
|---|---|---|
| 1er (à froid) | write 0 / read 0 | write 1,875 / read 0 |
| 2e, question différente | write 0 / read 0 | write 0 / read 1,875 |
Une lecture à chaud est facturée à environ 10 % du prix d’entrée. Sur Claude, ce simple changement de structure fait la différence entre payer plein tarif indéfiniment et bénéficier d’une réduction de 90 % sur la moitié stable de chaque appel. Les calculs de coûts sont dans la partie 1 ; le fonctionnement des marqueurs correspond à l’usage brut du SDK décrit dans la documentation de l’intégration LangChain Anthropic et le guide du prompt caching d’Anthropic.
L’emplacement de vos variables de template détermine votre taux de hit
Les templates LangChain rendent l’interpolation de variables triviale, où qu’on les place — et c’est précisément le piège. La clé de cache, c’est le préfixe exact au byte près. On a placé une date à l’intérieur du bloc mis en cache et voici les mesures :
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| Appel | Résultat |
|---|---|
| jour A, question 1 | write 1,865 (à froid pour cette valeur) |
| jour A, question 2 | read 1,865 (même valeur, hit) |
| jour B, question 1 | write 1,865 (nouvelle valeur, de nouveau à froid) |
Le cache n’est pas cassé. Il a été indexé sur la variable. Une valeur qui se répète, comme une date, coûte un cache write par valeur et fait un hit ensuite. Une valeur unique à chaque appel, comme un timestamp ou un request ID, transforme chaque appel en write à froid et ramène le taux de hit à zéro.
La version coûteuse et bien réelle de cette erreur, c’est le RAG. Le template vers lequel beaucoup de chaînes se tournent place le contexte récupéré en tête du prompt système, avant les instructions statiques. On a mesuré les deux ordres avec un contexte récupéré de 800 tokens qui change à chaque requête et un bloc système marqué :
| Ordre dans le prompt | Appel 1 | Appel 2 (nouvelle requête, nouveau contexte) |
|---|---|---|
| Contexte d’abord, puis règles | write 3,133 | write 3,133 à nouveau, read 0 |
| Règles d’abord (marquées), contexte dans le tour human | write 1,852 | read 1,852 |
La mauvaise ligne, ce n’est pas seulement « aucune réduction » : chaque appel paie la prime de cache write, environ 1,25× le prix d’entrée normal, sur la totalité des 3 133 tokens, et rien n’est jamais relu. Un prompt RAG mal ordonné avec le caching activé coûte plus cher que de ne pas cacher du tout. Le contenu fixe se retrouve après le contenu variable, autant dire qu’il n’existe pas.
La règle qui ressort de ces mesures :
- Le texte statique d’abord, à l’intérieur du bloc marqué. Règles système, définitions d’outils, exemples few-shot.
- Tout ce qui varie passe après le marqueur, idéalement dans le tour human : contexte récupéré, dates, questions de l’utilisateur.
- Une variable à l’intérieur du bloc n’est acceptable que si elle se répète assez souvent pour amortir son propre cache write.
Les définitions d’outils sont mises en cache aussi
Un agent renvoie ses schémas d’outils à chaque appel, et dans la structure de requête d’Anthropic les outils se trouvent avant le system prompt. Comme un marqueur signifie « mets en cache tout, du début de la requête jusqu’ici », deux questions concrètes se posent. Un marqueur placé sur le bloc system couvre-t-il aussi les outils qui le précèdent ? Et est-ce que bind_tools de LangChain produit exactement les mêmes octets à chaque appel, sachant qu’à la moindre variation dans la sérialisation le préfixe change et tous les appels ratent le cache.
Voici les réponses mesurées aux deux. Avec le même system prompt marqué, la lecture de cache à chaud était de 1 861 tokens sans outils et de 2 389 tokens avec deux outils bindés : les 528 tokens supplémentaires sont les schémas d’outils qui ressortent du cache. Et ces 2 389 tokens se sont répétés à l’identique sur trois appels consécutifs, ce qui prouve que bind_tools sérialise de la même façon à chaque fois ; le framework n’injecte pas de bruit dans le préfixe. Pour être clair : tant que le bloc system porte le marqueur, les outils eux-mêmes n’ont pas besoin de cache_control ; ce seul marqueur placé après eux fait tout le travail.
L’agencement inverse existe pour un cas précis : les outils sont l’élément stable le plus volumineux que vous envoyez, et le system prompt est léger ou absent. La requête a alors quand même besoin d’un marqueur quelque part, et il peut se placer sur un outil. Cela ne marche qu’avec le dict au format Anthropic brut, car une fonction décorée par @tool n’a aucun champ pour le porter ; bind_tools laisse passer le dict tel quel :
# 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
}])
Mesuré : 3 002 tokens écrits à froid, 3 002 lus à chaud, sans aucun message system marqué dans la requête.
Multi-tour : déplacer le marqueur sur le dernier message
Une conversation ressemble à un autre problème d’ordre, mais c’est le cas inverse : l’ordre est déjà parfait, puisque l’historique ne fait qu’ajouter à la fin, donc toute la transcription est un préfixe stable. Le problème ici, c’est la couverture. Un marqueur sur le bloc system met en cache le bloc system et rien de plus : à mesure que l’historique grossit, la lecture à chaud reste bloquée à la taille du system tandis que chaque tour accumulé est facturé comme de l’input ordinaire.
La solution est le même schéma que le SDK brut utilise : placer le marqueur sur le dernier message, pour que le breakpoint glisse vers l’avant et que toute la conversation jusqu’ici devienne le préfixe mis en cache :
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)])
Mesuré sur deux tours : le tour 1 a écrit 1 864 tokens ; le tour 2 a lu 1 864 tokens et n’a écrit que le delta de 15 tokens (la réponse précédente plus la nouvelle question), le préfixe précédent étant facturé au tarif de lecture d’environ 10 %. C’est exactement la forme dont une boucle d’agent a besoin, et LangChain l’exprime avec une simple liste de messages. Anthropic autorise jusqu’à quatre marqueurs par requête, donc le marqueur glissant se combine avec un marqueur fixe sur le bloc system ou sur les outils.
Lisez les compteurs, et sachez comment ils s’appellent
LangChain normalise l’usage dans usage_metadata, et voici le piège qu’on a rencontré : avec langchain-anthropic 1.4.8, sur chaque réponse de nos exécutions, le champ standard input_token_details.cache_creation est resté à 0 même quand une écriture de cache avait eu lieu. Le vrai nombre d’écritures atterrit dans une clé non standard :
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)
Le provider a bien rapporté l’écriture (cache_creation_input_tokens: 1875 dans la réponse brute, visible via r.response_metadata["usage"]) ; c’est le mapping normalisé qui la range sous la clé du bucket de TTL. Un dashboard de coûts qui surveille cache_creation vous fera croire que le caching est gratuit pendant que le surcoût d’écriture s’accumule en silence. Fiez-vous à l’objet usage brut, ou apprenez les clés des buckets. C’est la même famille de problème que les gateways qui rapportent mal les champs de cache, ce qu’on audite dans Votre gateway LLM ment-elle sur le cache ?.
Caches implicites : un mauvais ordre échoue en silence, alors surveillez-le encore plus ici
Le cache de Claude est explicite. GPT et la plupart des providers open-weight cachent automatiquement sur correspondance de préfixe, sans marqueurs, et via LangChain la même chaîne fonctionne après un seul changement dans le constructeur :
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
System prompt en chaîne de caractères simple, sans marqueurs : au deuxième appel, GLM 5.2 a lu 1 088 tokens sur le préfixe d’environ 1 850 tokens. (Pas la totalité : les caches automatiques matchent par incréments grossiers de blocs plutôt que octet par octet jusqu’à la fin ; OpenAI, par exemple, documente une granularité de 128 tokens.) Jusque-là, de l’argent gratuit. Mais le risque de mauvais ordre du tableau RAG ci-dessus s’applique ici de plein fouet, avec un mode d’échec plus vicieux. On a rejoué la même expérience d’ordre sur le chemin automatique, avec un nouveau contexte récupéré à chaque appel :
| Ordre (sans marqueurs, cache automatique) | Appel 1 | Appel 2 (nouvelle requête, nouveau contexte) |
|---|---|---|
| Contexte d’abord, puis règles | read 0 | read 0 |
| Règles d’abord, contexte dans le tour human | read 0 | read 1 088 |
Le mauvais ordre donne un zéro systématique : le contexte qui change est placé en tête, deux appels ne partagent jamais de préfixe, et la réduction n’arrive jamais. Sur le chemin explicite, la même erreur se voit au moins sur la facture sous forme de surcoût d’écriture de cache à chaque appel ; sur le chemin implicite, il n’y a pas de surcoût, pas d’erreur, aucun signal. Le prompt ne se qualifie tout simplement jamais, en silence, pendant que vous supposez qu’« automatique » veut dire « ça marche ». Et comme il n’y a pas de marqueur à placer, l’ordre du prompt est le seul levier que le chemin implicite vous offre.
Vérifiez donc depuis les compteurs, en production et pas une seule fois dans un test : input_token_details.cache_read (LangChain) ou prompt_tokens_details.cached_tokens (brut). Le caching automatique d’OpenAI documente en plus un préfixe minimum de 1 024 tokens, et le TTL comme l’éligibilité diffèrent selon le provider, ce qui est le terrain de la partie 2.
Checklist
- Sur Claude, un tuple de chaînes
("system", "...")n’a nulle part où portercache_control: rien n’est mis en cache, et rien ne vous prévient. Les system prompts cacheables passent par unSystemMessageavec des blocs de contenu et le marqueur. - La clé de cache est le préfixe exact au byte près : le contenu statique d’abord, les variables après le marqueur ou dans le tour humain. Un contexte RAG placé avant les règles ne se contente pas de rater le cache ; il paie le surcoût d’écriture à chaque appel.
- Une variable à l’intérieur du bloc caché crée une entrée de cache par valeur : les valeurs qui se répètent s’amortissent, celles qui sont uniques à chaque appel (timestamps, request IDs) ne touchent jamais le cache.
- Les tools se placent avant le system prompt dans le préfixe, donc le marqueur system met aussi en cache les tools liés (
bind_toolssérialise de façon déterministe). Si vos tools sont votre plus gros bloc stable, le marqueur peut aller sur un dict de tool au format Anthropic à la place. - Dans les conversations, un marqueur fixé sur le bloc system laisse l’historique croissant au prix plein ; placez-le sur le dernier message pour que chaque tour lise le préfixe précédent et n’écrive que le delta.
- Ne surveillez pas
input_token_details.cache_creation: il reste à 0 même lors des écritures, si bien qu’un dashboard conclut que le cache est gratuit pendant que les surcoûts d’écriture s’accumulent. Le vrai compteur est dansephemeral_5m_input_tokens, ou lisez leresponse_metadata["usage"]brut. - Sur les modèles à cache automatique (GPT, GLM, DeepSeek), l’ordre du prompt est le seul levier, et un mauvais ordre échoue silencieusement : pas de surcoût, pas d’erreur, juste une remise qui n’arrive jamais. Vérifiez les hits via les champs d’usage.
set_llm_cachestocke des réponses entières indexées sur le prompt et la config du modèle exacts ; ça ne paie que quand des requêtes identiques se répètent, jamais dans une boucle d’agent.
Les habitudes sont modestes : un bloc de contenu plutôt qu’une chaîne, le statique avant la variable, un marqueur qui suit la conversation, un champ d’usage lu correctement. La différence mesurée : une remise de 90 % sur chaque token stable contre rien du tout, et dans le cas du RAG mal ordonné, contre un surcoût. LangChain ne gêne pas le prompt caching ; il rend simplement la mauvaise forme de prompt aussi facile à écrire que la bonne.
Disclaimer
Mesuré le 2026-07-04 contre https://synthorai.io/ avec langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3, les modèles claude-sonnet-5 et glm-5.2, un préfixe system anglais d’environ 1 800 tokens, de petits échantillons, et un écart de 1 à 2 secondes entre appels consécutifs pour laisser aux écritures de cache le temps d’aboutir. Chaque expérience utilisait un préfixe fraîchement randomisé pour garantir un cache froid, d’où les compteurs de tokens de base qui varient légèrement d’une table à l’autre (1 852 à 1 875). Le mapping des champs de la librairie et le comportement du cache côté provider changent d’une version à l’autre ; refaites vos mesures sur votre propre stack avant de dépendre de ces chiffres.