Skip to content

Caching RAG — 3 niveaux pour diviser le coût par 8

TL;DR Sur un RAG e-commerce ou un helpdesk RH, 70-90 % des requêtes sont des redites ("comment retourner ?", "où est mon colis ?"). Sans cache, chaque question paie le retrieval + LLM. Avec un stack de 3 niveaux (Redis exact match + GPTCache semantic + Anthropic prompt caching), tu divises le coût LLM par 5 à 8 et la latence p95 par 4. Pour le freelance, c'est une mission packageable à 6-12 k€ seule, ou un add-on systématique dans toute mission RAG sérieuse. Hit ratio cible : > 50 % exact + > 25 % semantic = 75 % de cache global.

🧠 Mental model

Le cache RAG marche en cascade : on essaie le moins cher d'abord, on tombe sur le plus cher seulement si tout échoue.

   query ──┬──▶ ① Redis EXACT MATCH ──── hit ──▶ return (1 ms, 0 €)
           │                                ▲
           ▼                                │ miss
   ② GPTCache SEMANTIC ──── hit ──▶ return (40 ms, 0,0001 €)
           │                                ▲
           ▼                                │ miss
   ③ retrieve + LLM (Anthropic) ─────▶ generate (1500 ms, 0,005 €)

                └──▶ prompt caching réutilise system + docs persistants
                                 (économise 90 % sur tokens d'input)


       store in ① + ② for next time

Analogie : tu travailles dans un cabinet médical. Une assistante (Redis) a déjà la fiche du patient quand il revient → 0 effort. Sinon une infirmière (GPTCache) cherche dans les dossiers récents par symptôme similaire → effort moyen. Sinon le médecin (LLM) fait la consultation complète → effort max. Tu factures pareil aux 3 niveaux, mais tu paies différemment. Mêmes raisons d'être pour ton RAG.

Pour ton cerveau de dev : c'est HTTP cache mais pour LLM. Niveau 1 = If-None-Match. Niveau 2 = cache de proxy par hash sémantique. Niveau 3 = Last-Modified pour les morceaux invariants (prompt caching Anthropic).

Comparatif des 3 niveaux — comment un staff arbitre

Dimension① Exact (Redis)② Semantic (GPTCache)③ Prompt caching (Anthropic)
Ce qu'il économisetout l'appel LLMtout l'appel LLMle prefill du préfixe d'input
Cléhash exact (canonisé)similarité d'embeddingmatch de préfixe à l'octet
Latence d'un hit~1 ms~40 ms (embed + ANN)n/a (s'applique sur le miss ③)
Coût d'un hit≈ 0 €≈ coût embedding (~1e-5 €)cache-read ≈ 0,1× input
Risque principalfaible hit ratiofaux positif = mauvaise réponseinvalidateur silencieux (0 hit)
PersistanceTTL Redisvector storeTTL 5 min / 1h, par modèle
Invalidation cibléeSCAN + DEL / tagsmetadata filter (Qdrant)aucune — le préfixe change ou non

Le raisonnement clé : ① et ② sont des caches de réponse (ils sautent l'appel) ; ③ est un cache de calcul (il rend l'appel moins cher). Ils sont orthogonaux et se composent : sémantique d'abord, prompt caching sur le miss. Un junior croit qu'ils sont concurrents ; un senior les empile. Et le coût d'un faux positif sémantique (servir une réponse fausse) domine tout le reste de l'arbitrage — c'est pourquoi le threshold se tune sur le F1, pas sur le hit ratio (voir Exercice 2).

🛠️ Code minimal

Cache 3 niveaux en 30 lignes :

python
import hashlib, json, redis.asyncio as aredis
from gptcache import Cache
from gptcache.adapter.api import init_similar_cache
import anthropic

r = aredis.from_url("redis://localhost:6379/0")
init_similar_cache(data_dir="./gptcache_data")
sem_cache = Cache()
client = anthropic.AsyncAnthropic()

def k(q): return f"rag:exact:{hashlib.sha256(q.encode()).hexdigest()}"

async def cached_answer(query: str, docs: list[dict]) -> dict:
    # ① exact
    if cached := await r.get(k(query)):
        return {**json.loads(cached), "cache": "exact"}
    # ② semantic
    if hit := sem_cache.get(query):
        return {**json.loads(hit), "cache": "semantic"}
    # ③ LLM with prompt caching
    msg = await client.messages.create(
        model="claude-sonnet-4-6", max_tokens=1000,
        system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": f"DOCS:\n{docs}\n\nQ: {query}"}],
    )
    out = {"answer": msg.content[0].text, "cache": "miss"}
    await r.setex(k(query), 3600, json.dumps(out))
    sem_cache.set(query, json.dumps(out))
    return out

Tu as un cache fonctionnel. À industrialiser : metrics, invalidation, lock anti-stampede.

🎬 Cas d'usage concrets

Scénario 1 — Support client e-commerce mode (FR)

Qui : retailer mode FR (CA 22 M€), chatbot support traitant 3 200 questions/jour, RAG sur FAQ + politiques + catalogue.

Problème : 90 % des questions sont "où est mon colis ?", "comment retourner ?", "taille XS dispo en bleu ?". Chaque question = $0,008 LLM + retrieval. Coût mensuel : 7 600 € de tokens Claude. Latence p95 : 2,4 s. CFO panique.

Solution : stack 3 niveaux. Cache Redis sur la question canonisée (lowercased, stripped), GPTCache semantic sur le reste, prompt caching Anthropic sur le system + politique retour (qui ne change jamais).

python
def canonical(q: str) -> str:
    # Normalise : lower, strip ponctuation, remove articles
    q = q.lower().strip()
    q = re.sub(r"[^\w\s]", "", q)
    q = re.sub(r"\b(le|la|les|de|du|des|un|une|mon|ma)\b", "", q)
    return " ".join(q.split())

