Evaluation Pipelines — Mesurer la qualité d'un LLM avant et après la prod
TL;DR Sans evals tu pousses des changements de prompt en prod et tu pries. Une eval pipeline pro combine : offline evals (datasets golden rejoués à chaque PR), online evals (sampling de traces prod), et automated graders (LLM-as-judge pour ce qui n'est pas binaire). Les outils 2026 : Ragas pour RAG (faithfulness, answer relevancy, context precision/recall), DeepEval pour les agents (G-Eval, hallucination, toxicity), OpenAI/Anthropic evals pour les graders maison, Langfuse datasets pour stocker le golden. Le pattern qui paie : un job GitHub Actions qui run l'eval sur chaque PR de prompt → bloque le merge si regression > X%. Côté freelance, vendre un "eval framework" est un livrable ferme à 25-50 k€ qui débloque la confiance du client : il peut désormais itérer sur ses prompts sans terreur.
🧠 Mental model
┌────────────────────────────────────┐
│ ÉCOSYSTÈME EVAL │
└────────────────────────────────────┘
│
┌────────────────────┴────────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ OFFLINE │ │ ONLINE │
│ (CI/PR) │ │ (prod) │
└──────────────┘ └──────────────┘
│ │
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
Golden ds Regression Sampling traces User
(200 Q/A) on prompt (1-5% prod) feedback
(👍👎)
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ AUTOMATED GRADERS │
│ - Ragas (RAG metrics) │
│ - DeepEval (G-Eval, hallucination) │
│ - LLM-as-judge custom (rubrique métier) │
│ - Code graders (regex, JSON validity, contains) │
└──────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ DASHBOARD: score par version, par scénario, │
│ tendance, alertes regression │
└──────────────────────────────────────────────────┘Analogie : un LLM en prod, c'est un nouveau dev qu'on engage. Sans tests automatiques, à chaque PR tu pries que rien ne casse. L'eval pipeline c'est ta CI : elle exécute 200 questions golden à chaque PR de prompt, te dit "faithfulness +1.2%, helpfulness -0.4%, regression sur 3 scénarios catégorie A". Tu mergeas ou tu reviens en arrière, comme une vraie codebase. Le golden dataset, c'est ta test suite. Les graders, c'est tes assertions. LLM-as-judge, c'est ton reviewer senior qui lit chaque diff et note.
Une eval n'a de valeur que si elle est reproductible et versionnée. Si demain tu changes ton golden dataset sans changer ton numéro de version, tes courbes deviennent fausses. Traite ton dataset comme du code : pinné, versionné, commenté ("question Q42 ajoutée parce qu'incident #128 du 2026-02-12").
🛠️ Code minimal
Ragas sur un RAG juridique, 5 questions golden, 4 métriques.
# eval/ragas_minimal.py
from ragas import EvaluationDataset
from ragas.metrics import (
Faithfulness, AnswerRelevancy, ContextPrecision, ContextRecall
)
from ragas import evaluate
from ragas.llms import LangchainLLMWrapper
from langchain_anthropic import ChatAnthropic
# 1. ton golden dataset (Q + ground truth + ce que ton système a produit)
samples = [
{
"user_input": "Quel est le délai de prescription en droit du travail pour le paiement du salaire ?",
"retrieved_contexts": [
"Art. L3245-1 du Code du travail : l'action en paiement du salaire se prescrit par 3 ans.",
"Article L1471-1 : les actions portant sur l'exécution du contrat se prescrivent par 2 ans.",
],
"response": "Le délai de prescription pour le paiement du salaire est de 3 ans selon l'article L3245-1.",
"reference": "3 ans (L3245-1 Code du travail).",
},
# ... 199 autres
]
ds = EvaluationDataset.from_list(samples)
# Le judge tourne en `effort` bas et thinking adaptatif : un grader n'a pas besoin
# de raisonner longtemps, et chaque token de thinking est facturé sur 200 Q × 4 métriques.
# NB : sur Opus 4.8 le paramètre `temperature` est SUPPRIMÉ (HTTP 400). Le déterminisme
# d'un grader ne passe plus par temperature=0 mais par effort bas + prompt serré + cache.
judge = LangchainLLMWrapper(ChatAnthropic(model="claude-opus-4-8", max_tokens=1024))
result = evaluate(
dataset=ds,
metrics=[
Faithfulness(llm=judge),
AnswerRelevancy(llm=judge),
ContextPrecision(llm=judge),
ContextRecall(llm=judge),
],
)
print(result)
# faithfulness=0.92 answer_relevancy=0.88 context_precision=0.81 context_recall=0.79Ce que chaque métrique te dit :
- Faithfulness : la réponse est-elle ancrée dans les docs récupérés ? (hallucination).
- Answer relevancy : la réponse répond-elle bien à la question ?
- Context precision : les top docs sont-ils pertinents ?
- Context recall : les docs pertinents existent-ils dans le top-k ? (problème de retrieval).
Si context recall < seuil : ton retrieval merde. Si faithfulness < seuil mais context recall ok : ton prompt/modèle hallucine malgré les bonnes sources.
🎬 Cas d'usage concrets
Cas 1 — Eval continu d'un RAG juridique (200 questions golden, cabinet d'avocats)
Le client : cabinet d'affaires, RAG sur droit social FR (cf cas du chapitre précédent). 12 avocats interagissent quotidiennement. Le DPO et l'associé senior exigent une preuve mensuelle de qualité avant chaque release.
Le défi : comment prouver que la qualité n'a pas baissé après un update de prompt ou un changement de modèle ? Les avocats notent à la main quand ils ont le temps → pas systématique, pas chiffré.
La solution eval :
- Tu construis avec 3 avocats seniors un golden dataset de 200 questions/réponses idéales sur 8 catégories (rupture, salaire, durée du travail, congés, discipline, CSE, accord d'entreprise, harcèlement).
- Pour chaque question, tu stockes :
question,expected_answer,must_cite_articles(liste),forbidden_phrases(ex : "ce n'est qu'un avis", "consultez un avocat" sont forbidden ici parce qu'on est l'outil interne d'un cabinet). - Tu écris des graders multiples :
Faithfulness(Ragas).CitationCheck(custom) : la réponse cite-t-elle au moins un desmust_cite_articles?LLM-as-judge(Claude Opus) avec rubrique : "Réponse juridique correcte, précise, sans clause défensive parasite, langue pro, 0-5".
- Tu mets le tout dans un job GitHub Actions qui se déclenche sur PR
prompts/**ouretrieval/**.
Résultat : score moyen par catégorie sur dashboard Langfuse. Sur 6 mois, tu vois ContextRecall passer de 0.71 → 0.88 grâce à l'ajout d'un reranker bge-reranker-v2-m3. Tu factures une mission eval framework 28 k€ + maintenance 1500€/j 2j/mois.
Cas 2 — Eval d'un agent compta (% écritures correctes, ESN expertise comptable)
Le client : ESN qui revend un agent IA aux experts-comptables FR pour saisir automatiquement des factures fournisseurs (PDF → écriture comptable plan général : 401, 4456, 606xxx, etc.). 30 cabinets utilisateurs, ~50k factures/mois.
Le KPI client : "% d'écritures correctes du premier coup, sans modif humaine". Mesuré historiquement à 78%. Le client a besoin que tu le fasses passer à >92% avant le prochain salon EC.
Eval pipeline :
- Dataset golden : 500 factures réelles anonymisées (avec accord client) avec écriture comptable validée par expert. Couvre 12 types de fournisseurs (téléco, fournitures, prestataires, voyages, etc.) et 4 régimes TVA.
- Grader principal : code grader (pas LLM-as-judge). Compare
agent_journal_entryàexpected_journal_entryligne par ligne (compte, débit, crédit, libellé, TVA). Score binaire par facture + breakdown par type d'erreur. - Eval automatisée à chaque PR sur
apps/agent-compta/**. - Catégories d'erreur trackées :
wrong_account_606x,tva_missing,wrong_supplier_id,analytical_dim_missing, etc.
Résultat : tu identifies que 60% des erreurs viennent de 3 cas (sous-traitance 611, ventilation analytique multi-centre, refacturation interco). Tu rajoutes 3 sub-prompts spécifiques avec exemples few-shot, tu retiunes le retrieval sur le plan comptable du client. Score passe à 94% en 8 semaines. Mission 55 k€, success bonus 15 k€ déclenché au passage du seuil 92%.
Cas 3 — Eval d'un voicebot via LLM-as-judge sur transcripts (assurance santé)
Le client : assistance santé FR, voicebot qui gère les demandes simples (prise de RDV téléconsultation, état d'un remboursement). 8k appels/jour. CSAT humain mesuré par sondage post-appel, retour ~12% → données peu fiables.
Le défi : avoir un CSAT proxy automatisé sur 100% des appels.
Solution :
- Tous les transcripts (STT) + métadonnées (durée, escalation oui/non, raison) sont stockés.
- Tu nightly batch un LLM-as-judge sur les transcripts. Prompt évaluateur :
Tu juges une conversation entre un client et un voicebot d'assistance santé. Note de 1 à 5 sur 4 axes : - clarity: le bot a-t-il été clair, sans jargon ? - empathy: a-t-il reconnu l'émotion du client ? - resolution: la demande a-t-elle été traitée ? - escalation_quality: si transfert humain, était-il pertinent ? Renvoie JSON: {clarity, empathy, resolution, escalation_quality, comment}. - Le score moyen pondéré devient ton CSAT proxy. Tu compares avec le CSAT humain sur l'échantillon où tu as les deux → corrélation 0.81. Suffisant pour piloter en daily.
- Alertes : drop CSAT proxy > 5% par rapport à 7j → Slack PagerDuty.
Mission : 22 k€ pour livrer le pipeline + dashboard + premier mois de calibration. Maintenance 1800€/j 1j/mois.
🛠️ Exemple end-to-end — Pipeline CI GitHub Actions qui run Ragas sur RAG immobilier à chaque PR
Contexte projet : éditeur SaaS immobilier (estimation, recherche bien, RAG sur DPE/diagnostics/géoportail/PLU). PR de prompt fréquentes (data team itère). Tu livres un pipeline CI eval qui bloque le merge si regression > 2% sur l'une des 4 métriques principales.
1) Structure repo
.
├── apps/
│ └── rag-immo/
│ ├── prompts/
│ │ ├── system.md # @version: v3.4
│ │ └── rag.md # @version: v3.4
│ └── retrieval/
│ └── config.yaml # k, reranker, weights BM25/vec
├── eval/
│ ├── datasets/
│ │ └── golden_v2.jsonl # 250 Q/A versionnées
│ ├── run_eval.py
│ ├── thresholds.yaml
│ └── reporters/
│ └── langfuse_reporter.py
└── .github/workflows/eval-on-pr.yaml2) Dataset golden (extrait)
{"id":"Q001","category":"DPE","question":"Quelle est l'étiquette DPE seuil pour interdiction location à partir de 2025 ?","reference":"G en 2025 (loi Climat).","must_contain":["G","2025"],"forbidden":["A","B"]}
{"id":"Q002","category":"PLU","question":"Que signifie zone UA dans un PLU ?","reference":"Zone urbaine dense centrale.","must_contain":["zone urbaine"]}
{"id":"Q003","category":"diagnostic","question":"Validité du diagnostic amiante pour une maison construite en 1980 ?","reference":"Obligatoire si permis avant juillet 1997, validité illimitée si négatif.","must_contain":["1997"]}3) Script eval
# eval/run_eval.py
from __future__ import annotations
import json, os, sys, yaml, time
from pathlib import Path
from typing import Any
from ragas import EvaluationDataset, evaluate
from ragas.metrics import Faithfulness, AnswerRelevancy, ContextPrecision, ContextRecall
from ragas.llms import LangchainLLMWrapper
from langchain_anthropic import ChatAnthropic
from langfuse import Langfuse
from apps.rag_immo.app import run_rag # ton entry-point produit (question -> answer + contexts)
ROOT = Path(__file__).resolve().parents[1]
DATASET = ROOT / "eval/datasets/golden_v2.jsonl"
THRESHOLDS = ROOT / "eval/thresholds.yaml"
def load_dataset() -> list[dict[str, Any]]:
with DATASET.open() as f:
return [json.loads(line) for line in f]
def run_pipeline(samples: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for s in samples:
r = run_rag(question=s["question"], tenant_id="eval")
out.append({
"id": s["id"],
"category": s["category"],
"user_input": s["question"],
"retrieved_contexts": [c.text for c in r.contexts],
"response": r.answer,
"reference": s["reference"],
"must_contain": s.get("must_contain", []),
"forbidden": s.get("forbidden", []),
})
return out
def custom_code_graders(samples: list[dict[str, Any]]) -> dict[str, float]:
"""Graders binaires non-LLM."""
must_ok, forb_ok = 0, 0
for s in samples:
resp = s["response"].lower()
must_ok += all(m.lower() in resp for m in s["must_contain"])
forb_ok += not any(f.lower() in resp for f in s["forbidden"])
n = len(samples) or 1
return {"must_contain_rate": must_ok / n, "forbidden_avoid_rate": forb_ok / n}
def main() -> int:
samples = load_dataset()
produced = run_pipeline(samples)
ds = EvaluationDataset.from_list(produced)
judge = LangchainLLMWrapper(ChatAnthropic(model="claude-opus-4-8", max_tokens=1024))
result = evaluate(
ds,
metrics=[
Faithfulness(llm=judge),
AnswerRelevancy(llm=judge),
ContextPrecision(llm=judge),
ContextRecall(llm=judge),
],
)
scores = result.to_pandas().mean(numeric_only=True).to_dict()
scores.update(custom_code_graders(produced))
# push to Langfuse dataset run
lf = Langfuse()
run = lf.create_dataset_run(
dataset_name="rag-immo-golden-v2",
run_name=f"pr-{os.environ.get('GITHUB_PR', 'local')}-{int(time.time())}",
run_metadata={"sha": os.environ.get("GITHUB_SHA"), "scores": scores},
)
print(json.dumps({"scores": scores, "run_id": run.id}, indent=2))
# threshold check
with open(THRESHOLDS) as f:
thresholds = yaml.safe_load(f)
failed = []
for k, min_v in thresholds["min"].items():
if scores.get(k, 0) < min_v:
failed.append((k, scores.get(k, 0), min_v))
for k, max_drop in thresholds.get("max_regression_vs_baseline", {}).items():
baseline = float(os.environ.get(f"BASELINE_{k.upper()}", 0))
if baseline and (baseline - scores.get(k, 0)) > max_drop:
failed.append((f"{k}_regression", baseline - scores.get(k, 0), max_drop))
if failed:
print("FAILED EVAL THRESHOLDS:", failed, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())4) Seuils
# eval/thresholds.yaml
min:
faithfulness: 0.85
answer_relevancy: 0.80
context_precision: 0.75
context_recall: 0.78
must_contain_rate: 0.90
forbidden_avoid_rate: 0.98
max_regression_vs_baseline:
faithfulness: 0.02
answer_relevancy: 0.02
context_recall: 0.035) GitHub Actions
# .github/workflows/eval-on-pr.yaml
name: eval-on-pr
on:
pull_request:
paths:
- "apps/rag-immo/prompts/**"
- "apps/rag-immo/retrieval/**"
- "eval/**"
concurrency:
group: eval-${{ github.head_ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
LANGFUSE_HOST: ${{ secrets.LANGFUSE_HOST }}
QDRANT_URL: ${{ secrets.QDRANT_URL }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -e . -r eval/requirements.txt
- name: Fetch baseline scores
run: |
python eval/fetch_baseline.py --main-sha origin/main > baseline.json
cat baseline.json >> $GITHUB_ENV
- name: Run eval
run: python eval/run_eval.py
- name: Post PR comment
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('eval-report.md', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});6) Commentaire PR auto
## Eval report — PR #432
| Metric | Score | Baseline | Δ | Threshold |
|---------------------|-------|----------|--------|-----------|
| Faithfulness | 0.91 | 0.89 | +0.02 | ≥ 0.85 |
| Answer Relevancy | 0.84 | 0.86 | -0.02 | ≥ 0.80 |
| Context Precision | 0.78 | 0.77 | +0.01 | ≥ 0.75 |
| Context Recall | 0.83 | 0.82 | +0.01 | ≥ 0.78 |
| Must-Contain Rate | 0.93 | 0.92 | +0.01 | ≥ 0.90 |
Categories with regression > 1pt :
- `DPE` faithfulness -3pt (5 cas) — voir traces Langfuse [run-link]
Status: PASS7) Online eval sampling (bonus)
# eval/online_sample.py — cron toutes les heures
from langfuse import Langfuse
import random
lf = Langfuse()
traces = lf.api.trace.list(tags=["rag-immo", "prod"], limit=200)
sample = random.sample(traces.data, min(20, len(traces.data)))
for t in sample:
# rejoue judges Ragas sur le trace (input + retrieved + output déjà stockés)
scores = score_trace(t)
for k, v in scores.items():
lf.score(trace_id=t.id, name=k, value=v, source="online-sample")🎯 Patterns courants
- Dataset versionné dans Git : un fichier JSONL committé, jamais une table mutable. Tu peux git blame une question.
- Plusieurs catégories de graders :
- Code graders (regex, contains, JSON schema) → rapides, gratuits, déterministes.
- Ragas / DeepEval → métriques standard, communauté, comparables entre missions.
- LLM-as-judge custom → rubrique métier (ex : "ton commercial respecté", "pas de discrimination genre").
- Baseline glissante : la baseline n'est pas figée. Elle = scores de
mainà HEAD. À chaque merge, la baseline avance. - Catégoriser le golden : tag chaque question (
category,difficulty). Affiche les scores par catégorie → spot the weak spot. - Eval offline + online : offline = golden CI ; online = 1-5% des traces prod scorées async. La drift se voit que online.
- Cache des graders : LLM-as-judge sur 200 questions × 4 métriques × Claude Opus 4.8 (5 $/25 $ par Mtok) = ~3-6 $/run selon la taille des contextes. Deux leviers : (1) cache mémoïsé par
(question_hash, response_hash, metric_version)→ re-run gratuit si la réponse produite n'a pas bougé ; (2) prompt caching API sur la rubrique stable (cache_controlsur le system) → reads à ~0,1× sur les 200 appels qui partagent la même rubrique. Pour les métriques peu critiques, descends sur claude-haiku-4-5 (1 $/5 $) ou claude-sonnet-4-6 (3 $/15 $) ; garde Opus 4.8 pour les axes métier où une erreur de notation coûte cher. - A/B test prompts en prod : route 5% du trafic sur
prompt v3.5, compare scores online vsv3.4. Promote ou rollback. - Eval coverage : track
% scenarios covered by golden. Si une nouvelle feature arrive (e.g. "calcul indemnité licenciement"), tu ajoutes 10 questions golden. - Adversarial set : un mini-dataset de questions piégeuses (jailbreak, prompt injection, edge cases). Pas pour scorer la qualité, pour mesurer la robustesse.
🔄 Versions & écosystème 2026
- Ragas v0.3+ : stable, supporte les modèles 2026 (Claude Opus 4.8, GPT-5, Gemini 2.5), métriques avancées :
NoiseSensitivity,MultimodalFaithfulness. - DeepEval (Confident AI) : excellent pour agents et conversations multi-tour.
G-Eval(custom rubric),ConversationalGEval,Hallucination. Intégration native pytest. - OpenAI Evals : framework de référence pour evals model-vs-model. Bon pour benchmarks larges, moins pratique pour eval applicative.
- Anthropic evals (via console + SDK) : permet de comparer prompts/modèles dans la console Anthropic. Pratique pour les non-dev (product, métier).
- Langfuse Datasets : stocke golden + runs. Bien pour visualisation et collab non-tech.
- promptfoo : CLI YAML très efficace pour eval lourde sur la diff de prompts.
- Weave (W&B) : si l'équipe a déjà W&B, intégration eval propre.
- inspect-evals (UK AISI) : framework gov-grade pour evals de sécurité.
En 2026, les boîtes sérieuses ont Ragas/DeepEval pour les métriques + Langfuse pour le stockage/visualisation + GitHub Actions pour la CI. Tu peux vendre cette stack comme livrable standardisé.
⚠️ Pitfalls
- Golden trop petit : 20 questions ne couvrent rien. Minimum 150-250 pour signal stable.
- Golden non maintenu : tu fais évoluer le produit, le dataset reste figé → tu mesures un produit qui n'existe plus.
- LLM-as-judge avec le même modèle : juger Claude par Claude → biais "auto-favoritisme". Utilise un modèle différent ou ensemble (Claude + GPT).
- Pas de versionning de prompts : tu changes le prompt en prod, tu ne sais plus quelle version a été évaluée.
@versionou hash en frontmatter. - Score moyen seul : 92% en moyenne mais 40% sur la catégorie "DPE" critique → vu seulement avec breakdown.
- Eval qui coûte trop : 200 questions × 4 métriques × Opus 4.8 à chaque PR, ça chiffre vite. Prompt caching sur la rubrique + cache mémoïsé sur les réponses inchangées + Sonnet/Haiku sur les graders moins critiques. Un staff engineer ne paie jamais l'eval complète sur une PR qui ne touche qu'un fichier hors
prompts/**ouretrieval/**: la CI ne se déclenche que sur les paths qui peuvent changer la qualité. - Pas de seuil bloquant : eval qui fait juste un commentaire informatif → personne ne lit → regressions passent. Bloquer le merge.
- Confondre eval qualité et eval safety : faithfulness ≠ jailbreak resistance. Deux datasets séparés.
- Ignorer la variance LLM : un judge reste stochastique, ses scores bougent de ±1-3% d'un run à l'autre. Attention au piège 2026 : sur Opus 4.7/4.8 le paramètre
temperatureest supprimé (HTTP 400) — le réflexetemperature=0ne stabilise plus rien. On réduit la variance autrement :effortbas (moins de raisonnement = moins de dérive), structured outputs (messages.parse) pour borner le format, prompt serré, et on mesure la variance (σsur ≥ 10 runs de la même paire — cf.judge_sigmadans la section staff). Ton seuil de regression plancher est3σ: ne lis jamais un Δ de score inférieur à3σ, c'est du bruit, pas une regression. - Pas d'audit trail des changements de dataset : ajouter Q255 sans dire pourquoi → 3 mois plus tard tu ne sais plus.
💰 Pricing / ROI client
Mission types :
- Audit eval gap analysis (1 semaine) : 8-12 k€.
- Setup eval framework complet (golden + Ragas + CI + dashboard + 1 mois calibration) : 25-50 k€.
- Continuous eval maintenance : 1500-2000€/j, 2-4j/mois.
- Adversarial / red-teaming dataset (security focus) : 15-25 k€ en plus.
Coûts opérationnels :
- ~0.30-1.50€ par run d'eval complet (200 Q × 4 metrics) selon le judge (Haiku 4.5 → bas de fourchette, Opus 4.8 → haut). Avec prompt caching sur la rubrique tu divises encore le coût input par ~10 sur la partie cachée.
- Stockage Langfuse : marginal.
- Compute CI GH Actions : standard runner suffit (~30 min/PR).
Comment "defend the number" en review client : si on te demande "ton eval coûte 1,20€/PR, pourquoi pas 0,10€ ?", la réponse staff n'est pas "on baisse le judge" — c'est un arbitrage. Haiku 4.5 score 5× moins cher mais sa corrélation avec l'annotation humaine sur les axes métier (legal, compta) tombe sous 0,7 → tu repasses ton temps à débugguer des faux positifs. Le bon move : Haiku/Sonnet sur les métriques structurelles (faithfulness, code grader), Opus 4.8 sur les 1-2 axes métier critiques, et tu chiffres la corrélation pour chaque choix. Le coût d'eval doit être comparé au coût d'une regression non détectée en prod, pas à zéro.
ROI client : sans eval, un rollback de prompt = 2-3 jours de panique. Avec, c'est 30 min (revert PR). Sur une équipe qui itère 4 fois/semaine, eval pipeline économise 1 ETP / an (~80k€). Tu vends 35 k€, payback < 6 mois.
🧪 Testing / Eval
- Test des graders eux-mêmes : run le grader sur un dataset où tu connais déjà les bons scores (sanity check).
- Reproductibilité : 3 runs consécutifs sans rien changer → écart < 1% (sinon ton judge est trop chaud).
- Hold-out : 80% du golden visible par l'équipe, 20% caché (hold-out) pour mesurer overfitting au dataset.
- Annotation humaine périodique : trimestriellement, 50 traces prod sont notées à la main. Corrélation avec LLM-as-judge > 0.7 ? Sinon retrain rubrique.
- Coverage :
lcov-like : chaque feature produit a-t-elle au moins 10 questions golden ?
🔁 Quand utiliser / éviter
Utiliser :
- Tout produit LLM en prod avec utilisateurs payants.
- Domaine où la qualité est différenciante (legal, médical, compta).
- Équipe qui itère sur des prompts (>1 PR prompt / semaine).
- Avant chaque changement de modèle (migration GPT-4 → GPT-5).
Éviter / minimal-only :
- POC < 6 semaines (juste 30 questions smoke test suffisent).
- Tâche purement créative (writing assistant) où la métrique n'a pas vraiment de sens.
- Volume très faible (<100 req/mois) — eval humain manuel reste rentable.
🧩 Bonus — Patterns avancés mission FR
A. LLM-as-judge avec rubric métier (avocat)
Le judge générique Ragas ne suffit pas pour le legal. Tu écris une rubric métier signée par le chef du département. Le pattern de prod 2026 : structured outputs natifs (messages.parse() + schéma Pydantic) plutôt qu'un parsing XML/JSON fait main, AsyncAnthropic pour scorer 200 questions en parallèle, prompt caching sur la rubrique (qui est stable → cache_control sur le system), exceptions typées + max_retries, et logging resp.usage pour suivre le coût par run.
# eval/legal_judge.py
import asyncio
from pydantic import BaseModel, Field
from anthropic import AsyncAnthropic, RateLimitError, APIStatusError
# Le judge tourne en effort bas : noter une réponse n'est pas une tâche de
# raisonnement long, et l'effort bas réduit la latence ET le coût par grade.
client = AsyncAnthropic(max_retries=4) # backoff exponentiel SDK sur 429/5xx/529
LEGAL_RUBRIC = """
Tu juges la réponse d'un assistant juridique. Note 0-5 sur 5 axes :
1. EXACTITUDE_DROIT : la règle énoncée est-elle juridiquement correcte ?
0 = faux, 5 = exact avec article cité.
2. ANCRAGE_SOURCES : la réponse cite-t-elle au moins 1 article ou arrêt présent dans le contexte ?
0 = aucune source, 5 = sources nombreuses et pertinentes.
3. NUANCE : la réponse mentionne-t-elle les conditions/exceptions ?
0 = absolue, 5 = nuancée.
4. CLARTE : un avocat 2-3 ans comprend immédiatement ?
0 = jargon impénétrable, 5 = synthèse limpide.
5. ABSENCE_FAUTE : pas d'hallucination, pas de conseil illégal ?
0 = faute grave, 5 = parfait.
"""
class LegalGrade(BaseModel):
exactitude_droit: int = Field(ge=0, le=5)
ancrage: int = Field(ge=0, le=5)
nuance: int = Field(ge=0, le=5)
clarte: int = Field(ge=0, le=5)
absence_faute: int = Field(ge=0, le=5)
comment: str
_SEM = asyncio.Semaphore(20) # plafonne la concurrence pour rester sous les TPM/RPM
async def grade_legal(question: str, context: str, response: str) -> LegalGrade:
async with _SEM:
try:
res = await client.messages.parse(
model="claude-opus-4-8",
max_tokens=400,
# Adaptatif + effort bas : un grader n'a pas besoin de raisonner
# longtemps. `temperature` est SUPPRIMÉ sur Opus 4.8 (HTTP 400) ; on
# stabilise par effort bas + structured outputs, pas par temperature=0.
thinking={"type": "adaptive"},
output_config={"effort": "low"},
system=[{
"type": "text",
"text": LEGAL_RUBRIC,
"cache_control": {"type": "ephemeral"}, # rubrique stable → cache
}],
messages=[{
"role": "user",
"content": f"Question:\n{question}\n\nContexte:\n{context}\n\nRéponse:\n{response}",
}],
response_format=LegalGrade,
)
# Toujours vérifier stop_reason AVANT de lire le contenu : un refusal
# (HTTP 200, content vide) ferait crasher .parsed et droperait ce sample.
if res.stop_reason == "refusal":
raise RuntimeError("judge refused — sample non noté, à escalader")
# res.usage : log input/output/cache_read_input_tokens pour le coût/run
return res.parsed
except RateLimitError:
raise # remonte : la CI doit fail clairement, pas scorer 0 en silence
except APIStatusError as e:
raise RuntimeError(f"judge API error {e.status}: {e.type}") from e
async def grade_all(samples: list[dict]) -> list[tuple[str, LegalGrade | Exception]]:
# return_exceptions=True : un sample qui échoue ne tue PAS toute la run de 200.
# On renvoie l'erreur taggée par id pour la traiter (ré-essai ciblé / exclusion
# explicite + log), JAMAIS l'avaler silencieusement → la moyenne resterait fausse.
results = await asyncio.gather(
*(grade_legal(s["question"], s["context"], s["response"]) for s in samples),
return_exceptions=True,
)
return list(zip((s["id"] for s in samples), results))⚠️ Pourquoi
messages.parse()et pas un prompt "réponds en JSON" : le schéma Pydantic est validé côté serveur (output_config.format), donc tu ne récupères jamais un JSON tronqué ou unVoici le résultat : {...}parasite. Un grader qui crashe aujson.loads()une fois sur 50 fausse silencieusement ta moyenne (les questions qui crashent ne sont pas comptées). Le schéma supprime cette classe de bug.
⚠️ Le piège du
gathernu :asyncio.gather(*coros)sansreturn_exceptions=Truepropage la première exception et annule le reste — un seulrefusalou un 429 ponctuel et tu perds les 199 autres grades (et le coût déjà payé). Pire, si tu try/except autour et que tu metsscore=0sur l'erreur, tu biaises ta moyenne vers le bas sans le savoir. La règle staff : un sample non notable est exclu explicitement et compté à part, jamais noté 0 ni avalé. Tu veux pouvoir dire au client « 198/200 notés, 2 en erreur API ré-essayés », pas « moyenne 0,88 » sur un dénominateur flou.
B. Eval coverage par scénario
Tu mesures la couverture : chaque feature produit a-t-elle assez de Q golden ?
# eval/coverage.py
def coverage_report(samples):
by_cat = defaultdict(int)
for s in samples:
by_cat[s["category"]] += 1
target = {"DPE": 30, "PLU": 30, "diagnostic": 20, "fiscal": 20, "urbanisme": 15}
gaps = {cat: max(0, n - by_cat[cat]) for cat, n in target.items()}
return {"current": dict(by_cat), "target": target, "gaps": gaps}Ajoute un job qui poste le coverage dans Slack hebdo.
C. Online eval avec consensus 2/3
Sur 3 judges différents (Claude, GPT, Gemini) → consensus si 2/3 d'accord ; sinon ticket "expert" pour annotation humaine. Très utile sur les domaines sensibles (santé, legal).
D. Drift detection
Tu compares score moyen 7j glissant vs baseline mensuelle. Drift > 3% → alerte. Tu peux drift sans changement de prompt (modèle provider change en silence, dataset prod change de distribution).
-- Grafana
WITH daily AS (
SELECT toDate(start_time) AS day, avg(score_value) AS s
FROM scores
WHERE name = 'faithfulness' AND project_id = $project
GROUP BY day
)
SELECT
day,
s,
avg(s) OVER (ORDER BY day ROWS BETWEEN 30 PRECEDING AND 8 PRECEDING) AS baseline,
s - avg(s) OVER (ORDER BY day ROWS BETWEEN 30 PRECEDING AND 8 PRECEDING) AS drift
FROM daily ORDER BY day;E. Eval = livrable client en soi
Présente au comité direction client un deck mensuel "Eval & Quality" : score par catégorie, drift, regression évitées, top 5 catégories à améliorer. C'est un livrable très valorisé pour le COO/CMO. Tu factures 2 jours/mois rien que pour ça.
🧠 Comment un staff engineer raisonne sur une eval pipeline
Trois réflexes qui séparent une eval jouet d'une eval qui tient en prod :
1. La metric n'est qu'un proxy — calibre-la avant de lui faire confiance. Un score de faithfulness à 0,92 ne veut rien dire tant que tu n'as pas vérifié que le judge est d'accord avec un humain. Le pipeline correct : tu fais annoter 50 traces à la main, tu calcules la corrélation (Spearman/Kendall) judge ↔ humain, et tu n'autorises le judge à bloquer une PR que si la corrélation dépasse ~0,7. En-dessous, ton judge bloque des bonnes PR et laisse passer des mauvaises — pire que pas d'eval, parce que ça donne une fausse confiance. Re-calibre à chaque changement de modèle de judge (Opus 4.6 → 4.8 peut décaler tes scores).
2. Distingue les 4 sources de drift. Quand un score baisse, ce n'est pas forcément ton prompt. Les candidats, par ordre de fréquence : (a) ton prompt/retrieval a changé (la seule que la CI attrape), (b) le provider a changé le modèle en silence sous le même alias, (c) la distribution des questions prod a dérivé (nouveaux types d'utilisateurs), (d) ton golden dataset a vieilli et ne reflète plus le produit. Une eval qui ne sépare pas ces 4 te fait débugguer ton prompt pendant 2 jours alors que c'est (c). L'online eval + le drift detection (section Bonus D) existent précisément pour isoler (b) et (c).
3. Le seuil bloquant est une décision produit, pas technique. faithfulness < 0.85 → fail n'est pas un chiffre magique : c'est le point où le métier dit "en-dessous, le risque juridique/réputationnel dépasse la valeur d'itérer vite". Un staff engineer fait signer ce seuil par le métier (l'associé, le DPO), pas par l'équipe data. Et il distingue seuil absolu (qualité minimale acceptable) de seuil de regression (Δ vs baseline) : tu peux être à 0,88 absolu — au-dessus du plancher — mais avoir chuté de 0,94, ce qui signale un vrai problème même si tu restes "vert".
4. Le Δ détectable minimal est borné par la variance — chiffre-le, ne le devine pas. Les deux concepts ci-dessus (calibration humaine, seuil de regression) reposent sur des nombres qu'un junior cite ("corrélation > 0,7", "écart < 1%") sans savoir d'où ils sortent. Voici la machinerie qu'un staff sait poser :
Variance du judge → plancher de détection. Un LLM-as-judge reste stochastique même avec effort bas et structured outputs. Tu mesures son écart-type
σen rejouant la même (question, réponse)N=10fois et en prenantstd()des scores. Ton seuil de regression doit être > 3σ : en-dessous, tout Δ que tu vois est dans le bruit de mesure, pas une vraie baisse. Siσ = 0,4point sur une échelle 0-5 (= 0,08 normalisé), ne déclenche pas d'alerte pour une chute de 0,05. C'est la version eval du « ne lis pas un signal sous ton bruit de fond ».pythonimport numpy as np async def judge_sigma(question, context, response, n=10): grades = await asyncio.gather(*( grade_legal(question, context, response) for _ in range(n) )) # variance sur un axe représentatif (ex: exactitude_droit) scores = np.array([g.exactitude_droit for g in grades], dtype=float) return scores.mean(), scores.std(ddof=1) # σ échantillon mean, sigma = await judge_sigma(q, ctx, resp) min_detectable_delta = 3 * sigma # ton seuil de regression plancherCorrélation judge ↔ humain → droit de bloquer. Tu fais annoter
~50traces à la main, puis tu calcules Spearman (rangs, robuste aux échelles non-linéaires) entre les scores humains et ceux du judge. Tu ne donnes au judge le pouvoir de bloquer une PR que siρ ≳ 0,7. En-dessous : le judge note autre chose que ce que le métier appelle "qualité" → il bloque des bonnes PR et laisse passer les mauvaises, ce qui est pire que pas d'eval (fausse confiance).pythonfrom scipy.stats import spearmanr rho, p = spearmanr(human_scores, judge_scores) # 50 paires can_block = rho >= 0.70 and p < 0.05Pourquoi Spearman et pas Pearson : tu te fiches de l'accord sur la valeur absolue (le judge peut être systématiquement 0,3 plus généreux), tu veux qu'il classe les réponses dans le même ordre que l'humain. Pourquoi
p < 0,05: avec n=50 une corrélation de 0,7 est significative, mais sur n=15 elle peut être du hasard — un staff ne sur-interprète pas un petit échantillon.Le lien entre les deux. Ces deux nombres définissent ta zone aveugle : tu ne peux détecter de façon fiable qu'une regression
> max(3σ, plancher métier), et seulement sur les axes oùρ > 0,7. Tout le reste, tu le sais inaccessible à ton eval automatique — c'est là que l'annotation humaine trimestrielle et l'online eval prennent le relais. Savoir énoncer cette zone aveugle, c'est la différence entre "j'ai une eval" et "je sais ce que mon eval ne voit pas".
🏋️ Exercices
Datasets golden synthétiques OK pour démarrer, mais l'objectif est de te confronter aux pièges réels : variance du judge, biais d'auto-favoritisme, drift, et coût.
Exercice 1 — Code grader déterministe + breakdown par catégorie
Objectif : écrire un grader non-LLM qui score un agent compta ligne par ligne (compte, débit, crédit, TVA) et produit un breakdown par type d'erreur. Indice/Solution : compare agent_journal_entry vs expected_journal_entry champ par champ ; score binaire par facture ; agrège les erreurs dans un Counter par catégorie (wrong_account, tva_missing, …). Un code grader est gratuit, déterministe et rapide — privilégie-le partout où l'attendu est exact. Le breakdown est ce qui transforme "94% correct" en "60% des erreurs viennent de 3 cas" → roadmap d'amélioration.
Exercice 2 — Mesure la variance de ton judge, puis défends-la
Objectif : prouver chiffré que ton LLM-as-judge est assez stable pour bloquer une PR. Indice/Solution : run la même paire (question, réponse) × 10 fois à travers le judge (Opus 4.8, thinking={"type": "adaptive"}, effort: "low", messages.parse). Calcule σ = std(scores, ddof=1) (cf. judge_sigma dans la section staff). Piège : temperature=0 n'existe plus sur Opus 4.8 (HTTP 400) — tu stabilises via effort bas + structured outputs + prompt serré, pas via temperature. Ton seuil de regression plancher = 3σ ; en-dessous, tout Δ est du bruit. Twist senior : refais la mesure à effort: "medium" et à effort: "high" — montre que σ augmente avec l'effort (plus de raisonnement = plus de dérive) et conclus pourquoi un grader tourne en effort bas. Livrable à défendre : "mon Δ-min détectable est de X%, en-dessous je ne bloque pas."
Exercice 3 — Pipeline CI complet sur un golden de 150 Q
Objectif : un GitHub Actions qui run Ragas + code graders sur PR, compare à une baseline glissante (main), poste un commentaire markdown, et exit 1 si regression > seuil. Indice/Solution : reprends run_eval.py de la section end-to-end. La partie dure n'est pas Ragas, c'est la baseline : tu dois fetch les scores du SHA de main (depuis Langfuse ou un artefact CI) et calculer le Δ. Sans baseline, tu n'as qu'un seuil absolu et tu rates les regressions lentes. Bonus : parallélise les 150 appels judge avec asyncio.gather + Semaphore pour rester sous 25 min de CI.
Exercice 4 — Casse ton eval, puis prouve que tu l'as cassée
Objectif : démontrer le biais d'auto-favoritisme du LLM-as-judge. Indice/Solution : prends 30 réponses, fais-les noter une fois par Claude (judge), une fois par GPT (judge), sur la même rubrique. Mesure le delta sur les réponses générées par Claude. Tu devrais voir Claude se sur-noter légèrement. La parade prod : judge d'un modèle différent du générateur, ou consensus 2/3 (Claude + GPT + Gemini) avec escalade humaine en cas de désaccord (cf. Bonus C). Livrable : un tableau qui chiffre le biais — c'est exactement ce qu'un client te demandera de garantir.
Exercice 5 — Online eval + drift detection (production-grade)
Objectif : scorer async 1-5% des traces prod et alerter sur un drift qui n'apparaît PAS en CI. Indice/Solution : cron horaire qui sample des traces Langfuse, rejoue les judges Ragas (input + contexts + output déjà stockés → pas de re-génération), pousse les scores via lf.score(...). Puis la requête de drift (section Bonus D) : score 7j glissant vs baseline 30j, alerte Slack si Δ > 3%. Le test qui valide l'exo : change ton modèle de génération en silence (simule un changement provider) et vérifie que seul l'online eval le détecte, pas la CI.
Exercice 6 — Optimise le coût sans perdre la corrélation humaine
Objectif : réduire le coût/run de 60% tout en gardant corrélation judge↔humain > 0,7 sur les axes métier. Indice/Solution : route les métriques structurelles (faithfulness, must_contain) sur Haiku 4.5, garde Opus 4.8 sur les 2 axes métier ; ajoute prompt caching (cache_control sur la rubrique stable) ; mémoïse par (question_hash, response_hash, metric_version). Re-mesure la corrélation par métrique après le downgrade — si Haiku fait tomber faithfulness sous 0,7, tu remontes cette métrique sur Sonnet. Livrable : un tableau coût × corrélation par métrique qui justifie chaque choix de modèle. C'est l'exercice "defend the number" en condition réelle.
🎤 En entretien
Q : Comment tu garantis que ton LLM-as-judge mesure vraiment la qualité et pas n'importe quoi ? R : Je le calibre contre l'annotation humaine — corrélation Spearman judge↔humain sur ~50 traces, et je ne lui laisse un pouvoir bloquant que si elle dépasse 0,7 ; je re-calibre à chaque changement de modèle de judge.
Q : Ton score de faithfulness baisse de 4% du jour au lendemain sans déploiement. Tu débugges quoi en premier ? R : Pas mon prompt — je vérifie d'abord si c'est du bruit (variance du judge mesurée), puis un changement provider silencieux ou un drift de distribution prod ; l'online eval isole ces causes que la CI ne voit pas.
Q : Pourquoi pas juste temperature=0 sur ton judge pour le rendre déterministe ? R : Sur les modèles 2026 (Opus 4.7/4.8) temperature est supprimé et renvoie un 400 ; et même avant, ça ne garantissait pas l'identité des sorties. Je stabilise par effort bas + structured outputs (messages.parse) + prompt serré, et je mesure la variance résiduelle pour caler mon seuil de regression au-dessus du bruit.
Q : Offline eval vs online eval — pourquoi les deux ? R : L'offline (golden rejoué en CI) attrape les regressions que toi tu introduis sur une PR ; l'online (sampling de traces prod scorées async) attrape le drift que tu n'introduis pas — changement provider, dérive de distribution utilisateur. L'un sans l'autre te laisse aveugle sur la moitié des causes de baisse.
Q : Ton client te dit "ton eval coûte 1,20€/PR, baisse à 0,10€". Tu réponds quoi ? R : Ce n'est pas "on prend Haiku partout" — c'est un arbitrage chiffré. Je route les métriques structurelles (faithfulness, code graders) sur Haiku 4.5 et je garde Opus 4.8 sur les 1-2 axes métier où une erreur de notation coûte cher ; j'ajoute prompt caching sur la rubrique stable et une mémoïsation par (question_hash, response_hash, metric_version). Surtout, je re-mesure la corrélation humaine par métrique après chaque downgrade : si Haiku fait tomber un axe sous 0,7, je le remonte. Le coût d'eval se compare au coût d'une regression non détectée en prod, pas à zéro.
Q : Pourquoi ne pas juger Claude avec Claude ? Et comment tu le prouves ? R : Biais d'auto-favoritisme — un modèle qui se note lui-même se sur-évalue légèrement sur les axes subjectifs. Je le quantifie : je fais noter les mêmes réponses par deux familles de judges (ex. Claude + GPT) et je mesure le Δ sur les réponses générées par Claude. La parade prod : judge d'une famille différente du générateur, ou consensus 2/3 avec escalade humaine en cas de désaccord sur les domaines sensibles.
Q : Quel est le Δ minimal de score que tu déclares comme regression, et pourquoi pas plus bas ? R : Au moins 3σ où σ est l'écart-type du judge mesuré sur 10 runs de la même paire (question, réponse). En-dessous de 3σ, le Δ est dans le bruit de mesure — bloquer une PR là-dessus, c'est faire échouer du build sur du hasard. Mon plancher réel = max(3σ, seuil métier), et je sais énoncer la zone aveugle qui en résulte : ce que mon eval automatique ne peut tout simplement pas détecter, et que je couvre par annotation humaine trimestrielle.
🔗 Liens
- Ragas docs : https://docs.ragas.io
- DeepEval : https://docs.confident-ai.com
- OpenAI Evals : https://github.com/openai/evals
- Anthropic evals guide : https://docs.anthropic.com/claude/docs/evals
- Langfuse Datasets : https://langfuse.com/docs/datasets
- promptfoo : https://www.promptfoo.dev
- W&B Weave : https://wandb.ai/site/weave
- inspect-evals (UK AISI) : https://inspect.ai-safety-institute.org.uk
- Anthropic prompt console : https://console.anthropic.com
- Article FR : "Eval pipelines pour RAG prod" (Le Wagon AI blog, 2026)
- HumanEval / MMLU benchmarks : https://github.com/openai/human-eval