Aller au contenu principal

Évaluation automatisée des prompts

Mis à jour le 28 juillet 2026

Évaluation automatisée des prompts

L’évaluation automatisée est la pierre angulaire d’un pipeline de prompt engineering mature. Elle vous permet de détecter les régressions, de comparer les modèles et de valider les modifications avant le déploiement. Sans elle, chaque changement de prompt est un pari aveugle.

Types d’évaluation

Trois familles d’évaluateurs se répartissent le travail selon la nature de la sortie attendue, et vous en combinerez presque toujours plusieurs.

Évaluation exacte (match)

Pour les tâches avec une réponse attendue unique, la comparaison directe suffit. eval_exact_match normalise casse et espaces avant de comparer : sans cette précaution, « Bug » et « bug » compteraient comme une erreur et vous enverraient corriger un problème qui n’existe pas.

eval_set_match traite le cas où la réponse est un ensemble — les entités trouvées dans un document, les catégories attribuées à un article. Elle en tire précision et rappel, deux chiffres qui ne disent pas la même chose : une précision élevée avec un rappel faible signale un modèle trop prudent, qui n’extrait que ce dont il est sûr ; l’inverse trahit un modèle qui invente pour remplir. Le F1 les résume commodément mais ne les remplace pas dans le diagnostic.

def eval_exact_match(prediction: str, reference: str) -> bool:
    """Correspondance exacte après normalisation."""
    return prediction.strip().lower() == reference.strip().lower()

def eval_set_match(prediction: set, reference: set) -> dict:
    """Correspondance sur des ensembles (entités, catégories)."""
    tp = len(prediction & reference)
    fp = len(prediction - reference)
    fn = len(reference - prediction)

    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0
    f1 = (2 * precision * recall / (precision + recall)
          if (precision + recall) > 0 else 0)

    return {"precision": precision, "recall": recall, "f1": f1}

Évaluation sémantique (embedding)

Pour les réponses textuelles où la formulation peut varier, la correspondance exacte devient absurde : « Votre commande sera livrée demain » et « La livraison est prévue pour demain » disent la même chose et obtiendraient zéro. La similarité cosinus entre embeddings mesure la proximité de sens plutôt que celle des caractères, et ramène un score continu.

À vous de fixer le seuil au-dessus duquel une réponse est jugée acceptable. Calibrez-le sur quelques dizaines de paires que vous aurez classées à la main en « équivalent » ou « différent », puis retenez la valeur qui sépare le mieux les deux groupes : un seuil choisi au jugé produit des résultats aussi arbitraires que l’absence de mesure, avec en prime l’apparence de la rigueur.

from openai import OpenAI
import numpy as np

client = OpenAI()

def eval_semantic_similarity(prediction: str,
                              reference: str) -> float:
    """Similarité cosinus entre les embeddings."""
    response = client.embeddings.create(
        model="text-embedding-3-large",
        input=[prediction, reference]
    )
    emb_pred = np.array(response.data[0].embedding)
    emb_ref = np.array(response.data[1].embedding)

    similarity = float(
        np.dot(emb_pred, emb_ref) /
        (np.linalg.norm(emb_pred) * np.linalg.norm(emb_ref))
    )
    return similarity

Évaluation par LLM (LLM-as-judge)

Le modèle évalue la qualité d’une réponse. C’est la seule approche possible quand aucune référence n’existe : personne n’écrira à la main la « bonne » réponse de support pour deux cents tickets.

Ce qui rend un juge utilisable tient à peu de chose. Le modèle employé, d’abord : on prend un modèle plus capable comme juge, ici gpt-5.6-sol, parce qu’un juge du même niveau que l’évalué a tendance à valider ses propres travers. La demande de justification, ensuite : motiver chaque note stabilise le jugement et permet de repérer un évaluateur qui se trompe de critère — une justification qui parle de longueur alors que le critère portait sur la clarté est un signal. La temperature à 0.1 assure enfin la stabilité des notes d’une exécution à l’autre.

def eval_llm_judge(question: str, prediction: str,
                    criteria: list[str]) -> dict:
    """Évaluation par un modèle juge."""
    criteria_text = "\n".join(
        f"- {c}: note de 1 à 5" for c in criteria
    )

    response = client.responses.create(
        model="gpt-5.6-sol",  # Modèle plus capable comme juge
        instructions="Tu es un évaluateur expert et impartial.",
        input=f"""Évalue cette réponse selon les critères suivants :

Question posée : {question}

Réponse à évaluer : {prediction}

Critères :
{criteria_text}

Pour chaque critère, donne une note de 1 à 5 avec justification.""",
        text={
            "format": {
                "type": "json_schema",
                "name": "évaluation",
                "schema": {
                    "type": "object",
                    "properties": {
                        "scores": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "critere": {"type": "string"},
                                    "note": {"type": "integer"},
                                    "justification": {"type": "string"}
                                },
                                "required": ["critere", "note",
                                              "justification"],
                                "additionalProperties": False
                            }
                        },
                        "note_globale": {"type": "number"},
                        "verdict": {"type": "string"}
                    },
                    "required": ["scores", "note_globale", "verdict"],
                    "additionalProperties": False
                },
                "strict": True
            }
        },
        temperature=0.1
    )
    return json.loads(response.output_text)