Gains :

  • Hit ratio exact : 38 % (mêmes phrases répétées)
  • Hit ratio semantic : 31 % (paraphrases : "où est ma commande" vs "ma livraison")
  • Hit ratio total cache : 69 %
  • Coût LLM : 7 600 → 1 850 €/mois (réduction de 76 %)
  • Latence p95 : 2,4 s → 640 ms

Mission packagée : 8 jours × 1 200 €/j = 9,6 k€ + retainer 1,5 k€/mois (monitoring + invalidation policy).

Scénario 2 — Helpdesk RH d'une ETI bancassurance

Qui : assureur mutualiste (4 500 collaborateurs), assistant interne RH, 1 800 questions/mois sur onboarding, congés, mutuelle, formation.

Problème : pic d'activité chaque 1er du mois (paie) et septembre (rentrée). 600 questions identiques en quelques jours. Coût LLM amorti, mais latence pendant les pics → 8 s. DRH râle.

Solution : focus sur les questions saisonnières. Cache Redis avec TTL adaptatif (TTL court pour les questions de paie en début de mois, TTL long pour "comment poser une formation"). Pré-warming du cache la veille des pics.

python
TTL_RULES = {
    r"\b(cong[ée]|RTT|vacances)\b": 86400 * 7,        # 7 jours
    r"\b(paie|fiche.paie|bulletin)\b": 86400 * 30,    # 30 jours
    r"\b(mutuelle|sant[ée])\b": 86400 * 90,           # 90 jours
    r"\b(formation|CPF)\b": 86400 * 30,
}
def ttl_for(q: str) -> int:
    for pattern, ttl in TTL_RULES.items():
        if re.search(pattern, q.lower()):
            return ttl
    return 3600  # default 1h

# Pré-warming
async def warm_cache(known_questions: list[str]):
    for q in known_questions:
        await answer_pipeline(q)  # triggers cache fill

Gains : pas de timeout pendant le pic, latence stable 600 ms. Satisfaction collaborateur +18 pts. ROI : DSI estime 1,2 ETP support RH économisé = 65 k€/an. Mission : 6 j × 1 350 €/j = 8,1 k€.

Scénario 3 — Banque mutualiste, FAQ tarifaire conseillers

Qui : banque mutualiste régionale, 2 800 conseillers, agent interne pour FAQ tarifaire ("frais virement SEPA", "tarif carte gold", "découvert autorisé").

Problème : tarifs évoluent tous les 3 mois. Cache "naïf" = vieilles infos servies. Cache trop court = pas d'effet.

Solution : cache avec invalidation par event (changement tarif détecté dans le système source → message Kafka → purge ciblée). Plus prompt caching Anthropic sur la grille tarifaire (ne change pas en cours de mois).

python
# Kafka consumer purge sur changement tarif
async def on_tarif_update(msg):
    affected_keys = await r.keys(f"rag:exact:*tarif*{msg['product']}*")
    if affected_keys:
        await r.delete(*affected_keys)
    await sem_cache.purge_by_tag(f"tarif_{msg['product']}")

# Tagging au store
async def cache_answer(q, ans, tags):
    await r.setex(k(q), ttl_for(q), json.dumps(ans))
    sem_cache.set(q, json.dumps(ans), metadata={"tags": tags})

Gains : hit ratio 72 %, mais 0 % de réponses obsolètes (vs ~6 % avec cache time-based). Coût LLM divisé par 5. Mission : 10 j × 1 400 €/j = 14 k€ + retainer 2 k€/mois.

🛠️ Exemple end-to-end

Use case : RAG e-commerce avec 3 niveaux de cache (Redis exact + GPTCache semantic + Anthropic prompt cache), dashboard hit ratio + économies API. Tu factures 11 k€ HT (8 j × 1 400 €/j) en add-on à un client RAG existant.

python
# cached_rag.py
import os
import re
import json
import time
import hashlib
import asyncio
from dataclasses import dataclass, asdict
from typing import Any

import anthropic
import redis.asyncio as aredis
from openai import AsyncOpenAI
from gptcache import Cache, Config
from gptcache.adapter.api import init_similar_cache
from gptcache.embedding import OpenAI as GPTOpenAI
from gptcache.manager import manager_factory
from gptcache.similarity_evaluation import SearchDistanceEvaluation
from prometheus_client import Counter, Histogram, start_http_server

# ------------- Setup -------------

REDIS = aredis.from_url(os.environ["REDIS_URL"])
CLAUDE = anthropic.AsyncAnthropic(max_retries=3)  # SDK gère le backoff sur 429/5xx
CHAT = "claude-sonnet-4-6"  # alias nu, jamais de suffixe de date fabriqué

# GPTCache : semantic via OpenAI embeddings
encoder = GPTOpenAI()  # uses OPENAI_API_KEY
data_manager = manager_factory(
    "sqlite,faiss",
    data_dir="./gptcache_data",
    vector_params={"dimension": encoder.dimension},
)
SEM_CACHE = Cache()
SEM_CACHE.init(
    embedding_func=encoder.to_embeddings,
    data_manager=data_manager,
    similarity_evaluation=SearchDistanceEvaluation(),
    config=Config(similarity_threshold=0.86),  # threshold à tuner
)

# ------------- Metrics -------------

CACHE_HITS = Counter("rag_cache_hits_total", "Cache hits", ["level"])
CACHE_MISS = Counter("rag_cache_miss_total", "Cache miss")
LATENCY = Histogram("rag_request_seconds", "Request latency", ["level"])
COST = Counter("rag_cost_eur_total", "Cumulated cost in EUR", ["level"])
TOKENS_IN = Counter("rag_tokens_input_total", "Input tokens (post-cache)", ["cached"])

# ------------- Helpers -------------

def canonical(q: str) -> str:
    q = q.lower().strip()
    q = re.sub(r"[^\w\s]", " ", q)
    q = re.sub(r"\s+", " ", q)
    return q

