Le jeu de référence d'Assurances Ventoux compte 20 tickets étiquetés par l'équipe support. Le ticket T-1019 est le T-1003 recopié avec une minuscule et un double espace : compté deux fois, il donne à ce cas un poids double dans le score. Un autre jour, quelqu'un étiquette un ticket « Achats » au lieu de « achats » et l'évaluation compte une erreur que le modèle n'a pas commise. L'évaluation ne vaut que ce que vaut la préparation du jeu.
Le mécanisme
Deux composants font le travail de mesure :
prepare_datasetnormalise les espaces, rejette les étiquettes inconnues et les doublons (comparaison insensible à la casse), refuse un jeu de moins de 5 exemples, et calcule l'empreinte (SHA-256 tronqué) du jeu obtenu.evaluate_promptenvoie chaque ticket à Gemini avec le prompt de la version évaluée, et produit trois artefacts : les prédictions ligne à ligne, les métriques, la matrice de confusion.
sequenceDiagram
participant P as prepare_dataset
participant E as evaluate_prompt
participant G as Gemini (Vertex AI)
participant M as ML Metadata
P->>P: 20 lignes lues, 1 doublon rejeté
P->>M: Dataset (19 exemples, empreinte)
P->>E: dataset (artefact)
loop 19 tickets
E->>G: prompt + ticket, sortie = une des 5 files
G-->>E: "achats" (ou 429 : nouvel essai)
end
E->>M: Metrics (accuracy, latence p50, tokens, échecs)
E->>M: ClassificationMetrics (matrice 5 x 5)
Deux choix rendent la mesure fiable. La sortie est contrainte à une énumération : response_mime_type="text/x.enum" et un schéma qui liste les cinq files, si bien que Gemini ne peut répondre ni « Achats. » ni « Je pense que c'est achats ». Et la température est à 0, ce qui réduit la variance sans la supprimer (la leçon 3 montre un écart réel d'une exécution à l'autre).
La mise en œuvre
Un composant léger s'exécute dans un conteneur python:3.11-slim où seul le corps de la fonction est copié. D'où les imports à l'intérieur, et packages_to_install pour google-genai :
@dsl.component(base_image=BASE_IMAGE, packages_to_install=[GENAI])
def evaluate_prompt(dataset: Input[Dataset], prompt_template: str, model: str, version: str,
labels: list, project: str, location: str, predictions: Output[Dataset],
metrics: Output[Metrics], confusion: Output[ClassificationMetrics]):
from google import genai
from google.genai import errors, types
# Délai maximal par appel : sans lui, une connexion bloquée fige la tâche (et la facture).
http = types.HttpOptions(timeout=60_000)
client = (genai.Client(vertexai=True, project=project, location=location, http_options=http)
if project else genai.Client(http_options=http))
config = types.GenerateContentConfig(
temperature=0, response_mime_type="text/x.enum",
response_schema={"type": "STRING", "enum": labels})Une erreur sur un ticket n'annule pas l'évaluation : elle est comptée dans failures, et c'est la porte de promotion qui décide (leçon 3). Les métriques s'écrivent avec l'API des artefacts KFP, que Vertex affiche sans code supplémentaire :
metrics.log_metric("accuracy", round(accuracy, 4))
metrics.log_metric("latency_p50_ms", round(statistics.median(latencies), 1))
metrics.log_metric("total_tokens", tokens)
metrics.log_metric("failures", failures)
confusion.log_confusion_matrix(labels, matrix)Les tests appellent la fonction Python derrière le composant (python_func) avec de faux artefacts et un faux client Gemini qui se trompe volontairement sur un ticket :
monkeypatch.setattr(genai, "Client", lambda **kw: SimpleNamespace(models=FakeModels()))
c.evaluate_prompt.python_func(dataset=Art(ds), prompt_template="Ticket : {text}", model="fake", ...)
assert metrics.metadata["accuracy"] == round(5 / 6, 4)
assert sum(map(sum, conf.metadata["confusion"]["matrix"])) == 6Résultat obtenu (2026-10-16)
Extrait de make run (Gemini 2.5 Flash sur Vertex AI) :
[KFP Executor 2026-10-16 16:07:47,391 WARNING]: ligne 19 rejetée : doublon de "l'écran de mon poste clignote puis s'éte"
[KFP Executor 2026-10-16 16:09:32,932 INFO]: v2 : accuracy=1.000 sur 19 exemples
[KFP Executor 2026-10-16 16:10:57,938 INFO]: v1 : accuracy=0.947 sur 19 exemplesLa seule erreur de v1, lue dans l'artefact predictions :
{"id": "T-1013", "label": "achats", "text": "La licence de l'outil de BI de mon équipe expire vendredi, il faut la renouveler.", "prediction": "logiciel", "ok": false}C'est exactement l'incident de septembre : sans règle de départage, un renouvellement de licence ressemble à un sujet logiciel. Le prompt v2 l'écrit explicitement.
Ce qui casse en production
- Quota partagé saturé. Notre première exécution du 2026-10-16 a reçu un 429 sur T-1002 au bout de cinq essais (attentes de 1, 2, 4 et 8 s) ; le ticket a été compté en échec et la porte a rejeté le candidat (
reason: '1.0 appels en échec (max 0)'). Parade appliquée : six essais espacés de 2 à 32 s, environ une minute de patience par ticket. Rejeter sur un échec technique reste le bon comportement : mieux vaut relancer que promouvoir sur une mesure incomplète. - Appel sans délai maximal. Une connexion bloquée garde la tâche vivante, et la machine facturée, jusqu'au délai global du job. Parade :
HttpOptions(timeout=60_000). - Sortie libre au lieu d'une énumération. « Achats. » ne vaut pas « achats » : le score baisse sans que le modèle se soit trompé. Parade : sortie contrainte, et étiquettes inconnues rejetées dès la préparation.
- Jeu trop petit. Avec 19 tickets, une erreur vaut 5 points d'exactitude. Parade : le seuil
min_gainà 0 ici ; en production, visez plusieurs centaines de tickets et exigez un gain supérieur à l'écart observé entre deux exécutions identiques.
La règle de décision
Contraignez la sortie dès que la tâche a un ensemble fini de réponses. Comptez les erreurs techniques à part des erreurs de classement, et faites-les bloquer la promotion. Un jeu de référence se prépare dans le pipeline, pas à la main : la validation et l'empreinte font partie de la mesure.
Pratique
Après l'ajout de 30 tickets au jeu, l'exactitude de la production passe de 0,95 à 0,88 sans que rien d'autre ait changé. Que concluez-vous d'abord ?
Un score n'a de sens qu'avec son jeu. Les nouveaux tickets sont peut-être plus difficiles, ce qui est souvent le but. La question utile est : sur ce nouveau jeu, le candidat bat-il la production ? Accuser le modèle ou réécrire le prompt sans mesure appariée revient à réagir à un changement de thermomètre.
Une évaluation compte 3 échecs techniques (429) sur 19 tickets et une exactitude de 1,00 sur les 16 restants. La production est à 0,95. Promouvez-vous ?
Ici l'exactitude est calculée sur 19 tickets (les échecs comptent comme faux), mais quelle que soit la convention, la mesure est incomplète. La porte refuse dès qu'un échec dépasse max_failures. Compter les échecs comme justes serait inventer des résultats.
Exercice. Ajoutez à evaluate_prompt une métrique macro_recall (moyenne, sur les cinq files, de la part de tickets de chaque file correctement classés) et le test correspondant.
Voir la solution
À partir de matrix (ligne = vraie file, colonne = prédiction) :
recalls = [row[i] / sum(row) for i, row in enumerate(matrix) if sum(row)]
metrics.log_metric("macro_recall", round(sum(recalls) / len(recalls), 4))Dans le test, avec le faux Gemini qui classe T0 (« acces ») en « achats » : la file acces a 2 tickets dont 1 juste, les autres files sont parfaites, donc macro_recall == round((0.5 + 1 + 1 + 1 + 1) / 5, 4) == 0.9. Cette métrique révèle une file sacrifiée que l'exactitude globale masque quand les files sont déséquilibrées.
- Je valide, dédoublonne et prends l'empreinte du jeu dans le pipeline.
- Je contrains la sortie de Gemini à une énumération.
- Je donne un délai maximal et une reprise mesurée à chaque appel.
- Je teste un composant par
python_funcavec un faux client.