
Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.
Tests LLM qui cassent pour de bonnes raisons : gérer les faux positifs en CI
- ia-produit
Un test LLM qui échoue n'est pas forcément une régression. On a mis du temps à comprendre pourquoi nos tests flaky n'étaient pas un problème de code, mais un problème de calibration. Voici ce qu'on a appris.
Tests LLM qui cassent pour de bonnes raisons : gérer les faux positifs en CI
Six semaines après avoir déployé notre pipeline de tests sur les sorties LLM, notre taux de tests flaky était de 34 %. Un test sur trois échouait sans qu'on ait touché le prompt. Les développeurs ont commencé à ignorer les résultats de CI, exactement le contraire de l'objectif.
Le problème n'était pas le code des tests. C'était qu'on n'avait pas pensé à la variabilité comme une variable à modéliser, pas à éliminer.
La source des faux positifs : la variabilité légitime
Un LLM avec temperature > 0 produit des sorties différentes à chaque appel. Deux sorties peuvent être sémantiquement identiques et lexicalement différentes. Deux sorties peuvent diverger sur un détail de formulation sans qu'il y ait régression.
Si votre test compare les sorties à une référence fixe, tout changement de formulation, même légitime, déclenche une alerte. C'est du bruit.
La première chose à mesurer avant de concevoir des tests : quelle est la variabilité naturelle du modèle sur votre cas d'usage ?
# Mesurer la variabilité naturelle avant de calibrer les seuils
import statistics
from myapp.llm import classify_ticket
from openai import OpenAI
client = OpenAI()
def embed(text: str) -> list[float]:
resp = client.embeddings.create(model="text-embedding-3-small", input=text)
return resp.data[0].embedding
def cosine_similarity(a, b):
import numpy as np
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
# On appelle le même prompt 10 fois et on mesure la dispersion des embeddings
TEST_PROMPT = "Mon application plante au démarrage depuis la mise à jour"
responses = [classify_ticket(TEST_PROMPT) for _ in range(10)]
embeddings = [embed(r["reasoning"]) for r in responses]
# Calculer toutes les similarités pairées
similarities = [
cosine_similarity(embeddings[i], embeddings[j])
for i in range(len(embeddings))
for j in range(i + 1, len(embeddings))
]
print(f"Similarité min : {min(similarities):.3f}")
print(f"Similarité max : {max(similarities):.3f}")
print(f"Similarité médiane : {statistics.median(similarities):.3f}")
print(f"Écart-type : {statistics.stdev(similarities):.3f}")Sur notre cas de classification de tickets, la variabilité naturelle à temperature=0.3 produisait des similarités entre 0.82 et 0.97 sur les champs textuels. Notre seuil de test initial était à 0.90, ce qui expliquait les faux positifs à 34 %.
La bonne façon de calibrer un seuil : mesurer le percentile 5 de la similarité naturelle, puis fixer le seuil légèrement en dessous. Si la variabilité naturelle descend rarement sous 0.78, un seuil à 0.75 détecte les vraies régressions sans bruit.
Distinguer vraie régression et variabilité : la double mesure
Un seul appel ne suffit pas pour distinguer une régression d'une fluctuation. La technique qu'on a adoptée : on appelle le prompt deux fois et on compare les deux sorties entre elles avant de les comparer à la référence.
def test_with_variance_check(prompt: str, reference_embedding: list[float], threshold: float):
"""
Test robuste aux fluctuations LLM.
Échoue seulement si DEUX appels consécutifs dévient de la référence.
"""
results = [my_llm_function(prompt) for _ in range(2)]
embeddings = [embed(r) for r in results]
sim_1_ref = cosine_similarity(embeddings[0], reference_embedding)
sim_2_ref = cosine_similarity(embeddings[1], reference_embedding)
sim_1_2 = cosine_similarity(embeddings[0], embeddings[1])
# Si les deux sorties sont cohérentes entre elles mais divergent de la référence :
# c'est une vraie régression, le modèle a changé de comportement
if sim_1_2 > 0.90 and (sim_1_ref < threshold or sim_2_ref < threshold):
return False, f"Régression confirmée : sim_ref={min(sim_1_ref, sim_2_ref):.2f}, cohérence interne={sim_1_2:.2f}"
# Si les deux sorties divergent entre elles : variabilité naturelle
if sim_1_2 < 0.85:
return None, f"Variabilité naturelle trop élevée pour conclure (sim_interne={sim_1_2:.2f})"
return True, "OK"Ce pattern retourne trois états : True (OK), False (régression), None (indéterminé). Les cas indéterminés ne font pas échouer le build, ils génèrent un warning dans le rapport CI.
Après ce changement, notre taux de faux positifs est passé de 34 % à 4 %. Le taux de vrais positifs n'a pas changé.
La température comme levier, pas comme constante
La plupart des pipelines de test fixent la température à 0 pour avoir des sorties déterministes. C'est raisonnable pour les tests structurels (format JSON, présence de champs). Ce n'est pas représentatif pour les tests sémantiques.
Si votre produit tourne à temperature=0.7, tester à temperature=0 vous donne un modèle différent, plus conservateur, moins créatif, avec une distribution de sorties différente. Les régressions que vous détectez peuvent ne pas exister en production, et les régressions réelles peuvent passer.
Notre règle : les tests structurels tournent à temperature=0, les tests sémantiques et de snapshot tournent à la température de production.
# Configuration par type de test
TEST_CONFIGS = {
"structural": {
"temperature": 0.0, # déterministe, on teste le format
"calls": 1,
},
"semantic": {
"temperature": 0.7, # température de production
"calls": 2, # double appel pour la robustesse
},
"snapshot": {
"temperature": 0.7,
"calls": 3, # triple appel, on prend la médiane
},
}Le budget de test comme contrainte de calibration
Les tests LLM coûtent des tokens. Cette contrainte pousse à optimiser, ce qui est un bien. Mais l'optimisation naïve (modèle moins cher, moins de cas de test) réduit la sensibilité.
Nos chiffres sur un projet de classification documentaire :
| Configuration | Coût/run | Taux de détection | Taux de faux positifs |
|---|---|---|---|
| 20 cas, gpt-4o-mini, 1 appel | 0,04 $ | 71 % | 34 % |
| 20 cas, gpt-4o-mini, 2 appels | 0,08 $ | 71 % | 4 % |
| 50 cas, gpt-4o-mini, 2 appels | 0,20 $ | 88 % | 4 % |
| 50 cas, gpt-4o, 2 appels | 1,80 $ | 91 % | 3 % |
Le saut de 20 à 50 cas est plus rentable que le saut de mini à gpt-4o. La double mesure (2 appels vs 1) divise les faux positifs par 8 pour 2x le coût. Ce sont les deux leviers qui ont le meilleur rapport signal/bruit/coût.
Les tests qui échouent après une mise à jour de modèle
Un cas qu'on n'avait pas anticipé : les mises à jour de modèle côté provider changent le comportement sans changer l'API. GPT-4o-mini de mai 2025 ne produit pas exactement les mêmes sorties que GPT-4o-mini de janvier 2025.
Quand une mise à jour de modèle arrive, une partie des snapshots de référence deviennent invalides, non pas parce que les prompts ont régressé, mais parce que le modèle a changé.
On gère ça avec des snapshots versionnés :
# Snapshot metadata inclut la version du modèle
SNAPSHOT = {
"prompt_version": "v2.3",
"model": "gpt-4o-mini",
"model_date": "2025-05", # version approximative
"reference_embedding": [...],
"threshold": 0.78,
"created_at": "2025-05-15",
}
def should_update_snapshot(snapshot: dict, current_model_date: str) -> bool:
"""
Si le modèle a été mis à jour, les snapshots doivent être recalibrés.
"""
return snapshot["model_date"] != current_model_dateQuand on détecte un changement de version de modèle, on déclenche une étape de recalibration en CI : on régénère les embeddings de référence et on recalcule les seuils de variabilité naturelle.
Ce qu'un test qui échoue doit vous dire
Un test qui échoue sans information contextualisée ne sert à rien. Notre rapport CI pour chaque test raté inclut :
- La sortie actuelle du modèle (texte brut)
- La sortie de référence (texte brut)
- La similarité mesurée et le seuil
- La similarité inter-appels (pour distinguer régression / variabilité)
- Le diff du prompt depuis la dernière version verte
Ce dernier point est le plus important. Si un test échoue sans changement de prompt, c'est probablement du bruit ou une mise à jour de modèle. Si un test échoue avec un diff de prompt en amont, c'est une régression à investiguer.
La question qu'on garde ouverte : comment automatiser la distinction entre "la sortie a changé parce que le prompt a changé" et "la sortie a changé parce que le comportement du modèle a changé globalement" ? Les deux ont des implications différentes, mais le signal est souvent le même.