Coût, taux de cache et agent ADK : ce que la convention ne mesure pas
Le contrôle de gestion d'Ondine pose deux questions par mois : combien coûte l'assistant par jour, et le cache de réponses mis en place en septembre rapporte-t-il quelque chose ? Les conventions gen_ai donnent les tokens, pas les dollars, et rien sur un cache applicatif. Et l'agent de diagnostic de ligne, construit avec ADK, appelle le modèle par lui-même : ses tokens n'apparaissent dans aucun compteur écrit à la main. Cette leçon ajoute les trois mesures qui manquent et corrige un piège trouvé en écrivant ce projet : ADK et l'application publient la même métrique avec des bornes d'histogramme incompatibles.
Le mécanisme
flowchart LR
Q["POST /question"] --> K{"Cache<br/>(question normalisée)"}
K -->|"hit"| R["Réponse immédiate"]
K -->|"miss"| L["llm.appeler<br/>span chat + tokens"]
A["POST /agent"] --> ADK["Runner ADK<br/>spans et métriques natifs"]
ADK --> CB["after_model_callback<br/>tokens + coût"]
L --> U["enregistrer_usage()"]
CB --> U
U --> T["gen_ai.client.inference.usage.*"]
U --> C["assistant.llm.cost.usd"]
K --> H["assistant.cache.requests<br/>cache.result = hit | miss"]
Un seul point, enregistrer_usage(), convertit l'usage en tokens et en coût, que l'appel vienne du code direct ou de l'agent. Deux chemins qui calculeraient le coût chacun à sa façon divergeraient au premier changement de tarif.
La mise en œuvre
1. Le coût, à partir d'une table de prix datée
PRIX = {
"gemini-3.5-flash-lite": {"input": 0.30e-6, "cache_read": 0.03e-6, "output": 2.50e-6},
# Tarif de lancement valable jusqu'au 2026-12-31 ; il double le 2027-01-01 (1,50 / 7,50 USD par million).
"gemini-3.8-flash": {"input": 0.75e-6, "cache_read": 0.075e-6, "output": 3.75e-6},
"gemini-2.5-flash": {"input": 0.30e-6, "cache_read": 0.03e-6, "output": 2.50e-6},
}
def cout_usd(model, entree, sortie, cache=0, reflexion=0) -> float:
p = PRIX.get(model)
if p is None:
return 0.0
return (entree - cache) * p["input"] + cache * p["cache_read"] + (sortie + reflexion) * p["output"]Prix publics de l'API Gemini, palier payant standard, relevés le 2026-10-16 sur la page de tarifs. La réflexion est payée au prix de la sortie ; les tokens lus en cache de contexte, au dixième du prix de l'entrée. Un modèle absent de la table coûte 0 : c'est volontaire, et le test de bout en bout échoue si un coût nul accompagne des tokens non nuls.
Le compteur assistant.llm.cost.usd ne porte qu'un label, gen_ai.request.model. C'est une estimation : la facture fait foi (remises, crédits, palier gratuit). L'intérêt est ailleurs : voir en cinq minutes, et non en fin de mois, qu'une modification a multiplié la dépense.
2. Le cache et son taux de succès
en_cache = etat["cache"].lire(q.question)
t.cache.add(1, {"cache.result": "hit" if en_cache else "miss"})Le cache (cache.py) compare les questions après normalisation (minuscules, accents et ponctuation retirés), avec une durée de vie (CACHE_TTL_S, 600 s par défaut) et une taille bornée. Le taux de succès vaut hit / (hit + miss). Une correspondance exacte ne reconnaît pas les reformulations ; un cache sémantique (par embeddings) le ferait, au prix d'un seuil de similarité à régler et d'un risque de servir la réponse d'une autre question. Commencez par le cache exact et mesurez : si le taux plafonne bas, le compteur le montrera.
3. L'agent ADK : ce qui est natif, ce qui manque
ADK 2.10 émet sans aucun code les spans invoke_agent, call_llm, generate_content et execute_tool, ainsi que les histogrammes gen_ai.invoke_agent.duration, gen_ai.execute_tool.duration, gen_ai.client.operation.duration et l'ancien gen_ai.client.token.usage. Il suffit que les fournisseurs globaux soient configurés (globaux=True dans configurer()). Il ne publie ni les nouveaux compteurs de tokens ni de coût. Un after_model_callback comble l'écart :
def compteur_usage(tel: Telemetrie):
"""after_model_callback : chaque appel au modèle fait par l'agent alimente tokens et coût."""
def apres_modele(callback_context, llm_response):
u = llm_response.usage_metadata
if u:
enregistrer_usage(tel, attributs_appel(SETTINGS.model), SETTINGS.model,
u.prompt_token_count or 0, u.candidates_token_count or 0,
u.cached_content_token_count or 0, u.thoughts_token_count or 0)
return None # ne modifie pas la réponse
return apres_modeleLe runner est construit une fois, au démarrage du service (lifespan), et non à chaque requête.
4. Le piège des bornes d'histogramme
Premier test de bout en bout avec l'agent, le 2026-10-16 : latence p95 du modèle à 10 000 secondes.
Prometheus : {'appels_modele': 29.0, ..., 'p95_modele_s': 10000.0, 'invocations_adk': 4.0}Les buckets de gen_ai_client_operation_duration_seconds lus dans Prometheus expliquent tout : deux scopes publient la même métrique avec des bornes différentes.
le=0.64 otel_scope_name=assistant_obs 1
le=1.28 otel_scope_name=assistant_obs 19
le=5.0 otel_scope_name=gcp.vertex.agent 8
le=10.0 otel_scope_name=gcp.vertex.agent 8
...
le=10000.0 otel_scope_name=gcp.vertex.agent 8ADK crée son histogramme sans bornes conseillées : le SDK applique ses bornes par défaut (0, 5, 10, 25… 10 000), pensées pour des millisecondes, alors que l'unité est la seconde. histogram_quantile additionne des buckets qui ne se correspondent pas et renvoie n'importe quoi. Une vue du SDK impose les mêmes bornes à toutes les durées gen_ai, quel que soit le code qui les crée :
vue = View(instrument_name="gen_ai.*.duration",
aggregation=ExplicitBucketHistogramAggregation(BORNES_DUREE))
mesures = MeterProvider(resource=ressource, metric_readers=[lecteur], views=[vue])Après correction, même charge : 'p95_modele_s': 2.24. Un test (test_observabilite.py, test_vue_aligne_les_bornes_des_durees_adk) crée un histogramme dans le scope d'ADK et vérifie ses bornes.
La vue a un effet secondaire : le SDK 1.42 journalise Views ... will cause conflicting metrics identities, parce qu'il compare les noms d'instruments sans tenir compte du scope. Prometheus reçoit pourtant deux séries distinctes. telemetrie.py tait ce seul journal, avec la raison en commentaire.
Résultat obtenu
Le service lancé sur Vertex AI (make run avec GOOGLE_GENAI_USE_VERTEXAI=TRUE), le 2026-10-16 :
$ curl -s localhost:8088/question -d '{"question":"Où trouver mon code RIO ?","client":"C-1001"}' ...
{"reponse":"Vous pouvez obtenir votre code RIO très facilement en composant gratuitement le 3179 depuis votre
ligne mobile. Si vous rencontrez des difficultés, je peux vous transférer vers un conseiller pour vous aider.",
"cache":false} [1.50 s]
$ (même question)
{"reponse":"Vous pouvez obtenir votre code RIO ...","cache":true} [0.002 s]
$ curl -s localhost:8088/agent -d '{"question":"Ma ligne fixe ne fonctionne plus.","client":"C-1042"}' ...
{"reponse":"Bonjour, un incident collectif est actuellement en cours sur le nœud de raccordement de
Saint-Brieuc, ce qui explique la coupure de votre ligne. Le rétablissement du service est estimé pour
aujourd'hui à 18h00.","cache":false} [2.88 s]
$ curl -s -o /dev/null -w '%{http_code}' localhost:8088/question -d '{"question":"x"}'
422La deuxième réponse sort du cache en 2 ms au lieu de 1,5 s, pour un coût nul. L'agent a appelé etat_ligne("C-1042") et reformulé la panne simulée ; la question trop courte est rejetée par la validation Pydantic avant tout appel au modèle.
Sur une charge de 100 requêtes (make e2e, leçon 4), 29 appels au modèle, dont ceux de l'agent, ont coûté 0,0039 USD : le compteur de coût inclut bien les appels faits par ADK.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Latence p95 aberrante (10 000 s) dès qu'un agent ADK tourne | deux jeux de bornes sous le même nom de métrique | vue gen_ai.*.duration avec les bornes de la convention, testée |
| Coût affiché divisé par deux à partir du 1er janvier | la table de prix n'a pas suivi le changement de tarif de gemini-3.8-flash | date de validité en commentaire, revue trimestrielle, alerte « coût nul avec tokens non nuls » pour les modèles absents |
| Les dépenses de l'agent n'apparaissent pas | ADK ne publie pas de coût ; personne n'a branché de callback | after_model_callback qui passe par la même fonction que le code direct |
| Après une mise à jour des conditions de résiliation, l'assistant répond encore l'ancienne règle | réponses en cache pendant CACHE_TTL_S | TTL court sur les sujets réglementaires, purge du cache au déploiement d'un nouveau prompt |
| Taux de cache flatteur, mais en test seulement | le générateur de charge répète les mêmes 16 questions | mesurer le taux sur le trafic réel ; en test de charge, CACHE_TTL_S=0 pour mesurer le modèle |
La règle de décision
Mesurez le coût dans l'application dès qu'une équipe doit répondre « combien et pourquoi » plus vite que la facture mensuelle ; gardez une seule fonction de calcul et une table de prix datée. Instrumentez un cache dès sa mise en place, sinon personne ne saura s'il faut le garder. Pour un framework d'agents, prenez ce qu'il émet nativement et comblez les manques par ses points d'extension (callbacks), sans dupliquer ses spans. Surveillez la cohérence des bornes d'histogramme dès qu'une seconde bibliothèque publie sous le même nom.
Pratique
Le taux de succès du cache passe de 70 % à 5 % le jour d'un déploiement, sans changement de trafic. Quelle cause vérifiez-vous d'abord ?
Une chute brutale coïncidant avec un déploiement pointe vers le code du cache : si la clé change (normalisation, ajout du client dans la clé), plus rien ne correspond. Une panne de Gemini augmenterait les erreurs, pas les échecs de cache ; la rétention n'influe pas sur un taux calculé sur 5 minutes.
Un collègue propose de compter les tokens de l'agent en ajoutant, en plus du callback, l'instrumentation google-genai d'OpenTelemetry. Que répondez-vous ?
Deux instrumentations sur le même chemin produisent deux mesures du même appel : tokens et coût doublés. Diviser par deux dans Grafana ne tient que tant que toutes les requêtes passent par les deux chemins, ce qui n'est pas le cas de /question.
Exercice. La direction veut savoir combien rapporte le cache. Écrivez la requête PromQL qui estime les dollars économisés par heure, en supposant qu'une réponse servie depuis le cache aurait coûté le coût moyen d'un appel au modèle.
Voir la solution
Coût moyen d'un appel × nombre de succès de cache par heure :
(
sum(rate(assistant_llm_cost_usd_total{service_name="assistant-support"}[1h]))
/ sum(rate(gen_ai_client_operation_duration_seconds_count{service_name="assistant-support", error_type=""}[1h]))
)
* sum(rate(assistant_cache_requests_total{service_name="assistant-support", cache_result="hit"}[1h])) * 3600Deux limites à annoncer avec le chiffre : le coût moyen inclut les appels de l'agent, plus longs que ceux de /question, ce qui surestime l'économie ; et les questions en cache sont les plus fréquentes, souvent les plus courtes, ce qui la surestime encore. Le chiffre est un ordre de grandeur. Pour un chiffre exact, enregistrez le coût de la réponse au moment de la mise en cache et additionnez-le à chaque succès.
- Je sais calculer un coût estimé à partir de l'usage, réflexion et cache compris.
- Je sais mesurer le taux de succès d'un cache applicatif.
- Je sais compléter les métriques natives d'ADK par un
after_model_callback. - Je sais diagnostiquer et corriger des bornes d'histogramme incompatibles avec une vue.