def exact_key(q: str) -> str:
    return f"rag:exact:{hashlib.sha256(canonical(q).encode()).hexdigest()[:24]}"

TTL_RULES = {
    r"\b(prix|tarif|coût)\b": 3600 * 6,           # 6h prix peuvent bouger
    r"\b(stock|dispo|disponib)\b": 600,           # 10 min stock change vite
    r"\b(retour|remboursement|garantie)\b": 86400 * 7,  # politique stable
    r"\b(livraison|colis|commande)\b": 1800,      # 30 min
}
def ttl_for(q: str) -> int:
    for pat, ttl in TTL_RULES.items():
        if re.search(pat, q.lower()):
            return ttl
    return 3600

# ------------- Static prompt (cacheable Anthropic) -------------

SYSTEM_PROMPT = """Tu es un assistant support client pour la marque FashionFR.
Tu réponds en français, ton accueillant, courte (max 80 mots).
Tu n'inventes JAMAIS de tarif, délai ou politique : si non sourcé, dis 'je vais te transférer à un conseiller'.
Tu cites toujours [doc_id] pour chaque info."""

STATIC_POLICY = """[doc_retour_v3]
La politique de retour FashionFR : 30 jours à compter de la livraison.
Remboursement sous 14 jours après réception. Frais retour gratuits depuis la France.
[doc_livraison_v3]
Livraison standard 48-72h Colissimo. Express 24h +6€. Tracking SMS et email.
[doc_garantie_v3]
Garantie 2 ans pour vices cachés. Pas de garantie sur soldes < -50 %.
"""  # 5 k tokens typique — cacheable

# ------------- Pipeline -------------

@dataclass
class Answer:
    text: str
    cache_level: str  # exact | semantic | miss
    latency_ms: int
    cost_eur: float

async def llm_call(query: str, dynamic_docs: list[dict]) -> tuple[str, float]:
    """Anthropic call with prompt caching on system + static policy.

    IMPORTANT sur l'ordre de rendu : tools → system → messages. Le `cache_control`
    se met sur le PRÉFIXE STABLE (system + policy figée). La query (volatile) vit
    APRÈS le dernier breakpoint, sinon chaque requête réécrit le cache (0 hit).
    """
    docs_text = "\n".join(f"[{d['id']}] {d['text']}" for d in dynamic_docs)
    msg = await CLAUDE.messages.create(
        model=CHAT, max_tokens=400,
        system=[
            {"type": "text", "text": SYSTEM_PROMPT},  # pas de breakpoint ici
            # un seul breakpoint sur le DERNIER bloc stable cache system + tout ce qui précède
            {"type": "text", "text": STATIC_POLICY, "cache_control": {"type": "ephemeral"}},
        ],
        messages=[{
            "role": "user",  # volatile → après le breakpoint, jamais caché
            "content": f"DOCS DYNAMIQUES :\n{docs_text}\n\nQUESTION : {query}",
        }],
    )
    # Track tokens : usage.cache_read_input_tokens prouve les économies.
    # Si ce champ reste à 0 sur des requêtes répétées → un invalidateur silencieux
    # (datetime.now() dans le system, JSON non trié, ordre des tools instable, etc.).
    u = msg.usage
    cache_read = u.cache_read_input_tokens or 0
    cache_create = u.cache_creation_input_tokens or 0
    fresh_in = u.input_tokens  # input_tokens = reste NON caché uniquement
    out = u.output_tokens
    TOKENS_IN.labels(cached="read").inc(cache_read)
    TOKENS_IN.labels(cached="fresh").inc(fresh_in)
    # Pricing canonique Sonnet 4.6 (USD / 1M tok) : 3 in, 15 out.
    # cache-read ≈ 0,1× input = 0,30 ; cache-write 5 min ≈ 1,25× input = 3,75.
    cost_usd = (
        fresh_in * 3.0
        + cache_create * 3.75
        + cache_read * 0.30
        + out * 15.0
    ) / 1_000_000
    cost_eur = cost_usd * 0.92  # USD → EUR (re-baseliner le taux en prod)
    return msg.content[0].text, cost_eur

async def cached_answer(query: str, retrieve_fn) -> Answer:
    t0 = time.perf_counter()

    # ① EXACT match
    cached = await REDIS.get(exact_key(query))
    if cached:
        data = json.loads(cached)
        CACHE_HITS.labels(level="exact").inc()
        ms = int((time.perf_counter() - t0) * 1000)
        LATENCY.labels(level="exact").observe(ms / 1000)
        return Answer(data["text"], "exact", ms, 0.0)

    # ② SEMANTIC match (GPTCache)
    try:
        sem_hit = SEM_CACHE.get(query)  # sync API
    except Exception:
        sem_hit = None
    if sem_hit:
        data = json.loads(sem_hit)
        CACHE_HITS.labels(level="semantic").inc()
        # populate exact cache for next time
        await REDIS.setex(exact_key(query), ttl_for(query), sem_hit)
        ms = int((time.perf_counter() - t0) * 1000)
        LATENCY.labels(level="semantic").observe(ms / 1000)
        # cost = ~embedding cost ~0,00001 €
        COST.labels(level="semantic").inc(0.00001)
        return Answer(data["text"], "semantic", ms, 0.00001)

    # ③ FULL pipeline
    CACHE_MISS.inc()
    docs = await retrieve_fn(query)
    text, cost_eur = await llm_call(query, docs)
    payload = json.dumps({"text": text, "ts": time.time()})
    await REDIS.setex(exact_key(query), ttl_for(query), payload)
    try:
        SEM_CACHE.set(query, payload)
    except Exception as e:
        print(f"sem cache set failed: {e}")
    ms = int((time.perf_counter() - t0) * 1000)
    LATENCY.labels(level="miss").observe(ms / 1000)
    COST.labels(level="miss").inc(cost_eur)
    return Answer(text, "miss", ms, cost_eur)

