Aller au contenu principal

CI/CD pour l'IA : évals automatisées

Mis à jour le 28 juillet 2026

Intégrer les évals dans votre pipeline CI/CD

Un changement de prompt en production sans évaluation, c’est comme déployer du code sans tests. La comparaison est plus exacte qu’il n’y paraît : dans les deux cas, la modification semble anodine, elle a été vérifiée à la main sur un ou deux exemples, et l’effet de bord se manifestera trois jours plus tard chez un utilisateur qui ne vous en parlera pas. La différence tient à ceci qu’une régression de prompt ne lève aucune exception — l’application continue de répondre, simplement moins bien.

Reste à faire de l’évaluation automatisée un point de passage obligé du déploiement, afin que chaque modification maintienne ou améliore la qualité. C’est tout l’objet de ce qui suit.

Le principe : une porte, pas un rapport

Le pipeline d’évals s’intègre dans votre flux de déploiement selon une mécanique familière. Le développeur modifie un prompt ou un paramètre, le commit déclenche le pipeline CI, et les évals s’exécutent automatiquement sur le dataset de référence. Si les scores passent les seuils, le déploiement continue ; sinon il est bloqué, avec un rapport détaillé qui indique quelle évaluation a échoué et de combien.

Le point décisif est ce blocage. Une CI qui produit un joli rapport sans arrêter la chaîne devient un artefact que plus personne ne consulte au bout de deux semaines. C’est le code de sortie non nul, et lui seul, qui transforme la mesure en garde-fou.

Le script ci-dessous lit une configuration, charge chaque dataset, exécute les cas et compare la moyenne obtenue au seuil déclaré. La notation retenue — une simple inclusion de mots-clés — est volontairement rudimentaire : une vérification de CI doit être rapide, déterministe et sans dépendance à un juge dont la note varierait d’une exécution à l’autre.

import json
import sys
import openai
from pathlib import Path

client = openai.OpenAI()

def charger_config_eval(chemin: str = "evals/config.json") -> dict:
    """Charge la configuration des évaluations."""
    with open(chemin) as f:
        return json.load(f)

def charger_dataset(chemin: str) -> list[dict]:
    """Charge un dataset JSONL."""
    cas = []
    with open(chemin) as f:
        for ligne in f:
            cas.append(json.loads(ligne))
    return cas

def executer_eval_ci(config: dict) -> dict:
    """Exécute les évaluations pour la CI."""
    resultats = {}

    for eval_config in config["evaluations"]:
        nom = eval_config["nom"]
        dataset = charger_dataset(eval_config["dataset"])
        seuil = eval_config["seuil_minimum"]
        modele = eval_config["modele"]
        prompt = Path(eval_config["prompt_file"]).read_text()

        scores = []
        for cas in dataset:
            response = client.responses.create(
                model=modele,
                instructions=prompt,
                input=cas["input"],
                temperature=0.0,
            )

            # Évaluation simple par inclusion de mots-clés
            if "mots_cles" in cas:
                reponse_lower = response.output_text.lower()
                trouves = sum(
                    1 for mot in cas["mots_cles"]
                    if mot.lower() in reponse_lower
                )
                score = trouves / len(cas["mots_cles"])
            else:
                score = 1.0  # Pas de critère spécifique

            scores.append(score)

        score_moyen = sum(scores) / len(scores)
        passe = score_moyen >= seuil

        resultats[nom] = {
            "score": score_moyen,
            "seuil": seuil,
            "passe": passe,
            "nb_cas": len(dataset),
        }

    return resultats

def rapport_ci(resultats: dict) -> tuple[str, bool]:
    """Génère un rapport pour la CI et indique si c'est OK."""
    lignes = ["# Rapport d'évaluation IA\n"]
    tout_passe = True

    for nom, r in resultats.items():
        statut = "PASS" if r["passe"] else "FAIL"
        if not r["passe"]:
            tout_passe = False
        lignes.append(
            f"- [{statut}] {nom}: {r['score']:.2%} "
            f"(seuil: {r['seuil']:.0%}, {r['nb_cas']} cas)"
        )

    rapport = "\n".join(lignes)
    return rapport, tout_passe

Remarquez que le prompt est lu depuis un fichier plutôt qu’écrit dans le script. C’est ce qui rend le déclenchement possible : un prompt versionné dans le dépôt produit un diff, et un diff peut déclencher une évaluation. Tant que vos prompts vivent dans des chaînes de caractères disséminées à travers le code applicatif, aucune automatisation propre n’est possible.

La configuration déclare une évaluation par tâche, avec un seuil qui lui est propre.

{
  "evaluations": [
    {
      "nom": "classification-tickets",
      "dataset": "evals/datasets/classification.jsonl",
      "prompt_file": "prompts/classification.txt",
      "modele": "gpt-5.6-terra",
      "seuil_minimum": 0.90
    },
    {
      "nom": "generation-resume",
      "dataset": "evals/datasets/resumes.jsonl",
      "prompt_file": "prompts/resume.txt",
      "modele": "gpt-5.6-terra",
      "seuil_minimum": 0.75
    }
  ]
}

