درس 5 — Améliorer

Jeux de données, expériences et juge LLM

9 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Jeux de données, expériences et juge LLM

La v2 du prompt d'Arvea demande de citer la référence de la fiche. La responsable qualité la juge « nettement meilleure » après en avoir lu quatre réponses. Le responsable technique craint qu'elle allonge les réponses et introduise des erreurs. Les deux ont un avis, aucun n'a de mesure. Une expérience rejoue les mêmes questions sur les deux versions et note chaque réponse avec les mêmes critères : la discussion porte ensuite sur des chiffres.

Le mécanisme

Un jeu de données (dataset) est une liste d'éléments : une entrée, une sortie attendue, des métadonnées. Une expérience exécute une tâche (ici repondre avec une étiquette de prompt) sur chaque élément, crée une trace par exécution, puis applique des évaluateurs qui produisent des scores attachés à ces traces. Des évaluateurs de niveau run agrègent ensuite les scores de toute l'expérience.

flowchart LR
  D[("Dataset<br/>questions-conseillers<br/>8 éléments")] --> R1["Expérience prompt-production<br/>tâche : repondre(label=production)"]
  D --> R2["Expérience prompt-staging<br/>tâche : repondre(label=staging)"]
  R1 --> E["Évaluateurs par élément<br/>cite_reference (code)<br/>exactitude_juge (Gemini 3.8 Flash)"]
  R2 --> E
  E --> A["Évaluateurs de run<br/>moyennes"]
  A --> DEC{"v2 au moins aussi exacte<br/>(tolérance 0,05)<br/>et cite mieux ?"}
  DEC -->|"oui"| P["production → v2"]
  DEC -->|"non"| N["v1 reste en production"]

Deux familles d'évaluateurs, à combiner :

ÉvaluateurMesureCoûtFiabilité
Déterministe (code)ce qui se vérifie mécaniquement : référence citée, format, longueur, mot interditnultotale, sur un critère étroit
Juge LLMce qui demande de comprendre : exactitude par rapport à la référence, ajout de faitsun appel de modèle par élémentbonne si la grille est précise, variable d'un passage à l'autre

La mise en œuvre

1. Le jeu de données