# ------------- Anti-stampede lock -------------

async def with_lock(query: str, fn):
    """Empêche N requêtes simultanées de recalculer la même réponse."""
    lock_key = f"lock:{exact_key(query)}"
    got = await REDIS.set(lock_key, "1", ex=10, nx=True)
    if got:
        try:
            return await fn()
        finally:
            await REDIS.delete(lock_key)
    # autre worker calcule, on attend la réponse
    for _ in range(40):  # max 4s
        await asyncio.sleep(0.1)
        cached = await REDIS.get(exact_key(query))
        if cached:
            return Answer(json.loads(cached)["text"], "exact-after-lock", 0, 0.0)
    return await fn()  # fallback

# ------------- Invalidation -------------

async def invalidate_by_tag(tag: str):
    """Purge exact cache + semantic cache pour un tag donné."""
    # exact : scan + delete
    async for key in REDIS.scan_iter(f"rag:exact:*"):
        val = await REDIS.get(key)
        if val and tag in val.decode():
            await REDIS.delete(key)
    # semantic : reindex (GPTCache n'a pas de tag natif, on rebuild)
    # En prod : utilise une vector DB avec metadata filter (Qdrant)

# ------------- Demo -------------

async def fake_retrieve(query: str):
    return [
        {"id": "doc_stock_42", "text": "Le produit SKU-1234 en taille XS bleu est en stock à 12 unités."},
        {"id": "doc_livraison_42", "text": "Délai livraison estimé : 48h."},
    ]

async def main():
    start_http_server(9101)  # Prometheus
    print("Metrics: http://localhost:9101")
    queries = [
        "Comment retourner un article ?",
        "Comment faire un retour ?",  # → semantic hit du précédent
        "Comment retourner un article ?",  # → exact hit
        "Délai de livraison standard ?",
        "Quels sont les délais de livraison ?",  # → semantic hit
        "Taille XS bleu dispo en SKU-1234 ?",  # → miss (dynamique)
    ]
    for q in queries:
        ans = await cached_answer(q, fake_retrieve)
        print(f"[{ans.cache_level:>9}] {ans.latency_ms:>4}ms  {ans.cost_eur:.5f}€  Q: {q}")

if __name__ == "__main__":
    asyncio.run(main())

Dashboard Grafana suggéré :

promql
# Hit ratio total
sum(rate(rag_cache_hits_total[5m])) / (sum(rate(rag_cache_hits_total[5m])) + sum(rate(rag_cache_miss_total[5m])))

# Économies / heure (€)
sum(rate(rag_cost_eur_total{level="miss"}[1h])) * 3600 - sum(rate(rag_cost_eur_total[1h])) * 3600

# Latence par niveau
histogram_quantile(0.95, sum(rate(rag_request_seconds_bucket[5m])) by (level, le))

Tu présentes ça au client : "tu vois ton hit ratio à 68 %, voici les 1 250 €/jour qu'on économise". Le retainer mensuel devient évident.

🎯 Patterns courants

Pattern 1 — Exact match cache (Redis)

Hash de la query canonisée → réponse. Le moins cher, le plus rapide. Toujours niveau 1.

python
def k(q): return f"rag:exact:{hashlib.sha256(canonical(q).encode()).hexdigest()}"
await redis.setex(k(q), ttl, answer)

Quand : toujours. Évite : si tu n'as pas Redis (utilise un dict in-memory en MVP).

Pattern 2 — Semantic cache (GPTCache, vector sim)

Embed la query, cherche les queries similaires en base, si sim > threshold → renvoie la réponse cached.

python
SEM_CACHE.init(similarity_evaluation=SearchDistanceEvaluation(), config=Config(similarity_threshold=0.86))

Threshold : sensible. Trop bas (< 0.80) → false positives ("retourner ?" matche "remboursement ?" mais réponses différentes). Trop haut (> 0.95) → hit ratio nul. Sweet spot : 0.85-0.90.

Pattern 3 — Prompt caching (Anthropic / OpenAI)

Le prompt caching n'est pas un cache de réponse : c'est un cache du préfixe d'input. Tu marques les parties stables (system, big docs, policies) avec cache_control, et le provider réutilise leur calcul sur les requêtes suivantes. C'est orthogonal aux niveaux ① et ② : il s'applique sur le miss (③), quand tu appelles vraiment le LLM.

L'invariant qui explique tout : le cache est un match de préfixe. Le moindre octet changé dans le préfixe invalide tout ce qui suit. Ordre de rendu : toolssystemmessages. Donc le stable passe avant le volatil, et le breakpoint se met sur le dernier bloc stable (il cache aussi tout ce qui précède).

python
# ✅ un seul breakpoint sur le dernier bloc stable → cache tools + system d'un coup
system=[
    {"type": "text", "text": SYSTEM},                                   # stable, pas de marker
    {"type": "text", "text": BIG_POLICY, "cache_control": {"type": "ephemeral"}},
]
# la query volatile vit dans messages[], APRÈS le breakpoint → jamais cachée

Économie réelle (Anthropic, prix Sonnet 4.6 = 3 USD in / 15 USD out par 1M) : un hit cache-read coûte ~0,1× le prix input (0,30 contre 3 USD/1M). L'écriture coûte 1,25× input pour le TTL 5 min, 2× input pour le TTL 1h — donc le cache 5 min est rentable dès 2 requêtes sur le même préfixe (1,25× + 0,1× = 1,35× < 2× pour deux requêtes non cachées), le 1h dès 3 requêtes (2× + 0,2× = 2,2× < 3×). Max 4 breakpoints par requête.

💡 Sur quel modèle caches-tu ? La même logique tient pour Opus 4.8 (5 USD in / 25 USD out par 1M, contexte 1M) et Haiku 4.5 (1 USD / 5 USD). Sur un RAG, le cache-read est l'arme anti-coût-Opus : ton system + tes docs persistants à 0,5 USD/1M au lieu de 5 USD/1M. La règle nue, jamais de suffixe de date fabriqué : claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5.

