Lección 3 — Construire

La porte de promotion

6 min de lectura

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

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ègleParamètreValeur par défautRejette quand
Échecs techniquesmax_failures0un appel Gemini du candidat a échoué
Seuil absolumin_accuracy0,85le candidat est sous le seuil, même s'il bat une production faible
Gain relatifmin_gain0,0le 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 :

files/components.py (extrait)
@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 decision

Ce 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 :

files/pipeline.py (extrait)
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-projet

Ré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.local ci-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.py affiche la version en production en fin de make 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 fixer min_gain au-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=1 sur la planification (leçon 4), ou une écriture par le client Cloud Storage avec la précondition if_generation_match, qui échoue si le registre a changé depuis sa lecture.
  • Retour arrière à la main. Recopier un vieux prompt dans production.json contourne la porte. Parade : revenir en arrière en soumettant l'ancienne version comme candidat avec min_gain né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 ?

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 ?

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.
Siguiente