Lección 3 — Instrumenter

Coût, taux de cache et agent ADK : ce que la convention ne mesure pas

9 min de lectura

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

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

files/src/assistant_obs/couts.py (extrait)
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

files/src/assistant_obs/app.py (extrait)
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 :

files/src/assistant_obs/agent.py (extrait)
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_modele

Le 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 8

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

files/src/assistant_obs/telemetrie.py (extrait)
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"}'
422

La 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ômeCauseParade
Latence p95 aberrante (10 000 s) dès qu'un agent ADK tournedeux jeux de bornes sous le même nom de métriquevue gen_ai.*.duration avec les bornes de la convention, testée
Coût affiché divisé par deux à partir du 1er janvierla table de prix n'a pas suivi le changement de tarif de gemini-3.8-flashdate 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 pasADK ne publie pas de coût ; personne n'a branché de callbackafter_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ègleréponses en cache pendant CACHE_TTL_STTL court sur les sujets réglementaires, purge du cache au déploiement d'un nouveau prompt
Taux de cache flatteur, mais en test seulementle générateur de charge répète les mêmes 16 questionsmesurer 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 ?

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 ?

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])) * 3600

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