Vérifie que ça marche : resp.usage.cache_read_input_tokens doit être > 0 sur des requêtes répétées. S'il reste à 0, tu as un invalidateur silencieux dans le préfixe (datetime.now() dans le system, json.dumps sans sort_keys=True, un user_id interpolé, un set de tools qui varie). input_tokens = uniquement le reste non caché ; le total réel = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. Si ton agent tourne depuis des heures mais input_tokens affiche 4 k, le reste a été servi du cache — regarde la somme, pas le champ seul.

⚠️ Pré-requis taille : le préfixe doit dépasser le minimum cachable (4096 tok sur Opus 4.8 / Haiku 4.5, 2048 sur Sonnet 4.6) sinon il ne cache pas, sans erreur — juste cache_creation_input_tokens: 0. Un préfixe de 3 k tokens cache sur Sonnet 4.6 mais silencieusement pas sur Opus 4.8.

Hiérarchie d'invalidation — ce qui casse quoi

Un senior ne croit pas que "tout changement casse tout". L'API a 3 tiers de cache (toolssystemmessages) et un changement n'invalide que son tier et ceux qui suivent :

ChangementCache toolsCache systemCache messages
Tools (ajout/retrait/réordonnancement)
Switch de modèle
tool_choice, toggle thinking
Contenu du system prompt
Contenu d'un message

Implication production : tu peux changer tool_choice par requête ou toggler thinking sans perdre le cache tools+system. Mais changer un seul tool ou switcher de modèle force un rebuild total — d'où le pattern "subagent Haiku" plutôt que swap de modèle en cours de session.

Trois pièges que seul un staff connaît

  • Fenêtre de lookback de 20 blocs : chaque breakpoint remonte au plus 20 content blocks pour retrouver une entrée cache. Dans une boucle agentique qui empile > 20 paires tool_use/tool_result en un tour, le breakpoint du tour suivant ne trouve plus le cache et rate silencieusement. Fix : pose un breakpoint intermédiaire tous les ~15 blocs.
  • Timing concurrent : une entrée cache n'est lisible qu'après que la 1ʳᵉ réponse a commencé à streamer. N requêtes parallèles à préfixe identique paient toutes plein tarif. Pattern fan-out : envoie 1 requête, attends le 1ᵉʳ token streamé, puis tire les N−1 autres.
  • Pré-warming : pour éliminer la latence de cache-miss sur la première vraie requête (chat/voix), envoie un max_tokens: 0 au démarrage. L'API fait le prefill, écrit le cache au breakpoint, et renvoie immédiatement (content: [], zéro token output facturé, charge cache-write normale). À re-tirer juste avant l'expiration du TTL si le trafic a des trous > 5 min.

Gain : sur une mission helpdesk, je passe de 4 800 €/mois à 1 900 €/mois juste avec prompt caching sur le system + la grille de politiques.

Pattern 4 — Cache stampede prevention (lock)

Si 100 users posent la même question simultanément, sans lock tu calcules 100 fois. Avec un lock Redis (SET NX EX), un seul calcule, les 99 autres attendent puis lisent le cache.

python
got = await redis.set(lock, "1", ex=10, nx=True)
if got:
    answer = compute(); store(); release()
else:
    wait_then_read_cache()

Pattern 5 — TTL adaptatif par type de question

Le TTL n'est pas uniforme. "Politique retour" → 7 jours. "Stock dispo" → 5 min. "Tarif" → 1h.

python
TTL_RULES = {r"\bstock\b": 300, r"\bretour\b": 86400 * 7}

Pattern 6 — Invalidation par event (CDC)

Sur changement de donnée source (tarif update, nouveau produit, modif politique), tu purge ciblé au lieu d'attendre TTL.

python
async def on_price_change(product_id):
    pattern = f"rag:exact:*{product_id}*"
    async for k in redis.scan_iter(pattern): await redis.delete(k)

Quand : domaines à info changeante (e-commerce, finance). Évite : si tes données changent < 1×/mois (TTL suffit).

🔄 Versions & écosystème 2026

OutilStatut 2026
Redis 7.4Standard exact cache, support de scripts Lua
GPTCache 0.2.xMature, plugins multiples (FAISS, Milvus, OpenAI emb)
LangChain LLMCacheWrapper simple sur Redis, OK pour MVP
Anthropic prompt cachingcache_control: {"type": "ephemeral"} 5 min / {"type": "ephemeral", "ttl": "1h"}. Read ≈ 0,1× input ; write 1,25× (5 min) ou 2× (1h). Max 4 breakpoints. Min cachable 4096 tok (Opus 4.8/Haiku 4.5), 2048 (Sonnet 4.6).
OpenAI prompt cachingAuto sur 1024+ tokens, gratuit
Qdrant / WeaviateSemantic cache avec metadata filtering natif
Helicone / PortkeyCaches managés (proxies LLM), bons pour MVP
Vercel AI SDKStreaming compatible avec cache via revalidation
Cloudflare KVEdge cache pour app mondiale (rare en B2B FR)

Tendance 2026 : prompt caching natif chez tous les providers, GPTCache reste la solution semantic open-source dominante.

