From b74aaf74259a3ff395a6b7811e69067cf411a57d Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 29 Sep 2026 13:35:30 +0200 Subject: [PATCH] Add(genai,#18211): notebook 9d Production Caches + nav 9c -- tranche 2/3 (branche reconstruite) Reconstruction de feature/18211-caches : l'ancienne tete portait deux fusions de main resoluees du mauvais cote (11 fichiers hors sujet qui defontaient main) et un commit twin etranger au sujet. La branche est rebatie depuis main avec seulement le contenu du sujet : le notebook 9d (exact/semantique/prefixe, faux succes mesures) et la cellule Navigation de 9c (Suivant -> 9d). Le diff contre main ne porte plus que ces deux fichiers. Co-Authored-By: Claude Sonnet 5 --- .../Texte/09c_Production_Routage_Repli.ipynb | 2 +- .../GenAI/Texte/09d_Production_Caches.ipynb | 1387 +++++++++++++++++ 2 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 MyIA.AI.Notebooks/GenAI/Texte/09d_Production_Caches.ipynb 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 +}