Aller au contenu principal

A/B testing de prompts

Mis à jour le 28 juillet 2026

A/B testing de prompts

L’A/B testing de prompts vous permet de comparer objectivement deux versions d’un prompt sur du trafic réel. Au lieu de choisir un prompt par intuition, vous mesurez lequel produit les meilleurs résultats selon vos métriques métier. C’est le passage obligé entre le prototypage et la production.

Principe

Le trafic entrant est réparti aléatoirement entre deux variantes de prompt, ou davantage. Chaque réponse est évaluée selon des critères définis à l’avance, et la variante gagnante est déployée pour 100 % du trafic.

La force de la méthode tient à ce que les deux variantes affrontent le même trafic, dans les mêmes conditions, au même moment. Toutes les causes de variation extérieures — saisonnalité, incident réseau, arrivée d’une nouvelle catégorie de demandes — frappent les deux groupes également et s’annulent dans la comparaison. C’est ce qu’un test séquentiel ne peut pas garantir : une semaine avec l’ancien prompt puis une semaine avec le nouveau, et vous ne saurez jamais si l’amélioration vient de votre reformulation ou du calendrier.

Framework d’A/B testing

La classe PromptExperiment tient en trois responsabilités. assign_variant décide quelle variante sert une requête donnée, run exécute l’appel et journalise le résultat, get_stats agrège.

L’assignation mérite un mot. Lorsqu’un user_id est fourni, elle est déterministe, calculée à partir d’un hachage du couple expérience-utilisateur. Un même utilisateur reste donc dans le même groupe d’un appel à l’autre, et cette continuité n’est pas un raffinement théorique : un client qui recevrait une réponse chaleureuse puis, trois minutes plus tard, une réponse sèche à sa relance, penserait à un bug, et son insatisfaction polluerait vos deux variantes à la fois.

Chaque exécution enregistre l’entrée, la sortie, les tokens consommés et l’horodatage. Ces tokens ne sont pas là pour la décoration : une variante peut gagner en qualité tout en doublant la longueur des réponses, et _estimate_cost traduit cet écart en dollars pour que l’arbitrage se fasse en connaissance de cause.

import random
import json
from datetime import datetime
from openai import OpenAI

client = OpenAI()

class PromptExperiment:
    """Framework d'A/B testing pour prompts."""

    def __init__(self, name: str, variants: dict[str, dict]):
        """
        variants = {
            "control": {"system_prompt": "...", "model": "gpt-5.6-terra",
                        "temperature": 0.3},
            "variant_a": {"system_prompt": "...", "model": "gpt-5.6-terra",
                          "temperature": 0.1},
        }
        """
        self.name = name
        self.variants = variants
        self.results = {v: [] for v in variants}

    def assign_variant(self, user_id: str = None) -> str:
        """Assigne une variante (sticky par user_id si fourni)."""
        if user_id:
            # Assignation déterministe par user
            hash_val = hash(f"{self.name}:{user_id}") % 100
            keys = sorted(self.variants.keys())
            split = 100 // len(keys)
            for i, key in enumerate(keys):
                if hash_val < (i + 1) * split:
                    return key
            return keys[-1]
        return random.choice(list(self.variants.keys()))

    def run(self, input_text: str, user_id: str = None,
            schema: dict = None) -> dict:
        """Exécute le prompt avec la variante assignée."""
        variant_name = self.assign_variant(user_id)
        variant = self.variants[variant_name]

        kwargs = {
            "model": variant["model"],
            "instructions": variant["system_prompt"],
            "input": input_text,
            "temperature": variant.get("temperature", 0.3)
        }

        if schema:
            kwargs["text"] = {
                "format": {
                    "type": "json_schema",
                    "name": "response",
                    "schema": schema,
                    "strict": True
                }
            }

        response = client.responses.create(**kwargs)

        result = {
            "variant": variant_name,
            "input": input_text,
            "output": response.output_text,
            "tokens_input": response.usage.input_tokens,
            "tokens_output": response.usage.output_tokens,
            "timestamp": datetime.now().isoformat()
        }

        self.results[variant_name].append(result)
        return result

    def get_stats(self) -> dict:
        """Calcule les statistiques par variante."""
        stats = {}
        for variant, results in self.results.items():
            if not results:
                continue
            tokens_in = [r["tokens_input"] for r in results]
            tokens_out = [r["tokens_output"] for r in results]
            stats[variant] = {
                "count": len(results),
                "avg_tokens_input": sum(tokens_in) / len(tokens_in),
                "avg_tokens_output": sum(tokens_out) / len(tokens_out),
                "total_cost_estimate": self._estimate_cost(
                    sum(tokens_in), sum(tokens_out)
                )
            }
        return stats

    def _estimate_cost(self, tokens_in: int,
                        tokens_out: int) -> float:
        """Estime le coût en USD."""
        return (tokens_in * 2.5 + tokens_out * 10) / 1_000_000

