Mesurer ne suffit pas : en septembre, les scores existaient dans un notebook, mais personne n'avait écrit la règle qui dit quand un candidat a le droit de remplacer la production. La décision s'est prise à l'œil, un vendredi soir. Une porte de promotion est cette règle, écrite dans le code, appliquée à chaque exécution, avec son motif enregistré.
Le mécanisme
La porte compare deux artefacts Metrics produits dans la même exécution et applique trois règles, dans cet ordre :
| Règle | Paramètre | Valeur par défaut | Rejette quand |
|---|---|---|---|
| Échecs techniques | max_failures | 0 | un appel Gemini du candidat a échoué |
| Seuil absolu | min_accuracy | 0,85 | le candidat est sous le seuil, même s'il bat une production faible |
| Gain relatif | min_gain | 0,0 | le candidat fait moins bien que la production réévaluée |
Si la porte dit promote, la tâche promote réécrit production.json et copie l'ancien dans history/<version>.json. Le registre est un simple fichier JSON sur Cloud Storage, que Vertex monte dans chaque conteneur sous /gcs/<bucket>/.
stateDiagram-v2 [*] --> Mesures Mesures --> Rejet: échecs > max_failures Mesures --> Rejet: accuracy < min_accuracy Mesures --> Rejet: gain < min_gain Mesures --> Promotion: sinon Promotion --> Registre: production.json réécrit (écriture atomique) Registre --> Historique: ancienne version dans history/ Rejet --> [*]: motif dans l'artefact verdict Historique --> [*]
La mise en œuvre
La porte renvoie une seule valeur, promote ou reject, et écrit le motif dans un artefact verdict :
@dsl.component(base_image=BASE_IMAGE)
def compare_versions(candidate: Input[Metrics], production: Input[Metrics], min_accuracy: float,
min_gain: float, max_failures: int, verdict: Output[Artifact]) -> str:
c, p = candidate.metadata, production.metadata
gain = c["accuracy"] - p["accuracy"]
if c["failures"] > max_failures:
decision, reason = "reject", f"{int(c['failures'])} appels en échec (max {max_failures})"
elif c["accuracy"] < min_accuracy:
decision, reason = "reject", f"accuracy {c['accuracy']:.2f} < seuil {min_accuracy:.2f}"
elif gain < min_gain:
decision, reason = "reject", f"gain {gain:+.2f} < {min_gain:+.2f} exigé"
else:
decision, reason = "promote", f"accuracy {p['accuracy']:.2f} -> {c['accuracy']:.2f} ({gain:+.2f})"
verdict.metadata.update(decision=decision, reason=reason, gain=round(gain, 4),
candidate=c.get("version"), production=p.get("version"))
return decisionCe choix vient d'un écueil mesuré. La première version renvoyait un NamedTuple(decision, reason) et conditionnait la promotion sur gate.outputs["decision"]. Le compilateur l'accepte, mais kfp.local 2.17 ne sait pas résoudre un champ de NamedTuple dans un dsl.If : il journalise Could not resolve parent input: compare-versions-decision, saute la condition, et le pipeline finit en SUCCESS sans avoir rien promu. Une sortie unique se résout dans les deux environnements :
with dsl.If(gate.outputs["Output"] == "promote", name="promotion-gate"):
_small(promote(registry_dir=registry_dir, prompt_template=candidate_prompt,
model=candidate_model, version=candidate_version,
dataset_fingerprint=prep.outputs["fingerprint"],
candidate=cand_eval.outputs["metrics"],
verdict=gate.outputs["verdict"])).set_caching_options(False)La promotion écrit dans un fichier temporaire puis le renomme (os.replace) : un lecteur ne voit jamais un production.json à moitié écrit.
Exécution locale, avec Gemini sur Vertex AI :
GOOGLE_GENAI_USE_VERTEXAI=TRUE make run PROJECT=mon-projetRésultat obtenu (2026-10-16)
16:10:58.049 - INFO - Executing task 'compare-versions'
Porte : promote (accuracy 0.95 -> 1.00 (+0.05))
16:10:58.408 - INFO - Executing task 'promote'
16:10:58.790 - INFO - Task 'promote' finished with status SUCCESS
16:10:58.790 - INFO - Pipeline 'lab-vertex-ai-pipelines-prompt-gate' finished with status SUCCESS
Production après exécution locale : v2 (accuracy 0.95 -> 1.00 (+0.05))sorties/registry/production.json contient désormais v2, son motif, l'empreinte du jeu (007681d4b644e24e) et ses métriques ; history/v1.json garde l'ancienne version.
Deux exécutions identiques le même après-midi ont donné pour v1 une exactitude de 0,895 puis 0,947, à température 0. Un ticket de plus ou de moins bien classé suffit, et c'est la raison de réévaluer la production à chaque fois.
Ce qui casse en production
- Condition silencieusement ignorée. Le cas
kfp.localci-dessus : tout est vert, rien n'est promu. Parade : vérifier l'effet (le contenu du registre après exécution), pas seulement le statut ;run.pyaffiche la version en production en fin demake run. - Gain dans le bruit. Avec
min_gain=0, un candidat équivalent peut être promu sur une variation aléatoire. Parade : mesurer l'écart entre deux exécutions identiques de la production, et fixermin_gainau-dessus. - Deux exécutions concurrentes. Deux candidats évalués en parallèle contre la même production se promeuvent l'un après l'autre ; le dernier gagne sans avoir été comparé au premier. Parade :
max_concurrent_run_count=1sur la planification (leçon 4), ou une écriture par le client Cloud Storage avec la préconditionif_generation_match, qui échoue si le registre a changé depuis sa lecture. - Retour arrière à la main. Recopier un vieux prompt dans
production.jsoncontourne la porte. Parade : revenir en arrière en soumettant l'ancienne version comme candidat avecmin_gainnégatif explicite, ce qui laisse une trace.
La règle de décision
Une porte tient en quelques règles explicites, ordonnées de la plus grave à la plus fine, et chaque refus a un motif lisible. Elle refuse par défaut quand la mesure est incomplète. Ne lui confiez pas de critère que vous ne savez pas mesurer de façon reproductible ; ajoutez-le d'abord comme métrique observée, puis comme règle.
Pratique
La production est à 0,72 parce que le jeu de référence a été enrichi de cas difficiles. Le candidat obtient 0,80. La porte le rejette. Pourquoi, et que faites-vous ?
Le gain est de +0,08, mais min_accuracy protège contre la promotion d'une version simplement moins mauvaise. Si le jeu a changé de difficulté, le seuil doit être revu, comme une décision tracée (paramètre de l'exécution), pas en retirant la règle.
Un pipeline exécuté avec kfp.local finit en SUCCESS, mais production.json n'a pas changé alors que la porte a renvoyé promote. Que vérifiez-vous en premier ?
kfp.local n'a pas de cache, et une erreur d'écriture ferait échouer la tâche. Une tâche conditionnelle sautée sans erreur est le symptôme d'une condition non résolue, ici sur un champ de NamedTuple.
Exercice. Ajoutez une quatrième règle : rejeter si la latence médiane du candidat dépasse de plus de 50 % celle de la production. Écrivez le cas de test.
Voir la solution
Après la règle des échecs :
elif c["latency_p50_ms"] > 1.5 * p["latency_p50_ms"]:
decision, reason = "reject", f"latence p50 {c['latency_p50_ms']:.0f} ms > 1,5 x {p['latency_p50_ms']:.0f} ms"Test :
({"accuracy": 0.95, "failures": 0, "latency_p50_ms": 4000}, {"accuracy": 0.90, "latency_p50_ms": 2000}, "reject"),Les cas existants de test_gate doivent alors porter une latence (par exemple 1 000 ms des deux côtés). Une latence p50 sur 19 appels est bruitée : en production, appliquez cette règle sur plusieurs centaines d'appels ou sur le p90.
- J'écris la porte comme des règles ordonnées, avec un motif par refus.
- Je conditionne la promotion sur une sortie unique, résolue en local comme sur Vertex.
- Je vérifie l'effet d'une promotion dans le registre, pas seulement le statut du pipeline.
- Je garde l'historique des versions promues.