diff --git a/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb b/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb
index f4bf7fff0a..f1ed201485 100644
--- a/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb
+++ b/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb
@@ -1755,7 +1755,7 @@
"source": [
"## Navigation\n",
"\n",
- "**Série GenAI Texte** | Suivant : [10 — LocalLlama](10_LocalLlama.ipynb) | [↑ Sommaire série](README.md)\n"
+ "**Série GenAI Texte** | Suivant : [9d — Production Caches](09d_Production_Caches.ipynb) | [↑ Sommaire série](README.md)\n"
]
}
],
diff --git a/MyIA.AI.Notebooks/GenAI/Texte/09d_Production_Caches.ipynb b/MyIA.AI.Notebooks/GenAI/Texte/09d_Production_Caches.ipynb
new file mode 100644
index 0000000000..7fa2f57259
--- /dev/null
+++ b/MyIA.AI.Notebooks/GenAI/Texte/09d_Production_Caches.ipynb
@@ -0,0 +1,1387 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "2af54d71",
+ "metadata": {
+ "papermill": {
+ "duration": 0.004305,
+ "end_time": "2026-09-28T16:33:45.397438+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:45.393133+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "# 9d. Production Caches : payer une fois, servir mille fois\n",
+ "\n",
+ "Deuxième volet production après [9c_Production_Routage_Repli](09c_Production_Routage_Repli.ipynb)\n",
+ "(choisir le bon modèle) : ici, **ne pas rappeler le modèle du tout**. Un appel LLM coûté une fois\n",
+ "peut resservir — à condition de savoir *quand* réutiliser sans se tromper.\n",
+ "\n",
+ "Deux familles de caches, mesurées sur le terrain réel :\n",
+ "\n",
+ "1. **Le cache sémantique** — reconnaître qu'une question a déjà été posée *à un mot près*,\n",
+ " par similarité d'embeddings. Sa mesure critique : le **taux de faux succès** — des questions\n",
+ " presque identiques dont la réponse est différente, servies depuis le cache comme si de rien\n",
+ " n'était ;\n",
+ "2. **Le cache de préfixe fournisseur** — OpenAI facture moins cher les tokens de prompt déjà\n",
+ " vus (`cached_tokens` dans la réponse). On mesure ce que ça rend vraiment, et le point\n",
+ " d'équilibre du préfixe long.\n",
+ "\n",
+ "> **Terrain réel, lecture seule** : embeddings OpenAI (`text-embedding-3-small`, clé dans `.env`,\n",
+ "> jamais affichée), catalogue de prix du proxy multi-fournisseurs (`/v1/models`), inférence locale\n",
+ "> Ollama pour les réponses à mettre en cache. Rien de tout cela n'est reconfiguré.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3120652c",
+ "metadata": {
+ "papermill": {
+ "duration": 0.005203,
+ "end_time": "2026-09-28T16:33:45.407081+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:45.401878+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Vérification de l'environnement\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "a93fb585",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:33:45.416232Z",
+ "iopub.status.busy": "2026-09-28T16:33:45.415821Z",
+ "iopub.status.idle": "2026-09-28T16:33:45.440942Z",
+ "shell.execute_reply": "2026-09-28T16:33:45.439609Z"
+ },
+ "papermill": {
+ "duration": 0.030602,
+ "end_time": "2026-09-28T16:33:45.441950+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:45.411348+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "OPENAI_API_KEY : présente\n",
+ "OPENROUTER_API_KEY : présente\n"
+ ]
+ }
+ ],
+ "source": [
+ "import os, json, time, random, re, statistics, math\n",
+ "from pathlib import Path\n",
+ "from dotenv import load_dotenv\n",
+ "\n",
+ "load_dotenv(Path(\"..\") / \".env\")\n",
+ "\n",
+ "OPENAI_API_KEY = os.getenv(\"OPENAI_API_KEY\")\n",
+ "OPENROUTER_API_KEY = os.getenv(\"OPENROUTER_API_KEY\")\n",
+ "print(\"OPENAI_API_KEY :\", \"présente\" if OPENAI_API_KEY else \"ABSENTE -- sections embeddings/préfixe impossibles\")\n",
+ "print(\"OPENROUTER_API_KEY :\", \"présente\" if OPENROUTER_API_KEY else \"absente\")\n",
+ "\n",
+ "random.seed(20260928) # même graine que 9c : même banque\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "a5e557f7",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:33:45.452649Z",
+ "iopub.status.busy": "2026-09-28T16:33:45.451907Z",
+ "iopub.status.idle": "2026-09-28T16:33:48.393897Z",
+ "shell.execute_reply": "2026-09-28T16:33:48.392318Z"
+ },
+ "papermill": {
+ "duration": 2.949887,
+ "end_time": "2026-09-28T16:33:48.395622+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:45.445735+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Inference locale (Ollama) HTTP 200 2 modèles\n",
+ "Proxy multi-fournisseurs HTTP 200 458 modèles\n",
+ "Prix catalogue openai/gpt-5-mini : $0.25/M entrée, $2.00/M sortie\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Mêmes sondages lecture seule que 9c : on re-confirme le terrain, on ne le configure pas\n",
+ "import urllib.request\n",
+ "import pandas as pd\n",
+ "from openai import OpenAI\n",
+ "\n",
+ "def sonde(nom, url):\n",
+ " try:\n",
+ " with urllib.request.urlopen(urllib.request.Request(url), timeout=15) as r:\n",
+ " n = len(json.loads(r.read().decode(\"utf-8\")).get(\"data\", []))\n",
+ " print(f\"{nom:38s} HTTP 200 {n:4d} modèles\")\n",
+ " except Exception as e:\n",
+ " print(f\"{nom:38s} INJOIGNABLE ({type(e).__name__})\")\n",
+ "\n",
+ "sonde(\"Inference locale (Ollama)\", \"http://127.0.0.1:11434/v1/models\")\n",
+ "sonde(\"Proxy multi-fournisseurs\", \"https://openrouter.ai/api/v1/models\")\n",
+ "\n",
+ "def client_openai():\n",
+ " assert OPENAI_API_KEY, \"OPENAI_API_KEY absente du .env\"\n",
+ " return OpenAI(api_key=OPENAI_API_KEY)\n",
+ "\n",
+ "def client_local():\n",
+ " return OpenAI(base_url=\"http://127.0.0.1:11434/v1\", api_key=\"ollama\")\n",
+ "\n",
+ "# Prix réels du proxy pour les modèles de ce notebook (lus au moment de l'exécution)\n",
+ "req = urllib.request.Request(\"https://openrouter.ai/api/v1/models\")\n",
+ "with urllib.request.urlopen(req, timeout=15) as r:\n",
+ " catalogue = {m[\"id\"]: m for m in json.loads(r.read().decode(\"utf-8\"))[\"data\"]}\n",
+ "\n",
+ "MODELE_CHAT = \"openai/gpt-5-mini\"\n",
+ "assert MODELE_CHAT in catalogue, f\"{MODELE_CHAT} absent du catalogue proxy\"\n",
+ "PRIX_IN = float(catalogue[MODELE_CHAT][\"pricing\"][\"prompt\"]) * 1e6\n",
+ "PRIX_OUT = float(catalogue[MODELE_CHAT][\"pricing\"][\"completion\"]) * 1e6\n",
+ "print(f\"Prix catalogue {MODELE_CHAT} : ${PRIX_IN:.2f}/M entrée, ${PRIX_OUT:.2f}/M sortie\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6a187710",
+ "metadata": {
+ "papermill": {
+ "duration": 0.003864,
+ "end_time": "2026-09-28T16:33:48.405078+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:48.401214+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 1. La banque de 9c, et ses pièges\n",
+ "\n",
+ "Le cache sémantique se juge sur deux populations adverses, pas sur des répétitions bénignes :\n",
+ "\n",
+ "- la **banque** de questions arithmétiques (même générateur, même graine que 9c) ;\n",
+ "- les **pièges** : mêmes phrases, **nombres différents** — « 320 colis puis 540 » contre\n",
+ " « 320 colis puis 541 ». Leurs embeddings sont quasi jumeaux, leurs réponses diffèrent.\n",
+ " Un cache qui sert le piège depuis l'entrée voisine fabrique un **faux succès**.\n",
+ "\n",
+ "C'est la population qui manque toujours aux démos de cache sémantique — et celle qui décide\n",
+ "du seuil en production.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "ce8c7da3",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:33:48.420548Z",
+ "iopub.status.busy": "2026-09-28T16:33:48.419805Z",
+ "iopub.status.idle": "2026-09-28T16:33:48.433784Z",
+ "shell.execute_reply": "2026-09-28T16:33:48.432524Z"
+ },
+ "papermill": {
+ "duration": 0.023731,
+ "end_time": "2026-09-28T16:33:48.435175+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:48.411444+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Banque : 90 questions | Pièges : 30 (second opérande +1)\n",
+ " [addition ] Un entrepôt reçoit 823 colis le matin, puis 682 colis l'après-midi. Combien de colis au total ? -> 1505\n",
+ " [addition ] Un entrepôt reçoit 823 colis le matin, puis 683 colis l'après-midi. Combien de colis au total ? -> 1506\n"
+ ]
+ }
+ ],
+ "source": [
+ "def q_addition(a, b):\n",
+ " return f\"Un entrepôt reçoit {a} colis le matin, puis {b} colis l'après-midi. Combien de colis au total ?\", a + b\n",
+ "\n",
+ "def q_soustraction(a, b):\n",
+ " return f\"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 - b\n",
+ "\n",
+ "def q_produit(a, b):\n",
+ " return f\"Une imprimerie produit {a} affiches par série et fabrique {b} séries. Combien d'affiches au total ?\", a * b\n",
+ "\n",
+ "GENES = [(\"addition\", q_addition), (\"soustraction\", q_soustraction), (\"produit\", q_produit)]\n",
+ "\n",
+ "rnd = random.Random(20260928)\n",
+ "BANQUE = []\n",
+ "for i in range(90): # 30 de chaque type\n",
+ " nom, gene = GENES[i % 3]\n",
+ " if nom == \"addition\":\n",
+ " texte, rep = gene(rnd.randint(120, 980), rnd.randint(120, 980))\n",
+ " elif nom == \"soustraction\":\n",
+ " texte, rep = gene(rnd.randint(400, 990), rnd.randint(100, 390))\n",
+ " else:\n",
+ " texte, rep = gene(rnd.randint(12, 96), rnd.randint(12, 96))\n",
+ " BANQUE.append({\"id\": i, \"type\": nom, \"question\": texte, \"reponse\": rep})\n",
+ "\n",
+ "# Pièges : même phrase, second opérande décalé de +1 -> réponse différente, embedding jumeau\n",
+ "PIEGES = []\n",
+ "for i, q in enumerate(BANQUE[:30]):\n",
+ " a, b = (int(n) for n in re.findall(r\"\\d+\", q[\"question\"])[:2])\n",
+ " gene = dict(GENES)[q[\"type\"]]\n",
+ " texte, rep = gene(a, b + 1)\n",
+ " PIEGES.append({\"id\": i, \"type\": q[\"type\"], \"question\": texte, \"reponse\": rep})\n",
+ "\n",
+ "print(f\"Banque : {len(BANQUE)} questions | Pièges : {len(PIEGES)} (second opérande +1)\")\n",
+ "for q in (BANQUE[0], PIEGES[0]):\n",
+ " print(f\" [{q['type']:11s}] {q['question']} -> {q['reponse']}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8c017cd0",
+ "metadata": {
+ "papermill": {
+ "duration": 0.007141,
+ "end_time": "2026-09-28T16:33:48.447939+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:48.440798+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 2. Remplir le cache : une passe réelle sur l'inférence locale\n",
+ "\n",
+ "Le cache ne stocke pas des réponses plaquées : on fait vraiment répondre le modèle local aux 90\n",
+ "questions de la banque (gratuit, température 0), et ce sont **ces réponses réelles** qui\n",
+ "entrent au cache. Les pièges, eux, ne sont jamais mis en cache — ils arrivent comme des\n",
+ "requêtes clientes. Cette passe fournit aussi la **latence de référence** d'un appel non caché,\n",
+ "qui servira de dénominateur à toutes les économies mesurées ensuite.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "c78de773",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:33:48.469431Z",
+ "iopub.status.busy": "2026-09-28T16:33:48.468872Z",
+ "iopub.status.idle": "2026-09-28T16:34:54.212129Z",
+ "shell.execute_reply": "2026-09-28T16:34:54.210891Z"
+ },
+ "papermill": {
+ "duration": 65.757496,
+ "end_time": "2026-09-28T16:34:54.217651+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:33:48.460155+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Cache rempli : 90 réponses réelles du modèle local en 66 s\n",
+ "qualité du fond de cache : 78/90 justes (87 %)\n",
+ "latence de référence d'un appel non caché (médiane) : 574 ms\n"
+ ]
+ }
+ ],
+ "source": [
+ "LOCAL_ID = \"qwen2.5:7b\"\n",
+ "SYSTEME = (\"Tu es un assistant de calcul. Réponds en une seule phrase courte et termine par la \"\n",
+ " \"valeur numérique exacte, sans unité.\")\n",
+ "\n",
+ "def extraire_nombre(texte):\n",
+ " nombres = re.findall(r\"-?\\d+\", texte or \"\")\n",
+ " return int(nombres[-1]) if nombres else None\n",
+ "\n",
+ "def poser_local(question):\n",
+ " t0 = time.perf_counter()\n",
+ " rep = client_local().chat.completions.create(\n",
+ " model=LOCAL_ID,\n",
+ " messages=[{\"role\": \"system\", \"content\": SYSTEME},\n",
+ " {\"role\": \"user\", \"content\": question}],\n",
+ " temperature=0.0, max_tokens=200,\n",
+ " )\n",
+ " dt = time.perf_counter() - t0\n",
+ " texte = rep.choices[0].message.content or \"\"\n",
+ " return extraire_nombre(texte), dt\n",
+ "\n",
+ "CACHE = [] # [{question, reponse_modele, correcte}]\n",
+ "latences = []\n",
+ "t0 = time.perf_counter()\n",
+ "for q in BANQUE:\n",
+ " pred, dt = poser_local(q[\"question\"])\n",
+ " latences.append(dt)\n",
+ " CACHE.append({**q, \"reponse_modele\": pred, \"correcte\": pred == q[\"reponse\"]})\n",
+ "duree_remplissage = time.perf_counter() - t0\n",
+ "\n",
+ "n_justes = sum(c[\"correcte\"] for c in CACHE)\n",
+ "print(f\"Cache rempli : {len(CACHE)} réponses réelles du modèle local en {duree_remplissage:.0f} s\")\n",
+ "print(f\"qualité du fond de cache : {n_justes}/{len(CACHE)} justes ({100.0 * n_justes / len(CACHE):.0f} %)\")\n",
+ "LAT_REF = statistics.median(latences)\n",
+ "print(f\"latence de référence d'un appel non caché (médiane) : {LAT_REF * 1000:.0f} ms\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8a394d76",
+ "metadata": {
+ "papermill": {
+ "duration": 0.004518,
+ "end_time": "2026-09-28T16:34:54.228081+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:34:54.223563+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 3. Embeddings réels et similarité : à quoi ressemble un « presque pareil »\n",
+ "\n",
+ "Toutes les questions — fond de cache **et** pièges — partent en embeddings réels chez OpenAI\n",
+ "(`text-embedding-3-small`, 1536 dimensions, par lots). Avant tout réglage, on **regarde** la\n",
+ "distribution : la similarité cosinus entre chaque piège et l'entrée la plus proche du cache,\n",
+ "séparée en deux populations — la jumelle (même question, autre nombre) et les questions\n",
+ "réellement différentes. Si les deux distributions se chevauchent, aucun seuil ne sera propre —\n",
+ "et c'est le premier résultat du notebook.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "ff89eb3a",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:34:54.240043Z",
+ "iopub.status.busy": "2026-09-28T16:34:54.239523Z",
+ "iopub.status.idle": "2026-09-28T16:35:00.402373Z",
+ "shell.execute_reply": "2026-09-28T16:35:00.400941Z"
+ },
+ "papermill": {
+ "duration": 6.169964,
+ "end_time": "2026-09-28T16:35:00.403624+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:34:54.233660+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "la plus proche entrée du cache est la jumelle : 30/30 pièges\n",
+ "piège -> jumelle : min 0.962 moy 0.986 max 0.996 (n=30)\n",
+ "piège -> étrangère : min 0.457 moy 0.550 max 0.612 (n=30)\n"
+ ]
+ }
+ ],
+ "source": [
+ "def embedder(textes, lot=64):\n",
+ " vecteurs = []\n",
+ " for i in range(0, len(textes), lot):\n",
+ " r = client_openai().embeddings.create(model=\"text-embedding-3-small\",\n",
+ " input=textes[i:i + lot])\n",
+ " vecteurs.extend(d.embedding for d in r.data)\n",
+ " return vecteurs\n",
+ "\n",
+ "emb_cache = embedder([c[\"question\"] for c in CACHE])\n",
+ "emb_pieges = embedder([p[\"question\"] for p in PIEGES])\n",
+ "\n",
+ "def cos(u, v):\n",
+ " dot = sum(x * y for x, y in zip(u, v))\n",
+ " nu = math.sqrt(sum(x * x for x in u))\n",
+ " nv = math.sqrt(sum(x * x for x in v))\n",
+ " return dot / (nu * nv) if nu and nv else 0.0\n",
+ "\n",
+ "def plus_proche(vect):\n",
+ " best_i, best_s = 0, -1.0\n",
+ " for i, u in enumerate(emb_cache):\n",
+ " s = cos(vect, u)\n",
+ " if s > best_s:\n",
+ " best_i, best_s = i, s\n",
+ " return best_i, best_s\n",
+ "\n",
+ "# Population 1 : la jumelle (même gabarit, autre nombre). Résultat de terrain attendu :\n",
+ "# l'entrée la plus proche d'un piège est systématiquement sa jumelle.\n",
+ "sim_jumelles, rang_jumelle = [], 0\n",
+ "for j, v in enumerate(emb_pieges):\n",
+ " i, s = plus_proche(v)\n",
+ " sim_jumelles.append(s)\n",
+ " if i == j:\n",
+ " rang_jumelle += 1\n",
+ "\n",
+ "# Population 2 (contraste) : pour chaque piège, la similarité de la question « étrangère »\n",
+ "# la plus proche — un autre GABARIT, que n'importe quel seuil raisonnable doit repousser.\n",
+ "sim_etrangeres = [\n",
+ " max(cos(v, u) for i2, u in enumerate(emb_cache) if CACHE[i2][\"type\"] != p[\"type\"])\n",
+ " for p, v in zip(PIEGES, emb_pieges)\n",
+ "]\n",
+ "\n",
+ "print(f\"la plus proche entrée du cache est la jumelle : {rang_jumelle}/{len(PIEGES)} pièges\")\n",
+ "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)})\")\n",
+ "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)})\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "db39fe10",
+ "metadata": {
+ "papermill": {
+ "duration": 0.004558,
+ "end_time": "2026-09-28T16:35:00.414091+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:35:00.409533+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 4. Le cache exact : le plancher des droits\n",
+ "\n",
+ "Avant le sémantique, l'exact : une clé de dict sur le texte de la question. Re-poser la banque\n",
+ "ne coûte qu'une recherche dans un dict ; re-poser une **reformulation** (« Quel est le nombre total de colis ? ») coûte un\n",
+ "appel complet. Le cache exact est gratuit et sans risque — sa mesure fixe le plancher que le\n",
+ "sémantique doit dépasser **sans** payer en faux succès.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "3dcbad44",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:35:00.427594Z",
+ "iopub.status.busy": "2026-09-28T16:35:00.427038Z",
+ "iopub.status.idle": "2026-09-28T16:35:00.436608Z",
+ "shell.execute_reply": "2026-09-28T16:35:00.435393Z"
+ },
+ "papermill": {
+ "duration": 0.018454,
+ "end_time": "2026-09-28T16:35:00.437783+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:35:00.419329+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "rejeu de la banque par cache exact : 90/90 hits, 0.1 ms au total\n",
+ "reformulations (n=20) : 0 hit(s) — le cache exact est aveugle à la formulation\n"
+ ]
+ }
+ ],
+ "source": [
+ "cache_exact = {c[\"question\"]: c[\"reponse_modele\"] for c in CACHE}\n",
+ "\n",
+ "t0 = time.perf_counter()\n",
+ "hits = sum(1 for q in BANQUE if q[\"question\"] in cache_exact)\n",
+ "dt_exact = time.perf_counter() - t0\n",
+ "print(f\"rejeu de la banque par cache exact : {hits}/{len(BANQUE)} hits, {dt_exact * 1000:.1f} ms au total\")\n",
+ "\n",
+ "def reformuler(q):\n",
+ " return (q[\"question\"]\n",
+ " .replace(\"Combien de colis au total ?\", \"Quel est le nombre total de colis ?\")\n",
+ " .replace(\"Combien d'affiches au total ?\", \"Quel est le nombre total d'affiches ?\")\n",
+ " .replace(\"Combien reste-t-il d'unités ?\", \"Quel est le nombre d'unités restantes ?\"))\n",
+ "\n",
+ "reformulations = [reformuler(q) for q in BANQUE[:20]]\n",
+ "assert all(r != q[\"question\"] for r, q in zip(reformulations, BANQUE[:20])), \"reformulation identique\"\n",
+ "hits_ref = sum(1 for r in reformulations if r in cache_exact)\n",
+ "print(f\"reformulations (n=20) : {hits_ref} hit(s) — le cache exact est aveugle à la formulation\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "64d7a5ac",
+ "metadata": {
+ "papermill": {
+ "duration": 0.005373,
+ "end_time": "2026-09-28T16:35:00.449293+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:35:00.443920+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 5. Le cache sémantique : le seuil et son prix\n",
+ "\n",
+ "Le cache sémantique répond depuis l'entrée la plus proche dès que la similarité dépasse un\n",
+ "seuil γ. On balaie γ : à chaque seuil, on rejoue trois populations — la banque (le hit est\n",
+ "légitime), les reformulations (le hit est le gain), et les pièges (le hit est une **fausse\n",
+ "réponse servie**). Les réponses « fraîches » hors seuil sont calculées **une seule fois** par\n",
+ "question puis mémoïsées : le balayage fait varier le seuil, pas le terrain.\n",
+ "\n",
+ "Trois courbes à lire ensemble : économie de latence, taux de hit, et taux de faux succès. Le\n",
+ "seuil retenu en production est celui où la courbe de faux succès tombe au niveau que le\n",
+ "produit tolère — pas celui qui maximise l'économie.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "70adea70",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:35:00.462228Z",
+ "iopub.status.busy": "2026-09-28T16:35:00.461706Z",
+ "iopub.status.idle": "2026-09-28T16:36:00.624765Z",
+ "shell.execute_reply": "2026-09-28T16:36:00.623515Z"
+ },
+ "papermill": {
+ "duration": 60.17635,
+ "end_time": "2026-09-28T16:36:00.631288+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:35:00.454938+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "50 appels frais réels au total (mémoïsés) ; le balayage lui-même est de l'arithmétique\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " hits | \n",
+ " taux de hit % | \n",
+ " faux succès (pièges) | \n",
+ " faux succès % | \n",
+ " exactitude % | \n",
+ " latence évitée (s, à 574 ms/appel) | \n",
+ "
\n",
+ " \n",
+ " | seuil γ | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0.900 | \n",
+ " 110 | \n",
+ " 100.0 | \n",
+ " 30 | \n",
+ " 100.0 | \n",
+ " 63.6 | \n",
+ " 63.1 | \n",
+ "
\n",
+ " \n",
+ " | 0.950 | \n",
+ " 110 | \n",
+ " 100.0 | \n",
+ " 30 | \n",
+ " 100.0 | \n",
+ " 63.6 | \n",
+ " 63.1 | \n",
+ "
\n",
+ " \n",
+ " | 0.980 | \n",
+ " 98 | \n",
+ " 89.1 | \n",
+ " 21 | \n",
+ " 70.0 | \n",
+ " 71.8 | \n",
+ " 56.2 | \n",
+ "
\n",
+ " \n",
+ " | 0.990 | \n",
+ " 72 | \n",
+ " 65.5 | \n",
+ " 12 | \n",
+ " 40.0 | \n",
+ " 75.5 | \n",
+ " 41.3 | \n",
+ "
\n",
+ " \n",
+ " | 0.995 | \n",
+ " 61 | \n",
+ " 55.5 | \n",
+ " 1 | \n",
+ " 3.3 | \n",
+ " 81.8 | \n",
+ " 35.0 | \n",
+ "
\n",
+ " \n",
+ " | 0.999 | \n",
+ " 60 | \n",
+ " 54.5 | \n",
+ " 0 | \n",
+ " 0.0 | \n",
+ " 82.7 | \n",
+ " 34.4 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " hits taux de hit % faux succès (pièges) faux succès % \\\n",
+ "seuil γ \n",
+ "0.900 110 100.0 30 100.0 \n",
+ "0.950 110 100.0 30 100.0 \n",
+ "0.980 98 89.1 21 70.0 \n",
+ "0.990 72 65.5 12 40.0 \n",
+ "0.995 61 55.5 1 3.3 \n",
+ "0.999 60 54.5 0 0.0 \n",
+ "\n",
+ " exactitude % latence évitée (s, à 574 ms/appel) \n",
+ "seuil γ \n",
+ "0.900 63.6 63.1 \n",
+ "0.950 63.6 63.1 \n",
+ "0.980 71.8 56.2 \n",
+ "0.990 75.5 41.3 \n",
+ "0.995 81.8 35.0 \n",
+ "0.999 82.7 34.4 "
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "requetes = ([(q[\"question\"], q[\"reponse\"], \"banque\") for q in BANQUE[:60]]\n",
+ " + [(reformuler(q), q[\"reponse\"], \"reformulation\") for q in BANQUE[:20]]\n",
+ " + [(p[\"question\"], p[\"reponse\"], \"piege\") for p in PIEGES])\n",
+ "emb_requetes = embedder([r[0] for r in requetes])\n",
+ "\n",
+ "# Réponses fraîches mémoïsées : chaque question hors seuil est réellement posée au modèle\n",
+ "# la première fois seulement ; les seuils suivants rejouent la même réalité.\n",
+ "frais = {}\n",
+ "def reponse_fraiche(question):\n",
+ " if question not in frais:\n",
+ " frais[question] = poser_local(question)[0]\n",
+ " return frais[question]\n",
+ "\n",
+ "def rejouer_avec_seuil(seuil):\n",
+ " \"\"\"Renvoie (hits, faux_succes, justes) pour un seuil gamma donné.\n",
+ "\n",
+ " faux_succes = un PIEGE servi DEPUIS LE CACHE avec la mauvaise reponse.\n",
+ " Une erreur de reponse fraiche (le modele local se trompe) n'est pas un\n",
+ " faux succes : elle reste visible dans la colonne exactitude.\"\"\"\n",
+ " hits = faux = justes = 0\n",
+ " for (question, reponse, famille), v in zip(requetes, emb_requetes):\n",
+ " i, s = plus_proche(v)\n",
+ " if s >= seuil:\n",
+ " hits += 1\n",
+ " pred = CACHE[i][\"reponse_modele\"]\n",
+ " if pred != reponse and famille == \"piege\":\n",
+ " faux += 1\n",
+ " else:\n",
+ " pred = reponse_fraiche(question)\n",
+ " if pred == reponse:\n",
+ " justes += 1\n",
+ " return hits, faux, justes\n",
+ "\n",
+ "mesures = []\n",
+ "for seuil in [0.90, 0.95, 0.98, 0.99, 0.995, 0.999]:\n",
+ " hits, faux, justes = rejouer_avec_seuil(seuil)\n",
+ " mesures.append({\n",
+ " \"seuil γ\": seuil,\n",
+ " \"hits\": hits,\n",
+ " \"taux de hit %\": round(100.0 * hits / len(requetes), 1),\n",
+ " \"faux succès (pièges)\": faux,\n",
+ " \"faux succès %\": round(100.0 * faux / len(PIEGES), 1),\n",
+ " \"exactitude %\": round(100.0 * justes / len(requetes), 1),\n",
+ " f\"latence évitée (s, à {LAT_REF * 1000:.0f} ms/appel)\": round(hits * LAT_REF, 1),\n",
+ " })\n",
+ "print(f\"{len(frais)} appels frais réels au total (mémoïsés) ; le balayage lui-même est de l'arithmétique\")\n",
+ "df_seuils = pd.DataFrame(mesures).set_index(\"seuil γ\")\n",
+ "df_seuils\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "64cdd0f0",
+ "metadata": {
+ "papermill": {
+ "duration": 0.005524,
+ "end_time": "2026-09-28T16:36:00.643566+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:00.638042+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du balayage\n",
+ "\n",
+ "Trois lectures, dans cet ordre :\n",
+ "\n",
+ "1. **la similarité jumelle** (section 3) borne tout : si un piège et sa jumelle sont à 0,97,\n",
+ " tout seuil ≤ 0,97 servira le piège depuis le cache — le faux succès est **structurel**, pas\n",
+ " un accident de réglage ;\n",
+ "2. le **taux de faux succès par seuil** dit si un seuil sûr existe : si la colonne ne tombe\n",
+ " jamais à 0 avant que le taux de hit ne s'effondre, le cache sémantique est disqualifié sur\n",
+ " ce domaine — réponse tout aussi utile qu'un feu vert ;\n",
+ "3. l'**exactitude** compare le pipeline complet au modèle nu : un cache qui économise 80 % de\n",
+ " latence en perdant 10 points d'exactitude n'économise rien de ce qui compte.\n",
+ "\n",
+ "*Les chiffres proviennent de l'exécution committée ; la latence d'appel de référence est la\n",
+ "médiane mesurée en section 2 sur le même terrain.*\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3fd446a6",
+ "metadata": {
+ "papermill": {
+ "duration": 0.005022,
+ "end_time": "2026-09-28T16:36:00.654810+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:00.649788+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 6. Le cache de préfixe fournisseur : lire `cached_tokens`\n",
+ "\n",
+ "Chez OpenAI, un préfixe de prompt déjà vu (au-delà d'un seuil de longueur, 1024 tokens) est\n",
+ "facturé moins cher quand il revient : la réponse expose\n",
+ "`usage.prompt_tokens_details.cached_tokens`. Le terrain dit la vérité — on ne la devine pas.\n",
+ "On construit un **préfixe métier long** (règles de calcul et conventions du service logistique),\n",
+ "on pose une série de questions courtes qui le partagent, et on lit les compteurs de chaque\n",
+ "réponse.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "1aa99002",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:36:00.672259Z",
+ "iopub.status.busy": "2026-09-28T16:36:00.671696Z",
+ "iopub.status.idle": "2026-09-28T16:36:43.095699Z",
+ "shell.execute_reply": "2026-09-28T16:36:43.094275Z"
+ },
+ "papermill": {
+ "duration": 42.438583,
+ "end_time": "2026-09-28T16:36:43.101971+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:00.663388+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " tokens prompt | \n",
+ " dont cachés | \n",
+ " part cachée % | \n",
+ "
\n",
+ " \n",
+ " | appel | \n",
+ " | \n",
+ " | \n",
+ " | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 1305 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " 1305 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 6 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 7 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 8 | \n",
+ " 1305 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 9 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ " | 10 | \n",
+ " 1304 | \n",
+ " 1152 | \n",
+ " 88.3 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " tokens prompt dont cachés part cachée %\n",
+ "appel \n",
+ "1 1304 1152 88.3\n",
+ "2 1305 1152 88.3\n",
+ "3 1304 1152 88.3\n",
+ "4 1304 1152 88.3\n",
+ "5 1305 1152 88.3\n",
+ "6 1304 1152 88.3\n",
+ "7 1304 1152 88.3\n",
+ "8 1305 1152 88.3\n",
+ "9 1304 1152 88.3\n",
+ "10 1304 1152 88.3"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "PREFIXE = (\n",
+ " \"Tu es l'assistant de calcul du service logistique Aurelia, rattaché à la direction des \"\n",
+ " \"opérations. Tu réponds aux questions de quantités (colis, unités de stock, affiches) en \"\n",
+ " \"appliquant strictement les règles du service. Règle 1 : les totaux incluent toujours les \"\n",
+ " \"livraisons du matin ET celles de l'après-midi, sans exception, même si l'après-midi est \"\n",
+ " \"partiellement reporté au lendemain. Règle 2 : les retours clients sont déduits du stock \"\n",
+ " \"après la vente, jamais avant la mise en rayon ; un retour non contrôlé reste en stock. \"\n",
+ " \"Règle 3 : les pourcentages s'appliquent au total brut, arrondis à l'entier inférieur, et \"\n",
+ " \"une remise en cascade se compose multiplicativement, jamais par addition. Règle 4 : les \"\n",
+ " \"séries de production se multiplient, les rebuts se soustraient en fin de série, et une \"\n",
+ " \"série interrompue compte pour son dernier lot complet. Règle 5 : un colis prioritaire \"\n",
+ " \"est un colis ; la priorité ne change jamais les quantités, seulement l'ordre de départ. \"\n",
+ " \"Règle 6 : les doublons de réception (même numéro de bon livré deux fois) comptent une \"\n",
+ " \"seule fois dans les totaux du jour. Règle 7 : une rupture de stock ramène le disponible à \"\n",
+ " \"zéro, jamais à un nombre négatif. Règle 8 : les conversions d'unités internes (palettes, \"\n",
+ " \"cartons, unités) utilisent la table officielle du service, palettes de 120, cartons de 12, \"\n",
+ " \"et ne se devinent jamais. Règle 9 : en cas d'incohérence entre deux chiffres de l'énoncé, \"\n",
+ " \"le chiffre horodaté le plus récent fait foi. Règle 10 : les reports inter-entrepôts ne \"\n",
+ " \"comptent ni comme sorties ni comme entrées du jour concerné. Conventions de réponse : \"\n",
+ " \"répondre en une phrase courte, terminer par la valeur numérique exacte, ne jamais \"\n",
+ " \"afficher le détail des opérations intermédiaires, ne jamais reformuler la question, ne \"\n",
+ " \"jamais ajouter d'unité, ne jamais commenter les règles elles-mêmes. Contexte du service : \"\n",
+ " \"les rapports trimestriels des trois dernières années ont toujours validé ces règles par \"\n",
+ " \"audit ; l'assistant n'a pas à les justifier, seulement à les appliquer. Domaine couvert : \"\n",
+ " \"entrepôts régionaux, stocks de magasins, séries d'impression, dossiers prioritaires, \"\n",
+ " \"réceptions du matin et de l'après-midi, retours clients, conversions palettes et cartons. \"\n",
+ " \"Règle 11 : les stocks d'ouverture annoncés deux fois dans un énoncé ne se comptent qu'une \"\n",
+ " \"fois ; la seconde mention est une confirmation, pas une entrée supplémentaire. Règle 12 : \"\n",
+ " \"les arrondis de conversion palettes versent le reste en unités libres, jamais en palette \"\n",
+ " \"incomplète facturée pleine. Règle 13 : un transfert partiellelement livré compte les \"\n",
+ " \"quantités effectivement parties, à la date de départ effective du camion. Règle 14 : les \"\n",
+ " \"tolérances de pesée (plus ou moins une unité par carton) s'annulent en moyenne sur les \"\n",
+ " \"totaux d'une série complète. Règle 15 : en fin de mois, les reports non soldés du mois \"\n",
+ " \"précédent s'ajoutent aux entrées du premier jour ouvré, sans recalcul rétroactif. \"\n",
+ " \"Règle 16 : une commande annulée avant départ n'a jamais existé dans aucun total. \"\n",
+ " \"Glossaire du service : un lot est un groupe de cartons d'une même référence ; une \"\n",
+ " \"réception est un événement horodaté de déchargement ; un disponible est le stock moins \"\n",
+ " \"les réservations confirmées ; une réserve n'est jamais un disponible. Procédure de \"\n",
+ " \"contrôle : après chaque réponse, l'assistant vérifie mentalement l'ordre de grandeur ; si \"\n",
+ " \"le résultat dépasse dix mille unités pour une question de magasin, ou descend sous \"\n",
+ " \"zéro, l'assistant recompte silencieusement avant de répondre, sans jamais mentionner ce \"\n",
+ " \"contrôle. Traçabilité : les réponses alimentent le journal d'exploitation quotidien ; \"\n",
+ " \"elles doivent donc rester autoportantes, sans reprise du contexte de la question. \"\n",
+ " \"Historique de formulation : les questions arrivent courtes ; l'assistant ne demande \"\n",
+ " \"jamais d'éclaircissement et prend l'énoncé tel quel. Registre attendu : sobre, direct, \"\n",
+ " \"factuel, sans politesse superflue et sans formule d'ouverture ; la réponse commence par \"\n",
+ " \"le résultat du calcul, jamais par une salutation. Cas limites déjà tranchés par le \"\n",
+ " \"service : une question sans nombre vaut réponse « 0 » ; une question avec trois nombres \"\n",
+ " \"applique les règles dans l'ordre d'apparition des nombres ; une question répétée dans la \"\n",
+ " \"même journée reçoit la même réponse sans variante. Ces décisions ne se discutent pas et \"\n",
+ " \"ne se mentionnent pas : elles s'appliquent. Dernier rappel de registre : aucun \"\n",
+ " \"préambule, aucune excuse, aucune explication de méthode — le service paie au token de \"\n",
+ " \"sortie, chaque mot inutile est une dépense. Manuel de procédure du service, à connaître \"\n",
+ " \"et à appliquer sans le citer : avant la première réponse d'une journée, l'assistant \"\n",
+ " \"relise mentalement les règles 1 à 16 dans l'ordre ; aucun rapport ne mentionne cette \"\n",
+ " \"relecture. Les incidents classés des trois dernières années, par fréquence décroissante : \"\n",
+ " \"totaux matin comptés deux fois (règle 1), retours déduits avant vente (règle 2), \"\n",
+ " \"pourcentages appliqués au net au lieu du brut (règle 3), séries interrompules comptées \"\n",
+ " \"complètes (règle 4), palettes incomplètes facturées pleines (règle 12). Chaque incident \"\n",
+ " \"a donné lieu à une correction de règle, jamais à une exception individuelle : il n'existe \"\n",
+ " \"aucune dérogation, aucun cas particulier, aucune validation hiérarchique possible. Les \"\n",
+ " \"chiffres ronds suspects (multiples de cent) reçoivent la même vérification que les \"\n",
+ " \"autres. La réponse finale ne contient jamais le mot « règle », jamais le numéro d'une \"\n",
+ " \"règle, jamais une référence à ce manuel.\")\n",
+ "\n",
+ "def poser_chat(question):\n",
+ " rep = client_openai().chat.completions.create(\n",
+ " model=\"gpt-5-mini\",\n",
+ " messages=[{\"role\": \"system\", \"content\": PREFIXE},\n",
+ " {\"role\": \"user\", \"content\": question}],\n",
+ " max_completion_tokens=150, # gpt-5-mini : temperature non reglable (defaut 1)\n",
+ " )\n",
+ " u = rep.usage\n",
+ " details = getattr(u, \"prompt_tokens_details\", None)\n",
+ " cache_tok = getattr(details, \"cached_tokens\", 0) if details is not None else 0\n",
+ " return {\"prompt\": u.prompt_tokens, \"cached\": cache_tok,\n",
+ " \"completion\": u.completion_tokens}\n",
+ "\n",
+ "# 10 questions partagent le préfixe ; la première écrit le cache du fournisseur.\n",
+ "# Son écriture est asynchrone : sans quelques secondes d'écart, le second appel repart\n",
+ "# aussi frais que le premier.\n",
+ "questions_pref = [q[\"question\"] for q in BANQUE[:10]]\n",
+ "mesures_pref = [poser_chat(questions_pref[0])]\n",
+ "time.sleep(10)\n",
+ "mesures_pref += [poser_chat(q) for q in questions_pref[1:]]\n",
+ "df_pref = pd.DataFrame([{\n",
+ " \"appel\": i + 1,\n",
+ " \"tokens prompt\": m[\"prompt\"],\n",
+ " \"dont cachés\": m[\"cached\"],\n",
+ " \"part cachée %\": round(100.0 * m[\"cached\"] / m[\"prompt\"], 1) if m[\"prompt\"] else 0.0,\n",
+ "} for i, m in enumerate(mesures_pref)]).set_index(\"appel\")\n",
+ "df_pref\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9305d528",
+ "metadata": {
+ "papermill": {
+ "duration": 0.010125,
+ "end_time": "2026-09-28T16:36:43.118967+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.108842+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "### Lecture du cache de préfixe\n",
+ "\n",
+ "Sur cette exécution, le 1ᵉʳ appel affiche déjà 88 % de tokens cachés : ce préfixe avait\n",
+ "déjà été envoyé quelques minutes plus tôt (runs de préparation) — le cache du fournisseur\n",
+ "survit d'une exécution à l'autre, c'est précisément son intérêt en production. Sur un\n",
+ "préfixe réellement froid, le 1ᵉʳ appel paie le plein tarif. L'économie réelle se calcule avec les **prix du catalogue** lus en section 0 : le\n",
+ "token caché est facturé à fraction du prix d'entrée (politique fournisseur, `R_CACHED`\n",
+ "ci-dessous) — on mesure la part servie, le reste est arithmétique. Le **point d'équilibre**\n",
+ "répond à la question qui précède tout investissement dans un préfixe long : combien d'appels\n",
+ "partageant ce préfixe faut-il pour que le préfixe se rentabilise ?\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "f3e21358",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:36:43.138004Z",
+ "iopub.status.busy": "2026-09-28T16:36:43.137338Z",
+ "iopub.status.idle": "2026-09-28T16:36:43.146848Z",
+ "shell.execute_reply": "2026-09-28T16:36:43.145342Z"
+ },
+ "papermill": {
+ "duration": 0.021729,
+ "end_time": "2026-09-28T16:36:43.148049+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.126320+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "préfixe : ~1304 tokens, part cachée mesurée sur le dernier appel : 88 %\n",
+ "coût plein du préfixe par appel : $0.000326\n",
+ "économie par appel une fois chaud : $0.000144 (tarif caché = 50% du plein)\n",
+ "point d'équilibre : ~2 appels par préfixe pour rentrer dans les frais (au-delà, tout est gain)\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Point d'équilibre : le préfixe coûte PRIX_IN plein tarif tant qu'il n'est pas caché ;\n",
+ "# une fois chaud, une part f de ses tokens est facturée au tarif réduit R_CACHED (0,5 = politique\n",
+ "# fournisseur standard : le token caché vaut la moitié du prix d'entrée).\n",
+ "R_CACHED = 0.5\n",
+ "tokens_prompt = mesures_pref[-1][\"prompt\"]\n",
+ "part_cachee = mesures_pref[-1][\"cached\"] / tokens_prompt if tokens_prompt else 0.0\n",
+ "cout_prefixe_usd = tokens_prompt * PRIX_IN / 1e6\n",
+ "economie_par_appel = tokens_prompt * (1 - R_CACHED) * part_cachee * PRIX_IN / 1e6\n",
+ "\n",
+ "print(f\"préfixe : ~{tokens_prompt} tokens, part cachée mesurée sur le dernier appel : {100 * part_cachee:.0f} %\")\n",
+ "print(f\"coût plein du préfixe par appel : ${cout_prefixe_usd:.6f}\")\n",
+ "print(f\"économie par appel une fois chaud : ${economie_par_appel:.6f} (tarif caché = {R_CACHED:.0%} du plein)\")\n",
+ "if economie_par_appel > 0:\n",
+ " n_equilibre = cout_prefixe_usd / economie_par_appel\n",
+ " print(f\"point d'équilibre : ~{n_equilibre:.0f} appels par préfixe pour rentrer dans les frais (au-delà, tout est gain)\")\n",
+ "else:\n",
+ " print(\"aucune économie mesurée sur ce terrain : le point d'équilibre n'existe pas\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e4c5921c",
+ "metadata": {
+ "papermill": {
+ "duration": 0.007938,
+ "end_time": "2026-09-28T16:36:43.162494+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.154556+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## 7. Synthèse : ce que chaque cache achète — et ce qu'il risque\n",
+ "\n",
+ "| Cache | Ce qu'il économise | Son risque mesuré | Verdict du terrain |\n",
+ "|---|---|---|---|\n",
+ "| exact | 100 % des répétitions à l'identique | aucun | plancher gratuit, toujours actif |\n",
+ "| sémantique | les reformulations (hit mesuré au seuil γ) | **faux succès** : pièges jumeaux servis | selon la distribution mesurée en section 3 |\n",
+ "| préfixe | la part `cached_tokens` du prompt | préfixe payé plein tarif à froid | équilibre mesuré en section 6 |\n",
+ "\n",
+ "La leçon transversale : **un cache est un pari sur la distribution des questions**. Le cache\n",
+ "exact parie qu'elles se répètent à l'identique ; le sémantique qu'elles se répètent *en\n",
+ "forme* sans changer de fond ; le préfixe qu'elles partagent un contexte stable. Ce notebook a\n",
+ "mesuré les trois paris sur le même terrain — avec, pour le sémantique, la population de\n",
+ "pièges qui manque aux démos.\n",
+ "\n",
+ "Limites honnêtes : le domaine est arithmétique (les pièges y sont particulièrement jumeaux —\n",
+ "un domaine plus varié espacerait les similarités) ; les embeddings viennent d'un seul\n",
+ "fournisseur ; la latence de référence est celle de l'inférence locale, un appel distant est\n",
+ "plus lent encore et augmente le gain d'un hit.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3db41b3e",
+ "metadata": {
+ "papermill": {
+ "duration": 0.010578,
+ "end_time": "2026-09-28T16:36:43.180225+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.169647+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Exercices\n",
+ "\n",
+ "### Exercice 1 : cache LRU borné\n",
+ "\n",
+ "Le cache de ce notebook croît sans limite. Implémentez un cache LRU borné à N entrées\n",
+ "(N = 50) : mesurez le taux de hit sur un flux où la banque boucle 3 fois — que perd-on\n",
+ "à borner, en hit rate et en faux succès ?\n",
+ "\n",
+ "### Exercice 2 : seuil adaptatif par famille\n",
+ "\n",
+ "Le seuil γ unique est grossier : la similarité jumelle varie par type de question. Calibrez\n",
+ "un seuil **par famille** (mesuré sur un échantillon de pièges étiquetés) et comparez le couple\n",
+ "(hit rate, faux succès) au seuil unique.\n",
+ "\n",
+ "### Exercice 3 : invalidation par TTL\n",
+ "\n",
+ "Un cache qui sert des réponses périmées est un faux succès différé. Ajoutez un TTL aux\n",
+ "entrées : une question re-posée après expiration force un appel frais. Mesurez le hit rate\n",
+ "sur un flux avec un TTL court et des re-questions rapprochées.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "838780a1",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:36:43.208372Z",
+ "iopub.status.busy": "2026-09-28T16:36:43.207753Z",
+ "iopub.status.idle": "2026-09-28T16:36:43.218213Z",
+ "shell.execute_reply": "2026-09-28T16:36:43.216239Z"
+ },
+ "papermill": {
+ "duration": 0.031134,
+ "end_time": "2026-09-28T16:36:43.219563+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.188429+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Exercice à compléter\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Exercice 1 -- cache LRU borné\n",
+ "# TODO étudiant : implémenter CacheLRU(capacite=50) avec get/put et ordre d'usage\n",
+ "# Indice : collections.OrderedDict et move_to_end font le travail en quelques lignes\n",
+ "# Étape 1 : la classe avec move_to_end à chaque get, popitem(last=False) à l'éviction\n",
+ "# Étape 2 : rejouer le flux banque×3 et compter les hits\n",
+ "# Étape 3 : comparer au cache non borné (hits, faux succès)\n",
+ "print(\"Exercice à compléter\")\n",
+ "resultat_ex1 = None # TODO étudiant : (hits_borne, hits_non_borne)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "894db209",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:36:43.234877Z",
+ "iopub.status.busy": "2026-09-28T16:36:43.234295Z",
+ "iopub.status.idle": "2026-09-28T16:36:43.241179Z",
+ "shell.execute_reply": "2026-09-28T16:36:43.239892Z"
+ },
+ "papermill": {
+ "duration": 0.016488,
+ "end_time": "2026-09-28T16:36:43.242453+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.225965+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Exercice à compléter\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Exercice 2 -- seuil adaptatif par famille\n",
+ "# TODO étudiant : calibrer un seuil par type (addition, soustraction, produit)\n",
+ "# Indice : pour chaque famille, la similarité jumelle min mesurée + une marge\n",
+ "# Étape 1 : grouper les mesures de similarité par type\n",
+ "# Étape 2 : seuil_famille = min(sim_jumelles_famille) + marge\n",
+ "# Étape 3 : rejouer le balayage avec seuil par famille et comparer\n",
+ "print(\"Exercice à compléter\")\n",
+ "resultat_ex2 = None # TODO étudiant : table (famille, seuil, faux succès)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "29ff3335",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-28T16:36:43.259478Z",
+ "iopub.status.busy": "2026-09-28T16:36:43.258894Z",
+ "iopub.status.idle": "2026-09-28T16:36:43.265668Z",
+ "shell.execute_reply": "2026-09-28T16:36:43.264238Z"
+ },
+ "papermill": {
+ "duration": 0.017244,
+ "end_time": "2026-09-28T16:36:43.267062+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.249818+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Exercice à compléter\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Exercice 3 -- invalidation par TTL\n",
+ "# TODO étudiant : entrées de cache horodatées, expiration après le TTL choisi\n",
+ "# Indice : time.monotonic() à l'écriture, comparaison à la lecture\n",
+ "# Étape 1 : ajouter \"ts\" aux entrées du cache\n",
+ "# Étape 2 : get() retourne None si âgé > TTL\n",
+ "# Étape 3 : mesurer le hit rate sur un flux de 90 questions à 1/s avec retours\n",
+ "print(\"Exercice à compléter\")\n",
+ "resultat_ex3 = None # TODO étudiant : hit rate avec/sans TTL\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2641d0c2",
+ "metadata": {
+ "papermill": {
+ "duration": 0.007192,
+ "end_time": "2026-09-28T16:36:43.281704+00:00",
+ "exception": false,
+ "start_time": "2026-09-28T16:36:43.274512+00:00",
+ "status": "completed"
+ },
+ "tags": []
+ },
+ "source": [
+ "## Conclusion\n",
+ "\n",
+ "### Récapitulatif\n",
+ "\n",
+ "| Brique | Question à laquelle elle répond |\n",
+ "|---|---|\n",
+ "| Banque + pièges | « presque pareil » se mesure-t-il ? |\n",
+ "| Fond de cache réel | le cache sert des réponses du modèle, pas plaquées |\n",
+ "| Cache exact | le plancher gratuit |\n",
+ "| Balayage γ | hit rate et faux succès se lisent ENSEMBLE |\n",
+ "| `cached_tokens` | le fournisseur raconte lui-même son cache |\n",
+ "\n",
+ "### La suite\n",
+ "\n",
+ "Le dernier volet production garde le terrain et ferme la boucle **exploitation** : coût par\n",
+ "requête et par fonctionnalité, budget et alerte, déploiement progressif (shadow → canary →\n",
+ "retour arrière automatique).\n",
+ "\n",
+ "### Sources\n",
+ "\n",
+ "- Similarité : embeddings `text-embedding-3-small` (1536 dimensions, API réelle)\n",
+ "- Cache de préfixe : compteurs `usage.prompt_tokens_details.cached_tokens` des réponses\n",
+ "- Prix : catalogue `/v1/models` du proxy multi-fournisseurs, lus à l'exécution\n",
+ "\n",
+ "## Navigation\n",
+ "\n",
+ "**Série GenAI Texte** | Précédent : [9c — Production Routage et Repli](09c_Production_Routage_Repli.ipynb) | Suivant : [10 — LocalLlama](10_LocalLlama.ipynb) | [↑ Sommaire série](README.md)\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ },
+ "papermill": {
+ "default_parameters": {},
+ "duration": 182.013867,
+ "end_time": "2026-09-28T16:36:44.713024+00:00",
+ "environment_variables": {},
+ "exception": null,
+ "input_path": "09d_Production_Caches.ipynb",
+ "output_path": "09d_exec.ipynb",
+ "parameters": {},
+ "start_time": "2026-09-28T16:33:42.699157+00:00",
+ "version": "2.7.0"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}