Métriques d’évaluation

Les métriques dépendent de votre cas d’usage, et deux familles couvrent l’essentiel. Quand la tâche a une bonne réponse — classer un ticket, extraire un champ —, l’évaluation se ramène à une comparaison avec un label de référence. C’est le rôle de evaluer_classification, dont on notera un choix instructif : l’échec de parsing y est traité comme une prédiction vide plutôt qu’en levant une exception, car une variante qui produit du JSON invalide doit être pénalisée dans le score, pas faire tomber la mesure.

Quand la tâche produit du texte libre, il n’existe pas de réponse unique et l’on demande au modèle d’arbitrer. evaluer_qualite_texte décompose alors le jugement en quatre critères — pertinence, clarté, complétude, ton professionnel — plutôt que de réclamer une note globale directe. Cette décomposition rend l’évaluation plus stable et vous dit sur quel axe la variante gagnante l’emporte : savoir que le gain porte sur le ton et non sur la complétude est précisément l’information dont vous avez besoin pour écrire la version suivante.

def evaluer_classification(result: dict,
                            label_attendu: str) -> dict:
    """Évalue une réponse de classification."""
    try:
        output = json.loads(result["output"])
        prediction = output.get("category", "")
    except json.JSONDecodeError:
        prediction = ""

    return {
        "correct": prediction == label_attendu,
        "prediction": prediction,
        "attendu": label_attendu
    }

def evaluer_qualite_texte(result: dict) -> dict:
    """Évalue la qualité d'une réponse textuelle avec le LLM."""
    eval_response = client.responses.create(
        model="gpt-5.6-terra",
        instructions="Tu es un évaluateur de qualité de texte. "
                     "Note sur 10 selon : pertinence, clarté, "
                     "complétude, ton professionnel.",
        input=f"Question : {result['input']}\n\n"
              f"Réponse : {result['output']}",
        text={
            "format": {
                "type": "json_schema",
                "name": "évaluation",
                "schema": {
                    "type": "object",
                    "properties": {
                        "pertinence": {"type": "integer"},
                        "clarte": {"type": "integer"},
                        "completude": {"type": "integer"},
                        "ton": {"type": "integer"},
                        "note_globale": {"type": "number"},
                        "commentaire": {"type": "string"}
                    },
                    "required": ["pertinence", "clarte",
                                 "completude", "ton",
                                 "note_globale", "commentaire"],
                    "additionalProperties": False
                },
                "strict": True
            }
        },
        temperature=0.1
    )
    return json.loads(eval_response.output_text)

Exemple complet : tester deux styles de prompt

L’expérience ci-dessous oppose deux conceptions du support client. Le contrôle se contente d’une consigne minimale, professionnelle et neutre. La variante « empathique » impose une structure en trois temps : reconnaître le problème, exprimer de l’empathie, proposer une solution avec ses étapes. Tout le reste est identique, modèle comme température, et cette symétrie est la condition pour que l’écart mesuré soit attribuable au prompt. Changer le ton et la température ensemble ne produirait qu’un résultat ininterprétable.

Les cinq tickets de test couvrent volontairement des registres différents : un incident de livraison chargé émotionnellement, un bug fonctionnel, une question neutre sur un mot de passe, une demande de remboursement conflictuelle, une panne d’API signalée par un profil technique. On s’attend à ce que la variante empathique domine sur les deux premiers et soit pénalisée sur le dernier, où l’interlocuteur veut un statut et un délai, pas de la compassion. C’est la nuance qu’une moyenne globale masque et qu’un examen ticket par ticket révèle — souvent pour déboucher sur la vraie décision : router selon le type de demande plutôt que choisir un ton unique.

