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 :
- 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
concurrencese déclare par suite. - 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.
- La clé de cache contient tout ce qui peut changer la sortie. Y compris l'empreinte du code source de la cible : modifier
support.pyinvalide son cache sans que personne ait à penser à incrémenter un numéro de version. - 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
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 :
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]) / 1e6Les 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 ARé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 casTrois 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 casVotre 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 ?
Le débit utile est plafonné par le quota, pas par la machine. Au-delà, chaque fil supplémentaire produit des 429, des attentes exponentielles et une durée totale plus longue. Le parallélisme ne change pas les sorties (chaque cas est indépendant et l'ordre des résultats est conservé par pool.map) ; la non-reproductibilité vient du modèle lui-même.
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-flashont donné une catégorie exacte de 0,967, 1,000 et 1,000 ;gemini-3.5-flash-liteune 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_computen'é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_usden 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 ?
Un changement de prompt change prompt_sha, donc la clé de cache : au moins les 30 appels de la cible devraient être recalculés. Un cache plein trahit une exécution qui n'a pas pris la modification en compte, par exemple un prompt passé par --prompt dans une étape et oublié dans l'autre. Comparez prompt_sha dans les deux summary.json. Deux prompts différents qui donnent 30 sorties identiques au caractère près sont très improbables ; un budget nul aurait produit des cas « budget », pas un cache plein.
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.