⚠️ Pitfalls

  1. Threshold semantic trop bas → false positives critiques ("rembourser article" et "annuler commande" deviennent une seule entrée). Test obligatoire sur 200+ paires similaires/différentes.
  2. TTL infini sur des données qui bougent → tu sers du périmé. Mettre TTL même sur les "politiques stables" (max 30 jours).
  3. Pas de canonisation → "Comment retourner ?" et "comment retourner ?" sont 2 entrées différentes. Lower+strip+ponctuation.
  4. Cache key qui inclut le user_id → cache personnalisé inutile sur queries FAQ génériques. Sépare cache par scope (global vs personnel).
  5. Pas d'anti-stampede → tu factures 50× la même réponse en pic de charge. Lock Redis.
  6. Invalidation oubliée → tu déploies une nouvelle politique, le bot continue à servir l'ancienne pendant 7 jours. Hook CI/CD : redis.flushdb() sur les keys concernés.
  7. GPTCache stocké en SQLite + FAISS local → tu redémarres ton container, tu perds tout. Volume persistant ou backend distant.
  8. Prompt caching mal placé → tu mets le cache_control sur la partie variable. Marche pas. Mets sur les segments stables (system + policies), pas sur la query.
  9. Pas de metrics → tu ne sais pas si ton cache marche. Prometheus + dashboard obligatoires dès le jour 1.
  10. Cache + RGPD → si les queries sont nominatives ("où est la commande de Jean Dupont ?"), tu stockes des PII dans Redis. Filtre PII avant cache OU chiffre les valeurs.
  11. Cache key qui mélange env → dev et prod partagent Redis, tes tests polluent la prod. Préfixe par environnement : rag:prod:exact:....
  12. GPTCache sans monitoring du drift → tu modifies l'embedder, l'index FAISS devient incohérent. Versioning du modèle dans la key.
  13. Pas d'observabilité sur taux d'erreur Redis → un Redis down et ton RAG ralentit silencieusement (fallback miss systématique). Alerte Prometheus + circuit breaker.
  14. Switch de modèle ou tool en cours de session → les caches Anthropic sont scoped par modèle et les tools sont en position 0 du préfixe : changer l'un OU l'autre invalide les 3 tiers d'un coup, plein tarif au tour suivant. Pour un sous-modèle moins cher (résumé, classification), spawn un subagent au lieu de switcher la boucle principale.
  15. Tour agentique > 20 blocs → la fenêtre de lookback du cache (20 content blocks) pousse le cache du tour précédent hors de portée : miss silencieux, zéro erreur, facture qui monte. Breakpoint intermédiaire tous les ~15 blocs dans les tours longs.
  16. Fan-out parallèle sur cache froid → N requêtes simultanées à préfixe identique paient toutes cache_creation car une entrée n'est lisible qu'après le 1ᵉʳ token streamé de la 1ʳᵉ. Pattern : 1 requête → 1ᵉʳ token → puis les N−1.

💰 Pricing / ROI client

ROI typique d'un stack 3 niveaux sur RAG e-commerce (3 000 q/jour, ~90 k q/mois) :

MétriqueSans cacheAvec stack 3 niveaux
Coût LLM (Sonnet)7 600 €/mois1 900 €/mois
Latence p952 400 ms640 ms
Hit ratio exact0 %38 %
Hit ratio semantic0 %31 %
Redis hosting0~25 €/mois
Total mensuel7 600 €~1 925 €
Économies~5 675 €/mois

Sur 12 mois : 68 k€ économisés. Sur un investissement freelance de 10-15 k€ : ROI < 3 mois.

Mission packaging :

  • Audit + design cache (3-4 j, 4-5 k€) : tu mesures leur trafic actuel, tu proposes une stratégie chiffrée.
  • Implémentation 3 niveaux (5-8 j, 6-11 k€) : Redis, GPTCache, prompt caching, anti-stampede.
  • Dashboard + invalidation (2-3 j, 3-4 k€) : Prometheus/Grafana, hooks de purge.
  • Retainer ajustement (1-2 j/mois, 1,5-3 k€/mois) : tuning thresholds, ajustement TTL, monitoring.

Argument commercial irrésistible : "je m'engage sur 50 % de réduction du coût LLM ou je rembourse la mission". Hit ratio > 60 % = quasi-certain sur RAG e-commerce/helpdesk.

🧪 Testing / Eval

Tests unitaires :

python
@pytest.mark.asyncio
async def test_exact_cache_canonicalization():
    await cached_answer("Comment retourner ?", fake_retrieve)
    out = await cached_answer("comment retourner ?", fake_retrieve)
    assert out.cache_level == "exact"  # casse ignorée

@pytest.mark.asyncio
async def test_semantic_cache_paraphrase():
    await cached_answer("Délai de livraison ?", fake_retrieve)
    out = await cached_answer("Quel est le temps de livraison ?", fake_retrieve)
    assert out.cache_level == "semantic"

@pytest.mark.asyncio
async def test_anti_stampede():
    async def slow_retrieve(q):
        await asyncio.sleep(1)
        return [{"id": "1", "text": "x"}]
    # 10 requêtes simultanées sur la même query
    results = await asyncio.gather(*[
        with_lock("test_q", lambda: cached_answer("test_q", slow_retrieve)) for _ in range(10)
    ])
    misses = sum(1 for r in results if r.cache_level == "miss")
    assert misses == 1

Tests de qualité semantic cache :

python
# fixtures/semantic_pairs.json
[
    {"q1": "Délai de livraison ?", "q2": "Combien de temps pour recevoir ?", "should_match": true},
    {"q1": "Comment retourner ?", "q2": "Comment annuler ma commande ?", "should_match": false},
]

@pytest.mark.parametrize("pair", load_pairs())
def test_semantic_threshold(pair):
    SEM_CACHE.set(pair["q1"], "cached_answer")
    hit = SEM_CACHE.get(pair["q2"])
    if pair["should_match"]:
        assert hit is not None
    else:
        assert hit is None

Métriques clé en prod (Prometheus) :

  • rag_cache_hits_total{level} → tendance
  • rag_cache_miss_total → baseline
  • Hit ratio = hits/(hits+miss) → seuil alerte si < 40 %
  • rag_cost_eur_total → euros cumulés économisés
  • Latency p50/p95/p99 par level

🔁 Quand utiliser / éviter

