Lección 4 — Suivre et évaluer

Évaluer avec mlflow.genai

7 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

La version 2 du prompt de Valoria « a l'air meilleure » : les trois questions essayées à la main donnent des réponses plus courtes. C'est exactement l'impression qui a précédé l'incident des notes de frais. Il faut dix questions fixes, des critères écrits, et un score par version, enregistré, comparable d'une semaine à l'autre.

Le mécanisme

mlflow.genai.evaluate (MLflow 3.17.1, vérifié dans le code installé) prend un jeu de lignes {"inputs": ..., "expectations": ...}, une fonction de prédiction et une liste d'évaluateurs (scorers). Pour chaque ligne, il appelle la prédiction, trace l'appel, puis applique chaque évaluateur sur la trace ; il écrit un run d'évaluation avec la moyenne de chaque évaluateur (<nom>/mean).

ÉvaluateurTypeVérifieCoût
cite_la_sectionrègle (@scorer)la réponse cite la section attendue, ou refuse exactement quand le guide ne dit riengratuit
concisionrègle (@scorer)80 mots au plusgratuit
correctnessjuge LLM (Correctness)les faits attendus (expected_facts) sont présentsun appel Gemini par ligne
sequenceDiagram
  participant L as lab.evaluate(v)
  participant H as mlflow.genai.evaluate
  participant P as predict(question)
  participant G as Gemini
  participant J as Juge Correctness
  participant S as Serveur MLflow
  L->>H: 10 lignes, predict, 3 évaluateurs
  par 4 fils
    H->>P: question
    P->>G: prompt vN + guide + question
    G-->>P: réponse
    H->>H: cite_la_section, concision
    H->>J: question, réponse, faits attendus
    J->>G: consigne de jugement
  end
  H->>S: run prompt-vN, métriques /mean, traces et évaluations

Les deux règles couvrent ce qu'un programme vérifie mieux qu'un modèle ; le juge couvre le sens. Deux questions du jeu n'ont pas de réponse dans le guide (grille salariale, remboursement de lunettes) : elles mesurent le refus, le défaut le plus coûteux d'un assistant RH.

La mise en œuvre

Une règle est une fonction décorée qui renvoie un Feedback avec sa justification :

files/scorers.py (extrait)
@scorer
def cite_la_section(outputs: str, expectations: dict) -> Feedback:
    expected = expectations.get("section", "")
    cited = set(re.findall(r"§\d", outputs or ""))
    if expected == "aucune":
        ok = OUT_OF_SCOPE in (outputs or "").lower() and not cited
        why = "refus attendu" + ("" if ok else f", obtenu sections {sorted(cited) or 'aucune'} sans refus")
    else:
        ok = expected in cited
        why = f"attendu {expected}, cité {sorted(cited) or 'rien'}"
    return Feedback(value=ok, rationale=why)

Le juge prend un modèle au format fournisseur:/modèle. En mode clé d'API, gemini:/gemini-3.5-flash lit GEMINI_API_KEY ; en mode Vertex AI, le fournisseur vertex_ai lit VERTEX_PROJECT et VERTEX_LOCATION (lu dans mlflow/metrics/genai/model_utils.py de la version installée), que judge_model() déduit de GOOGLE_CLOUD_PROJECT :

files/scorers.py (extrait)
if os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1"):
    os.environ.setdefault("VERTEX_PROJECT", os.environ.get("GOOGLE_CLOUD_PROJECT", ""))
    os.environ.setdefault("VERTEX_LOCATION", os.environ.get("GOOGLE_CLOUD_LOCATION", "europe-west1"))
    return "vertex_ai:/gemini-2.5-flash"

L'évaluation d'une version, avec trois protections issues d'échecs réels :

files/lab.py (extrait)
os.environ.setdefault("MLFLOW_GENAI_EVAL_MAX_WORKERS", "4")   # 10 par défaut : rafales de 429

def evaluate(version: int, with_judge: bool = True) -> dict:
    uri = f"prompts:/{PROMPT_NAME}/{version}"
    preload()   # imports faits dans le fil principal, voir « Ce qui casse »

    @mlflow.trace(name="question_rh")   # racine = la question seule : appariement entre runs
    def predict(question: str) -> str:
        return answer(question, prompt_uri=uri)

    with mlflow.start_run(run_name=f"prompt-v{version}") as run:
        mlflow.log_params({"prompt_uri": uri, "prompt_version": version,
                           "model": model_name(), "judge": judge_model() if with_judge else "aucun"})
        result = mlflow.genai.evaluate(data=load_eval(), predict_fn=predict,
                                       scorers=all_scorers(with_judge))
    metrics = {k: round(float(v), 3) for k, v in result.metrics.items() if k.endswith("/mean")}
    missing = [s.name for s in all_scorers(with_judge) if f"{s.name}/mean" not in metrics]
    if missing:
        raise RuntimeError(f"évaluateurs sans résultat : {missing} (clé du juge ? voir les traces du run)")

