9d. Production Caches : payer une fois, servir mille fois
Deuxième volet production après 9c_Production_Routage_Repli (choisir le bon modèle) : ici, ne pas rappeler le modèle du tout. Un appel LLM coûté une fois peut resservir — à condition de savoir quand réutiliser sans se tromper.
Deux familles de caches, mesurées sur le terrain réel :
Le cache sémantique — reconnaître qu’une question a déjà été posée à un mot près, par similarité d’embeddings. Sa mesure critique : le taux de faux succès — des questions presque identiques dont la réponse est différente, servies depuis le cache comme si de rien n’était ;
Le cache de préfixe fournisseur — OpenAI facture moins cher les tokens de prompt déjà vus (cached_tokens dans la réponse). On mesure ce que ça rend vraiment, et à quelles conditions l’allonger se justifie.
Terrain réel, lecture seule : embeddings OpenAI (text-embedding-3-small, clé dans .env, jamais affichée), catalogue de prix du proxy multi-fournisseurs (/v1/models), inférence locale Ollama pour les réponses à mettre en cache. Rien de tout cela n’est reconfiguré.
Vérification de l’environnement
import os, json, time, random, re, statistics, mathfrom pathlib import Pathfrom dotenv import load_dotenvload_dotenv(Path("..") /".env")OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")print("OPENAI_API_KEY :", "présente"if OPENAI_API_KEY else"ABSENTE -- sections embeddings/préfixe impossibles")print("OPENROUTER_API_KEY :", "présente"if OPENROUTER_API_KEY else"absente")random.seed(20260928) # même graine que 9c : même banque
OPENAI_API_KEY : présente
OPENROUTER_API_KEY : présente
# Mêmes sondages lecture seule que 9c : on re-confirme le terrain, on ne le configure pasimport urllib.requestimport pandas as pdfrom openai import OpenAIdef sonde(nom, url):try:with urllib.request.urlopen(urllib.request.Request(url), timeout=15) as r: n =len(json.loads(r.read().decode("utf-8")).get("data", []))print(f"{nom:38s} HTTP 200 {n:4d} modèles")exceptExceptionas e:print(f"{nom:38s} INJOIGNABLE ({type(e).__name__})")sonde("Inference locale (Ollama)", "http://127.0.0.1:11434/v1/models")sonde("Proxy multi-fournisseurs", "https://openrouter.ai/api/v1/models")def client_openai():assert OPENAI_API_KEY, "OPENAI_API_KEY absente du .env"return OpenAI(api_key=OPENAI_API_KEY)def client_local():return OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama")# Prix réels du proxy pour les modèles de ce notebook (lus au moment de l'exécution)req = urllib.request.Request("https://openrouter.ai/api/v1/models")with urllib.request.urlopen(req, timeout=15) as r: catalogue = {m["id"]: m for m in json.loads(r.read().decode("utf-8"))["data"]}MODELE_CHAT ="openai/gpt-5-mini"assert MODELE_CHAT in catalogue, f"{MODELE_CHAT} absent du catalogue proxy"PRIX_IN =float(catalogue[MODELE_CHAT]["pricing"]["prompt"]) *1e6PRIX_OUT =float(catalogue[MODELE_CHAT]["pricing"]["completion"]) *1e6print(f"Prix catalogue {MODELE_CHAT} : ${PRIX_IN:.2f}/M entrée, ${PRIX_OUT:.2f}/M sortie")
Le cache sémantique se juge sur deux populations adverses, pas sur des répétitions bénignes :
la banque de questions arithmétiques (même générateur, même graine que 9c) ;
les pièges : mêmes phrases, nombres différents — « 320 colis puis 540 » contre « 320 colis puis 541 ». Leurs embeddings sont quasi jumeaux, leurs réponses diffèrent. Un cache qui sert le piège depuis l’entrée voisine fabrique un faux succès.
C’est la population qui manque toujours aux démos de cache sémantique — et celle qui décide du seuil en production.
def q_addition(a, b):returnf"Un entrepôt reçoit {a} colis le matin, puis {b} colis l'après-midi. Combien de colis au total ?", a + bdef q_soustraction(a, b):returnf"Un magasin possède {a} unités de stock et en vend {b} dans la journée. Combien reste-t-il d'unités ?", a - bdef q_produit(a, b):returnf"Une imprimerie produit {a} affiches par série et fabrique {b} séries. Combien d'affiches au total ?", a * bGENES = [("addition", q_addition), ("soustraction", q_soustraction), ("produit", q_produit)]rnd = random.Random(20260928)BANQUE = []for i inrange(90): # 30 de chaque type nom, gene = GENES[i %3]if nom =="addition": texte, rep = gene(rnd.randint(120, 980), rnd.randint(120, 980))elif nom =="soustraction": texte, rep = gene(rnd.randint(400, 990), rnd.randint(100, 390))else: texte, rep = gene(rnd.randint(12, 96), rnd.randint(12, 96)) BANQUE.append({"id": i, "type": nom, "question": texte, "reponse": rep})# Pièges : même phrase, second opérande décalé de +1 -> réponse différente, embedding jumeauPIEGES = []for i, q inenumerate(BANQUE[:30]): a, b = (int(n) for n in re.findall(r"\d+", q["question"])[:2]) gene =dict(GENES)[q["type"]] texte, rep = gene(a, b +1) PIEGES.append({"id": i, "type": q["type"], "question": texte, "reponse": rep})print(f"Banque : {len(BANQUE)} questions | Pièges : {len(PIEGES)} (second opérande +1)")for q in (BANQUE[0], PIEGES[0]):print(f" [{q['type']:11s}] {q['question']} -> {q['reponse']}")
Banque : 90 questions | Pièges : 30 (second opérande +1)
[addition ] Un entrepôt reçoit 823 colis le matin, puis 682 colis l'après-midi. Combien de colis au total ? -> 1505
[addition ] Un entrepôt reçoit 823 colis le matin, puis 683 colis l'après-midi. Combien de colis au total ? -> 1506
2. Remplir le cache : une passe réelle sur l’inférence locale
Le cache ne stocke pas des réponses plaquées : on fait vraiment répondre le modèle local aux 90 questions de la banque (gratuit, température 0), et ce sont ces réponses réelles qui entrent au cache. Les pièges, eux, ne sont jamais mis en cache — ils arrivent comme des requêtes clientes. Cette passe fournit aussi la latence de référence d’un appel non caché, qui servira de dénominateur à toutes les économies mesurées ensuite.
LOCAL_ID ="qwen2.5:7b"SYSTEME = ("Tu es un assistant de calcul. Réponds en une seule phrase courte et termine par la ""valeur numérique exacte, sans unité.")def extraire_nombre(texte): nombres = re.findall(r"-?\d+", texte or"")returnint(nombres[-1]) if nombres elseNonedef poser_local(question): t0 = time.perf_counter() rep = client_local().chat.completions.create( model=LOCAL_ID, messages=[{"role": "system", "content": SYSTEME}, {"role": "user", "content": question}], temperature=0.0, max_tokens=200, ) dt = time.perf_counter() - t0 texte = rep.choices[0].message.content or""return extraire_nombre(texte), dtCACHE = [] # [{question, reponse_modele, correcte}]latences = []t0 = time.perf_counter()for q in BANQUE: pred, dt = poser_local(q["question"]) latences.append(dt) CACHE.append({**q, "reponse_modele": pred, "correcte": pred == q["reponse"]})duree_remplissage = time.perf_counter() - t0n_justes =sum(c["correcte"] for c in CACHE)print(f"Cache rempli : {len(CACHE)} réponses réelles du modèle local en {duree_remplissage:.0f} s")print(f"qualité du fond de cache : {n_justes}/{len(CACHE)} justes ({100.0* n_justes /len(CACHE):.0f} %)")LAT_REF = statistics.median(latences)print(f"latence de référence d'un appel non caché (médiane) : {LAT_REF *1000:.0f} ms")
Cache rempli : 90 réponses réelles du modèle local en 74 s
qualité du fond de cache : 78/90 justes (87 %)
latence de référence d'un appel non caché (médiane) : 806 ms
3. Embeddings réels et similarité : à quoi ressemble un « presque pareil »
Toutes les questions — fond de cache et pièges — partent en embeddings réels chez OpenAI (text-embedding-3-small, 1536 dimensions, par lots). Avant tout réglage, on regarde la distribution : la similarité cosinus entre chaque piège et l’entrée la plus proche du cache, séparée en deux populations — la jumelle (même question, autre nombre) et les questions réellement différentes. Si les deux distributions se chevauchent, aucun seuil ne sera propre — et c’est le premier résultat du notebook.
def embedder(textes, lot=64): vecteurs = []for i inrange(0, len(textes), lot): r = client_openai().embeddings.create(model="text-embedding-3-small",input=textes[i:i + lot]) vecteurs.extend(d.embedding for d in r.data)return vecteursemb_cache = embedder([c["question"] for c in CACHE])emb_pieges = embedder([p["question"] for p in PIEGES])def cos(u, v): dot =sum(x * y for x, y inzip(u, v)) nu = math.sqrt(sum(x * x for x in u)) nv = math.sqrt(sum(x * x for x in v))return dot / (nu * nv) if nu and nv else0.0def plus_proche(vect): best_i, best_s =0, -1.0for i, u inenumerate(emb_cache): s = cos(vect, u)if s > best_s: best_i, best_s = i, sreturn best_i, best_s# Population 1 : la jumelle (même gabarit, autre nombre). Résultat de terrain attendu :# l'entrée la plus proche d'un piège est systématiquement sa jumelle.sim_jumelles, rang_jumelle = [], 0for j, v inenumerate(emb_pieges): i, s = plus_proche(v) sim_jumelles.append(s)if i == j: rang_jumelle +=1# Population 2 (contraste) : pour chaque piège, la similarité de la question « étrangère »# la plus proche — un autre GABARIT, que n'importe quel seuil raisonnable doit repousser.sim_etrangeres = [max(cos(v, u) for i2, u inenumerate(emb_cache) if CACHE[i2]["type"] != p["type"])for p, v inzip(PIEGES, emb_pieges)]print(f"la plus proche entrée du cache est la jumelle : {rang_jumelle}/{len(PIEGES)} pièges")print(f"piège -> jumelle : min {min(sim_jumelles):.3f} moy {statistics.mean(sim_jumelles):.3f} max {max(sim_jumelles):.3f} (n={len(sim_jumelles)})")print(f"piège -> étrangère : min {min(sim_etrangeres):.3f} moy {statistics.mean(sim_etrangeres):.3f} max {max(sim_etrangeres):.3f} (n={len(sim_etrangeres)})")
la plus proche entrée du cache est la jumelle : 30/30 pièges
piège -> jumelle : min 0.962 moy 0.986 max 0.996 (n=30)
piège -> étrangère : min 0.457 moy 0.550 max 0.612 (n=30)
4. Le cache exact : le plancher des droits
Avant le sémantique, l’exact : une clé de dict sur le texte de la question. Re-poser la banque ne coûte qu’une recherche dans un dict ; re-poser une reformulation (« Quel est le nombre total de colis ? ») coûte un appel complet. Le cache exact est gratuit et sans risque — sa mesure fixe le plancher que le sémantique doit dépasser sans payer en faux succès.
cache_exact = {c["question"]: c["reponse_modele"] for c in CACHE}t0 = time.perf_counter()hits =sum(1for q in BANQUE if q["question"] in cache_exact)dt_exact = time.perf_counter() - t0print(f"rejeu de la banque par cache exact : {hits}/{len(BANQUE)} hits, {dt_exact *1000:.1f} ms au total")def reformuler(q):return (q["question"] .replace("Combien de colis au total ?", "Quel est le nombre total de colis ?") .replace("Combien d'affiches au total ?", "Quel est le nombre total d'affiches ?") .replace("Combien reste-t-il d'unités ?", "Quel est le nombre d'unités restantes ?"))reformulations = [reformuler(q) for q in BANQUE[:20]]assertall(r != q["question"] for r, q inzip(reformulations, BANQUE[:20])), "reformulation identique"hits_ref =sum(1for r in reformulations if r in cache_exact)print(f"reformulations (n=20) : {hits_ref} hit(s) — le cache exact est aveugle à la formulation")
rejeu de la banque par cache exact : 90/90 hits, 0.2 ms au total
reformulations (n=20) : 0 hit(s) — le cache exact est aveugle à la formulation
5. Le cache sémantique : le seuil et son prix
Le cache sémantique répond depuis l’entrée la plus proche dès que la similarité dépasse un seuil γ. On balaie γ : à chaque seuil, on rejoue trois populations — la banque (le hit est légitime), les reformulations (le hit est le gain), et les pièges (le hit est une fausse réponse servie). Les réponses « fraîches » hors seuil sont calculées une seule fois par question puis mémoïsées : le balayage fait varier le seuil, pas le terrain.
Trois courbes à lire ensemble : économie de latence, taux de hit, et taux de faux succès. Le seuil retenu en production est celui où la courbe de faux succès tombe au niveau que le produit tolère — pas celui qui maximise l’économie.
requetes = ([(q["question"], q["reponse"], "banque") for q in BANQUE[:60]]+ [(reformuler(q), q["reponse"], "reformulation") for q in BANQUE[:20]]+ [(p["question"], p["reponse"], "piege") for p in PIEGES])emb_requetes = embedder([r[0] for r in requetes])# Réponses fraîches mémoïsées : chaque question hors seuil est réellement posée au modèle# la première fois seulement ; les seuils suivants rejouent la même réalité.frais = {}def reponse_fraiche(question):if question notin frais: frais[question] = poser_local(question)[0]return frais[question]def rejouer_avec_seuil(seuil):"""Renvoie (hits, faux_succes, justes) pour un seuil gamma donné. faux_succes = un PIEGE servi DEPUIS LE CACHE avec la mauvaise reponse. Une erreur de reponse fraiche (le modele local se trompe) n'est pas un faux succes : elle reste visible dans la colonne exactitude.""" hits = faux = justes =0for (question, reponse, famille), v inzip(requetes, emb_requetes): i, s = plus_proche(v)if s >= seuil: hits +=1 pred = CACHE[i]["reponse_modele"]if pred != reponse and famille =="piege": faux +=1else: pred = reponse_fraiche(question)if pred == reponse: justes +=1return hits, faux, justesmesures = []for seuil in [0.90, 0.95, 0.98, 0.99, 0.995, 0.999]: hits, faux, justes = rejouer_avec_seuil(seuil) mesures.append({"seuil γ": seuil,"hits": hits,"taux de hit %": round(100.0* hits /len(requetes), 1),"faux succès (pièges)": faux,"faux succès %": round(100.0* faux /len(PIEGES), 1),"exactitude %": round(100.0* justes /len(requetes), 1),f"latence évitée (s, à {LAT_REF *1000:.0f} ms/appel)": round(hits * LAT_REF, 1), })print(f"{len(frais)} appels frais réels au total (mémoïsés) ; le balayage lui-même est de l'arithmétique")df_seuils = pd.DataFrame(mesures).set_index("seuil γ")df_seuils
50 appels frais réels au total (mémoïsés) ; le balayage lui-même est de l'arithmétique
hits
taux de hit %
faux succès (pièges)
faux succès %
exactitude %
latence évitée (s, à 806 ms/appel)
seuil γ
0.900
110
100.0
30
100.0
63.6
88.7
0.950
110
100.0
30
100.0
63.6
88.7
0.980
98
89.1
21
70.0
71.8
79.0
0.990
72
65.5
12
40.0
75.5
58.0
0.995
61
55.5
1
3.3
81.8
49.2
0.999
60
54.5
0
0.0
82.7
48.4
Lecture du balayage
Trois lectures, dans cet ordre :
la similarité jumelle (section 3) borne tout : si un piège et sa jumelle sont à 0,97, tout seuil ≤ 0,97 servira le piège depuis le cache — le faux succès est structurel, pas un accident de réglage ;
le taux de faux succès par seuil dit si un seuil sûr existe : si la colonne ne tombe jamais à 0 avant que le taux de hit ne s’effondre, le cache sémantique est disqualifié sur ce domaine — réponse tout aussi utile qu’un feu vert ;
l’exactitude compare le pipeline complet au modèle nu : un cache qui économise 80 % de latence en perdant 10 points d’exactitude n’économise rien de ce qui compte.
Les chiffres proviennent de l’exécution committée ; la latence d’appel de référence est la médiane mesurée en section 2 sur le même terrain.
6. Le cache de préfixe fournisseur : lire cached_tokens
Chez OpenAI, un préfixe de prompt déjà vu (au-delà d’un seuil de longueur, 1024 tokens) est facturé moins cher quand il revient : la réponse expose usage.prompt_tokens_details.cached_tokens. Le terrain dit la vérité — on ne la devine pas. On construit un préfixe métier long (règles de calcul et conventions du service logistique), on pose une série de questions courtes qui le partagent, et on lit les compteurs de chaque réponse.
PREFIXE = ("Tu es l'assistant de calcul du service logistique Aurelia, rattaché à la direction des ""opérations. Tu réponds aux questions de quantités (colis, unités de stock, affiches) en ""appliquant strictement les règles du service. Règle 1 : les totaux incluent toujours les ""livraisons du matin ET celles de l'après-midi, sans exception, même si l'après-midi est ""partiellement reporté au lendemain. Règle 2 : les retours clients sont déduits du stock ""après la vente, jamais avant la mise en rayon ; un retour non contrôlé reste en stock. ""Règle 3 : les pourcentages s'appliquent au total brut, arrondis à l'entier inférieur, et ""une remise en cascade se compose multiplicativement, jamais par addition. Règle 4 : les ""séries de production se multiplient, les rebuts se soustraient en fin de série, et une ""série interrompue compte pour son dernier lot complet. Règle 5 : un colis prioritaire ""est un colis ; la priorité ne change jamais les quantités, seulement l'ordre de départ. ""Règle 6 : les doublons de réception (même numéro de bon livré deux fois) comptent une ""seule fois dans les totaux du jour. Règle 7 : une rupture de stock ramène le disponible à ""zéro, jamais à un nombre négatif. Règle 8 : les conversions d'unités internes (palettes, ""cartons, unités) utilisent la table officielle du service, palettes de 120, cartons de 12, ""et ne se devinent jamais. Règle 9 : en cas d'incohérence entre deux chiffres de l'énoncé, ""le chiffre horodaté le plus récent fait foi. Règle 10 : les reports inter-entrepôts ne ""comptent ni comme sorties ni comme entrées du jour concerné. Conventions de réponse : ""répondre en une phrase courte, terminer par la valeur numérique exacte, ne jamais ""afficher le détail des opérations intermédiaires, ne jamais reformuler la question, ne ""jamais ajouter d'unité, ne jamais commenter les règles elles-mêmes. Contexte du service : ""les rapports trimestriels des trois dernières années ont toujours validé ces règles par ""audit ; l'assistant n'a pas à les justifier, seulement à les appliquer. Domaine couvert : ""entrepôts régionaux, stocks de magasins, séries d'impression, dossiers prioritaires, ""réceptions du matin et de l'après-midi, retours clients, conversions palettes et cartons. ""Règle 11 : les stocks d'ouverture annoncés deux fois dans un énoncé ne se comptent qu'une ""fois ; la seconde mention est une confirmation, pas une entrée supplémentaire. Règle 12 : ""les arrondis de conversion palettes versent le reste en unités libres, jamais en palette ""incomplète facturée pleine. Règle 13 : un transfert partiellelement livré compte les ""quantités effectivement parties, à la date de départ effective du camion. Règle 14 : les ""tolérances de pesée (plus ou moins une unité par carton) s'annulent en moyenne sur les ""totaux d'une série complète. Règle 15 : en fin de mois, les reports non soldés du mois ""précédent s'ajoutent aux entrées du premier jour ouvré, sans recalcul rétroactif. ""Règle 16 : une commande annulée avant départ n'a jamais existé dans aucun total. ""Glossaire du service : un lot est un groupe de cartons d'une même référence ; une ""réception est un événement horodaté de déchargement ; un disponible est le stock moins ""les réservations confirmées ; une réserve n'est jamais un disponible. Procédure de ""contrôle : après chaque réponse, l'assistant vérifie mentalement l'ordre de grandeur ; si ""le résultat dépasse dix mille unités pour une question de magasin, ou descend sous ""zéro, l'assistant recompte silencieusement avant de répondre, sans jamais mentionner ce ""contrôle. Traçabilité : les réponses alimentent le journal d'exploitation quotidien ; ""elles doivent donc rester autoportantes, sans reprise du contexte de la question. ""Historique de formulation : les questions arrivent courtes ; l'assistant ne demande ""jamais d'éclaircissement et prend l'énoncé tel quel. Registre attendu : sobre, direct, ""factuel, sans politesse superflue et sans formule d'ouverture ; la réponse commence par ""le résultat du calcul, jamais par une salutation. Cas limites déjà tranchés par le ""service : une question sans nombre vaut réponse « 0 » ; une question avec trois nombres ""applique les règles dans l'ordre d'apparition des nombres ; une question répétée dans la ""même journée reçoit la même réponse sans variante. Ces décisions ne se discutent pas et ""ne se mentionnent pas : elles s'appliquent. Dernier rappel de registre : aucun ""préambule, aucune excuse, aucune explication de méthode — le service paie au token de ""sortie, chaque mot inutile est une dépense. Manuel de procédure du service, à connaître ""et à appliquer sans le citer : avant la première réponse d'une journée, l'assistant ""relise mentalement les règles 1 à 16 dans l'ordre ; aucun rapport ne mentionne cette ""relecture. Les incidents classés des trois dernières années, par fréquence décroissante : ""totaux matin comptés deux fois (règle 1), retours déduits avant vente (règle 2), ""pourcentages appliqués au net au lieu du brut (règle 3), séries interrompules comptées ""complètes (règle 4), palettes incomplètes facturées pleines (règle 12). Chaque incident ""a donné lieu à une correction de règle, jamais à une exception individuelle : il n'existe ""aucune dérogation, aucun cas particulier, aucune validation hiérarchique possible. Les ""chiffres ronds suspects (multiples de cent) reçoivent la même vérification que les ""autres. La réponse finale ne contient jamais le mot « règle », jamais le numéro d'une ""règle, jamais une référence à ce manuel.")def poser_chat(question): rep = client_openai().chat.completions.create( model="gpt-5-mini", messages=[{"role": "system", "content": PREFIXE}, {"role": "user", "content": question}], max_completion_tokens=150, # gpt-5-mini : temperature non reglable (defaut 1) ) u = rep.usage details =getattr(u, "prompt_tokens_details", None) cache_tok =getattr(details, "cached_tokens", 0) if details isnotNoneelse0return {"prompt": u.prompt_tokens, "cached": cache_tok,"completion": u.completion_tokens}# 10 questions partagent le préfixe ; la première écrit le cache du fournisseur.# Son écriture est asynchrone : sans quelques secondes d'écart, le second appel repart# aussi frais que le premier.questions_pref = [q["question"] for q in BANQUE[:10]]mesures_pref = [poser_chat(questions_pref[0])]time.sleep(10)mesures_pref += [poser_chat(q) for q in questions_pref[1:]]df_pref = pd.DataFrame([{"appel": i +1,"tokens prompt": m["prompt"],"dont cachés": m["cached"],"part cachée %": round(100.0* m["cached"] / m["prompt"], 1) if m["prompt"] else0.0,} for i, m inenumerate(mesures_pref)]).set_index("appel")df_pref
tokens prompt
dont cachés
part cachée %
appel
1
1304
0
0.0
2
1305
1152
88.3
3
1304
1152
88.3
4
1304
1152
88.3
5
1305
1152
88.3
6
1304
1152
88.3
7
1304
1152
88.3
8
1305
1152
88.3
9
1304
1152
88.3
10
1304
1152
88.3
Lecture du cache de préfixe
Cette exécution est partie d’un cache froid : le 1ᵉʳ appel affiche 0 token caché — il paie le préfixe au plein tarif — et dès le 2ᵉ appel, 88 % des tokens du prompt sont servis depuis le cache. Ce contraste entre la première ligne du tableau et les suivantes EST la mécanique du cache fournisseur : un premier paiement plein tarif, puis la même part servie au tarif réduit à chaque appel suivant (le cache survit d’une exécution à l’autre ; une série démarrée quelques minutes après une autre peut afficher son 1ᵉʳ appel déjà chaud — c’est précisément son intérêt en production). L’économie réelle se calcule avec les prix du catalogue lus en section 0 : le token caché est facturé à une fraction du prix d’entrée — 0,1× sur la route mesurée ici, un ratio qui dépend du fournisseur et du modèle (R_CACHED ci-dessous), à vérifier avant d’estimer une facture. Et la bonne question n’est pas un « point d’équilibre » universel : si le préfixe est commun aux appels — un contexte qu’on enverrait de toute façon —, chaque hit est une économie immédiate, il n’y a rien à amortir ; si on ajoute un préfixe long pour atteindre le cache, il faut le comparer à la requête courte qu’on vient d’allonger.
# Tarif cache de la route mesurée : gpt-5-mini facture l'entrée CACHÉE à 0,1 fois le prix# d'entrée (politique publiée par le fournisseur, relevée le 2026-10-01). Ce ratio n'est pas# universel : il dépend de la route et du modèle -- nommer les deux avant d'estimer une facture.R_CACHED =0.1prix_cache = R_CACHED * PRIX_INtokens_prompt = mesures_pref[-1]["prompt"]tokens_cache = mesures_pref[-1]["cached"]part_cachee = tokens_cache / tokens_prompt if tokens_prompt else0.0def cout_appel(entree_non_cachee, entree_cachee, sortie):"""Coût exact d'un appel, poste par poste : entrée non cachée, entrée cachée, sortie."""return (entree_non_cachee * PRIX_IN + entree_cachee * prix_cache + sortie * PRIX_OUT) /1e6print(f"route : {MODELE_CHAT} via proxy, catalogue mesuré ${PRIX_IN:.2f}/M entrée, "f"${PRIX_OUT:.2f}/M sortie, entrée cachée ${prix_cache:.3f}/M ({R_CACHED:.0%} de l'entrée)")# Scénario 1 -- préfixe COMMUN : les appels paieraient ce contexte au plein tarif même sans# cache ; chaque hit économise la différence plein/réduit sur la part servie. Rien à amortir.economie_par_hit = tokens_cache * (PRIX_IN - prix_cache) /1e6print(f"préfixe : ~{tokens_prompt} tokens dont {100* part_cachee:.0f} % cachés ; scénario préfixe "f"commun : chaque hit économise ${economie_par_hit:.7f} -- gain dès le premier hit, sans seuil")# Scénario 2 -- préfixe AJOUTÉ : on allonge la requête pour atteindre le cache. Témoin,# entrée seule : requête courte de 100 tokens contre le même prompt long déjà chaud.court = cout_appel(100, 0, 0)long_chaud = cout_appel(tokens_prompt - tokens_cache, tokens_cache, 0)print(f"scénario préfixe ajouté : requête courte = ${court:.7f}, prompt long déjà chaud = ${long_chaud:.7f}")print(f"le hit fonctionne (entrée servie au tarif réduit) mais la stratégie reste "f"${long_chaud - court:.7f} plus chère : un préfixe ajouté ne se justifie que s'il ""remplace du travail, jamais pour le cache seul")
route : openai/gpt-5-mini via proxy, catalogue mesuré $0.25/M entrée, $2.00/M sortie, entrée cachée $0.025/M (10% de l'entrée)
préfixe : ~1304 tokens dont 88 % cachés ; scénario préfixe commun : chaque hit économise $0.0002592 -- gain dès le premier hit, sans seuil
scénario préfixe ajouté : requête courte = $0.0000250, prompt long déjà chaud = $0.0000668
le hit fonctionne (entrée servie au tarif réduit) mais la stratégie reste $0.0000418 plus chère : un préfixe ajouté ne se justifie que s'il remplace du travail, jamais pour le cache seul
7. Synthèse : ce que chaque cache achète — et ce qu’il risque
Cache
Ce qu’il économise
Son risque mesuré
Verdict du terrain
exact
100 % des répétitions à l’identique
aucun
plancher gratuit, toujours actif
sémantique
les reformulations (hit mesuré au seuil γ)
faux succès : pièges jumeaux servis
selon la distribution mesurée en section 3
préfixe
la part cached_tokens du prompt
préfixe payé plein tarif à froid
économie par hit mesurée en section 6
La leçon transversale : un cache est un pari sur la distribution des questions. Le cache exact parie qu’elles se répètent à l’identique ; le sémantique qu’elles se répètent en forme sans changer de fond ; le préfixe qu’elles partagent un contexte stable. Ce notebook a mesuré les trois paris sur le même terrain — avec, pour le sémantique, la population de pièges qui manque aux démos.
Limites honnêtes : le domaine est arithmétique (les pièges y sont particulièrement jumeaux — un domaine plus varié espacerait les similarités) ; les embeddings viennent d’un seul fournisseur ; la latence de référence est celle de l’inférence locale, un appel distant est plus lent encore et augmente le gain d’un hit.
Exercices
Exercice 1 : cache LRU borné
Le cache de ce notebook croît sans limite. Implémentez un cache LRU borné à N entrées (N = 50) : mesurez le taux de hit sur un flux où la banque boucle 3 fois — que perd-on à borner, en hit rate et en faux succès ?
Exercice 2 : seuil adaptatif par famille
Le seuil γ unique est grossier : la similarité jumelle varie par type de question. Calibrez un seuil par famille (mesuré sur un échantillon de pièges étiquetés) et comparez le couple (hit rate, faux succès) au seuil unique.
Exercice 3 : invalidation par TTL
Un cache qui sert des réponses périmées est un faux succès différé. Ajoutez un TTL aux entrées : une question re-posée après expiration force un appel frais. Mesurez le hit rate sur un flux avec un TTL court et des re-questions rapprochées.
# Exercice 1 -- cache LRU borné# TODO étudiant : implémenter CacheLRU(capacite=50) avec get/put et ordre d'usage# Indice : collections.OrderedDict et move_to_end font le travail en quelques lignes# Étape 1 : la classe avec move_to_end à chaque get, popitem(last=False) à l'éviction# Étape 2 : rejouer le flux banque×3 et compter les hits# Étape 3 : comparer au cache non borné (hits, faux succès)print("Exercice à compléter")resultat_ex1 =None# TODO étudiant : (hits_borne, hits_non_borne)
Exercice à compléter
# Exercice 2 -- seuil adaptatif par famille# TODO étudiant : calibrer un seuil par type (addition, soustraction, produit)# Indice : pour chaque famille, la similarité jumelle min mesurée + une marge# Étape 1 : grouper les mesures de similarité par type# Étape 2 : seuil_famille = min(sim_jumelles_famille) + marge# Étape 3 : rejouer le balayage avec seuil par famille et comparerprint("Exercice à compléter")resultat_ex2 =None# TODO étudiant : table (famille, seuil, faux succès)
Exercice à compléter
# Exercice 3 -- invalidation par TTL# TODO étudiant : entrées de cache horodatées, expiration après le TTL choisi# Indice : time.monotonic() à l'écriture, comparaison à la lecture# Étape 1 : ajouter "ts" aux entrées du cache# Étape 2 : get() retourne None si âgé > TTL# Étape 3 : mesurer le hit rate sur un flux de 90 questions à 1/s avec retoursprint("Exercice à compléter")resultat_ex3 =None# TODO étudiant : hit rate avec/sans TTL
Exercice à compléter
Conclusion
Récapitulatif
Brique
Question à laquelle elle répond
Banque + pièges
« presque pareil » se mesure-t-il ?
Fond de cache réel
le cache sert des réponses du modèle, pas plaquées
Cache exact
le plancher gratuit
Balayage γ
hit rate et faux succès se lisent ENSEMBLE
cached_tokens
le fournisseur raconte lui-même son cache
La suite
Le dernier volet production garde le terrain et ferme la boucle exploitation : coût par requête et par fonctionnalité, budget et alerte, déploiement progressif (shadow → canary → retour arrière automatique).
Sources
Similarité : embeddings text-embedding-3-small (1536 dimensions, API réelle)
Cache de préfixe : compteurs usage.prompt_tokens_details.cached_tokens des réponses
Prix : catalogue /v1/models du proxy multi-fournisseurs, lus à l’exécution