درس 2 — Instrumenter

Tracer et mesurer chaque appel au modèle selon les conventions gen_ai

9 دقائق قراءة

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

Tracer et mesurer chaque appel au modèle selon les conventions gen_ai

Chez Ondine, deux équipes ont instrumenté leurs assistants chacune de son côté. L'une publie llm_latency_ms, l'autre model_call_seconds ; l'une compte les tokens de réflexion à part, l'autre pas du tout. Le jour où la direction demande la consommation totale de tokens, il faut une requête différente par équipe, et aucun tableau de bord communautaire ne fonctionne. Les conventions sémantiques OpenTelemetry pour l'IA générative (préfixe gen_ai) existent pour cela : des noms, des unités et des attributs communs à tous les fournisseurs et tous les frameworks, y compris ADK qui les émet déjà.

Le mécanisme : un span et quatre instruments par appel

Pour chaque appel au modèle, le service produit un span (une étape chronométrée d'une trace) et alimente des métriques (des agrégats sans détail individuel) :

flowchart TB
  Q["POST /question<br/>span SERVER (FastAPI)"] --> C["chat gemini-3.5-flash-lite<br/>span CLIENT"]
  C --> D["gen_ai.client.operation.duration<br/>histogramme, secondes"]
  C --> I["gen_ai.client.inference.usage.input_tokens<br/>compteur, cache compris"]
  C --> O["gen_ai.client.inference.usage.output_tokens<br/>compteur, réflexion comprise"]
  C --> R["...cache_read.input_tokens<br/>...reasoning.output_tokens<br/>sous-ensembles"]

Les conventions GenAI ont quitté le dépôt principal de spécifications pour un dépôt dédié, open-telemetry/semantic-conventions-genai, et restent au statut Development (relevé le 2026-10-15). Elles changent encore : le compteur de tokens est passé d'un histogramme unique gen_ai.client.token.usage à une famille de compteurs gen_ai.client.inference.usage.*. ADK 2.10 émet encore l'ancien histogramme ; ce service émet les nouveaux compteurs. Épinglez la version des conventions que vous suivez et testez les noms (section « Mise en œuvre », étape 4).

Les champs de Gemini ne correspondent pas un pour un à la convention :

usage_metadata de GeminiConventionConversion
prompt_token_countinput_tokensidentique : Gemini inclut déjà les tokens lus en cache
candidates_token_countoutput_tokensajouter thoughts_token_count : la convention compte la réflexion dans la sortie, Gemini la sépare
cached_content_token_countcache_read.input_tokenssous-ensemble de l'entrée
thoughts_token_countreasoning.output_tokenssous-ensemble de la sortie

Oublier la réflexion, c'est sous-estimer la sortie, et donc le coût, des modèles qui réfléchissent : un simple « bonjour » à gemini-3.8-flash a consommé 4 tokens de réponse et 246 tokens de réflexion lors d'un essai sur Vertex AI le 2026-10-16.

La mise en œuvre

1. Les fournisseurs, injectables

files/src/assistant_obs/telemetrie.py (extrait)
def configurer(*, span_exporter=None, metric_reader=None, log_exporter=None, globaux=True) -> Telemetrie:
    """Sans argument : exportateurs OTLP/HTTP (OTEL_EXPORTER_OTLP_ENDPOINT). Les tests injectent les leurs."""
    ressource = Resource.create({"service.name": SETTINGS.service, "service.version": "1.0.0",
                                 "service.instance.id": f"{socket.gethostname()}-{os.getpid()}",
                                 "deployment.environment.name": SETTINGS.environnement})
    traces = TracerProvider(resource=ressource)
    traces.add_span_processor(BatchSpanProcessor(span_exporter or OTLPSpanExporter()))
    ...
    duree = m.create_histogram("gen_ai.client.operation.duration", unit="s",
                               explicit_bucket_boundaries_advisory=BORNES_DUREE)
    tokens_entree = m.create_counter("gen_ai.client.inference.usage.input_tokens", unit="{token}")

Trois choix :

  • service.instance.id distingue deux processus du même service. Sans lui, deux répliques écrivent la même série Prometheus et leurs compteurs s'écrasent ; le test de bout en bout s'en sert aussi pour isoler les mesures de son propre processus (leçon 4).
  • Les bornes de l'histogramme sont celles que recommande la convention (de 0,01 à 81,92 secondes, en doublant). Les bornes par défaut du SDK vont de 0 à 10 000 : pensées pour des millisecondes, elles mettraient tous les appels dans le premier intervalle.
  • Les exportateurs injectables permettent aux tests de remplacer OTLP par des exportateurs en mémoire, sans réseau.

2. L'appel instrumenté

files/src/assistant_obs/llm.py (extrait)
def appeler(tel, question, *, modele=None, nom=None) -> ReponseModele:
    attrs = attributs_appel(nom)   # gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model
    with tel.tracer.start_as_current_span(f"chat {nom}", kind=SpanKind.CLIENT,
                                          attributes={**attrs, "server.address": serveur}) as span:
        debut = time.perf_counter()
        try:
            r = (modele or modele_par_defaut()).generer(nom, question)
        except Exception as e:
            type_erreur = str(e.code) if isinstance(e, ErreurModele) else type(e).__qualname__
            tel.duree.record(time.perf_counter() - debut, {**attrs, "error.type": type_erreur})
            span.set_status(Status(StatusCode.ERROR, type_erreur))
            raise
        tel.duree.record(time.perf_counter() - debut, {**attrs, "gen_ai.response.model": r.modele_servi})
        cout = enregistrer_usage(tel, attrs, nom, r.entree, r.sortie, r.cache, r.reflexion)
  • Le nom du span suit la convention : {opération} {modèle}.
  • gen_ai.provider.name vaut gcp.gemini avec une clé AI Studio et gcp.vertex_ai quand GOOGLE_GENAI_USE_VERTEXAI=TRUE : les deux facturations restent séparables.
  • Les erreurs sont mesurées dans le même histogramme, distinguées par error.type (429, 503, ou le nom de l'exception). Le taux d'erreur se calcule alors sans compteur supplémentaire, et un appel qui échoue après 30 secondes pèse dans la latence, comme pour l'utilisateur qui l'a attendu.
  • Les tokens ne sont comptés qu'en cas de succès. La convention recommande d'enregistrer aussi les échecs, avec la valeur 0 et gen_ai.token.modality="unknown" ; ce service s'en écarte délibérément, pour que la modalité unknown n'apparaisse pas dans les labels. Le nombre d'échecs reste lisible dans l'histogramme de durée.

3. Ce qui reste hors des métriques

L'identifiant du client va sur le span (assistant.client_id), jamais en label de métrique. Chaque valeur distincte d'un label crée une série Prometheus : 60 000 clients, c'est 60 000 séries par métrique et par bucket d'histogramme. Le span, lui, est stocké une fois, et Tempo le retrouve par recherche.

Le texte du prompt et de la réponse ne quitte jamais le service : le code ne le met dans aucun attribut, et le collecteur supprime gen_ai.input.messages et gen_ai.output.messages si une bibliothèque les ajoute (leçon 1).

4. Tester les noms sans réseau

files/tests/test_observabilite.py (extrait)
def test_appel_reussi_conventions_genai(tel):
    r = llm.appeler(tel, "Comment résilier ?", modele=llm.ModeleFaux(0.0, graine=1, dormir=False),
                    nom="gemini-3.5-flash-lite")
    tel.fournisseurs[0].force_flush()  # BatchSpanProcessor : exporter avant de lire
    (span,) = tel.spans.get_finished_spans()
    assert span.name == "chat gemini-3.5-flash-lite" and span.kind == SpanKind.CLIENT
    m = points(tel.lecteur)
    assert m["gen_ai.client.inference.usage.input_tokens"][0].value == r.entree

ModeleFaux simule latences (loi log-normale, médiane d'environ 0,67 s), tokens et erreurs 503 à un taux réglable. Il sert aux tests, à la démonstration d'alerte et à l'intégration continue, sans clé ni coût.

Résultat obtenu

$ make test
..........                                                               [100%]
10 passed, 2 warnings in 1.15s

Un span réel lu dans Tempo le 2026-10-16, service exécuté sur Vertex AI (nom d'hôte masqué) :

resource : service.name=assistant-support, service.instance.id=poste-dev-2022412,
           deployment.environment.name=lab, telemetry.sdk.version=1.42.1
chat gemini-3.5-flash-lite  SPAN_KIND_CLIENT  1,14 s
   gen_ai.operation.name    chat
   gen_ai.provider.name     gcp.vertex_ai
   gen_ai.request.model     gemini-3.5-flash-lite
   gen_ai.response.model    gemini-3.5-flash-lite
   gen_ai.usage.input_tokens  55
   gen_ai.usage.output_tokens 54
   assistant.cost_usd       0.0001515
   server.address           aiplatform.googleapis.com

Côté Prometheus, les noms sont traduits par l'exportateur du collecteur : points remplacés par des soulignés, unité ajoutée en suffixe, _total sur les compteurs.

Instrument OpenTelemetrySérie Prometheus
gen_ai.client.operation.duration (s)gen_ai_client_operation_duration_seconds_bucket, _count, _sum
gen_ai.client.inference.usage.input_tokens ({token})gen_ai_client_inference_usage_input_tokens_total
assistant.llm.cost.usd ({USD})assistant_llm_cost_usd_total

Une unité entre accolades est une annotation : Prometheus ne l'ajoute pas au nom. Avec unit="USD", la série se serait appelée assistant_llm_cost_USD_total.

Ce qui casse en production

SymptômeCauseParade
Prometheus ralentit, la mémoire grimpe chaque jouridentifiant client, de session ou de question en label de métriqueidentifiants sur les spans ; labels de métriques limités à des valeurs bornées (modèle, résultat, code d'erreur)
Coût sous-estimé d'un tiers sur un modèle qui réfléchitthoughts_token_count oublié dans la sortiesortie = candidats + réflexion, testé
Les dernières secondes de données manquent après chaque déploiementfournisseurs non arrêtés : les tampons du BatchSpanProcessor et du lecteur périodique ne sont pas vidésarreter() dans le lifespan de FastAPI
Un panneau devient vide après une mise à jour d'une bibliothèqueconventions au statut Development, nom de métrique changéversions épinglées, test qui vérifie les noms émis et ceux du tableau de bord
Le texte des conversations apparaît dans Tempoune instrumentation tierce capture le contenu par défautsuppression au collecteur, en plus du réglage de la bibliothèque

La règle de décision

Suivez les conventions gen_ai pour tout ce qu'elles couvrent (durée, tokens, attributs de span) : vous héritez des tableaux de bord et des intégrations qui les connaissent, et les métriques d'ADK s'additionnent aux vôtres. Préfixez par le nom de votre application (assistant.) ce qu'elles ne couvrent pas, comme le coût. Surveillez le statut des conventions à chaque montée de version : tant qu'elles sont en Development, un renommage est possible.

Pratique

Le produit veut un graphe du nombre de questions par client pour repérer les gros utilisateurs. Un développeur propose d'ajouter le label client_id au compteur de tokens. Que faites-vous ?

Après passage de gemini-3.5-flash-lite à gemini-3.8-flash, le nombre de tokens de sortie sur le tableau de bord est stable mais la facture double. Quelle est l'explication la plus probable ?

Exercice. Ajoutez l'attribut gen_ai.request.max_tokens au span de chaque appel, et un test qui vérifie sa présence.

Voir la solution

Dans llm.appeler, l'attribut va sur le span, pas dans attrs (qui sert aussi de labels aux métriques : inutile d'y ajouter une dimension) :

with tel.tracer.start_as_current_span(f"chat {nom}", kind=SpanKind.CLIENT,
        attributes={**attrs, "server.address": serveur, "gen_ai.request.max_tokens": 1024}) as span:

La valeur doit venir de la même constante que celle passée à GenerateContentConfig(max_output_tokens=...), sinon le span ment. Test :

def test_max_tokens_sur_le_span(tel):
    llm.appeler(tel, "x", modele=llm.ModeleFaux(0.0, graine=1, dormir=False))
    tel.fournisseurs[0].force_flush()
    (span,) = tel.spans.get_finished_spans()
    assert span.attributes["gen_ai.request.max_tokens"] == 1024
  • Je sais convertir usage_metadata de Gemini vers les compteurs gen_ai.client.inference.usage.*.
  • Je sais pourquoi les erreurs sont mesurées dans l'histogramme de durée avec error.type.
  • Je sais choisir ce qui va en label de métrique et ce qui reste sur le span.
  • Je sais tester l'instrumentation avec des exportateurs en mémoire.
التالي