9e — Production : l’exploitation (coût, budget, déploiement, SLO)
Les notebooks précédents de la série ont construit les organes : le routage multi-fournisseurs et son repli (9c), les caches exact, sémantique et de préfixe (9d). Ce dernier volet répond à la question de l’exploitant : combien ça coûte, quand prévenir, comment mettre une nouvelle version en production sans perdre la qualité mesurée, et quel niveau de service on a promis.
Toutes les mesures de ce notebook viennent du terrain réel : inférence locale et API distante, prix lus au moment de l’exécution dans le catalogue public du proxy, clés dans .env (jamais écrites ni affichées). Comme pour toute la série, le terrain est en lecture seule — on l’interroge, on ne le configure pas.
import os, json, time, random, re, statisticsfrom pathlib import Pathfrom dotenv import load_dotenvload_dotenv(Path("..") /".env")OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")print("OPENAI_API_KEY :", "présente"if OPENAI_API_KEY else"ABSENTE -- notebook impossible")random.seed(20260928) # même graine que 9c/9d : banque comparable
OPENAI_API_KEY : présente
Terrain. L’inférence locale (Ollama) et l’API directe du fournisseur sont les deux mains servie et candidate. Le catalogue du proxy donne les prix au moment de l’exécution.
# Sondages lecture seule + clients (mêmes organes que 9c/9d)import urllib.requestfrom openai import OpenAIdef sonde(nom, url):try:with urllib.request.urlopen(urllib.request.Request(url), timeout=15) as r: n =len(json.loads(r.read().decode("utf-8")).get("data", []))print(f"{nom:38s} HTTP 200 {n:4d} modèles")exceptExceptionas e:print(f"{nom:38s} INJOIGNABLE ({type(e).__name__})")sonde("Inference locale (Ollama)", "http://127.0.0.1:11434/v1/models")# L'API directe exige l'authentification même pour lister les modèles : sonde avec la clé du .envreq_api = urllib.request.Request("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {OPENAI_API_KEY}"})try:with urllib.request.urlopen(req_api, timeout=15) as r:print(f"{'API directe fournisseur':38s} HTTP 200 "f"{len(json.loads(r.read().decode('utf-8'))['data']):4d} modèles")exceptExceptionas e:print(f"{'API directe fournisseur':38s} INJOIGNABLE ({type(e).__name__})")def client_distant():assert OPENAI_API_KEY, "OPENAI_API_KEY absente du .env"return OpenAI(api_key=OPENAI_API_KEY)def client_local():return OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama")# Prix réels du proxy, lus au moment de l'exécution (jamais codés en dur)req = urllib.request.Request("https://openrouter.ai/api/v1/models")with urllib.request.urlopen(req, timeout=15) as r: catalogue = {m["id"]: m for m in json.loads(r.read().decode("utf-8"))["data"]}MODELE_DISTANT_CATALOGUE ="openai/gpt-5-mini"assert MODELE_DISTANT_CATALOGUE in cataloguePRIX_IN =float(catalogue[MODELE_DISTANT_CATALOGUE]["pricing"]["prompt"]) *1e6PRIX_OUT =float(catalogue[MODELE_DISTANT_CATALOGUE]["pricing"]["completion"]) *1e6print(f"Prix catalogue {MODELE_DISTANT_CATALOGUE} : ${PRIX_IN:.2f}/M entrée, ${PRIX_OUT:.2f}/M sortie")
def extraire_nombre(texte):"""Dernier entier du texte rendu (organe 9c : le modèle conclut par la valeur).""" nombres = re.findall(r"-?\d+", texte)returnint(nombres[-1]) if nombres elseNoneSYSTEME = ("Tu es un assistant de calcul. Réponds en une seule phrase courte et termine par la ""valeur numérique exacte, sans unité.")def poser_local(question): r = client_local().chat.completions.create( model="qwen2.5:7b", temperature=0, messages=[{"role": "system", "content": SYSTEME}, {"role": "user", "content": question}], )return r.choices[0].message.content, r.usagedef poser_distant(question): r = client_distant().chat.completions.create( model="gpt-5-mini", # temperature non réglable sur ce modèle (défaut 1) max_completion_tokens=200, reasoning_effort="minimal", # sans cela, le raisonnement interne épuise le budget de tokens messages=[{"role": "system", "content": SYSTEME}, {"role": "user", "content": question}], )return r.choices[0].message.content, r.usagedef cout_usd(usage):return usage.prompt_tokens * PRIX_IN /1e6+ usage.completion_tokens * PRIX_OUT /1e6print("Organe de réponse prêt : local (gratuit) et distant (facturé au token).")
Organe de réponse prêt : local (gratuit) et distant (facturé au token).
1. Le coût par requête, mesuré sur appels réels
Le coût d’une requête n’est pas un chiffre de documentation : c’est des tokens mesurés multipliés par des prix lus au moment de l’exécution. On fait répondre les deux modèles à la même moitié d’évaluation de la banque, et on rapporte pour chacun : justesse, latence médiane, coût par requête. La version locale n’est pas facturée au token — son coût marginal monétaire est nul (hors électricité et machine).
MESURES = {"local": [], "distant": []}for q in EVAL:for nom, poser in (("local", poser_local), ("distant", poser_distant)): t0 = time.perf_counter() rep, usage = poser(q["question"]) dt = time.perf_counter() - t0 MESURES[nom].append({"type": q["type"],"juste": extraire_nombre(rep) == q["reponse"],"latence_s": dt,"cout_usd": cout_usd(usage) if nom =="distant"else0.0, })resume = []for nom, m in MESURES.items(): justes =sum(x["juste"] for x in m) resume.append({"modèle": nom,"justesse %": round(100* justes /len(m), 1),"latence médiane (s)": round(statistics.median(x["latence_s"] for x in m), 3),"coût/requête ($)": round(sum(x["cout_usd"] for x in m) /len(m), 6),"coût total ($)": round(sum(x["cout_usd"] for x in m), 6), })import pandas as pddisplay(pd.DataFrame(resume))
modèle
justesse %
latence médiane (s)
coût/requête ($)
coût total ($)
0
local
86.7
1.001
0.000000
0.000000
1
distant
100.0
1.572
0.000043
0.001277
Lecture. Les deux colonnes qui décident d’un routage sont devant vous : la justesse et le coût. Le modèle local est gratuit mais se trompe sur une part des questions — c’est exactement l’écart que 9c exploitait pour router ; ici il devient le risque d’exploitation qu’il faut surveiller. Le distant est facturé au token, et son coût par requête est mesuré, pas estimé : c’est ce chiffre qui alimente le budget de la section suivante.
2. Budget : trajectoire, seuil d’alerte, date de franchissement
Un budget d’exploitation ne se décrète pas, il se simule à partir du coût mesuré. À volume fixe, la trajectoire de dépense est une droite dont la pente est le coût par requête ; le mix local/distant fait varier la pente. Le budget est mensuel et renouvelable : le compteur retombe à zéro à chaque frontière de mois, et c’est la trajectoire d’un mois qui décide — pas un cumul annuel. L’exploitant veut trois choses : cette trajectoire, le jour du mois où elle franchit le budget, et une alerte avant — posée ici à quatre-vingts pour cent du budget.
JOURS_MOIS =30# mois normalisé de la simulation (paramètre de l'exploitant)def trajectoire(cout_distant_par_req, volume_par_jour, part_locale, budget_usd):"""Dépense cumulée dans le mois courant. Le budget est mensuel RENOUVELABLE : le compteur retombe à zéro à chaque frontière de mois, et comme la pente est constante, la trajectoire d'un mois vaut pour tous. Retourne (cumuls_du_mois, jour_du_mois_80, jour_du_mois_epuisement).""" pente = volume_par_jour * (1- part_locale) * cout_distant_par_req cumuls, c = [], 0.0 jour80 = jour_plein =Nonefor jour inrange(1, JOURS_MOIS +1): c += pente cumuls.append(c)if jour80 isNoneand c >=0.8* budget_usd: jour80 = jourif jour_plein isNoneand c >= budget_usd: jour_plein = jourreturn cumuls, jour80, jour_pleinCOUT_DISTANT =sum(x["cout_usd"] for x in MESURES["distant"]) /len(MESURES["distant"])VOLUME =1500# requêtes/jour posées au service (paramètre de l'exploitant)BUDGET =10.0# budget mensuel renouvelable en dollars (paramètre de l'exploitant)lignes = []for part_locale in (0.0, 0.5, 0.9, 1.0): cumuls, j80, jp = trajectoire(COUT_DISTANT, VOLUME, part_locale, BUDGET) lignes.append({"part locale": part_locale,"pente ($/jour)": round(VOLUME * (1- part_locale) * COUT_DISTANT, 4),"alerte 80 % (jour du mois)": j80 if j80 else"jamais","budget épuisé (jour du mois)": jp if jp else"jamais", })display(pd.DataFrame(lignes))print(f"Entrées : coût distant mesuré ${COUT_DISTANT:.6f}/requête, volume {VOLUME} req/jour, "f"budget ${BUDGET:.0f}/mois renouvelable (compteur remis à zéro tous les {JOURS_MOIS} jours)")# Témoins de contrôle : le modèle mensuel doit rendre les deux régimes observables._, j80_t, jp_t = trajectoire(0.40/ VOLUME, VOLUME, 0.0, BUDGET) # pente à 0,40 $/jour_, j80_z, jp_z = trajectoire(COUT_DISTANT, 0, 0.0, BUDGET) # zéro requête poséeprint(f"Témoin à $0.40/jour : alerte 80 % au jour {j80_t}, budget épuisé au jour {jp_t} du mois")print(f"Témoin zéro requête : alerte {j80_z or'jamais'}, épuisement {jp_z or'jamais'}")
part locale
pente ($/jour)
alerte 80 % (jour du mois)
budget épuisé (jour du mois)
0
0.0
0.0639
jamais
jamais
1
0.5
0.0319
jamais
jamais
2
0.9
0.0064
jamais
jamais
3
1.0
0.0000
jamais
jamais
Entrées : coût distant mesuré $0.000043/requête, volume 1500 req/jour, budget $10/mois renouvelable (compteur remis à zéro tous les 30 jours)
Témoin à $0.40/jour : alerte 80 % au jour 20, budget épuisé au jour 25 du mois
Témoin zéro requête : alerte jamais, épuisement jamais
def verifier_alerte(cumuls, budget_usd, au_jour):"""L'organe d'alerte : dit si, au jour du mois donné, on a franchi le seuil de quatre-vingts pour cent."""return cumuls[au_jour -1] >=0.8* budget_usdcumuls, j80, jp = trajectoire(COUT_DISTANT, VOLUME, 0.5, BUDGET)calme = verifier_alerte(cumuls, BUDGET, JOURS_MOIS) # régime calme : vérifié au dernier jour du moiscumuls_t, j80_t, _ = trajectoire(0.40/ VOLUME, VOLUME, 0.0, BUDGET)tendu = verifier_alerte(cumuls_t, BUDGET, j80_t) # régime tendu : au jour d'alerte calculéprint(f"Mois calme (pente mesurée), jour {JOURS_MOIS} : seuil {'franchi -- ALERTE'if calme else'non franchi'}")print(f"Mois tendu ($0.40/jour), jour {j80_t} : seuil {'franchi -- ALERTE'if tendu else'non franchi'}")
Mois calme (pente mesurée), jour 30 : seuil non franchi
Mois tendu ($0.40/jour), jour 20 : seuil franchi -- ALERTE
Lecture. La pente varie d’un facteur dix selon le mix, mais aucun mix ne menace le budget : à 0,063 $/jour, le mois complet consomme moins de 2 $ des 10 $ — et le compteur retombe à zéro à chaque frontière de mois, la dépense ne s’accumule pas d’un mois sur l’autre. Le témoin à 0,40 $/jour montre l’autre régime : alerte au jour 20, budget épuisé au jour 25 du mois. C’est la lecture mensuelle qui rend ce franchissement observable — un cumul annuel le noierait deux fois (sous-alerte à pente faible, franchissement noyé à pente forte). L’alerte ne se déclenche pas sur un sentiment : c’est une fonction testée sur la trajectoire simulée dans les deux régimes, avec la pente mesurée à la section précédente — ré-exécuter le notebook avec un autre prix de catalogue et tout se recalcule.
3. Shadow : évaluer la candidate sans la servir
Avant d’exposer une nouvelle version aux utilisateurs, on la fait travailler en ombre : elle répond aux mêmes requêtes, ses réponses sont comparées, mais personne ne les voit. Le shadow de ce notebook a une propriété que l’exploitant apprécie : il réutilise les appels réels de la section coût — la candidate a déjà répondu à la moitié d’évaluation, il suffit de comparer les verdicts. Le coût du shadow est donc exactement ce qui a déjà été payé : il est nul en plus.
justesse_locale =sum(x["juste"] for x in MESURES["local"]) /len(MESURES["local"])justesse_candidate =sum(x["juste"] for x in MESURES["distant"]) /len(MESURES["distant"])cout_shadow =sum(x["cout_usd"] for x in MESURES["distant"]) # déjà payé à la section 1delta = justesse_candidate - justesse_localeverdict_shadow ="PROMOTE -- la candidate bat la version servie"if delta >0else"REJET"print(f"Version servie (local) : justesse {100*justesse_locale:.1f} %")print(f"Candidate (distant) : justesse {100*justesse_candidate:.1f} %")print(f"Delta candidat - servie : {100*delta:+.1f} points")print(f"Coût *additionnel* du shadow : ${cout_shadow -sum(x['cout_usd'] for x in MESURES['distant']):.6f} "f"(les appels sont ceux, réels, de la section coût)")print(f"Verdict du shadow : {verdict_shadow}")
Version servie (local) : justesse 86.7 %
Candidate (distant) : justesse 100.0 %
Delta candidat - servie : +13.3 points
Coût *additionnel* du shadow : $0.000000 (les appels sont ceux, réels, de la section coût)
Verdict du shadow : PROMOTE -- la candidate bat la version servie
Lecture. Le shadow conclut par un verdict binaire, mais la décision d’exploitation est gradation : le delta mesuré ici décide si la candidate mérite un canary (exposition progressive) ou retour au plateau. Notez l’argument économique : réutiliser l’évaluation déjà payée est la meilleure façon de faire un shadow gratuit.
4. Canary et auto-rollback : la régression détectée sur erreurs réelles
Le canary sert la candidate à une part croissante du trafic, par fenêtres, et compare chaque fenêtre à la baseline mesurée. Si une fenêtre passe sous la baseline moins la marge, le rollback est automatique — pas de comité, pas de nuit blanche.
Ce notebook démontre la détection de manière honnête : la candidate du canary est le modèle local (le plus faible des deux, tel que mesuré) et la baseline est le distant. Les erreurs qui déclenchent le rollback sont donc de vraies erreurs de modèle sur des questions jamais vues — rien n’est simulé sur la qualité.
BASELINE = justesse_candidate # mesurée en shadow (le distant)MARGE =0.10# 10 points sous la baseline -> rollbackTAILLE_FENETRE =10fenetres = []declenche_au =Nonei =0while i <len(CANARY): lot = CANARY[i:i + TAILLE_FENETRE] justes =0for q in lot: rep, _ = poser_local(q["question"])if extraire_nombre(rep) == q["reponse"]: justes +=1 j = justes /len(lot) rollback = j < BASELINE - MARGE fenetres.append({"fenêtre": len(fenetres) +1,"questions": len(lot),"justesse %": round(100* j, 1),"seuil rollback %": round(100* (BASELINE - MARGE), 1),"rollback": rollback, })if rollback and declenche_au isNone: declenche_au =len(fenetres)print(f"AUTO-ROLLBACK déclenché à la fenêtre {len(fenetres)} : "f"justesse {100*j:.1f} % < seuil {100*(BASELINE - MARGE):.1f} %")break i += TAILLE_FENETREdisplay(pd.DataFrame(fenetres))print("Canary arrêté."if declenche_au else"Canary terminé sans rollback : la candidate tenait la baseline.")
AUTO-ROLLBACK déclenché à la fenêtre 1 : justesse 80.0 % < seuil 90.0 %
fenêtre
questions
justesse %
seuil rollback %
rollback
0
1
10
80.0
90.0
True
Canary arrêté.
Lecture. La trace montre l’organe complet : exposition par fenêtres, comparaison au seuil dérivé de la baseline mesurée, et rollback automatique dès la première fenêtre fautive — ici déclenché par les erreurs réelles du modèle local. En production, la candidate serait la nouvelle version du service ; l’organe ne change pas d’un iota, seul le fournisseur de verdict change.
5. SLO et error budget : ce qu’on a promis, ce qu’il reste à dépenser
Un SLO de justesse transforme la qualité mesurée en contrat interne : au-dessus, tout va bien ; en dessous, on puise dans un error budget — le stock d’erreurs tolérées sur la période. Le burn rate dit à quelle vitesse on le consomme. Tout se calcule sur les mesures de ce notebook : justesse baseline, volume de la simulation de budget, fenêtre du canary.
SLO_JUSTESSE =0.95# contrat interne (paramètre de l'exploitant)JOURS_PERIODE =30volume_periode = VOLUME * JOURS_PERIODE # requêtes servies sur la périodebudget_erreurs = (1- SLO_JUSTESSE) * volume_periodeerreurs_attendues_baseline = (1- BASELINE) * volume_periode# Burn rate sur la fenêtre canary qui a déclenché le rollback (la pire mesurée)pire_justesse =min(f["justesse %"] for f in fenetres) /100burn_rate = (1- pire_justesse) / (1- SLO_JUSTESSE)lignes_slo = [ {"indicateur": "SLO de justesse promis", "valeur": f"{100*SLO_JUSTESSE:.0f} %"}, {"indicateur": "Justesse baseline mesurée", "valeur": f"{100*BASELINE:.1f} %"}, {"indicateur": "Error budget de la période (erreurs tolérées)", "valeur": f"{budget_erreurs:.0f} erreurs"}, {"indicateur": "Erreurs attendues à la baseline", "valeur": f"{erreurs_attendues_baseline:.0f} erreurs"}, {"indicateur": "Verdict budget", "valeur": "TENU"if erreurs_attendues_baseline <= budget_erreurs else"DÉPASSÉ -- geler les fonctionnalités"}, {"indicateur": "Burn rate sur la pire fenêtre canary", "valeur": f"{burn_rate:.1f}x"}, {"indicateur": "Lecture du burn", "valeur": "consomme le budget plus vite que prévu"if burn_rate >1else"dans le rythme du budget"},]display(pd.DataFrame(lignes_slo))
indicateur
valeur
0
SLO de justesse promis
95 %
1
Justesse baseline mesurée
100.0 %
2
Error budget de la période (erreurs tolérées)
2250 erreurs
3
Erreurs attendues à la baseline
0 erreurs
4
Verdict budget
TENU
5
Burn rate sur la pire fenêtre canary
4.0x
6
Lecture du burn
consomme le budget plus vite que prévu
Lecture. Le verdict final est devant vous : si la baseline mesurée consomme plus d’erreurs que le contrat n’en accorde, la discipline d’exploitation est de geler les changements et de travailler la qualité — c’est le sens de l’error budget. Le burn rate de la fenêtre fautive, lui, dit à quelle vitesse une régression isolée épuiserait le stock : c’est l’alarme qui justifie l’auto-rollback de la section précédente.
Ce que la série a construit
De 9 à 9e, la série a posé les organes d’un service LLM en production : la boucle de base et son coût, le routage multi-fournisseurs avec repli, les trois caches et leurs limites mesurées, et maintenant le pilotage — budget, alerte, shadow, canary, rollback, SLO. Chaque chiffre de la série vient d’une exécution réelle sur le terrain ; ré-exécuter un notebook, c’est re-mesurer.
Exercices
Exercice 1 : alerte prédictive
L’alerte actuelle attend le franchissement du seuil. Écrivez jour_franchissement_prevu(cumuls, budget) qui extrapole la pente des premiers jours du mois pour prédire le jour du mois où le budget s’épuise — l’exploitant veut être prévenu des semaines à l’avance, pas le jour du seuil.
# TODO étudiant : extrapoler la pente (différence des cumuls) et projeter le jour de franchissement# Indice : la pente quotidienne est cumuls[j] - cumuls[j-1] ; le reste à courir est budget - cumuls[-1]# Étape 1 : mesurer la pente sur les premiers jours# Étape 2 : diviser le reste à courir par la pente# Étape 3 : retourner le jour prévu (entier) ou None si la pente est nulledef jour_franchissement_prevu(cumuls, budget):print("Exercice à compléter")returnNoneresultat_ex1 = jour_franchissement_prevu(cumuls, BUDGET)
Exercice à compléter
Exercice 2 : rollback à double critère
Le rollback actuel ne regarde que la justesse. Ajoutez le critère de latence : rollback si la fenêtre passe sous le seuil de justesse ou si sa latence médiane dépasse un plafond dérivé de la baseline. Les latences des deux modèles sont déjà mesurées — servez-vous en pour calibrer le plafond.
# TODO étudiant : fenêtre de canary jugée sur justesse ET latence médiane# Indice : statistics.median existe ; le plafond peut être un multiple de la latence baseline mesurée# Étape 1 : relever la latence médiane baseline depuis MESURES["distant"]# Étape 2 : poser le plafond (par exemple le double de la baseline)# Étape 3 : écrire devrait_rollback(justesse_fenetre, latence_fenetre) -> booldef devrait_rollback(justesse_fenetre, latence_fenetre):print("Exercice à compléter")returnFalseresultat_ex2 = devrait_rollback(0.5, 10.0)
Exercice à compléter
Exercice 3 : le gel automatique
Écrivez verdict_feature_freeze(burn_rate) qui décide si l’équipe doit geler les nouveautés : gel si le burn rate consomme l’error budget plus vite que la période ne le permet, sinon travaillez normalement. Testez-la sur le burn rate mesuré de la pire fenêtre du canary.
# TODO étudiant : convertir le burn rate en décision de gel# Indice : un burn rate au-dessus de 1 signifie consommer le budget plus vite que prévu# Étape 1 : comparer burn_rate à 1# Étape 2 : retourner la chaîne de décisiondef verdict_feature_freeze(burn_rate):print("Exercice à compléter")returnNoneresultat_ex3 = verdict_feature_freeze(burn_rate)