L’écart entre les deux seuils est le cœur de la méthode. Une classification de tickets est une tâche fermée : en dessous de 0,90, la fonctionnalité ne rend pas le service attendu. Un résumé est une tâche ouverte, jugée sur des mots-clés qu’un bon texte peut légitimement formuler autrement ; exiger 0,90 reviendrait à bloquer tous les déploiements et, très vite, à désactiver la vérification. Calibrez chaque seuil sur les scores observés en production plutôt que sur un chiffre rond qui vous paraît rassurant.

Câbler le tout dans GitHub Actions

Le workflow ne se déclenche que sur les chemins concernés — les prompts, les évals, le code d’intégration IA — de sorte qu’un changement de feuille de style ne consomme pas d’appels API.

# .github/workflows/eval-ia.yml
name: Évaluation IA

on:
  pull_request:
    paths:
      - "prompts/**"
      - "evals/**"
      - "src/ia/**"

jobs:
  evaluer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install openai

      - name: Exécuter les évals
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: python evals/run_ci.py

      - name: Publier le rapport
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: rapport-eval
          path: evals/rapport.md

Deux détails valent d’être relevés. La clé d’API passe par les secrets du dépôt, jamais par le fichier de workflow, qui est lisible par tous ceux qui ont accès au code. Et la publication du rapport porte if: always(), sans quoi l’artefact ne serait pas produit lorsque les évals échouent — c’est-à-dire précisément dans le seul cas où vous avez besoin de le lire.

Le script d’entrée fait la jonction : il écrit le rapport sur disque pour l’artefact, l’affiche dans les logs, et positionne le code de sortie qui autorisera ou non la suite du pipeline.

#!/usr/bin/env python3
"""Script d'évaluation pour la CI — evals/run_ci.py"""

import sys
from pathlib import Path

def main():
    config = charger_config_eval("evals/config.json")
    resultats = executer_eval_ci(config)
    rapport, tout_passe = rapport_ci(resultats)

    # Écrire le rapport
    Path("evals/rapport.md").write_text(rapport)
    print(rapport)

    # Exit code pour la CI
    if not tout_passe:
        print("\nDes évaluations ont échoué. Déploiement bloqué.")
        sys.exit(1)
    else:
        print("\nToutes les évaluations passent.")
        sys.exit(0)

if __name__ == "__main__":
    main()

Comparer plutôt que juger dans l’absolu

Un seuil fixe répond à la question « est-ce assez bon ? ». Il ne répond pas à « est-ce meilleur qu’avant ? », qui est souvent la vraie question. Un prompt réécrit peut rester au-dessus du seuil tout en ayant perdu trois points par rapport à la version précédente : la CI passe au vert et la dégradation entre en production sans que personne ne l’ait vue.

Le test de régression exécute donc les deux versions sur le même dataset, dans la même campagne, et compare les moyennes. La tolérance de 5 % absorbe le bruit inhérent aux sorties génératives ; au-delà, le verdict est explicite.

def test_regression_prompt(
    prompt_actuel: str,
    prompt_precedent: str,
    dataset: list[dict],
    modele: str,
    seuil_degradation: float = 0.05,
) -> dict:
    """Vérifie qu'un nouveau prompt ne dégrade pas les performances."""
    scores_actuel = []
    scores_precedent = []

    for cas in dataset:
        # Évaluer avec le prompt actuel
        r1 = client.responses.create(
            model=modele, instructions=prompt_actuel,
            input=cas["input"], temperature=0.0,
        )
        # Évaluer avec le prompt précédent
        r2 = client.responses.create(
            model=modele, instructions=prompt_precedent,
            input=cas["input"], temperature=0.0,
        )

        s1 = evaluer_reponse(r1.output_text, cas)
        s2 = evaluer_reponse(r2.output_text, cas)
        scores_actuel.append(s1)
        scores_precedent.append(s2)

    moy_actuel = sum(scores_actuel) / len(scores_actuel)
    moy_precedent = sum(scores_precedent) / len(scores_precedent)
    delta = moy_actuel - moy_precedent

    return {
        "score_actuel": moy_actuel,
        "score_precedent": moy_precedent,
        "delta": delta,
        "regression": delta < -seuil_degradation,
        "verdict": "OK" if delta >= -seuil_degradation else "REGRESSION",
    }

Ce test double le nombre d’appels, donc le coût et la durée. Réservez-le aux modifications de prompt, en le laissant hors du chemin des commits ordinaires, et faites-le tourner sur un sous-ensemble représentatif plutôt que sur l’intégralité de votre dataset de référence.

Points clés à retenir

  • Intégrez les évals dans votre pipeline CI/CD comme des tests unitaires
  • Versionnez les prompts dans des fichiers : sans diff, pas de déclenchement possible
  • Définissez des seuils minimaux par type d’évaluation, calibrés sur la production
  • Bloquez les déploiements via le code de sortie quand les scores descendent sous les seuils
  • Testez les régressions en comparant nouveau prompt et ancien prompt sur le même dataset