Un juge en échec (clé absente, modèle introuvable) ne lève pas d'exception : sa métrique manque, simplement. Sans la vérification missing, la porte de la leçon 5 comparerait en silence sur deux critères au lieu de trois.

python lab.py evaluate 2 --no-judge évalue sans juge, gratuitement, pour itérer sur les règles.

Résultat obtenu (2026-10-16)

INFO lab: v1 run=3d502ef6 {'cite_la_section/mean': 0.8, 'concision/mean': 0.8, 'correctness/mean': 1.0}
INFO lab: v2 run=363a1ba5 {'concision/mean': 1.0, 'cite_la_section/mean': 0.9, 'correctness/mean': 1.0}
Runs d'évaluation dans MLflow (2026-10-16) : prompt-v2 et prompt-v1 avec les moyennes concision, cite_la_section et correctness

Les faits sont justes dans les deux versions (correctness à 1,0) ; v1 échoue sur la forme : réponses longues, sections non citées, refus approximatifs (« Bonjour, en tant qu'assistant RH… »). Sur cinq évaluations de v1 le même jour, cite_la_section a valu 0,5, 0,6, 0,6, 0,6 et 0,8, et correctness une fois 0,9 au lieu de 1,0 : avec dix questions, une ligne fait varier un score de 0,1.

Ce qui casse en production

  • Évaluation figée, sans erreur. Sur 7 exécutions avant correction, 5 se sont bloquées indéfiniment (dans la version précédente du lab, avec un délai de 300 s par évaluateur, cela donnait une cascade de Scorer 'cite_la_section' timed out after 300 seconds). Un vidage des piles (PYTHONFAULTHANDLER=1, puis kill -ABRT) a montré la cause : des fils d'évaluateurs bloqués dans _get_module_lock, chacun important à la première utilisation un module (mlflow.genai.scorers.job, mlflow.server.jobs.utils, pydantic.json_schema) que l'autre importait. Parade : preload() importe ces modules dans le fil principal avant evaluate ; trois exécutions sur trois sans blocage ensuite.
  • Appel Gemini sans délai maximal. Une connexion bloquée suspend un fil d'évaluation pour toujours. Parade : HttpOptions(timeout=60_000) et reprise sur httpx.TimeoutException, testée sans réseau.
  • Parallélisme par défaut sur un quota partagé. Dix fils déclenchent des 429 en rafale (27 lignes de reprise sur 429 dans un seul make e2e). Parade : MLFLOW_GENAI_EVAL_MAX_WORKERS=4, et des reprises jusqu'à 16 s.
  • Juge muet. Le cas missing ci-dessus. Symptôme : un run d'évaluation sans colonne correctness. Parade : refuser le run.
  • Racine de trace trop riche. Si la racine reçoit prompt_uri en entrée, les requêtes de deux versions diffèrent et l'interface ne peut pas les apparier ; la comparaison n'affiche que des null. Parade : la fonction predict(question) tracée.

La règle de décision

Règles déterministes pour tout ce qui se vérifie par programme (format, citation, longueur, refus exact), juge LLM pour le fond, et un jeu fixe versionné dans le dépôt. Refusez un run incomplet. Avec dix questions, ne lisez pas un écart de 0,1 comme un progrès ; il faut un jeu plus grand ou plusieurs exécutions.

Pratique

Un run d'évaluation n'a que deux colonnes de métriques, cite_la_section et concision, et lab.py n'a pas échoué. Que s'est-il passé ?

make e2e ne termine plus depuis 20 minutes, sans message d'erreur, et aucune trace nouvelle n'arrive dans l'interface depuis 18 minutes. Que faites-vous ?

Exercice. Ajoutez un évaluateur pas_de_markdown qui échoue si la réponse contient **, # en début de ligne ou une puce - , et ajoutez-le aux règles. Quel score attendez-vous pour v1 et v2 ?

Voir la solution
@scorer
def pas_de_markdown(outputs: str) -> Feedback:
    found = [m for m in (r"\*\*", r"(?m)^#", r"(?m)^- ") if re.search(m, outputs or "")]
    return Feedback(value=not found, rationale=f"motifs trouvés : {found}" if found else "texte brut")

et rules = [cite_la_section, concision, pas_de_markdown] dans all_scorers. Le prompt v2 interdit la mise en forme Markdown ; v1 ne dit rien et Gemini en produit volontiers sur les réponses longues. Attendez un score nettement plus bas pour v1. Mesurez avant de l'affirmer : python lab.py evaluate 1 --no-judge ne coûte que les dix réponses.

  • J'évalue chaque version sur le même jeu, avec des règles et un juge.
  • Je refuse un run d'évaluation auquel il manque un évaluateur.
  • Je borne le parallélisme et le délai de chaque appel.
  • Je sais diagnostiquer une évaluation figée par un vidage des piles.
Siguiente