Caching utileCaching inutile / contre-productif
FAQ répétitives (support, helpdesk)Queries toutes uniques (recherche libre web)
Tarifs / politiques relativement stablesDonnées très volatiles (cours bourse intraday)
Volume > 500 queries/jour< 50 queries/jour (overhead > gain)
Latence importe pour UXCalcul async batch sans contrainte temps
Coût LLM > 500 €/moisTu paies 20 €/mois en tokens, on s'en fout
RAG personnalisé par segment (B2B FAQ)Réponses 100 % personnalisées par user

Argumentaire commercial type

Quand tu pitches l'audit cache, tu poses 3 questions au client :

  1. "Combien tu payes par mois en tokens LLM ?" (> 500 € → ROI quasi certain)
  2. "Quel % de tes queries sont des redites estimées ?" (> 30 % → tu gagnes)
  3. "Combien de temps moyen pour ouvrir un ticket support ?" (> 1,5 s → tu peux diviser par 3)

Avec ces 3 chiffres, tu présentes en 1 page un business case "économies estimées vs investissement". Conversion mission > 60 % dans mon expérience.

Sample 1-page business case (pour copier-coller en RDV) :

État actuel
- 90k queries/mois × 0,085 €/q = 7 650 €/mois LLM
- p95 latence : 2 400 ms
- Hit ratio : 0 %

Après cache (estimé)
- 90k × 0,025 €/q = 2 250 €/mois LLM
- p95 latence : 700 ms
- Hit ratio : 65 %
- Économies annuelles : 64 800 €

Investissement
- Mission freelance : 11 k€
- Redis hosting : 300 €/an
- ROI : 1,9 mois

🏋️ Exercices

Progressifs, du « ça tourne » au « défends ton chiffre en COMEX ». Fais-les dans l'ordre — chacun casse une hypothèse du précédent.

Exercice 1 — Le cache 3 niveaux qui mesure (fondation)

Objectif : implémenter le pipeline cached_answer (exact + semantic + miss) avec Prometheus, et prouver par les métriques que les 3 chemins sont empruntés.

Indice/Solution : pars du cached_rag.py de la section end-to-end. Écris un harness qui rejoue les 6 requêtes de main() et asserte rag_cache_hits_total{level="exact"} >= 1, {level="semantic"} >= 1, rag_cache_miss_total >= 1. Le piège : sans canonisation, « Comment retourner ? » et « comment retourner ? » sont 2 entrées → 0 hit exact. Vérifie aussi que usage.cache_read_input_tokens > 0 au 2ᵉ miss sur le même préfixe.

Exercice 2 — Tuner le threshold semantic sans te mentir

Objectif : trouver le threshold qui maximise le F1 sur un jeu de 200 paires annotées (should_match true/false), pas le hit ratio.

Indice/Solution : balaye similarity_threshold de 0,80 à 0,95 par pas de 0,01. Pour chaque valeur, calcule précision/rappel/F1 sur les 200 paires. Trace la courbe. Le hit ratio seul est un piège : à 0,80 tu as un hit ratio énorme et des faux positifs critiques (« rembourser » qui matche « annuler »). Un faux positif sert une mauvaise réponse = pire qu'un miss. Pondère ton coût : coût(FP) >> coût(miss). Le sweet spot réel est souvent plus haut que ce que le hit ratio brut suggère.

Exercice 3 — Casse le prompt caching, puis répare-le

Objectif : partir d'un code où cache_read_input_tokens reste à 0 sur 100 requêtes identiques, diagnostiquer l'invalidateur silencieux, le corriger, et re-prouver le hit.

Indice/Solution : introduis volontairement le bug — SYSTEM_PROMPT = f"Tu es un assistant. Date: {datetime.now()}". Chaque requête change le préfixe → 0 cache-read, 100 % cache-write. Diagnostic : diff les octets du prompt rendu entre 2 requêtes. Fix : sortir la partie volatile du préfixe (la mettre dans messages[], après le breakpoint), figer le system. Autres invalidateurs à reproduire : json.dumps(docs) sans sort_keys=True, un set de tools réordonné, un user_id interpolé dans le system. Asserte cache_read_input_tokens > 0 à partir de la 2ᵉ requête.

Exercice 4 — Anti-stampede sous charge réelle

Objectif : sous 200 requêtes concurrentes sur la même query froide, garantir exactement 1 appel LLM, et mesurer la latence p95 des 199 autres.

Indice/Solution : reprends with_lock. Lance asyncio.gather de 200 coroutines sur une query non cachée avec un retrieve qui dort 1,5 s. Sans lock : 200 appels LLM (200× le coût, 200× la charge retrieval). Avec lock Redis SET NX EX : 1 calcule, 199 poll le cache. Le piège production : que se passe-t-il si le worker qui détient le lock crash avant de remplir le cache ? Ton ex=10 libère le lock, mais les 199 ont déjà fallback. Ajoute un jitter sur le poll et un compteur stampede_fallback_total. Défends : « combien je perds si le lock TTL est trop court vs trop long ».

Exercice 5 — Invalidation event-driven, 0 réponse périmée

Objectif : sur un changement de tarif (message Kafka), purger uniquement les entrées cache concernées — exact ET semantic — et prouver par un test que le bot ne sert jamais l'ancien tarif après l'event.

Indice/Solution : le scan_iter sur pattern de keys marche pour l'exact, mais GPTCache + FAISS n'a pas de filtre par tag natif → tu dois soit rebuild l'index, soit migrer vers une vector DB avec metadata filtering (Qdrant/Weaviate) et delete(filter={tag: ...}). Test : warm le cache avec « tarif carte gold = 50 € », émet l'event {product: "carte_gold", price: 60}, puis re-query → la réponse doit refléter 60 €, pas 50. Mesure le taux de réponses obsolètes sur 1000 requêtes post-event : cible 0 %. Compare au cache time-based (TTL 7j) qui sert ~6 % de périmé.

