From b84039b93ca20263df7793b96268a31a74cd5ffc Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 28 Sep 2026 17:34:49 +0200 Subject: [PATCH 1/2] Add(genai,#18211): notebook 9c Routage et Repli -- multi-fournisseurs (tranche 1/3) Nouveau notebook 09c_Production_Routage_Repli.ipynb : banque de 100 questions a reponse verifiable, routage par politique (91.0% > 88.0% > 65.0%), cascade avec calibration mesuree (0/100 escalades -- confiance declaree 1.00 en permanence), repli fournisseur sur panne simulee (30/30 servies, tentative morte 7.45 s). Terrain reel : OpenRouter + Ollama local en lecture seule, quota gemma-4-31b 30 req/min mesure et respecte par throttle. See #18211 (tranche 1 sur 3 ; 9d caches et 9e exploitation suivent) Co-Authored-By: Claude Sonnet 5 --- .../Texte/09c_Production_Routage_Repli.ipynb | 1785 +++++++++++++++++ 1 file changed, 1785 insertions(+) create mode 100644 MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.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 new file mode 100644 index 0000000000..1a4f76f590 --- /dev/null +++ b/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb @@ -0,0 +1,1785 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e4017c6c", + "metadata": { + "papermill": { + "duration": 0.009379, + "end_time": "2026-09-28T15:12:55.931200+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:55.921821+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "# 9c. Routage et Repli : choisir le bon modèle, au bon moment\n", + "\n", + "Ce notebook poursuit [09_Production_Patterns.ipynb](09_Production_Patterns.ipynb), qui traitait des\n", + "patterns d'appel à une API donnée (retry, streaming, rate limiting). Ici, la question change\n", + "d'échelle : **quand on dispose de plusieurs modèles et de plusieurs fournisseurs, qui répond à\n", + "quelle question — et que fait-on quand l'un d'eux tombe ?**\n", + "\n", + "Trois briques de production :\n", + "\n", + "1. **Le routage par politique** — router chaque question vers un modèle selon son coût, la\n", + " difficulté estimée de la question et un budget par requête ;\n", + "2. **La cascade petit-vers-grand** — tenter d'abord le modèle économique, n'escalader vers le\n", + " modèle coûteux que quand la réponse semble douteuse ;\n", + "3. **Le repli fournisseur** — survivre à la panne d'un fournisseur en basculant sur le suivant.\n", + "\n", + "> **Terrain réel.** La flotte fait tourner un proxy multi-fournisseurs (OpenRouter, qui expose son\n", + "> catalogue et ses prix via `/v1/models`) et un service d'inférence local (Ollama sur ce poste).\n", + "> Ce notebook **lit** ce que ce terrain expose — catalogue, prix, latences — et ne touche jamais à\n", + "> sa configuration, qui appartient à un autre workspace.\n", + "\n", + "> **Clés API.** Les clés vivent dans `.env` (cf. `00-GenAI-Environment`), chargées par\n", + "`python-dotenv`. Aucune clé n'est jamais écrite ni affichée dans ce notebook.\n" + ] + }, + { + "cell_type": "markdown", + "id": "4d276579", + "metadata": { + "papermill": { + "duration": 0.008817, + "end_time": "2026-09-28T15:12:55.948442+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:55.939625+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Vérification de l'environnement\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "717da74c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:55.966042Z", + "iopub.status.busy": "2026-09-28T15:12:55.965415Z", + "iopub.status.idle": "2026-09-28T15:12:56.011585Z", + "shell.execute_reply": "2026-09-28T15:12:56.006959Z" + }, + "papermill": { + "duration": 0.05751, + "end_time": "2026-09-28T15:12:56.013541+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:55.956031+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OPENROUTER_API_KEY : présente\n", + "OPENAI_API_KEY : présente\n" + ] + } + ], + "source": [ + "import os, json, time, random, re, statistics\n", + "from pathlib import Path\n", + "from dotenv import load_dotenv\n", + "\n", + "# Le .env de la série GenAI (deux niveaux au-dessus du dossier Texte/)\n", + "load_dotenv(Path(\"..\") / \".env\")\n", + "\n", + "OPENROUTER_API_KEY = os.getenv(\"OPENROUTER_API_KEY\")\n", + "OPENAI_API_KEY = os.getenv(\"OPENAI_API_KEY\")\n", + "print(\"OPENROUTER_API_KEY :\", \"présente\" if OPENROUTER_API_KEY else \"ABSENTE -- sections proxy limitées\")\n", + "print(\"OPENAI_API_KEY :\", \"présente\" if OPENAI_API_KEY else \"absente\")\n", + "\n", + "# Reproductibilité : la banque de questions est générée avec une graine fixe\n", + "random.seed(20260928)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "d5e715c5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:56.032224Z", + "iopub.status.busy": "2026-09-28T15:12:56.031620Z", + "iopub.status.idle": "2026-09-28T15:12:56.321607Z", + "shell.execute_reply": "2026-09-28T15:12:56.320207Z" + }, + "papermill": { + "duration": 0.300411, + "end_time": "2026-09-28T15:12:56.322760+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.022349+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Inference locale (Ollama) HTTP 200 2 modèles (93 ms)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Proxy multi-fournisseurs (OpenRouter) HTTP 200 458 modèles (184 ms)\n" + ] + } + ], + "source": [ + "# Sondage du terrain, en LECTURE SEULE : on interroge les catalogues, on ne configure rien\n", + "import urllib.request\n", + "\n", + "def probe_catalogue(nom, url):\n", + " req = urllib.request.Request(url)\n", + " t0 = time.perf_counter()\n", + " try:\n", + " with urllib.request.urlopen(req, timeout=15) as r:\n", + " data = json.loads(r.read().decode(\"utf-8\"))\n", + " dt = (time.perf_counter() - t0) * 1000\n", + " modeles = [m[\"id\"] for m in data.get(\"data\", [])]\n", + " print(f\"{nom:38s} HTTP 200 {len(modeles):4d} modèles ({dt:.0f} ms)\")\n", + " return modeles\n", + " except Exception as e:\n", + " print(f\"{nom:38s} INJOIGNABLE ({type(e).__name__})\")\n", + " return []\n", + "\n", + "ollama_modeles = probe_catalogue(\"Inference locale (Ollama)\", \"http://127.0.0.1:11434/v1/models\")\n", + "or_modeles = probe_catalogue(\"Proxy multi-fournisseurs (OpenRouter)\",\n", + " \"https://openrouter.ai/api/v1/models\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "be98b738", + "metadata": { + "papermill": { + "duration": 0.009256, + "end_time": "2026-09-28T15:12:56.340286+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.331030+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du terrain\n", + "\n", + "Deux fournisseurs **réels et mesurables** répondent :\n", + "\n", + "- l'**inférence locale** expose peu de modèles mais a un coût marginal nul et une latence sans\n", + " réseau ;\n", + "- le **proxy multi-fournisseurs** expose des centaines de modèles avec leurs prix par token —\n", + " c'est cette donnée de catalogue qui rend un routage économique *calculable*, et non intuitif.\n", + "\n", + "La suite du notebook construit toutes ses décisions sur ces deux catalogues lus à l'instant.\n" + ] + }, + { + "cell_type": "markdown", + "id": "fd428e63", + "metadata": { + "papermill": { + "duration": 0.006712, + "end_time": "2026-09-28T15:12:56.358363+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.351651+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 1. Le catalogue et ses prix : la matière première du routeur\n", + "\n", + "Un routeur qui arbitre sur le coût a besoin des coûts. L'API `/v1/models` du proxy renvoie, pour\n", + "chaque modèle, son prix par token d'entrée et de sortie — le terrain publie lui-même la table de\n", + "décision. On y ajoute le modèle local, dont le prix marginal est nul.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2821e689", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:56.384400Z", + "iopub.status.busy": "2026-09-28T15:12:56.383978Z", + "iopub.status.idle": "2026-09-28T15:12:56.542126Z", + "shell.execute_reply": "2026-09-28T15:12:56.540069Z" + }, + "papermill": { + "duration": 0.174887, + "end_time": "2026-09-28T15:12:56.543444+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.368557+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "modèle $in/M $out/M ctx fournisseur" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "mistralai/mistral-nemo 0.019 0.030 131072 openrouter\n", + "meta-llama/llama-3.1-8b-instruct 0.050 0.080 131072 openrouter\n", + "google/gemma-4-31b-it:free 0.000 0.000 262144 openrouter\n", + "local/qwen2.5:7b 0.000 0.000 32768 ollama (local)\n" + ] + } + ], + "source": [ + "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", + "def prix_par_million(m):\n", + " \"\"\"Prix USD par million de tokens (entrée, sortie) — 0.0 pour les modèles :free.\"\"\"\n", + " p = m.get(\"pricing\", {})\n", + " try:\n", + " return float(p.get(\"prompt\", \"1\")) * 1e6, float(p.get(\"completion\", \"1\")) * 1e6\n", + " except (TypeError, ValueError):\n", + " return float(\"nan\"), float(\"nan\")\n", + "\n", + "# Candidats retenus pour la démonstration — choisis dans le catalogue RÉEL sondé ci-dessus\n", + "CANDIDATS_OR = [\n", + " \"mistralai/mistral-nemo\", # payant, quasi gratuit\n", + " \"meta-llama/llama-3.1-8b-instruct\", # payant, petit\n", + " \"google/gemma-4-31b-it:free\", # grand, gratuit\n", + "]\n", + "\n", + "catalogue_prix = {}\n", + "print(f\"{'modèle':36s} {'$in/M':>9s} {'$out/M':>9s} {'ctx':>8s} fournisseur\")\n", + "for mid in CANDIDATS_OR:\n", + " m = catalogue[mid]\n", + " pin, pout = prix_par_million(m)\n", + " catalogue_prix[mid] = {\"in\": pin, \"out\": pout, \"ctx\": m.get(\"context_length\", 0)}\n", + " print(f\"{mid:36s} {pin:9.3f} {pout:9.3f} {m.get('context_length', 0):8d} openrouter\")\n", + "\n", + "# Le modèle local : gratuit, mais limité au matériel présent\n", + "LOCAL_ID = \"qwen2.5:7b\"\n", + "LOCAL_KEY = f\"local/{LOCAL_ID}\"\n", + "catalogue_prix[LOCAL_KEY] = {\"in\": 0.0, \"out\": 0.0, \"ctx\": 32768}\n", + "print(f\"{LOCAL_KEY:36s} {0.0:9.3f} {0.0:9.3f} {32768:8d} ollama (local)\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "7d8575cf", + "metadata": { + "papermill": { + "duration": 0.00787, + "end_time": "2026-09-28T15:12:56.559377+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.551507+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du catalogue\n", + "\n", + "Le catalogue couvre un facteur de prix important entre le local gratuit et les modèles payants,\n", + "mais la ligne décisive pour la suite est ailleurs : **un grand modèle gratuit existe dans le\n", + "catalogue**. Le routage n'est donc pas seulement local-contre-payant — le proxy expose une vraie\n", + "diversité, et chaque brique du notebook mesurera ce que cette diversité vaut en exactitude, en\n", + "latence et en dollars.\n", + "\n", + "Les prix affichés sont ceux publiés par le terrain au moment de l'exécution ; OpenRouter les fait\n", + "varier, et le notebook relit le catalogue à chaque passage plutôt que de les coder en dur.\n" + ] + }, + { + "cell_type": "markdown", + "id": "c12dca69", + "metadata": { + "papermill": { + "duration": 0.013408, + "end_time": "2026-09-28T15:12:56.581732+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.568324+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 2. Cent questions à réponse vérifiable\n", + "\n", + "Mesurer un routeur exige de **noter les réponses automatiquement**. On génère donc une banque de\n", + "questions arithmétiques en français — problèmes à une ou deux étapes, chaque question portant sa\n", + "réponse exacte calculée par le générateur lui-même. La note est une égalité numérique stricte :\n", + "aucun juge, aucune ambiguïté.\n", + "\n", + "La graine est fixée : la même banque sert à tous les modèles du notebook, et deux exécutions du\n", + "notebook produisent les mêmes questions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a7070f5b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:56.619452Z", + "iopub.status.busy": "2026-09-28T15:12:56.618900Z", + "iopub.status.idle": "2026-09-28T15:12:56.632584Z", + "shell.execute_reply": "2026-09-28T15:12:56.631503Z" + }, + "papermill": { + "duration": 0.032158, + "end_time": "2026-09-28T15:12:56.635597+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.603439+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Banque : 100 questions, 5 types\n", + " [ 0] (addition ) Un entrepôt reçoit 823 colis le matin, puis 682 colis l'après-midi. Combien de colis au total ? -> 1505\n", + " [ 1] (soustraction ) Un magasin possède 686 unités de stock et en vend 116 dans la journée. Combien reste-t-il d'unités ? -> 570\n", + " [ 2] (produit ) Une imprimerie produit 39 affiches par série et fabrique 24 séries. Combien d'affiches au total ? -> 936\n", + " [ 3] (pourcentage ) Un service compte 560 dossiers et 75% sont traités en priorité. Combien de dossiers prioritaires ? -> 420\n", + " [ 4] (deux_etapes ) Une équipe produit 40 pièces par heure pendant 8 heures, puis 85 pièces de plus en heures supplémentaires. Combien de pièces au total ? -> 405\n", + " [ 98] (pourcentage ) Un service compte 472 dossiers et 20% sont traités en priorité. Combien de dossiers prioritaires ? -> 94\n", + " [ 99] (deux_etapes ) Une équipe produit 53 pièces par heure pendant 4 heures, puis 74 pièces de plus en heures supplémentaires. Combien de pièces au total ? -> 286\n" + ] + } + ], + "source": [ + "def question_addition(rnd):\n", + " a, b = rnd.randint(120, 980), rnd.randint(120, 980)\n", + " q = f\"Un entrepôt reçoit {a} colis le matin, puis {b} colis l'après-midi. Combien de colis au total ?\"\n", + " return q, a + b\n", + "\n", + "def question_soustraction(rnd):\n", + " a, b = rnd.randint(400, 990), rnd.randint(100, 390)\n", + " q = 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 ?\"\n", + " return q, a - b\n", + "\n", + "def question_produit(rnd):\n", + " a, b = rnd.randint(12, 96), rnd.randint(12, 96)\n", + " q = f\"Une imprimerie produit {a} affiches par série et fabrique {b} séries. Combien d'affiches au total ?\"\n", + " return q, a * b\n", + "\n", + "def question_pourcentage(rnd):\n", + " p = rnd.choice([10, 15, 20, 25, 30, 40, 50, 75])\n", + " total = rnd.randrange(200, 1000, 4)\n", + " q = f\"Un service compte {total} dossiers et {p}% sont traités en priorité. Combien de dossiers prioritaires ?\"\n", + " return q, total * p // 100\n", + "\n", + "def question_deux_etapes(rnd):\n", + " a, b, c = rnd.randint(15, 80), rnd.randint(3, 12), rnd.randint(20, 90)\n", + " q = (f\"Une équipe produit {a} pièces par heure pendant {b} heures, puis {c} pièces de plus \"\n", + " f\"en heures supplémentaires. Combien de pièces au total ?\")\n", + " return q, a * b + c\n", + "\n", + "GENES = [question_addition, question_soustraction, question_produit,\n", + " question_pourcentage, question_deux_etapes]\n", + "GENE_NOMS = [g.__name__.replace(\"question_\", \"\") for g in GENES]\n", + "\n", + "rnd = random.Random(20260928) # graine fixée : banque identique à chaque exécution\n", + "BANQUE = []\n", + "for i in range(100):\n", + " g = GENES[i % len(GENES)] # répartition égale : 20 questions par type\n", + " texte, reponse = g(rnd)\n", + " BANQUE.append({\"id\": i, \"type\": GENE_NOMS[i % len(GENES)],\n", + " \"question\": texte, \"reponse\": reponse})\n", + "\n", + "print(f\"Banque : {len(BANQUE)} questions, {len(GENES)} types\")\n", + "for q in BANQUE[:5] + BANQUE[-2:]:\n", + " print(f\" [{q['id']:3d}] ({q['type']:14s}) {q['question']} -> {q['reponse']}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "e6ffaf59", + "metadata": { + "papermill": { + "duration": 0.008253, + "end_time": "2026-09-28T15:12:56.652393+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.644140+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture de la banque\n", + "\n", + "Les types couvrent une échelle de difficulté grossière : l'addition est triviale, le problème à\n", + "deux étapes demande de composer deux opérations. Cette échelle est **la variable que le routeur\n", + "essaiera de capter** — et la source de ses erreurs : la difficulté estimée n'est pas toujours la\n", + "difficulté réelle.\n" + ] + }, + { + "cell_type": "markdown", + "id": "026f8c44", + "metadata": { + "papermill": { + "duration": 0.006865, + "end_time": "2026-09-28T15:12:56.666636+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.659771+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 3. Baselines : chaque modèle face à la banque\n", + "\n", + "Un client unique parle aux deux fournisseurs : Ollama et OpenRouter exposent tous deux l'API\n", + "compatible OpenAI. Le même code d'appel sert donc partout — c'est ce standard qui rend le repli\n", + "fournisseur possible au section 6.\n", + "\n", + "La réponse est extraite par expression régulière (dernier nombre du texte rendu), et la note\n", + "compare à la réponse exacte du générateur.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "97d2261b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:56.691263Z", + "iopub.status.busy": "2026-09-28T15:12:56.690679Z", + "iopub.status.idle": "2026-09-28T15:12:59.624142Z", + "shell.execute_reply": "2026-09-28T15:12:59.622736Z" + }, + "papermill": { + "duration": 2.951207, + "end_time": "2026-09-28T15:12:59.625431+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:56.674224+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "\n", + "def client_local():\n", + " return OpenAI(base_url=\"http://127.0.0.1:11434/v1\", api_key=\"ollama\")\n", + "\n", + "def client_openrouter():\n", + " assert OPENROUTER_API_KEY, \"OPENROUTER_API_KEY absente du .env\"\n", + " return OpenAI(base_url=\"https://openrouter.ai/api/v1\", api_key=OPENROUTER_API_KEY)\n", + "\n", + "def extraire_nombre(texte):\n", + " \"\"\"Dernier entier du texte rendu (le modèle conclut en général par la valeur).\"\"\"\n", + " nombres = re.findall(r\"-?\\d+\", texte)\n", + " return int(nombres[-1]) if nombres else None\n", + "\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 poser(client, modele, question, max_retries=3, delai_min=0.0):\n", + " \"\"\"Un appel, avec retry simple sur erreur. Renvoie (réponse_extraite, texte, latence_s).\n", + "\n", + " ``delai_min`` espace les appels pour respecter le quota du fournisseur : le terrain gratuit\n", + " du proxy est limité à 30 requêtes/minute sur gemma-4-31b (mesuré : 429 au-delà). La latence\n", + " rendue est celle de l'appel, hors délai d'espacement.\"\"\"\n", + " for tentative in range(max_retries + 1):\n", + " try:\n", + " t0 = time.perf_counter()\n", + " rep = client.chat.completions.create(\n", + " model=modele,\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", + " if delai_min and dt < delai_min:\n", + " time.sleep(delai_min - dt) # espacement anti-quota, hors mesure de latence\n", + " return extraire_nombre(texte), texte, dt\n", + " except Exception as e:\n", + " if tentative == max_retries:\n", + " raise\n", + " # 429 = quota fournisseur : attendre la fenêtre de quota avant de retenter\n", + " if \"429\" in str(e) or \"RateLimit\" in type(e).__name__:\n", + " time.sleep(20.0 * (tentative + 1))\n", + " else:\n", + " time.sleep(1.5 * (tentative + 1))\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5025f0b5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:59.644961Z", + "iopub.status.busy": "2026-09-28T15:12:59.644570Z", + "iopub.status.idle": "2026-09-28T15:12:59.656481Z", + "shell.execute_reply": "2026-09-28T15:12:59.655028Z" + }, + "papermill": { + "duration": 0.023364, + "end_time": "2026-09-28T15:12:59.657730+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:59.634366+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "def evaluer_banque(client, modele, banque=BANQUE, delai_min=0.0):\n", + " \"\"\"Fait passer la banque à un modèle. Renvoie la liste des résultats par question.\"\"\"\n", + " resultats = []\n", + " for q in banque:\n", + " pred, texte, dt = poser(client, modele, q[\"question\"], delai_min=delai_min)\n", + " resultats.append({**q, \"pred\": pred, \"correct\": pred == q[\"reponse\"],\n", + " \"latence\": dt, \"texte\": (texte or \"\")[:120]})\n", + " return resultats\n", + "\n", + "def bilan(resultats, nom):\n", + " n = len(resultats)\n", + " acc = 100.0 * sum(r[\"correct\"] for r in resultats) / n\n", + " lat = statistics.mean(r[\"latence\"] for r in resultats)\n", + " print(f\"{nom:34s} exactitude {acc:5.1f}% latence moy. {lat:5.2f}s (n={n})\")\n", + " return {\"nom\": nom, \"accuracy\": acc, \"latence\": lat}\n" + ] + }, + { + "cell_type": "markdown", + "id": "89d95ac9", + "metadata": { + "papermill": { + "duration": 0.013166, + "end_time": "2026-09-28T15:12:59.679432+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:59.666266+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Passage 1 : le modèle local, gratuit\n", + "\n", + "Les 100 questions passent d'abord au modèle local. C'est la mesure de référence du coût zéro :\n", + "ce que le matériel présent offre sans déboursé un centime.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f78b6fe6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:12:59.710856Z", + "iopub.status.busy": "2026-09-28T15:12:59.710444Z", + "iopub.status.idle": "2026-09-28T15:13:20.176457Z", + "shell.execute_reply": "2026-09-28T15:13:20.174981Z" + }, + "papermill": { + "duration": 20.489825, + "end_time": "2026-09-28T15:13:20.177782+00:00", + "exception": false, + "start_time": "2026-09-28T15:12:59.687957+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "local/qwen2.5:7b exactitude 65.0% latence moy. 0.19s (n=100)\n", + "passe complète en 20 s\n", + " addition 95.0% correct\n", + " deux_etapes 5.0% correct\n", + " pourcentage 60.0% correct\n", + " produit 65.0% correct\n", + " soustraction 100.0% correct\n" + ] + } + ], + "source": [ + "t_debut = time.perf_counter()\n", + "res_local = evaluer_banque(client_local(), LOCAL_ID)\n", + "duree_local = time.perf_counter() - t_debut\n", + "bil_local = bilan(res_local, LOCAL_KEY)\n", + "print(f\"passe complète en {duree_local:.0f} s\")\n", + "par_type = {}\n", + "for r in res_local:\n", + " par_type.setdefault(r[\"type\"], []).append(r[\"correct\"])\n", + "for t, cors in sorted(par_type.items()):\n", + " print(f\" {t:14s} {100.0 * sum(cors) / len(cors):5.1f}% correct\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "7b37fd7e", + "metadata": { + "papermill": { + "duration": 0.01273, + "end_time": "2026-09-28T15:13:20.205186+00:00", + "exception": false, + "start_time": "2026-09-28T15:13:20.192456+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Passage 2 : un grand modèle du proxy, gratuit lui aussi\n", + "\n", + "Le catalogue expose un grand modèle gratuit. Le passer sur la même banque donne la deuxième\n", + "référence : celle du coût zéro distant — gratuit en dollars, payé en latence réseau et en\n", + "dépendance.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9443df6c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:13:20.232336Z", + "iopub.status.busy": "2026-09-28T15:13:20.231629Z", + "iopub.status.idle": "2026-09-28T15:17:31.345245Z", + "shell.execute_reply": "2026-09-28T15:17:31.343507Z" + }, + "papermill": { + "duration": 251.13321, + "end_time": "2026-09-28T15:17:31.350586+00:00", + "exception": false, + "start_time": "2026-09-28T15:13:20.217376+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "google/gemma-4-31b-it:free exactitude 88.0% latence moy. 1.34s (n=100)\n", + "passe complète en 251 s\n", + " addition 100.0% correct\n", + " deux_etapes 100.0% correct\n", + " pourcentage 40.0% correct\n", + " produit 100.0% correct\n", + " soustraction 100.0% correct\n" + ] + } + ], + "source": [ + "GEMMA = \"google/gemma-4-31b-it:free\"\n", + "t_debut = time.perf_counter()\n", + "res_gemma = evaluer_banque(client_openrouter(), GEMMA, delai_min=2.5)\n", + "duree_gemma = time.perf_counter() - t_debut\n", + "bil_gemma = bilan(res_gemma, GEMMA)\n", + "print(f\"passe complète en {duree_gemma:.0f} s\")\n", + "par_type = {}\n", + "for r in res_gemma:\n", + " par_type.setdefault(r[\"type\"], []).append(r[\"correct\"])\n", + "for t, cors in sorted(par_type.items()):\n", + " print(f\" {t:14s} {100.0 * sum(cors) / len(cors):5.1f}% correct\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "c4c0cf01", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:17:31.380363Z", + "iopub.status.busy": "2026-09-28T15:17:31.379841Z", + "iopub.status.idle": "2026-09-28T15:17:32.799828Z", + "shell.execute_reply": "2026-09-28T15:17:32.798563Z" + }, + "papermill": { + "duration": 1.435294, + "end_time": "2026-09-28T15:17:32.801527+00:00", + "exception": false, + "start_time": "2026-09-28T15:17:31.366233+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", + "
additionsoustractionproduitpourcentagedeux_etapes
type de question
local/qwen2.5:7b95.0100.065.060.05.0
google/gemma-4-31b-it:free100.0100.0100.040.0100.0
\n", + "
" + ], + "text/plain": [ + " addition soustraction produit pourcentage \\\n", + "type de question \n", + "local/qwen2.5:7b 95.0 100.0 65.0 60.0 \n", + "google/gemma-4-31b-it:free 100.0 100.0 100.0 40.0 \n", + "\n", + " deux_etapes \n", + "type de question \n", + "local/qwen2.5:7b 5.0 \n", + "google/gemma-4-31b-it:free 100.0 " + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "comparatif_types = pd.DataFrame({\n", + " t: {\n", + " LOCAL_KEY: 100.0 * sum(r[\"correct\"] for r in res_local if r[\"type\"] == t) / 20,\n", + " GEMMA: 100.0 * sum(r[\"correct\"] for r in res_gemma if r[\"type\"] == t) / 20,\n", + " } for t in GENE_NOMS\n", + "})\n", + "comparatif_types.index.name = \"type de question\"\n", + "comparatif_types.round(1)\n" + ] + }, + { + "cell_type": "markdown", + "id": "1210aa78", + "metadata": { + "papermill": { + "duration": 0.007845, + "end_time": "2026-09-28T15:17:32.818927+00:00", + "exception": false, + "start_time": "2026-09-28T15:17:32.811082+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture des baselines\n", + "\n", + "Les deux modèles affichent leurs forces sur des types différents — c'est exactement la matière\n", + "d'un routeur : **si aucun modèle ne domine partout, le choix par question a de la valeur**. La\n", + "différence de latence entre local et distant complète le tableau : le dollar n'est pas la seule\n", + "monnaie.\n", + "\n", + "*Les chiffres affichés proviennent de l'exécution committée avec ce notebook — chaque valeur de\n", + "prose de la suite cite cette exécution et pas une autre.*\n" + ] + }, + { + "cell_type": "markdown", + "id": "f1dfdec6", + "metadata": { + "papermill": { + "duration": 0.007447, + "end_time": "2026-09-28T15:17:32.833401+00:00", + "exception": false, + "start_time": "2026-09-28T15:17:32.825954+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 4. Routage par politique : coût × difficulté × budget\n", + "\n", + "Le routeur le plus simple est une **règle écrite avant l'expérience** : estimer la difficulté de\n", + "la question, comparer au budget, choisir le modèle. Ici l'estimation lit le type de question\n", + "(donnée par le générateur) — en production, l'estimateur serait un classifieur ou un petit\n", + "modèle, et ses erreurs feraient partie du coût.\n", + "\n", + "La politique retenue : les questions estimées faciles vont au local gratuit, les autres au grand\n", + "modèle du proxy ; un budget par requête plafonne ce que le proxy peut coûter.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a1f24ac9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:17:32.868170Z", + "iopub.status.busy": "2026-09-28T15:17:32.867650Z", + "iopub.status.idle": "2026-09-28T15:18:46.100588Z", + "shell.execute_reply": "2026-09-28T15:18:46.098811Z" + }, + "papermill": { + "duration": 73.258029, + "end_time": "2026-09-28T15:18:46.105251+00:00", + "exception": false, + "start_time": "2026-09-28T15:17:32.847222+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "local/qwen2.5:7b 60 questions\n", + "google/gemma-4-31b-it:free 40 questions\n" + ] + } + ], + "source": [ + "def difficulte_estimee(q):\n", + " \"\"\"Score de difficulté 0-3, calculé SANS la réponse — ce que le routeur sait en production.\"\"\"\n", + " base = {\"addition\": 0, \"soustraction\": 1, \"pourcentage\": 1,\n", + " \"produit\": 2, \"deux_etapes\": 3}\n", + " return base.get(q[\"type\"], 1)\n", + "\n", + "class RouteurPolitique:\n", + " \"\"\"Route statique : facile -> local, difficile -> proxy. Budget plafonné en $/1k tokens.\"\"\"\n", + " def __init__(self, seuil_difficulte=2, budget_1k=0.01):\n", + " self.seuil = seuil_difficulte\n", + " self.budget = budget_1k\n", + "\n", + " def choisir(self, q):\n", + " if difficulte_estimee(q) < self.seuil:\n", + " return LOCAL_KEY, client_local(), LOCAL_ID\n", + " # Vérifie que le modèle distant respecte le budget (prix in+out pour ~1k tokens)\n", + " px = catalogue_prix[GEMMA]\n", + " cout_1k = (px[\"in\"] + px[\"out\"]) * 1000 / 1e6\n", + " if cout_1k > self.budget:\n", + " return LOCAL_KEY, client_local(), LOCAL_ID\n", + " return GEMMA, client_openrouter(), GEMMA\n", + "\n", + "routeur = RouteurPolitique(seuil_difficulte=2, budget_1k=0.01)\n", + "\n", + "# Où va chaque question ?\n", + "destinations = {}\n", + "for q in BANQUE:\n", + " dest, _, _ = routeur.choisir(q)\n", + " destinations.setdefault(dest, []).append(q[\"id\"])\n", + "for dest, ids in destinations.items():\n", + " print(f\"{dest:38s} {len(ids):3d} questions\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ed2bf176", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:18:46.126525Z", + "iopub.status.busy": "2026-09-28T15:18:46.125931Z", + "iopub.status.idle": "2026-09-28T15:22:28.611843Z", + "shell.execute_reply": "2026-09-28T15:22:28.609928Z" + }, + "papermill": { + "duration": 222.505249, + "end_time": "2026-09-28T15:22:28.620214+00:00", + "exception": false, + "start_time": "2026-09-28T15:18:46.114965+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "routeur politique exactitude 91.0% latence moy. 1.06s (n=100)\n", + " local/qwen2.5:7b 60 questions, 85.0% correct\n", + " google/gemma-4-31b-it:free 40 questions, 100.0% correct\n" + ] + } + ], + "source": [ + "def evaluer_routeur(routeur, banque=BANQUE):\n", + " resultats = []\n", + " for q in banque:\n", + " dest, client, modele = routeur.choisir(q)\n", + " pred, texte, dt = poser(client, modele, q[\"question\"],\n", + " delai_min=2.5 if dest == GEMMA else 0.0)\n", + " resultats.append({**q, \"pred\": pred, \"correct\": pred == q[\"reponse\"],\n", + " \"latence\": dt, \"modele\": dest})\n", + " return resultats\n", + "\n", + "res_route = evaluer_routeur(routeur)\n", + "bil_route = bilan(res_route, \"routeur politique\")\n", + "utilisations = {}\n", + "for r in res_route:\n", + " utilisations.setdefault(r[\"modele\"], [0, 0])\n", + " utilisations[r[\"modele\"]][0] += 1\n", + " utilisations[r[\"modele\"]][1] += r[\"correct\"]\n", + "for dest, (n, nc) in utilisations.items():\n", + " print(f\" {dest:38s} {n:3d} questions, {100.0 * nc / n:5.1f}% correct\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "4ed17665", + "metadata": { + "papermill": { + "duration": 0.009366, + "end_time": "2026-09-28T15:22:28.639045+00:00", + "exception": false, + "start_time": "2026-09-28T15:22:28.629679+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du routage\n", + "\n", + "Le routeur est jugé sur deux chiffres qui se lisent **ensemble** : son exactitude globale, et la\n", + "répartition qu'il a produite. Un routeur peut battre les deux baselines s'il envoie chaque type au\n", + "modèle qui le réussit ; il peut aussi les perdre si son estimateur de difficulté se trompe — et\n", + "comme l'estimateur lit ici le type déclaré (information parfaite), la mesure borne par le haut ce\n", + "qu'un estimateur réel obtiendrait.\n" + ] + }, + { + "cell_type": "markdown", + "id": "0fcf8509", + "metadata": { + "papermill": { + "duration": 0.009838, + "end_time": "2026-09-28T15:22:28.660628+00:00", + "exception": false, + "start_time": "2026-09-28T15:22:28.650790+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 5. Cascade petit-vers-grand sur seuil de confiance\n", + "\n", + "Le routage statique décide **avant** de voir la réponse. La cascade décide **après** : le modèle\n", + "économique répond d'abord, il déclare lui-même sa confiance, et une confiance sous le seuil\n", + "déclenche une seconde demande au modèle grand.\n", + "\n", + "Le point délicat — et ce que cette section **mesure** — est la calibration : combien de réponses\n", + "fausses le petit modèle déclare-t-il avec assurance (faux succès) ? Combien de réponses justes\n", + "escalade-t-il inutilement ? Ces deux taux valident ou cassent le seuil.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "28196846", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:22:28.683691Z", + "iopub.status.busy": "2026-09-28T15:22:28.683058Z", + "iopub.status.idle": "2026-09-28T15:24:16.996136Z", + "shell.execute_reply": "2026-09-28T15:24:16.994693Z" + }, + "papermill": { + "duration": 108.334913, + "end_time": "2026-09-28T15:24:17.005458+00:00", + "exception": false, + "start_time": "2026-09-28T15:22:28.670545+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Confiance moyenne — justes : 1.00 fausses : 1.00\n", + "Faux succès (réponse fausse, confiance >= 0.8) : 21 / 21 réponses fausses\n" + ] + } + ], + "source": [ + "SYSTEME_CONFIANCE = (\"Tu es un assistant de calcul. Réponds sur une seule ligne, au format exact : \"\n", + " \"REPONSE: CONFIANCE: \")\n", + "\n", + "def poser_avec_confiance(client, modele, question):\n", + " t0 = time.perf_counter()\n", + " rep = client.chat.completions.create(\n", + " model=modele,\n", + " messages=[{\"role\": \"system\", \"content\": SYSTEME_CONFIANCE},\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 \"\").replace(\" \", \"\")\n", + " m_val = re.search(r\"REPONSE:\\\"?(-?\\d+)\", texte)\n", + " m_conf = re.search(r\"CONFIANCE:\\\"?([01]?[.,]?\\d*)\", texte)\n", + " val = int(m_val.group(1)) if m_val else None\n", + " try:\n", + " conf = float(m_conf.group(1).replace(\",\", \".\")) if m_conf and m_conf.group(1) else 0.5\n", + " except ValueError:\n", + " conf = 0.5\n", + " return val, min(max(conf, 0.0), 1.0), dt\n", + "\n", + "# Phase de calibration : on mesure la confiance déclarée sur la banque, SANS escalader\n", + "calibration = []\n", + "for q in BANQUE:\n", + " val, conf, dt = poser_avec_confiance(client_local(), LOCAL_ID, q[\"question\"])\n", + " calibration.append({**q, \"pred\": val, \"correct\": val == q[\"reponse\"], \"confiance\": conf})\n", + "\n", + "conf_justes = [c[\"confiance\"] for c in calibration if c[\"correct\"]]\n", + "conf_fausses = [c[\"confiance\"] for c in calibration if not c[\"correct\"]]\n", + "n_fausses = len(conf_fausses)\n", + "faux_succes = sum(1 for c in calibration if not c[\"correct\"] and c[\"confiance\"] >= 0.8)\n", + "print(f\"Confiance moyenne — justes : {statistics.mean(conf_justes):.2f} \"\n", + " f\"fausses : {statistics.mean(conf_fausses) if conf_fausses else float('nan'):.2f}\")\n", + "print(f\"Faux succès (réponse fausse, confiance >= 0.8) : {faux_succes} / {n_fausses} réponses fausses\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "6c18e02e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:24:17.028512Z", + "iopub.status.busy": "2026-09-28T15:24:17.027786Z", + "iopub.status.idle": "2026-09-28T15:24:17.126051Z", + "shell.execute_reply": "2026-09-28T15:24:17.124406Z" + }, + "papermill": { + "duration": 0.111273, + "end_time": "2026-09-28T15:24:17.127304+00:00", + "exception": false, + "start_time": "2026-09-28T15:24:17.016031+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", + "
réponse exacteFalseTrue
confiance déclarée
(0.8, 1.0]2179
\n", + "
" + ], + "text/plain": [ + "réponse exacte False True \n", + "confiance déclarée \n", + "(0.8, 1.0] 21 79" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_cal = pd.DataFrame(calibration)\n", + "table_calibration = pd.crosstab(\n", + " pd.cut(df_cal[\"confiance\"], [0, .4, .6, .8, 1.0], include_lowest=True),\n", + " df_cal[\"correct\"],\n", + " rownames=[\"confiance déclarée\"], colnames=[\"réponse exacte\"])\n", + "table_calibration\n" + ] + }, + { + "cell_type": "markdown", + "id": "9b530b2b", + "metadata": { + "papermill": { + "duration": 0.010181, + "end_time": "2026-09-28T15:24:17.147854+00:00", + "exception": false, + "start_time": "2026-09-28T15:24:17.137673+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture de la calibration\n", + "\n", + "La table croisée dit tout : si la confiance déclarée était calibrée, la colonne `True` dominerait\n", + "en haut et la colonne `False` en bas. Les lignes du haut à droite sont les **faux succès** — les\n", + "réponses fausses qui ne déclencheront jamais d'escalade. C'est le plafond d'exactitude de la\n", + "cascade : aucun seuil ne récupère une réponse fausse déclarée à 0.95.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "71ee6f1c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:24:17.171666Z", + "iopub.status.busy": "2026-09-28T15:24:17.171095Z", + "iopub.status.idle": "2026-09-28T15:26:16.562849Z", + "shell.execute_reply": "2026-09-28T15:26:16.560725Z" + }, + "papermill": { + "duration": 119.413574, + "end_time": "2026-09-28T15:26:16.571449+00:00", + "exception": false, + "start_time": "2026-09-28T15:24:17.157875+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cascade (seuil 0.6) exactitude 79.0% latence moy. 0.32s (n=100)\n", + "escalades vers le grand modèle : 0/100\n" + ] + } + ], + "source": [ + "SEUIL_CASCADE = 0.6 # choisi depuis la table de calibration ci-dessus\n", + "\n", + "def cascade(question):\n", + " \"\"\"Local d'abord ; escalade vers le grand modèle si confiance < seuil.\"\"\"\n", + " val, conf, dt1 = poser_avec_confiance(client_local(), LOCAL_ID, question)\n", + " if conf >= SEUIL_CASCADE:\n", + " return val, f\"local(conf={conf:.2f})\", dt1, False\n", + " val2, texte2, dt2 = poser(client_openrouter(), GEMMA, question, delai_min=2.5)\n", + " return val2, \"cascade->gemma\", dt1 + dt2, True\n", + "\n", + "res_cascade = []\n", + "n_escalades = 0\n", + "for q in BANQUE:\n", + " val, origine, dt, escalade = cascade(q[\"question\"])\n", + " n_escalades += escalade\n", + " res_cascade.append({**q, \"pred\": val, \"correct\": val == q[\"reponse\"],\n", + " \"latence\": dt, \"escalade\": escalade})\n", + "\n", + "bil_cascade = bilan(res_cascade, f\"cascade (seuil {SEUIL_CASCADE})\")\n", + "print(f\"escalades vers le grand modèle : {n_escalades}/{len(BANQUE)}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0f3f2396", + "metadata": { + "papermill": { + "duration": 0.009811, + "end_time": "2026-09-28T15:26:16.591864+00:00", + "exception": false, + "start_time": "2026-09-28T15:26:16.582053+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture de la cascade\n", + "\n", + "Trois chiffres jugent la cascade : exactitude finale, taux d'escalade, latence. La comparer aux\n", + "baselines du section 3 donne le verdict honnête — si le petit modèle est mal calibré, la cascade\n", + "peut coûter plus que le grand modèle seul pour un résultat égal. **Ce n'est pas un échec de la\n", + "méthode, c'est la mesure que la calibration doit précéder le seuil.**\n" + ] + }, + { + "cell_type": "markdown", + "id": "2f21ec30", + "metadata": { + "papermill": { + "duration": 0.015838, + "end_time": "2026-09-28T15:26:16.618890+00:00", + "exception": false, + "start_time": "2026-09-28T15:26:16.603052+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 6. Repli fournisseur sur panne simulée\n", + "\n", + "Dernière brique : le proxy tombe. On simule la panne en pointant le client vers une URL valide en\n", + "apparence mais morte (le port d'un service arrêté) — le code d'appel, lui, reste celui de\n", + "production. La chaîne de repli essaie le maillon mort, puis bascule sur le proxy réel, puis sur\n", + "l'inférence locale.\n", + "\n", + "On mesure ce que le repli **sauve** (requêtes servies malgré la panne) et ce qu'il **coûte**\n", + "(latence des tentatives mortes avant bascule).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "dbdc2287", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:26:16.654248Z", + "iopub.status.busy": "2026-09-28T15:26:16.653342Z", + "iopub.status.idle": "2026-09-28T15:31:17.857758Z", + "shell.execute_reply": "2026-09-28T15:31:17.856581Z" + }, + "papermill": { + "duration": 301.229918, + "end_time": "2026-09-28T15:31:17.866069+00:00", + "exception": false, + "start_time": "2026-09-28T15:26:16.636151+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "requêtes servies malgré la panne du premier maillon : 30/30\n", + "dont exactes : 27/30\n", + "surcoût de latence de la tentative morte : 7.45 s en moyenne\n" + ] + } + ], + "source": [ + "PROXY_MORT = \"http://127.0.0.1:11435/v1\" # port fermé : la panne simulée\n", + "\n", + "def poser_avec_repli(question, chaine):\n", + " \"\"\"Essaie chaque (client, modèle) de la chaîne jusqu'à une réponse. Renvoie (pred, essais).\"\"\"\n", + " essais = []\n", + " for client, modele in chaine:\n", + " try:\n", + " t0 = time.perf_counter()\n", + " pred, texte, _ = poser(client, modele, question, max_retries=0,\n", + " delai_min=2.5 if modele == GEMMA else 0.0)\n", + " essais.append((modele, \"ok\", time.perf_counter() - t0))\n", + " return pred, essais\n", + " except Exception as e:\n", + " essais.append((modele, f\"échec: {type(e).__name__}\", time.perf_counter() - t0))\n", + " continue\n", + " return None, essais\n", + "\n", + "CHAINE = [\n", + " (OpenAI(base_url=PROXY_MORT, api_key=\"simulation\"), GEMMA), # simule le proxy en panne\n", + " (client_openrouter(), GEMMA), # proxy réel\n", + " (client_local(), LOCAL_ID), # dernier recours local\n", + "]\n", + "\n", + "res_repli = []\n", + "for q in BANQUE[:30]: # échantillon : la démonstration suffit, le motif est répétitif\n", + " pred, essais = poser_avec_repli(q[\"question\"], CHAINE)\n", + " res_repli.append({**q, \"pred\": pred, \"correct\": pred == q[\"reponse\"],\n", + " \"essais\": essais, \"n_essais\": len(essais)})\n", + "\n", + "servies = sum(1 for r in res_repli if r[\"pred\"] is not None)\n", + "justes = sum(1 for r in res_repli if r[\"correct\"])\n", + "essais_morts = [r[\"essais\"][0][2] for r in res_repli if r[\"n_essais\"] > 1]\n", + "print(f\"requêtes servies malgré la panne du premier maillon : {servies}/{len(res_repli)}\")\n", + "print(f\"dont exactes : {justes}/{len(res_repli)}\")\n", + "if essais_morts:\n", + " print(f\"surcoût de latence de la tentative morte : {statistics.mean(essais_morts):.2f} s en moyenne\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "c735b6b3", + "metadata": { + "papermill": { + "duration": 0.008283, + "end_time": "2026-09-28T15:31:17.882553+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.874270+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture du repli\n", + "\n", + "Le premier maillon étant mort par construction, chaque requête paie une tentative morte puis est\n", + "servie par le maillon suivant : **disponibilité totale sur cet échantillon, payée en secondes**.\n", + "En production, c'est l'arbitrage exact entre SLA de disponibilité et latence p99 — et la raison\n", + "pour laquelle une sonde de santé remplace avantageusement la tentative morte (un circuit ouvert\n", + "évite même d'essayer).\n" + ] + }, + { + "cell_type": "markdown", + "id": "3dd1596b", + "metadata": { + "papermill": { + "duration": 0.010057, + "end_time": "2026-09-28T15:31:17.902332+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.892275+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## 7. Synthèse : ce que chaque stratégie achète\n", + "\n", + "Les passes de ce notebook sont réunies ici. Chaque ligne est une stratégie **réellement\n", + "exécutée** sur la même banque de 100 questions ; les colonnes sont les trois monnaies du routage.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "2a521ca7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:31:17.923217Z", + "iopub.status.busy": "2026-09-28T15:31:17.922641Z", + "iopub.status.idle": "2026-09-28T15:31:17.941768Z", + "shell.execute_reply": "2026-09-28T15:31:17.940415Z" + }, + "papermill": { + "duration": 0.031518, + "end_time": "2026-09-28T15:31:17.942939+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.911421+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", + "
stratégieaccuracylatencecoût USD
0local seul65.00.19480.0
1grand distant seul88.01.34180.0
2routage politique91.01.05720.0
3cascade79.00.32020.0
\n", + "
" + ], + "text/plain": [ + " stratégie accuracy latence coût USD\n", + "0 local seul 65.0 0.1948 0.0\n", + "1 grand distant seul 88.0 1.3418 0.0\n", + "2 routage politique 91.0 1.0572 0.0\n", + "3 cascade 79.0 0.3202 0.0" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def cout_usd(n_questions, modele_key):\n", + " px = catalogue_prix[modele_key]\n", + " # estimation : ~60 tokens d'entrée, ~30 de sortie par question (messages courts du notebook)\n", + " return n_questions * (60 * px[\"in\"] + 30 * px[\"out\"]) / 1e6\n", + "\n", + "synthese = pd.DataFrame([\n", + " {**bil_local, \"coût USD\": cout_usd(len(res_local), LOCAL_KEY), \"stratégie\": \"local seul\"},\n", + " {**bil_gemma, \"coût USD\": cout_usd(len(res_gemma), GEMMA), \"stratégie\": \"grand distant seul\"},\n", + " {**bil_route, \"coût USD\": cout_usd(n_escalades_route := sum(1 for r in res_route if r[\"modele\"] == GEMMA), GEMMA), \"stratégie\": \"routage politique\"},\n", + " {**bil_cascade, \"coût USD\": cout_usd(n_escalades, GEMMA), \"stratégie\": \"cascade\"},\n", + "])[[\"stratégie\", \"accuracy\", \"latence\", \"coût USD\"]]\n", + "synthese.round(4)\n" + ] + }, + { + "cell_type": "markdown", + "id": "08a7afd0", + "metadata": { + "papermill": { + "duration": 0.008492, + "end_time": "2026-09-28T15:31:17.959912+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.951420+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Lecture de la synthèse\n", + "\n", + "**Aucune stratégie ne domine sur les trois colonnes** — c'est la leçon : le routage est un\n", + "arbitrage, pas un classement. Le local achète le coût et souvent la latence ; le distant achète\n", + "l'exactitude sur les types durs ; la cascade n'achète quelque chose que si la calibration la\n", + "porte ; le repli n'achète rien en exactitude mais tout en disponibilité.\n", + "\n", + "Limites honnêtes de la mesure :\n", + "\n", + "- la banque est arithmétique — elle étalonne le routage, pas le raisonnement long ;\n", + "- les prix sont ceux publiés par le proxy au moment de l'exécution ;\n", + "- une seule exécution par stratégie est committée : les modèles à température 0 sont stabilisés,\n", + " mais la latence réseau varie d'un passage à l'autre ;\n", + "- l'estimateur de difficulté du routeur lit le type déclaré — information parfaite qu'un\n", + " système réel n'a pas.\n" + ] + }, + { + "cell_type": "markdown", + "id": "53bab9de", + "metadata": { + "papermill": { + "duration": 0.009535, + "end_time": "2026-09-28T15:31:17.977969+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.968434+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercices\n", + "\n", + "### Exercice 1 : budget par requête durci\n", + "\n", + "Le `RouteurPolitique` vérifie un budget pour ~1k tokens. Durcissez-le : le routeur doit refuser\n", + "tout modèle dont le coût estimé dépasse un budget exprimé en **dollars par requête**, estimé\n", + "depuis les `max_tokens` et la longueur constatée des questions. Documentez ce que le budget\n", + "durci change aux destinations.\n", + "\n", + "### Exercice 2 : cascade à trois niveaux\n", + "\n", + "Ajoutez un niveau intermédiaire (par ex. `mistralai/mistral-nemo`, payant quasi gratuit) entre le\n", + "local et le grand : local → intermédiaire → grand. Quelle exactitude pour quel taux d'escalade,\n", + "par rapport à la cascade à deux niveaux ?\n", + "\n", + "### Exercice 3 : circuit breaker\n", + "\n", + "Le repli du section 6 essaie le maillon mort à chaque requête. Implémentez un circuit breaker :\n", + "après N échecs consécutifs, le maillon est déclaré ouvert pendant une durée de refroidissement,\n", + "et les requêtes vont directement au suivant. Mesurez le gain de latence sur les 30 mêmes\n", + "questions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "21300035", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:31:18.004694Z", + "iopub.status.busy": "2026-09-28T15:31:18.004210Z", + "iopub.status.idle": "2026-09-28T15:31:18.010117Z", + "shell.execute_reply": "2026-09-28T15:31:18.008863Z" + }, + "papermill": { + "duration": 0.018626, + "end_time": "2026-09-28T15:31:18.011126+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:17.992500+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice à compléter\n" + ] + } + ], + "source": [ + "# Exercice 1 -- budget par requête durci\n", + "# TODO étudiant : spécialiser RouteurPolitique en RouteurBudgetDure\n", + "# Indice : estimer tokens_entree ~ 60 + len(question)//4, tokens_sortie ~ max_tokens\n", + "# Étape 1 : ajouter un paramètre budget_par_requete (USD)\n", + "# Étape 2 : calculer le coût pire-cas par modèle du catalogue\n", + "# Étape 3 : aiguiller vers le premier modèle sous budget, du moins cher au plus cher\n", + "print(\"Exercice à compléter\")\n", + "resultat_ex1 = None # TODO étudiant : DataFrame des destinations sous budget durci\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "13650505", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:31:18.030558Z", + "iopub.status.busy": "2026-09-28T15:31:18.030002Z", + "iopub.status.idle": "2026-09-28T15:31:18.035902Z", + "shell.execute_reply": "2026-09-28T15:31:18.034762Z" + }, + "papermill": { + "duration": 0.017176, + "end_time": "2026-09-28T15:31:18.036995+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:18.019819+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice à compléter\n" + ] + } + ], + "source": [ + "# Exercice 2 -- cascade à trois niveaux\n", + "# TODO étudiant : généraliser cascade() en cascade_niveaux(question, niveaux, seuils)\n", + "# Indice : chaque niveau porte son propre seuil de confiance\n", + "# Étape 1 : définir la liste [(local, 0.6), (nemo, 0.75), (gemma, None)]\n", + "# Étape 2 : escalader tant que la confiance déclarée est sous le seuil du niveau\n", + "# Étape 3 : mesurer exactitude, escalades et latence, comparer à la cascade à deux niveaux\n", + "print(\"Exercice à compléter\")\n", + "resultat_ex2 = None # TODO étudiant : bilan de la cascade à trois niveaux\n" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "43942432", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-28T15:31:18.058613Z", + "iopub.status.busy": "2026-09-28T15:31:18.058033Z", + "iopub.status.idle": "2026-09-28T15:31:18.064330Z", + "shell.execute_reply": "2026-09-28T15:31:18.063039Z" + }, + "papermill": { + "duration": 0.019054, + "end_time": "2026-09-28T15:31:18.065458+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:18.046404+00:00", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Exercice à compléter\n" + ] + } + ], + "source": [ + "# Exercice 3 -- circuit breaker\n", + "# TODO étudiant : implémenter la classe CircuitBreaker\n", + "# Indice : états fermé / ouvert / semi-ouvert, compteur d'échecs, horodatage d'ouverture\n", + "# Étape 1 : CircuitBreaker(seuil=3, refroidissement_s=30) avec méthode autoriser()\n", + "# Étape 2 : poser_avec_repli() consulte le breaker avant chaque maillon, enregistre succès/échec\n", + "# Étape 3 : rejouer les 30 questions et comparer la latence moyenne au section 6\n", + "print(\"Exercice à compléter\")\n", + "resultat_ex3 = None # TODO étudiant : latence moyenne avec et sans breaker\n" + ] + }, + { + "cell_type": "markdown", + "id": "0c616c45", + "metadata": { + "papermill": { + "duration": 0.008643, + "end_time": "2026-09-28T15:31:18.084064+00:00", + "exception": false, + "start_time": "2026-09-28T15:31:18.075421+00:00", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Conclusion\n", + "\n", + "### Récapitulatif\n", + "\n", + "| Brique | Ce qu'elle décide | Ce qu'elle coûte si mal réglée |\n", + "|---|---|---|\n", + "| Catalogue et prix | la matière première | décision sur des prix périmés |\n", + "| Banque vérifiable | la mesure | conclusions non falsifiables |\n", + "| Routage politique | **avant** la réponse | erreurs d'estimation de difficulté |\n", + "| Cascade | **après** la réponse | faux succès mal calibrés |\n", + "| Repli fournisseur | **pendant** la panne | tentative morte payée en latence |\n", + "\n", + "### La suite\n", + "\n", + "Le notebook suivant (09d) garde la même banque et s'attaque aux **caches** : cache sémantique et\n", + "son taux de faux succès mesuré, cache de préfixe et son point d'équilibre.\n", + "\n", + "### Sources\n", + "\n", + "- Catalogue et prix : `GET /v1/models` du proxy multi-fournisseurs (lectures de ce notebook)\n", + "- Terrain local : service d'inférence local, `GET /v1/models`\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": 1109.185628, + "end_time": "2026-09-28T15:31:19.222215+00:00", + "environment_variables": {}, + "exception": null, + "input_path": "09c_Production_Routage_Repli.ipynb", + "output_path": "09c_output.ipynb", + "parameters": {}, + "start_time": "2026-09-28T15:12:50.036587+00:00", + "version": "2.7.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From fcf7c23ed6f0bcce87d6c025ba1650066ee909cf Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 28 Sep 2026 17:48:30 +0200 Subject: [PATCH 2/2] Fix(genai,#18211): nav-chain 9c -- cellule Navigation 09b/09c (orphan_entry resolue) Le garde check-nav-chain refusait la tete precedente : 09c sans lien entrant (orphan_entry). Cellule ## Navigation canonique (#17277) en fin de 09b (Suivant : 9c) et de 09c (Suivant : 10 + Sommaire). La forme mutuelle Precedent/Suivant est ecartee a dessein : 09b est un orphelin PRE-EXISTANT de la serie (baseline) -- la paire mutuelle 09b<->09c creait un independent_chain. Verifie localement : 0 NEW finding vs baseline (RC=0). Co-Authored-By: Claude Sonnet 5 --- .../GenAI/Texte/09b_Prompt_Security_RedTeam.ipynb | 10 ++++++++++ .../GenAI/Texte/09c_Production_Routage_Repli.ipynb | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/MyIA.AI.Notebooks/GenAI/Texte/09b_Prompt_Security_RedTeam.ipynb b/MyIA.AI.Notebooks/GenAI/Texte/09b_Prompt_Security_RedTeam.ipynb index 941b3c7300..774beedec2 100644 --- a/MyIA.AI.Notebooks/GenAI/Texte/09b_Prompt_Security_RedTeam.ipynb +++ b/MyIA.AI.Notebooks/GenAI/Texte/09b_Prompt_Security_RedTeam.ipynb @@ -1211,6 +1211,16 @@ "\n", "**Références croisées** : [`9_Production_Patterns`](09_Production_Patterns.ipynb) (fiabilité), [`13_Agentic_Orchestration`](13_Agentic_Orchestration.ipynb) (agents), série RAG (`5_RAG_Modern`, `6_PDF_Web_Search`).\n" ] + }, + { + "cell_type": "markdown", + "id": "908d56c5", + "metadata": {}, + "source": [ + "## Navigation\n", + "\n", + "**Série GenAI Texte** | Suivant : [9c — Production Routage et Repli](09c_Production_Routage_Repli.ipynb) | [↑ Sommaire série](README.md)\n" + ] } ], "metadata": { 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 1a4f76f590..f4bf7fff0a 100644 --- a/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb +++ b/MyIA.AI.Notebooks/GenAI/Texte/09c_Production_Routage_Repli.ipynb @@ -1747,6 +1747,16 @@ "- Catalogue et prix : `GET /v1/models` du proxy multi-fournisseurs (lectures de ce notebook)\n", "- Terrain local : service d'inférence local, `GET /v1/models`\n" ] + }, + { + "cell_type": "markdown", + "id": "1e910f12", + "metadata": {}, + "source": [ + "## Navigation\n", + "\n", + "**Série GenAI Texte** | Suivant : [10 — LocalLlama](10_LocalLlama.ipynb) | [↑ Sommaire série](README.md)\n" + ] } ], "metadata": {