Leçon 7 — Versionner et tester

Tests de non-régression en CI

5 min de lecture

Tests de non-régression en CI

Le principe

Un test de non-régression vérifie qu'un changement ne dégrade pas ce qui marchait. Pour un LLM, on ne peut pas tester « la réponse exacte » ; on teste des propriétés sur un jeu de référence (golden set) : des entrées fixes avec la sortie attendue.

flowchart LR
  G["golden.jsonl : 12 cas"] --> E["evaluate(version)"]
  E --> S["exactitude, JSON valide"]
  B["baseline.json : score figé de la version en production"] --> P{"gate : baisse > 5 points ou JSON invalide ?"}
  S --> P
  P -->|"oui"| X["ÉCHEC : la CI bloque la fusion"]
  P -->|"non"| OK["OK : la version peut passer en production"]

Le jeu du lab, golden.jsonl, contient 12 demandes fictives : 3 par service, avec des urgences hautes et normales.

files/golden.jsonl (extrait)
{"demande": "J'ai cliqué sur un lien bizarre dans un e-mail et une page m'a demandé mon mot de passe.", "service": "it", "urgence": "haute"}
{"demande": "Combien de jours de congés me reste-t-il pour cette année ?", "service": "rh", "urgence": "normale"}

La porte de qualité

files/regression.py (extrait)
def gate(candidate: dict, baseline: dict, tolerance: float = TOLERANCE) -> list[str]:
    """Raisons de refuser la candidate (liste vide = acceptée)."""
    reasons = []
    if candidate["exactitude"] < baseline["exactitude"] - tolerance:
        reasons.append(f"exactitude {candidate['exactitude']:.2f} < référence {baseline['exactitude']:.2f} - {tolerance}")
    if candidate["json_valide"] < 1.0:
        reasons.append(f"JSON valide {candidate['json_valide']:.0%} < 100 %")
    return reasons

Deux règles : l'exactitude ne baisse pas de plus de 5 points (une tolérance absorbe un peu de variabilité), et toutes les réponses sont un JSON exploitable (un JSON invalide casse l'application en aval). La référence est mesurée une fois sur la version en production, puis figée dans baseline.json :

python regression.py --version v1 --save-baseline   # 12 appels : fige la référence
python regression.py --version v2                    # 12 appels : code de sortie 1 si régression

Pourquoi une tolérance de 5 points plutôt qu'exiger une exactitude au moins égale à la référence ?

En CI : deux étages

ÉtageQuandClé nécessaireCe qui est testé
tests hors ligne (pytest)à chaque pushnonregistre intact, wrapper, rapport, logique de la porte avec un faux modèle
non-régression réellequand prompts.json changeoui (secret CI)la version de production contre baseline.json
files/test_llmops.py (extrait)
def test_gate_rejects_degraded_version():
    good = fake(['{"service":"it","urgence":"haute"}', '{"service":"rh","urgence":"normale"}'])
    bad = fake(['{"service":"Informatique","urgence":"élevée"}', "pas du JSON"])
    base = regression.evaluate("v1", good, CASES)
    cand = regression.evaluate("v2", bad, CASES)
    assert base["exactitude"] == 1.0 and regression.gate(base, base) == []
    assert cand["exactitude"] == 0.0 and cand["json_valide"] == 0.5
    assert len(regression.gate(cand, base)) == 2

Le fichier ci-llmops.yml est un exemple GitHub Actions des deux étages ; la même logique s'écrit dans Cloud Build. La clé vient d'un secret de la CI, jamais du dépôt.

La version v2 est 11 % moins chère et 0 % d'erreurs HTTP. Le test de non-régression échoue. Que faire ?

Exercice : un critère de coût

Ajoutez à gate une troisième règle : la candidate est refusée si ses tokens d'entrée moyens dépassent ceux de la référence de plus de 50 %. Que faut-il changer dans evaluate et dans baseline.json ?

Voir la solution

Dans evaluate, cumulez r["tokens_in"] et renvoyez "tokens_in_moy": round(total / n). Dans --save-baseline, ajoutez "tokens_in_moy" à la liste des champs enregistrés. Puis :

    if candidate.get("tokens_in_moy", 0) > 1.5 * baseline.get("tokens_in_moy", float("inf")):
        reasons.append(f"tokens d'entrée {candidate['tokens_in_moy']} > 1,5 × {baseline['tokens_in_moy']}")

.get avec des valeurs par défaut garde la compatibilité avec un ancien baseline.json. Ajoutez un test avec un faux modèle qui renvoie 300 tokens pour vérifier que la règle se déclenche.

Résultat obtenu

Exécution réelle du 2026-10-15, gemini-3.5-flash-lite, 24 appels (sortie JSON condensée sur une ligne) :

$ python regression.py --version v1 --save-baseline
{"version": "v1", "cas": 12, "exactitude": 1.0, "json_valide": 1.0, "echecs": []}
Référence enregistrée dans baseline.json

$ python regression.py --version v2 ; echo "exit=$?"
RÉGRESSION : exactitude 0.00 < référence 1.00 - 0.05
RÉGRESSION : JSON valide 92% < 100 %
ÉCHEC
exit=1

Extraits des échecs de v2 : le modèle invente ses propres valeurs dès que la liste fermée disparaît.

attendu ['it', 'normale']        obtenu ['Support Informatique', 'Faible']
attendu ['it', 'haute']          obtenu ['Sécurité informatique', 'Haute']
attendu ['rh', 'normale']        obtenu ['RH', 'Faible']
attendu ['rh', 'haute']          obtenu ['Ressources Humaines', 'Haute']

Je sais faire

  • Constituer un jeu de référence et figer le score de la version en production
  • Écrire une porte de qualité avec tolérance et règles strictes
  • Séparer tests hors ligne et tests réels en CI
  • Lire les échecs d'une version refusée pour proposer la suivante
Suivant