Exercice 6 — Défends ton ROI devant le CFO (capstone)

Objectif : à partir de vrais logs de trafic (90k requêtes/mois), produire un business case chiffré où chaque nombre est traçable à une métrique, et résister à 3 objections.

Indice/Solution : calcule le hit ratio réel par cohorte de questions (pas une moyenne). Le CFO va attaquer : (1) « ton hit ratio de 65 % tient-il quand on lance une promo ? » → montre la sensibilité au drift de distribution, ajoute un warm-up pré-pic. (2) « et le RGPD sur les queries nominatives ? » → filtre PII avant cache ou chiffre les valeurs, sépare cache global vs personnel. (3) « si Redis tombe ? » → circuit breaker + fallback miss, alerte sur redis_errors_total, montre que la dégradation est gracieuse (lent, pas cassé). Le livrable : 1 page, économies annuelles = (coût_sans − coût_avec) × 12, ROI en mois = invest / économie_mensuelle. Si tu ne peux pas pointer chaque chiffre vers une métrique Prometheus, tu as inventé — recommence.

Exercice 7 — Le prompt caching qui rate sans erreur (staff)

Objectif : sur un agent RAG outillé (tools + system + boucle), faire apparaître un cache_read_input_tokens qui s'effondre à 0 dans trois scénarios distincts, diagnostiquer chacun via la hiérarchie d'invalidation, et reprouver le hit.

Indice/Solution : reproduis les trois indépendamment. (a) Tier tools — ajoute un tool en cours de session : le préfixe en position 0 change → les 3 tiers tombent (cache_read = 0 sur tout). Fix : ne swappe pas le set de tools, passe le mode en message, ou utilise tool-search (append, pas replace). (b) Fenêtre 20 blocs — fais empiler à ta boucle > 20 paires tool_use/tool_result en un tour : le breakpoint du tour suivant ne retrouve plus le cache (miss silencieux, aucune erreur). Fix : breakpoint intermédiaire tous les ~15 blocs. (c) Timing concurrent — tire 50 requêtes parallèles à préfixe identique sur cache froid : toutes paient cache_creation plein tarif car aucune ne peut lire ce que les autres écrivent encore. Fix fan-out : 1 requête, attends le 1ᵉʳ token streamé, puis les 49 autres. Défends devant un pair : « pour chacun des trois, quel tier de cache est tombé et pourquoi le coût a bougé de tel montant ». Bonus : ajoute un pré-warming max_tokens: 0 au démarrage et montre la latence p99 du premier vrai appel avant/après.

🎤 En entretien

« Quelle est la différence entre un cache sémantique et le prompt caching d'Anthropic ? » Le cache sémantique court-circuite tout l'appel LLM (match par similarité d'embedding de la query → réponse stockée) ; le prompt caching ne saute aucun appel, il réutilise le calcul du préfixe d'input (system + docs stables) côté provider, à ~0,1× le prix input. L'un évite la requête, l'autre la rend moins chère — ils se combinent : sémantique d'abord, prompt caching sur le miss.

« Comment tu choisis le threshold de similarité d'un cache sémantique ? » Pas sur le hit ratio — sur le F1 contre un jeu annoté de paires similaires/différentes, parce qu'un faux positif sert une mauvaise réponse (pire qu'un miss). Je pondère coût(FP) >> coût(miss) ; en pratique le sweet spot est 0,85–0,90, plus haut que ce que le hit ratio brut suggère.

« Ton cache_read_input_tokens est à zéro alors que tu envoies 100 fois le même prompt. Pourquoi ? » Invalidateur silencieux dans le préfixe : un datetime.now() ou un UUID dans le system, un json.dumps non déterministe (sans sort_keys), un user_id interpolé, ou un set de tools réordonné. Le cache est un match de préfixe exact à l'octet — je diff les octets rendus entre 2 requêtes, je sors le volatil du préfixe et je le mets après le dernier breakpoint.

« 500 utilisateurs posent la même question au même instant sur un cache froid. Que se passe-t-il ? » Cache stampede : sans protection, 500 appels LLM identiques en parallèle (500× coût + charge retrieval). Lock Redis SET NX EX : un seul calcule et remplit le cache, les autres poll puis lisent. Edge case à mentionner : le détenteur du lock crash avant de remplir → le TTL du lock libère, fallback gracieux + métrique. Côté Anthropic, deux requêtes parallèles à préfixe identique paient aussi plein tarif tant que la 1ʳᵉ n'a pas commencé à streamer — d'où le pattern « 1 requête, puis fan-out ».

« Tu changes un seul tool dans ton agent en cours de session et ta facture explose. Pourquoi ? » Le cache Anthropic est un match de préfixe sur 3 tiers rendus dans l'ordre toolssystemmessages. Les tools sont en position 0 : ajouter, retirer ou réordonner un tool invalide les trois tiers d'un coup (tools + system + messages), donc tout le préfixe est re-prefillé plein tarif à chaque tour. Idem pour un switch de modèle (les caches sont scoped par modèle). Le fix senior : ne swappe pas le set de tools pour faire des « modes » — passe le mode en contenu de message, ou utilise tool-search qui append les schémas au lieu de les remplacer. Pour un sous-modèle moins cher, spawn un subagent plutôt que switcher le modèle de la boucle principale.

« Ton agent empile 30 paires tool_use/tool_result en un tour et le cache rate au tour suivant alors que rien n'a changé. » Fenêtre de lookback de 20 blocs : chaque breakpoint ne remonte qu'au plus 20 content blocks pour retrouver une entrée cache. Un tour qui dépasse 20 blocs pousse le cache du tour précédent hors de portée → miss silencieux. Fix : poser un breakpoint intermédiaire tous les ~15 blocs dans les tours longs, ou marquer un bloc qui reste à moins de 20 du dernier bloc caché.

🔗 Liens

Bibliothèque tech perso — Achref