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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
hitstaux de hit %faux succès (pièges)faux succès %exactitude %latence évitée (s, à 574 ms/appel)
seuil γ
0.900110100.030100.063.663.1
0.950110100.030100.063.663.1
0.9809889.12170.071.856.2
0.9907265.51240.075.541.3
0.9956155.513.381.835.0
0.9996054.500.082.734.4
\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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
tokens promptdont cachéspart cachée %
appel
11304115288.3
21305115288.3
31304115288.3
41304115288.3
51305115288.3
61304115288.3
71304115288.3
81305115288.3
91304115288.3
101304115288.3
\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 +}