درس 3 — Exécuter et noter

Exécution parallèle, cache et budget

8 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Exécution parallèle, cache et budget

La première version d'un harness tourne en séquentiel : 30 cas × 4 secondes, deux minutes, acceptable. Puis le jeu passe à 600 cas, quelqu'un met 50 fils en parallèle, le quota renvoie des 429 en rafale, la moitié des cas finissent en erreur, et la facture du mois montre que chaque PR relance tout, y compris ce qui n'a pas changé. Ces trois problèmes (débit, reprise, coût) se règlent dans le runner, pas dans les scorers.

Le mécanisme

flowchart TB
  C["cas"] --> B{"budget atteint ?"}
  B -->|"oui"| S["statut budget, score 0"]
  B -->|"non"| K["clé = cible + empreinte du code + modèle + empreinte du prompt + entrée"]
  K --> H{"dans .cache/ ?"}
  H -->|"oui"| O["sortie + coût d'origine"]
  H -->|"non"| CALL["appel via MeteredClient"]
  CALL -->|"429, 500, 503"| RETRY["attente 2^n s × aléa, 5 essais max"]
  RETRY --> CALL
  CALL -->|"400, 401, 403"| ERR["statut erreur : le cas échoue, le harness continue"]
  CALL --> W["écriture atomique dans .cache/"]
  W --> O
  O --> SC["scorers"]

Quatre décisions structurent ce code :

  1. Le parallélisme se règle sur le quota, pas sur la machine. Les fils passent leur temps à attendre le réseau ; 4 fils suffisent pour 30 cas, et concurrence se déclare par suite.
  2. On ne réessaie que ce qui peut réussir. Un 429 ou un 503 est transitoire ; un 400 (requête invalide) ou un 401 (clé refusée) échouera à l'identique. L'aléa dans l'attente évite que 4 fils réessaient à la même milliseconde.
  3. La clé de cache contient tout ce qui peut changer la sortie. Y compris l'empreinte du code source de la cible : modifier support.py invalide son cache sans que personne ait à penser à incrémenter un numéro de version.
  4. Le coût d'un cas est mesuré dans le fil qui l'exécute et rangé avec la sortie. Un cas servi par le cache garde donc son coût réel, et la comparaison de coût entre deux modèles reste juste même quand rien n'est recalculé.

La mise en œuvre

files/evalh/runner.py (extrait)
key = cache.key(kind="cible", cible=conf["cible"], version=app_version, model=model,
                prompt=prompt_sha, input=case.input)

def call_target() -> dict[str, Any]:
    budget.start_case()
    try:
        out = target(case.input, model=model, client=client, prompt=prompt)
    finally:
        cost = budget.end_case()
    return {"output": out, "cout_usd": cost}

hit, row["cached"] = cache.get_or_compute(key, call_target)

start_case ouvre un compteur dans un threading.local : chaque fil additionne ses propres appels, sans verrou ni mélange entre cas. Le compteur global, lui, est protégé par un verrou et sert au plafond :

files/evalh/llm.py (extrait)
def record(self, model: str, tokens_in: int, tokens_out: int) -> float:
    price = PRICES.get(model)
    if price is None:
        self.unknown_models.add(model)
        price = max(PRICES.values())   # modèle sans prix : on surestime plutôt que l'inverse
    cost = (tokens_in * price[0] + tokens_out * price[1]) / 1e6

Les jetons de réflexion (thoughts_token_count) sont facturés comme de la sortie et comptés comme tels. Sur un simple tri, gemini-3.8-flash en a produit 162 pour 7 jetons de réponse lors du premier essai : c'est là que part l'argent.

Lancez la première exécution réelle :

make run        # A puis B ; on regarde ici la partie A

Résultat obtenu (2026-10-15)

triage · gemini-3.8-flash · 30 cas · {'ok': 30, 'erreur': 0, 'budget': 0} · dépensé 0.1814 USD · cache 0/120 · 102.1 s
  coût de l'application seule : 0.0495 USD, soit 1.65 USD / 1000 cas