catalogue.py déclare 8 questions écrites comme les posent les conseillers, dont une hors périmètre (l'assurance automobile) pour vérifier que l'assistant sait dire non. Chaque élément porte la fiche attendue en métadonnée :

files/src/assistant/catalogue.py (extrait)
{"q": "Une cliente adhérente depuis 3 ans veut résilier, quel délai ?", "ref": "PROC-030",
 "attendu": "Résiliation possible à tout moment après un an ; effet un mois après réception."},
...
langfuse.create_dataset_item(dataset_name=NOM_DATASET, id=f"{NOM_DATASET}-{i:02d}",
                             input={"question": item["q"]}, expected_output=item["attendu"],
                             metadata={"reference": item["ref"]})

L'id fixe rend make seed rejouable : relancer met l'élément à jour au lieu de le dupliquer.

2. Les évaluateurs

files/src/assistant/evaluation.py (extrait)
def cite_la_reference(*, output, metadata, **_) -> Evaluation:
    attendue = (metadata or {}).get("reference")
    citees = set(re.findall(r"PROC-\d{3}", output or ""))
    ok = (attendue in citees) if attendue else not citees
    return Evaluation(name="cite_reference", value=1.0 if ok else 0.0, comment=f"attendue={attendue} citées={sorted(citees)}")

Le juge reçoit une grille explicite, répond dans un schéma JSON imposé, à température 0, et son appel est tracé comme une génération (sans cela, son coût serait invisible) :

files/src/assistant/evaluation.py (extrait)
CONSIGNE_JUGE = """Tu évalues la réponse d'un assistant de mutuelle.
Question : {question}
Réponse de référence : {attendu}
Réponse à évaluer : {reponse}

Note de 1 à 5 l'exactitude par rapport à la référence (5 = tous les faits justes, rien d'inventé ;
1 = faux ou dangereux). Une réponse qui ajoute un fait absent de la référence perd au moins un point.
Réponds en JSON : {{"note": <entier 1-5>, "justification": "<une phrase>"}}"""

def lire_verdict(texte: str) -> tuple[float, str]:
    d = json.loads(texte)
    note = int(d["note"])
    if not 1 <= note <= 5:
        raise ValueError(f"note hors bornes : {note}")
    return (note - 1) / 4, str(d.get("justification", ""))

La note 1-5 est ramenée sur 0-1 pour être comparable aux autres scores. Une note hors bornes lève une erreur : le SDK isole l'échec sur cet élément, l'expérience continue.

3. L'expérience et la décision

files/src/assistant/evaluation.py (extrait)
resultat = dataset.run_experiment(
    name=f"prompt-{label}", task=tache,
    evaluators=[cite_la_reference, juge_exactitude],
    run_evaluators=[moyenne("cite_reference"), moyenne("exactitude_juge")],
    max_concurrency=2,  # quota partagé : on évite les rafales de 429
    metadata={"modele": SETTINGS.model, "juge": SETTINGS.judge_model},
)

La règle de promotion, dans __main__.py, est écrite avant de connaître les résultats : v2 passe si elle cite mieux et si son exactitude ne baisse pas de plus de 0,05.

Résultat obtenu

make eval (dans make e2e), 2026-10-15 :

INFO assistant.evaluation expérience prompt-production : {'moyenne_cite_reference': 0.25, 'moyenne_exactitude_juge': 0.78125}
INFO assistant.evaluation expérience prompt-staging : {'moyenne_cite_reference': 0.875, 'moyenne_exactitude_juge': 0.75}
production : {'moyenne_cite_reference': 0.25, 'moyenne_exactitude_juge': 0.78125}
staging    : {'moyenne_cite_reference': 0.875, 'moyenne_exactitude_juge': 0.75}
staging promu en production (version 2)
Comparaison des deux expériences dans Langfuse : cite_reference 0,88 contre 0,25, exactitude 0,75 contre 0,78, justification du juge au survol

Capture réelle : la vue de comparaison, élément par élément. Au survol, la justification du juge.

Ce que disent ces chiffres, et ce qu'ils ne disent pas :

  • Citation : gain net. De 2 réponses sur 8 à 7 sur 8. L'écart dépasse largement le bruit.
  • Exactitude : pas de différence mesurable. 0,75 contre 0,78 représente une note d'écart sur 8 éléments. Trois exécutions de la même expérience pendant la rédaction ont donné 0,78 / 0,81, 0,78 / 0,75 et 0,78 / 0,75 : la variation d'un passage à l'autre est du même ordre que l'écart. La tolérance de 0,05 est là pour cela.
  • Une panne de recherche, pas de prompt. Sur la question de résiliation, les traces montrent que la recherche lexicale a renvoyé PROC-012 (RIB) au lieu de PROC-030 : « résilier » ne correspond pas à « résiliation ». Aucun prompt ne corrige ce défaut. Le jeu de données l'a révélé ; la correction relève de la recherche.
  • Coût : le juge domine. Sur l'ensemble de make e2e, les 16 jugements (gemini-3.8-flash) ont coûté 0,0258 USD, contre 0,0037 USD pour les 23 appels de l'assistant et de l'agent (make couts, leçon 6). Évaluer coûte ici sept fois plus que répondre.

Ce qui casse en production

SymptômeCauseParade
Une version « gagne » puis « perd » au passage suivant8 éléments : un élément pèse 0,125 ; le juge varie50 à 200 éléments pour décider d'un écart fin ; plusieurs passages ; tolérance explicite
Le juge préfère les réponses longuesbiais de verbosité connu des juges LLMgrille qui pénalise l'ajout de faits ; évaluateur déterministe de longueur
json.decoder.JSONDecodeError dans les évaluationsjuge sans sortie structuréeresponse_schema imposé ; validation de bornes
Facture d'évaluation supérieure à celle de la productionjuge plus cher que le modèle évalué, sur chaque élément, à chaque passagejuge sur un échantillon en production ; jeu complet seulement avant promotion
Rafale de 429 pendant l'expériencemax_concurrency=50 par défaut dans le SDKconcurrence bornée (2 ici), réessais côté client Gemini
Le jeu de données donne de bons scores mais les conseillers se plaignentquestions inventées par l'équipe, trop propresalimenter le jeu depuis des traces réelles (source_trace_id), après anonymisation

La règle de décision

Un changement de prompt ou de modèle ne passe en production qu'après une expérience sur un jeu de données fixe, avec une règle de décision écrite avant de lancer. Commencez par les évaluateurs déterministes, qui ne coûtent rien et ne varient pas ; ajoutez un juge LLM pour ce qui demande de comprendre, avec un modèle au moins aussi capable que celui qu'il juge. Surveillez le coût des évaluations à part, et faites grossir le jeu de données à partir des traces qui ont posé problème.

Pratique

Une v3 du prompt obtient 0,84 d'exactitude contre 0,78 pour la v2, sur les 8 éléments, en un seul passage. Le chef de produit veut la promouvoir aujourd'hui. Que proposez-vous ?

La DSI propose de supprimer le juge LLM pour économiser, et de ne garder que cite_reference. Quel risque acceptez-vous ?

Exercice. Ajoutez un évaluateur déterministe trois_phrases qui vaut 1 si la réponse compte au plus trois phrases (la consigne du prompt), puis un évaluateur de run qui en calcule la moyenne. Sans appel réseau, écrivez son test.

Voir la solution
def trois_phrases(*, output, **_) -> Evaluation:
    phrases = [p for p in re.split(r"(?<=[.!?])\s+", (output or "").strip()) if p]
    return Evaluation(name="trois_phrases", value=1.0 if len(phrases) <= 3 else 0.0,
                      comment=f"{len(phrases)} phrase(s)")

# dans lancer() :
evaluators=[cite_la_reference, juge_exactitude, trois_phrases],
run_evaluators=[moyenne("cite_reference"), moyenne("exactitude_juge"), moyenne("trois_phrases")],

# test
def test_trois_phrases():
    assert trois_phrases(output="Un. Deux. Trois.").value == 1.0
    assert trois_phrases(output="Un. Deux. Trois. Quatre.").value == 0.0

La seconde réponse de la démo (« Pour ajouter son fils... Si ces informations ne suffisent pas, je vous propose d'ouvrir un ticket. ») compte trois phrases : elle passe, de justesse.

  • Je sais construire un jeu de données rejouable avec des sorties attendues et des métadonnées.
  • Je sais écrire un évaluateur déterministe et un juge LLM à sortie structurée.
  • Je sais lire une comparaison d'expériences en tenant compte de la taille du jeu et de la variance.
  • Je sais chiffrer le coût d'une évaluation et le séparer de celui de la production.
التالي