Pipeline d’évaluation complet

EvalPipeline assemble ces briques : il charge une suite de cas, exécute le prompt sur chacun, applique tous les évaluateurs fournis et agrège les scores. Les évaluateurs sont passés en paramètre sous forme de liste de fonctions, toutes appelées avec la même signature ; ajouter une métrique ne demande donc aucune modification du pipeline, et vous pouvez faire cohabiter un exact match, une similarité sémantique et un juge sur la même exécution, sans payer trois fois la génération.

La méthode summary cumule aussi les tokens consommés. Cette ligne apparemment secondaire vous protège d’un piège fréquent : une version de prompt qui gagne deux points de score en triplant la facture n’est pas forcément la bonne, et il vaut mieux l’apprendre à l’arbitrage qu’à la réception de la note mensuelle.

import json
from dataclasses import dataclass
from pathlib import Path

@dataclass
class TestCase:
    """Un cas de test pour l'évaluation."""
    input_text: str
    expected_output: str = None
    metadata: dict = None

class EvalPipeline:
    """Pipeline d'évaluation automatisée."""

    def __init__(self, test_suite_path: str):
        """Charge la suite de tests depuis un fichier JSON."""
        data = json.loads(Path(test_suite_path).read_text())
        self.test_cases = [TestCase(**tc) for tc in data]
        self.results = []

    def run(self, prompt_config: dict,
            evaluators: list[callable]) -> dict:
        """Exécute tous les tests et évalue les résultats."""
        self.results = []

        for tc in self.test_cases:
            # Générer la réponse
            response = client.responses.create(
                model=prompt_config["model"],
                instructions=prompt_config["system_prompt"],
                input=tc.input_text,
                temperature=prompt_config.get("temperature", 0.1)
            )

            prediction = response.output_text

            # Évaluer avec chaque évaluateur
            scores = {}
            for evaluator in evaluators:
                score = evaluator(
                    question=tc.input_text,
                    prediction=prediction,
                    reference=tc.expected_output
                )
                scores[evaluator.__name__] = score

            self.results.append({
                "input": tc.input_text,
                "expected": tc.expected_output,
                "prediction": prediction,
                "scores": scores,
                "tokens": {
                    "input": response.usage.input_tokens,
                    "output": response.usage.output_tokens
                }
            })

        return self.summary()

    def summary(self) -> dict:
        """Résume les résultats de l'évaluation."""
        if not self.results:
            return {"error": "Aucun résultat"}

        # Agréger les scores par évaluateur
        all_scores = {}
        for result in self.results:
            for eval_name, score in result["scores"].items():
                if eval_name not in all_scores:
                    all_scores[eval_name] = []
                if isinstance(score, (int, float)):
                    all_scores[eval_name].append(score)
                elif isinstance(score, dict) and "f1" in score:
                    all_scores[eval_name].append(score["f1"])

        summary = {
            "total_tests": len(self.results),
            "scores_moyens": {
                name: sum(scores) / len(scores)
                for name, scores in all_scores.items()
                if scores
            },
            "cout_total_tokens": sum(
                r["tokens"]["input"] + r["tokens"]["output"]
                for r in self.results
            )
        }
        return summary

Suite de tests : format

Le format de la suite reste volontairement minimal : une entrée, la sortie attendue, et des métadonnées libres. Ces métadonnées sont plus utiles qu’elles n’en ont l’air — en taguant chaque cas par catégorie, vous ventilez les scores et découvrez que votre classificateur n’échoue pas au hasard mais sur une famille de demandes précise, ce qu’une moyenne globale de 88 % ne dit pas. Les trois exemples ci-dessous montrent aussi ce que doit contenir une bonne suite : des cas nets, comme la panne au démarrage, mais aussi des cas qui frôlent une autre catégorie — une question sur une fonctionnalité absente ressemble beaucoup à un signalement de bug, et c’est sur cette frontière que se joue la qualité d’un classificateur.

[
    {
        "input_text": "Mon colis n'est pas arrivé",
        "expected_output": "reclamation",
        "metadata": {"categorie": "livraison"}
    },
    {
        "input_text": "Comment activer le mode sombre ?",
        "expected_output": "question",
        "metadata": {"categorie": "fonctionnalite"}
    },
    {
        "input_text": "L'app plante au démarrage sur iOS 18",
        "expected_output": "bug",
        "metadata": {"categorie": "technique"}
    }
]

Intégration CI/CD

Exécutez l’évaluation à chaque modification de prompt. La fonction ci_eval_check transforme la mesure en garde-fou : elle lance le pipeline, compare le score moyen à un seuil et renvoie un booléen que votre chaîne d’intégration interprète comme un succès ou un échec de build. Une modification qui fait passer la précision sous 85 % ne part plus en production, comme un test unitaire cassé bloque une fusion.

