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 :
| Évaluateur | Mesure | Coût | Fiabilité |
|---|---|---|---|
| Déterministe (code) | ce qui se vérifie mécaniquement : référence citée, format, longueur, mot interdit | nul | totale, sur un critère étroit |
| Juge LLM | ce qui demande de comprendre : exactitude par rapport à la référence, ajout de faits | un appel de modèle par élément | bonne 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 :
{"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
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) :
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
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)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ôme | Cause | Parade |
|---|---|---|
| Une version « gagne » puis « perd » au passage suivant | 8 éléments : un élément pèse 0,125 ; le juge varie | 50 à 200 éléments pour décider d'un écart fin ; plusieurs passages ; tolérance explicite |
| Le juge préfère les réponses longues | biais de verbosité connu des juges LLM | grille qui pénalise l'ajout de faits ; évaluateur déterministe de longueur |
json.decoder.JSONDecodeError dans les évaluations | juge sans sortie structurée | response_schema imposé ; validation de bornes |
| Facture d'évaluation supérieure à celle de la production | juge plus cher que le modèle évalué, sur chaque élément, à chaque passage | juge sur un échantillon en production ; jeu complet seulement avant promotion |
| Rafale de 429 pendant l'expérience | max_concurrency=50 par défaut dans le SDK | concurrence bornée (2 ici), réessais côté client Gemini |
| Le jeu de données donne de bons scores mais les conseillers se plaignent | questions inventées par l'équipe, trop propres | alimenter 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 ?
Avec 8 éléments, un écart de 0,06 correspond à deux notes sur quarante et la variation entre passages identiques a atteint 0,03 à 0,06. Promouvoir sur ce signal, c'est tirer à pile ou face. Un juge plus petit ne réduit pas la variance, il réduit la qualité du jugement.
La DSI propose de supprimer le juge LLM pour économiser, et de ne garder que cite_reference. Quel risque acceptez-vous ?
cite_reference vérifie la présence d'une référence, pas l'exactitude de ce qui est dit. C'est précisément l'incident d'origine : un délai faux. Une alternative moins chère consiste à garder le juge avant chaque promotion et à ne l'exécuter que sur un échantillon en production.
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.0La 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.