Prompt caching LangChain : configs qui touchent vraiment le cache
Sommaire
- D’abord, de quelle « mise en cache » parle-t-on ?
- Le correctif : des blocs de contenu, pas des chaînes
- Le placement des variables du template détermine le taux de hit
- Les définitions d’outils sont également mises en cache
- Conversations multi-tours : déplacez le marqueur sur le dernier message
- Lisez les compteurs et connaissez le nom de leurs champs
- Caches implicites : un mauvais ordre échoue en silence, surveillez-le de près
- Checklist
- Avertissement
- Sources
Voici un prompt système LangChain qui semble parfaitement correct, mais 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}"),
])
Nous l’avons exécuté deux fois avec claude-sonnet-5, en conservant le même prompt système de 1,800 tokens, puis nous avons examiné les champs d’usage. Pour les deux appels : 0 token écrit dans le cache, 0 token lu. Ce n’est ni un hit partiel ni un cache fragmenté. Rien n’est mis en cache. Anthropic ne met en cache que le contenu marqué avec cache_control, et une simple chaîne dans un tuple ("system", ...) ne permet pas d’ajouter ce marqueur. La syntaxe la plus pratique de LangChain est aussi celle qui vous prive de toute la remise, sans aucune erreur pour vous prévenir.
TL;DR
- Le tuple
("system", "string")de LangChain ne peut pas contenircache_control. Claude ne met donc rien en cache : avec un prompt système identique de 1,800 tokens, nous avons mesuré 0 écriture et 0 lecture surclaude-sonnet-5. - Il faut utiliser un
SystemMessagedont le bloc de contenu portecache_control. Un seul marqueur sur le bloc système couvre également les outils associés avecbind_tools. - Avec
langchain-anthropic1.4.8,input_token_details.cache_creationreste à 0 même lorsqu’une écriture a réellement lieu. Le vrai compteur se trouve dansephemeral_5m_input_tokens. - Un prompt RAG mal ordonné, avec le contexte variable avant les règles stables, paie la majoration d’écriture dans le cache à chaque appel, soit environ 1.25x. Cela revient plus cher que de ne rien mettre en cache.
Série : Partie 5 sur 5 · Précédemment : Partie 1 — Principes de mise en cache · Partie 2 — Comparatif et évaluation des fournisseurs · Partie 3 — Tutoriel avec du code fonctionnel · Partie 4 — Meilleur LLM selon le cas d’usage
Voici la cinquième partie de notre série sur la mise en cache. La partie 1 explique le fonctionnement du cache par préfixe, la partie 3 présente le tutoriel avec les SDK bruts, et le guide complet sur la mise en cache des prompts fait le tour des différents fournisseurs. Cette partie traite des changements à prévoir lorsque LangChain assemble les prompts à votre place. Toutes les mesures ci-dessous ont été réalisées le 2026-07-04 via la gateway Synthorai, avec langchain-core 1.4.8, langchain-anthropic 1.4.8 et langchain-openai 1.3.3.
D’abord, de quelle « mise en cache » parle-t-on ?
Deux fonctions sans rapport portent le même nom, et la page de documentation LangChain proposée par les moteurs de recherche n’est généralement pas celle qu’il vous faut.
Cache des réponses (InMemoryCache de LangChain) | Cache des prompts (cette série) | |
|---|---|---|
| Contenu stocké | La réponse complète, dans votre application | L’état KV du préfixe du prompt, chez le fournisseur |
| Source d’économies | Une requête strictement identique se répète | Plusieurs requêtes différentes partagent un même préfixe |
| Emplacement | set_llm_cache(InMemoryCache()), SQLite, Redis | Marqueurs cache_control ou détection automatique des préfixes |
| Boucles d’agent, RAG, chat | Presque inutile, car chaque requête diffère | Le principal levier, puisque le système et les outils se répètent à chaque tour |
Et « requête strictement identique » signifie bien identique au caractère près : les implémentations intégrées utilisent comme clé la paire formée du prompt sérialisé et de la chaîne de configuration du modèle. Nos mesures le confirment : un second appel identique a répondu en 0 ms sans appel API ; l’ajout d’une seule espace au prompt a raté le cache ; le même prompt avec un max_tokens décalé de un l’a également raté. La réponse rejouée depuis le cache contient par ailleurs les compteurs d’usage de l’appel initial. Une comptabilisation naïve des tokens les comptera donc deux fois. Des caches sémantiques sont disponibles via des intégrations tierces, mais les implémentations intégrées fonctionnent uniquement par correspondance exacte.
set_llm_cache convient donc pour dédupliquer des appels identiques dans des tests. En revanche, la mise en cache du prompt système de 2,000 tokens renvoyé à chaque tour d’un agent relève du cache de prompts, ce qui exige d’assembler le prompt correctement.
Le correctif : des blocs de contenu, pas des chaînes
cache_control se place dans un bloc de contenu. Le message système doit donc être un SystemMessage contenant des blocs, 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
Avec le même prompt système de 1,800 tokens et la même gateway, nous avons obtenu les mesures suivantes :
| Appel | Syntaxe avec tuple et chaîne | Syntaxe avec bloc de contenu |
|---|---|---|
| 1er (cache froid) | écriture 0 / lecture 0 | écriture 1,875 / lecture 0 |
| 2e, question différente | écriture 0 / lecture 0 | écriture 0 / lecture 1,875 |
Une lecture depuis un cache chaud est facturée environ 10% du prix d’entrée. Sur Claude, ce simple changement de structure fait donc toute la différence entre payer le plein tarif indéfiniment et bénéficier d’une remise de 90% sur la partie stable de chaque appel. Les aspects économiques sont détaillés dans la partie 1. Le fonctionnement des marqueurs correspond à l’utilisation du SDK brut présentée dans la documentation de l’intégration Anthropic de LangChain et le guide Anthropic sur la mise en cache des prompts.
Le placement des variables du template détermine le taux de hit
Les templates LangChain permettent d’insérer des variables n’importe où sans effort. C’est précisément le risque. La clé de cache correspond au préfixe exact, octet par octet. Nous avons placé une date dans le bloc mis en cache et effectué les mesures suivantes :
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 | écriture 1,865 (cache froid pour cette valeur) |
| jour A, question 2 | lecture 1,865 (même valeur, hit) |
| jour B, question 1 | écriture 1,865 (nouvelle valeur, nouveau cache froid) |
Le cache n’est pas défaillant. La variable fait simplement partie de sa clé. Une valeur répétée, comme une date, entraîne une écriture par valeur, puis les appels suivants touchent le cache. Une valeur unique à chaque appel, comme un timestamp ou un ID de requête, transforme chaque appel en écriture à froid et ramène le taux de hit à zéro.
Dans un cas réel, l’erreur la plus coûteuse concerne le RAG. De nombreuses chaînes placent le contexte récupéré en haut du prompt système, avant les instructions statiques. Nous avons testé les deux ordres avec un contexte récupéré de 800 tokens, différent à 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 | écriture 3,133 | nouvelle écriture 3,133, lecture 0 |
| Règles d’abord (marquées), contexte dans le tour utilisateur | écriture 1,852 | lecture 1,852 |
La première ligne ne signifie pas seulement « aucune remise ». Chaque appel paie la majoration d’écriture dans le cache, soit environ 1.25× le prix d’entrée normal, sur l’ensemble des 3,133 tokens, sans qu’aucun ne soit jamais relu. Avec la mise en cache activée, un prompt RAG mal ordonné coûte plus cher que sans cache. Le contenu fixe se trouve après le contenu variable : pour le cache, c’est comme s’il n’existait pas.
Les mesures conduisent aux règles suivantes :
- Placez d’abord le texte statique, dans le bloc marqué. Cela comprend les règles système, les définitions d’outils et les exemples few-shot.
- Tout ce qui varie doit venir après le marqueur, idéalement dans le tour utilisateur : contexte récupéré, dates et questions de l’utilisateur.
- Une variable dans le bloc peut convenir si elle se répète assez souvent pour amortir sa propre écriture dans le cache.
Les définitions d’outils sont également mises en cache
Un agent renvoie le schéma de ses outils à chaque appel. Dans la structure des requêtes Anthropic, les outils se trouvent avant le prompt système. Un marqueur signifie « mettre en cache tout ce qui se trouve entre le début de la requête et ce point ». Deux questions pratiques se posent donc. Un marqueur sur le bloc système couvre-t-il aussi les outils placés avant lui ? Et bind_tools sérialise-t-il les outils de manière strictement identique à chaque appel ? La moindre variation de sérialisation modifierait le préfixe et ferait rater tous les appels.
Nos mesures répondent oui dans les deux cas. Avec le même prompt système marqué, la lecture depuis le cache chaud était de 1,861 tokens sans outil, contre 2,389 tokens avec deux outils associés. Les 528 tokens supplémentaires correspondent aux schémas des outils lus depuis le cache. Ce total de 2,389 s’est répété à l’identique sur trois appels consécutifs. bind_tools produit donc une sérialisation stable à chaque fois ; le framework n’ajoute aucun bruit dans le préfixe. En clair : tant que le bloc système porte le marqueur, les outils n’ont pas besoin de leur propre cache_control. Le marqueur placé après eux suffit.
La configuration inverse convient à un cas précis : les outils constituent le plus gros bloc stable envoyé, tandis que le prompt système est très court ou absent. La requête a toujours besoin d’un marqueur, qui peut alors être placé sur un outil. Il faut pour cela utiliser un dictionnaire brut au format Anthropic, car une fonction décorée avec @tool ne dispose d’aucun champ adapté. bind_tools transmet le dictionnaire sans le modifier :
# 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
}])
Résultat mesuré : écriture à froid de 3,002, puis lecture à chaud de 3,002, sans aucun message système marqué dans la requête.
Conversations multi-tours : déplacez le marqueur sur le dernier message
Une conversation peut sembler présenter le même problème d’ordre, mais c’est l’inverse : l’ordre est déjà idéal. L’historique ne fait que s’allonger, si bien que l’ensemble de la transcription constitue un préfixe stable. Le problème concerne ici la couverture. Un marqueur placé sur le bloc système ne met en cache que ce bloc. À mesure que l’historique s’allonge, la lecture à chaud reste limitée à la taille du système, tandis que chaque tour accumulé est facturé comme une entrée ordinaire.
Le SDK brut utilise la bonne approche : placer le marqueur sur le message le plus récent. Le point de coupure avance ainsi à chaque tour, et toute la conversation jusque-là devient 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)])
Sur deux tours, nous avons mesuré une écriture de 1,864 au premier tour. Au deuxième, le cache a lu 1,864 et écrit uniquement le delta de 15 tokens, composé de la réponse précédente et de la nouvelle question. Le préfixe antérieur a été facturé au tarif de lecture d’environ 10%. C’est exactement le comportement recherché dans une boucle d’agent, et LangChain le permet avec une simple liste de messages. Anthropic accepte jusqu’à quatre marqueurs par requête. Le marqueur mobile peut donc être combiné à un marqueur fixe sur le bloc système ou les outils.
Lisez les compteurs et connaissez le nom de leurs champs
LangChain normalise les données d’usage dans usage_metadata. Nous avons rencontré un piège : avec langchain-anthropic 1.4.8, le champ standard input_token_details.cache_creation est resté à 0 même lorsqu’une écriture avait bien lieu, et ce sur toutes les réponses de nos tests. Le vrai compteur d’écriture se trouve 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 fournisseur signalait correctement l’écriture : la réponse brute contenait cache_creation_input_tokens: 1875, accessible via r.response_metadata["usage"]. C’est le mapping standardisé qui la range sous la clé correspondant au TTL. Un dashboard de coûts qui surveille cache_creation indiquera que la mise en cache ne coûte rien, alors que les majorations d’écriture continuent de s’accumuler. Utilisez l’objet d’usage brut ou tenez compte des clés associées aux TTL. Ce problème appartient à la même catégorie que les champs de cache erronés renvoyés par certaines gateways, que nous vérifions dans Votre gateway LLM ment-elle à propos du cache ?.
Caches implicites : un mauvais ordre échoue en silence, surveillez-le de près
Le cache de Claude est explicite. GPT et la plupart des fournisseurs de modèles open weight utilisent automatiquement le cache lorsqu’un préfixe correspond, sans marqueur. Avec LangChain, la même chaîne fonctionne en modifiant un seul constructeur :
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
Avec un prompt système sous forme de simple chaîne et sans marqueur, le deuxième appel à GLM 5.2 a lu 1,088 tokens sur un préfixe d’environ 1,850 tokens. Il ne l’a pas lu en entier : les caches automatiques recherchent des correspondances par blocs de taille fixe, et non octet par octet jusqu’à la fin. OpenAI documente par exemple une granularité de 128 tokens. Jusque-là, la remise est automatique. Mais le risque lié au mauvais ordre, illustré dans le tableau RAG ci-dessus, s’applique tout autant ici, avec un mode d’échec encore plus difficile à détecter. Nous avons répété la même expérience sur le chemin automatique, avec un nouveau contexte récupéré à chaque appel :
| Ordre (sans marqueur, cache automatique) | Appel 1 | Appel 2 (nouvelle requête, nouveau contexte) |
|---|---|---|
| Contexte d’abord, puis règles | lecture 0 | lecture 0 |
| Règles d’abord, contexte dans le tour utilisateur | lecture 0 | lecture 1,088 |
Avec le mauvais ordre, le résultat reste systématiquement à zéro : le contexte variable se trouve en tête, deux appels ne partagent donc jamais le même préfixe, et aucune remise ne s’applique. Sur le chemin explicite, cette erreur apparaît au moins sur la facture sous forme d’une majoration d’écriture à chaque appel. Sur le chemin implicite, il n’y a ni majoration, ni erreur, ni signal. Le prompt ne remplit simplement jamais les conditions requises, alors que vous supposez qu’« automatique » signifie « opérationnel ». En l’absence de marqueur, l’ordre du prompt est le seul réglage disponible avec le cache implicite.
Vérifiez donc les compteurs en production, pas seulement une fois pendant un test : input_token_details.cache_read avec LangChain ou prompt_tokens_details.cached_tokens avec l’API brute. La documentation sur la mise en cache automatique d’OpenAI indique également un préfixe minimal de 1,024 tokens. Les TTL et les critères d’éligibilité varient selon les fournisseurs ; ils sont traités dans la partie 2.
Checklist
- Sur Claude, un tuple de chaînes
("system", "...")ne peut pas contenircache_control: rien n’est mis en cache et aucun avertissement n’est émis. Placez les prompts système à mettre en cache dans unSystemMessagecomposé de blocs de contenu et ajoutez-y le marqueur. - La clé de cache correspond exactement au préfixe, octet par octet : contenu statique d’abord, variables après le marqueur ou dans le tour utilisateur. Placer le contexte RAG avant les règles ne provoque pas seulement un cache miss : cela entraîne la majoration d’écriture à chaque appel.
- Une variable placée dans le bloc mis en cache crée une entrée par valeur. Les valeurs répétées s’amortissent ; les valeurs uniques à chaque appel, comme les timestamps et les ID de requête, ne touchent jamais le cache.
- Les outils précèdent le prompt système dans le préfixe. Le marqueur système met donc également en cache les outils associés, et
bind_toolsles sérialise de manière déterministe. Si les outils constituent votre plus gros bloc stable, le marqueur peut être placé sur un dictionnaire d’outil au format Anthropic. - Dans une conversation, un marqueur fixe sur le bloc système laisse l’historique croissant au plein tarif. Placez-le sur le dernier message afin 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. Un dashboard conclurait alors que le cache est gratuit pendant que les majorations d’écriture s’accumulent. Le vrai compteur se trouve dansephemeral_5m_input_tokens; vous pouvez aussi lire directementresponse_metadata["usage"]. - Sur les modèles à cache automatique, comme GPT, GLM et DeepSeek, l’ordre du prompt est le seul réglage disponible et un mauvais ordre échoue silencieusement : aucune majoration, aucune erreur, simplement une remise qui ne s’applique jamais. Vérifiez les hits dans les champs d’usage.
set_llm_cachestocke les réponses complètes en utilisant comme clé le prompt exact et la configuration du modèle. Il n’est rentable que si des requêtes identiques se répètent, jamais dans une boucle d’agent.
Les bonnes pratiques tiennent à peu de choses : un bloc de contenu plutôt qu’une chaîne, le statique avant le variable, un marqueur qui avance avec la conversation et la lecture du bon champ d’usage. Nous avons mesuré un écart entre une remise de 90% sur chaque token stable et aucune remise. Dans le cas du prompt RAG mal ordonné, l’alternative était même de payer davantage. LangChain ne bloque pas la mise en cache des prompts ; il rend simplement la mauvaise structure aussi facile à écrire que la bonne.
Avertissement
Mesures réalisées le 2026-07-04 sur 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 système en anglais d’environ 1,800 tokens, de petits échantillons et un délai de 1–2 secondes entre les appels consécutifs pour laisser le temps aux écritures dans le cache d’aboutir. Chaque expérience utilisait un préfixe aléatoire inédit afin de garantir un cache froid. C’est pourquoi les nombres de tokens de référence varient légèrement d’un tableau à l’autre, de 1,852 à 1,875. Le mapping des champs dans les bibliothèques et le comportement du cache des fournisseurs peuvent changer d’une version à l’autre. Reproduisez les mesures sur votre propre stack avant de vous appuyer sur ces chiffres.