Le seuil se fixe à partir du score déjà atteint par la version en place, pas à partir d’un idéal. Le mettre trop haut condamne l’équipe à contourner la vérification par des désactivations temporaires qui deviennent permanentes ; le mettre trop bas revient à ne rien vérifier tout en affichant un badge vert. Gardez enfin à l’esprit que la suite doit rester représentative du trafic réel : un seuil satisfait sur vingt cas rédigés il y a deux ans ne prouve rien sur les demandes d’aujourd’hui.

def ci_eval_check(prompt_config: dict,
                   test_suite: str,
                   min_score: float = 0.85) -> bool:
    """Gate CI/CD : vérifie que le prompt atteint le score minimum."""
    pipeline = EvalPipeline(test_suite)

    def accuracy(question, prediction, reference):
        return 1.0 if prediction.strip().lower() == reference.strip().lower() else 0.0

    results = pipeline.run(prompt_config, [accuracy])
    avg_score = results["scores_moyens"].get("accuracy", 0)

    print(f"Score moyen : {avg_score:.2%}")
    print(f"Seuil minimum : {min_score:.2%}")

    if avg_score < min_score:
        print("ÉCHEC : score inférieur au seuil")
        return False

    print("SUCCÈS : prompt validé")
    return True

Tableau de bord d’évaluation

Tracez l’évolution des scores dans le temps. Un score isolé ne dit rien ; la série raconte l’histoire de votre prompt et répond à la question qui revient toujours en réunion : est-ce qu’on progresse ? La fonction ci-dessous produit un tableau Markdown mettant côte à côte, pour chaque version, le score moyen, les tokens consommés et le coût estimé. Le format n’est pas anodin : il se colle tel quel dans une pull request, et rend la comparaison lisible par des personnes qui n’ouvriront jamais votre code.

def rapport_comparatif(resultats: dict[str, dict]) -> str:
    """Génère un rapport comparatif entre versions de prompts."""
    rapport = "# Rapport d'évaluation comparatif\n\n"
    rapport += "| Version | Score moyen | Tokens moyens | Coût |\n"
    rapport += "|---------|-------------|---------------|------|\n"

    for version, data in resultats.items():
        score = data.get("scores_moyens", {})
        score_val = list(score.values())[0] if score else 0
        tokens = data.get("cout_total_tokens", 0)
        rapport += (f"| {version} | {score_val:.2%} | "
                   f"{tokens} | ${tokens * 5 / 1e6:.4f} |\n")

    return rapport

Installer le garde-fou chez vous

Constituez une suite de vingt cas de test pour un prompt réel, en y plaçant une poignée de cas limites plutôt que vingt exemples confortables : une suite qui passe à 100 % dès le premier essai ne vous apprendra rien. Implémentez les trois types d’évaluateurs et faites-les tourner ensemble sur deux versions de votre prompt. L’exact match donnera un chiffre net, la similarité sémantique rattrapera les reformulations légitimes, le juge éclairera les cas où les deux premiers se contredisent — ces désaccords sont les plus riches en enseignements sur ce que vous attendez réellement du modèle.

Comparez, identifiez la version gagnante, puis mettez en place le script CI qui bloque le déploiement si le score baisse. Vous saurez que le dispositif fonctionne le jour où il refusera une modification que vous pensiez évidente.

Points clés à retenir

  • L’évaluation automatisée est indispensable pour itérer avec confiance
  • Combinez évaluation exacte, sémantique et LLM-as-judge selon le cas
  • Maintenez une suite de tests qui évolue avec votre application
  • Intégrez l’évaluation dans votre CI/CD avec un seuil minimum
  • Utilisez un modèle plus capable comme juge pour évaluer un modèle moins cher
  • Documentez les scores de chaque version pour tracer l’évolution

Testez vos connaissances

Techniques avancées et industrialisation : le contrôle final.

1. Que contient un system prompt efficace ?

Réponse : Rôle, périmètre, règles de comportement, format de sortie et gestion des cas limites — écrit comme une spécification, testé comme du code.

2. Quand utiliser la self-consistency ?

Réponse : Sur les problèmes de raisonnement à réponse vérifiable : on génère plusieurs raisonnements indépendants et on retient la réponse majoritaire — la variance devient un allié.

3. Que garantit le JSON mode avec schema enforcement ?

Réponse : Une sortie strictement conforme au schéma (types, champs requis, enums) — validée côté API et revalidée côté code (JSON Schema, Zod) pour une chaîne sans surprise.

4. Qu'est-ce que le grounding ?

Réponse : Ancrer les réponses dans des faits fournis (documents, données récupérées) plutôt que dans la mémoire du modèle — avec l’exigence de citer ou de s’abstenir hors des sources.

5. À quoi sert un catalogue de prompts versionné ?

Réponse : À traiter les prompts comme des artefacts : versions, A/B tests, évaluations automatisées à chaque changement — on sait qui tourne en production, et pourquoi.

La boucle finale du cours — versionner, tester, évaluer automatiquement — transforme le prompt engineering artisanal en pratique d’équipe.