Apogée Consult
Retour au blog
Jules Ginhac
Jules GinhacCo-Founder & ingénieur IA

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 :

ConfigurationCoût/runTaux de détectionTaux de faux positifs
20 cas, gpt-4o-mini, 1 appel0,04 $71 %34 %
20 cas, gpt-4o-mini, 2 appels0,08 $71 %4 %
50 cas, gpt-4o-mini, 2 appels0,20 $88 %4 %
50 cas, gpt-4o, 2 appels1,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_date

Quand 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.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Tests LLM faux positifs CI : calibration et flakiness | Apogée Consult