triage · gemini-3.5-flash-lite · 30 cas · {'ok': 30, 'erreur': 0, 'budget': 0} · dépensé 0.1321 USD · cache 30/120 · 82.3 s
  coût de l'application seule : 0.0133 USD, soit 0.44 USD / 1000 cas

Trois lectures. L'évaluation coûte plus cher que l'application : sur 0,1814 USD dépensés pour A, 0,0495 vont à l'application et 0,1319 au juge et aux embeddings, soit 73 %. Le cache 30/120 de B, ce sont les 30 embeddings des réponses de référence, déjà calculés pendant A. Et la même commande relancée sans changement (ici, rejouée dans la simulation de CI de la leçon 3.2) :

triage · gemini-3.8-flash · 30 cas · {'ok': 30, 'erreur': 0, 'budget': 0} · dépensé 0.0000 USD · cache 120/120 · 0.0 s
  coût de l'application seule : 0.0495 USD, soit 1.65 USD / 1000 cas

Votre clé autorise environ 60 requêtes par minute. Un cas coûte 4 appels (cible, juge, deux embeddings) d'environ 4 secondes chacun. Quelle concurrence choisissez-vous ?

Ce qui casse en production

  • Sorties différentes à temperature 0. Le même jour, même modèle, même prompt, trois exécutions de gemini-3.8-flash ont donné une catégorie exacte de 0,967, 1,000 et 1,000 ; gemini-3.5-flash-lite une priorité de 0,933, 0,933 et 0,867. La température 0 réduit l'aléa, elle ne le supprime pas. Le cache rend une exécution rejouable, il ne la rend pas représentative : pour mesurer cette variance, exécutez sans cache (--no-cache) plusieurs fois et regardez l'écart, avant de fixer vos marges.
  • Cache périmé. Une clé qui oublie un ingrédient sert d'anciennes sorties à une nouvelle version : la porte compare la référence avec elle-même et passe. Ici le code de la cible est haché, mais seulement son module (ponytail: noté dans le code) ; un module importé par la cible doit être ajouté à la clé. La clé n'inclut pas non plus le fournisseur (API Gemini ou Vertex AI) : pour comparer les deux, passez --no-cache.
  • Erreurs mises en cache. Une exception mise en cache rendrait un 503 passager définitif. get_or_compute n'écrit qu'après succès, et un test le vérifie.
  • Dépassement de budget. Le plafond est vérifié avant chaque appel ; les appels déjà en vol se terminent. Le dépassement maximal vaut donc environ la concurrence multipliée par le coût d'un appel : réglez budget_usd en conséquence.

Une PR remplace le prompt de tri. La porte passe : 0 cas qui changent, 0,0000 USD dépensés pour le candidat, cache 120/120. Que vérifiez-vous en premier ?

La règle de décision

Mettez un cache dès la deuxième exécution, avec dans la clé le modèle, le prompt, le code et l'entrée. Plafonnez chaque exécution en dollars, pas en nombre d'appels : le prix par appel varie d'un facteur 3 entre deux modèles. Surveillez trois chiffres dans summary.json : cout_cible_usd (ce que coûtera la production), cout_usd (ce que coûte la mesure) et le taux de cache.

Pratique

Exercice. Vous voulez comparer le même modèle servi par l'API Gemini et par Vertex AI. Modifiez la clé de cache pour que les deux ne partagent jamais leurs sorties.

Voir la solution
import os

backend = "vertex" if os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").upper() == "TRUE" else "gemini-api"
key = cache.key(kind="cible", cible=conf["cible"], version=app_version, model=model,
                prompt=prompt_sha, input=case.input, backend=backend)

Faites de même dans les clés du juge et des embeddings (scorers.py), sinon la note de la version Vertex réutilisera les verdicts calculés pour la version API. Une alternative sans code : deux répertoires de cache, EVALH_CACHE=.cache-vertex make run.

  • Je règle la concurrence à partir du quota et du nombre d'appels par cas.
  • Je sais lister les ingrédients d'une clé de cache correcte, et ce qui manque à celle du projet.
  • Je distingue le coût de l'application du coût de l'évaluation dans summary.json.
  • Je sais pourquoi deux exécutions à temperature 0 peuvent différer, et comment le mesurer.
التالي