experiment = PromptExperiment(
    name="style-support-client",
    variants={
        "control": {
            "system_prompt": "Tu es un agent de support. "
                             "Réponds de manière professionnelle.",
            "model": "gpt-5.6-terra",
            "temperature": 0.3
        },
        "empathique": {
            "system_prompt": "Tu es un agent de support bienveillant. "
                             "Commence par reconnaître le problème du "
                             "client, montre de l'empathie, puis propose "
                             "une solution concrète avec les étapes.",
            "model": "gpt-5.6-terra",
            "temperature": 0.3
        }
    }
)

# Simuler du trafic
test_tickets = [
    "Mon colis n'est pas arrivé depuis 2 semaines.",
    "L'application plante quand je clique sur Paramètres.",
    "Comment changer mon mot de passe ?",
    "Je veux un remboursement, le produit est défectueux.",
    "Votre API retourne une erreur 500 depuis ce matin.",
]

for ticket in test_tickets:
    result = experiment.run(ticket)
    quality = evaluer_qualite_texte(result)
    print(f"Variante: {result['variant']} | "
          f"Note: {quality['note_globale']}/10")

print("\nStatistiques :")
print(json.dumps(experiment.get_stats(), indent=2))

Significativité statistique

Ne tirez pas de conclusions trop vite. Avec peu de données, les résultats sont bruités, et l’erreur classique consiste à voir 7,4 contre 6,9 sur douze observations et à décréter un vainqueur. Le test t de Student ci-dessous répond à la seule question qui compte : l’écart observé est-il plus grand que ce que le hasard produirait naturellement entre deux séries équivalentes ?

Une p_value inférieure à 0,05 signifie qu’un tel écart serait improbable si les deux variantes se valaient. Au-delà, vous n’avez rien démontré, ce qui n’est pas la même chose que d’avoir démontré l’égalité : le plus souvent, il vous manque des données. Comptez au minimum une centaine d’observations par variante avant de conclure, et méfiez-vous de l’envie de regarder les résultats tous les matins — à force de consulter, on tombe toujours sur un moment où l’écart semble parlant.

from scipy import stats

def test_significativite(scores_a: list[float],
                          scores_b: list[float]) -> dict:
    """Test t de Student pour comparer deux variantes."""
    t_stat, p_value = stats.ttest_ind(scores_a, scores_b)

    return {
        "moyenne_a": sum(scores_a) / len(scores_a),
        "moyenne_b": sum(scores_b) / len(scores_b),
        "p_value": p_value,
        "significatif": p_value < 0.05,
        "recommandation": (
            "Différence significative, déployez la variante gagnante"
            if p_value < 0.05
            else "Pas assez de données ou différence non significative"
        )
    }

Monter votre première expérience

Prenez une tâche que vous maîtrisez et montez un PromptExperiment à deux variantes, en ne changeant qu’une seule chose entre le contrôle et la variante : un ton, une structure imposée, une consigne supplémentaire. Définissez deux ou trois métriques avant de lancer quoi que ce soit, car choisir la métrique après avoir vu les résultats revient à désigner le gagnant à l’avance, et c’est le biais le plus répandu dans les tests maison.

Faites tourner l’expérience sur vingt entrées, puis analysez. Regardez la moyenne, mais ouvrez surtout les cas où les deux variantes divergent le plus : ce sont eux qui expliquent l’écart. Vingt observations ne suffiront évidemment pas à atteindre la significativité, et le constater vous-même vaut mieux que le lire ici. Documentez enfin la décision et archivez les résultats bruts avec la date et la configuration exacte des variantes — c’est cette archive qui vous évitera de refaire la même expérience dans un an.

Points clés à retenir

  • L’A/B testing remplace l’intuition par des données mesurables
  • Assignez les variantes de manière déterministe par utilisateur
  • Mesurez les métriques métier, pas seulement la qualité perçue
  • Attendez la significativité statistique avant de conclure
  • Documentez chaque expérience et ses résultats