درس 2 — Instrumenter

Tracer une application Gemini

8 دقائق قراءة

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

Tracer une application Gemini

Première version de l'assistant d'Arvea, première semaine : un conseiller signale des réponses coupées au milieu d'une phrase. Les journaux applicatifs montrent la question et la réponse, rien d'autre. Il manque ce qui explique la panne : combien de tokens le modèle a consommés pour réfléchir, pourquoi il s'est arrêté, avec quel prompt. Une trace Langfuse bien construite répond à ces trois questions sans relancer l'appel.

Le mécanisme : le modèle de données de Langfuse

NotionCe que c'estDans le projet
Traceune requête de bout en bout, identifiée par un trace_id OpenTelemetryune question d'un conseiller
Observationune étape dans la trace : span (durée), generation (appel de modèle), tool, agent, eventrepondre-conseiller (span), gemini (generation)
Sessionplusieurs traces d'une même conversation (session_id)un appel téléphonique client
Utilisateurqui a déclenché la trace (user_id)l'identifiant interne du conseiller
Scoreune évaluation attachée à une trace ou une observationleçon 5

Le SDK Python v4 est construit sur OpenTelemetry : chaque observation est un span OTel, exporté en OTLP/HTTP vers langfuse-web. Langfuse v4 écrit ensuite chaque observation une seule fois, dans une table où user_id et session_id sont recopiés sur chaque ligne. Conséquence pratique : ces attributs doivent être posés au moment où l'observation est créée, pas ajoutés après coup sur la trace.

flowchart TB
  T["Trace (trace_id)"] --> S["span repondre-conseiller<br/>input : la question"]
  S --> G["generation gemini<br/>model, input, output<br/>usage_details, prompt v1"]
  P["propagate_attributes<br/>user_id, session_id, tags"] -.->|"copiés sur chaque observation du bloc"| S
  P -.-> G
  M["Définition de prix<br/>gemini-3.5-flash-lite"] -.->|"le worker calcule le coût"| G

Trois façons de créer une génération coexistent :

MéthodeQuand l'utiliser
Instrumentation automatique (OpenInference, OpenTelemetry contrib)un framework que vous ne contrôlez pas : ADK, LangChain (leçon 3)
Décorateur @observeune fonction de votre code devient un span, entrée et sortie capturées
Gestionnaire de contexte start_as_current_observation(as_type="generation")vous voulez maîtriser exactement ce qui est enregistré : usage, modèle, lien vers le prompt

Pour l'application directe, ce projet choisit la troisième : l'appel Gemini est à vous, autant en contrôler la trace.

La mise en œuvre

Le code complet est dans appli_gemini.py. L'essentiel :

files/src/assistant/appli_gemini.py (extrait)
@observe(name="repondre-conseiller", capture_input=False)
def repondre(langfuse, question, *, user_id, session_id, label=None, client=None) -> Reponse:
    langfuse.update_current_span(input={"question": question})
    prompt = charger_prompt(langfuse, label or SETTINGS.prompt_label)
    fiches = rechercher(question)
    ...
    with propagate_attributes(user_id=user_id, session_id=session_id,
                              tags=["appli-gemini"], trace_name="repondre-conseiller"):
        with langfuse.start_as_current_observation(
            as_type="generation", name="gemini", model=SETTINGS.model, prompt=prompt,
            input={"system": systeme, "user": contenu}, model_parameters=parametres,
        ) as generation:
            resultat = client_gemini().models.generate_content(model=SETTINGS.model, contents=contenu,
                config=types.GenerateContentConfig(system_instruction=systeme, **parametres))
            fin = resultat.candidates[0].finish_reason if resultat.candidates else None
            generation.update(
                output=resultat.text,
                usage_details=usage_gemini(resultat.usage_metadata),
                metadata={"model_version": resultat.model_version, "finish_reason": str(fin)},
            )
            if fin == types.FinishReason.MAX_TOKENS:
                generation.update(level="WARNING", status_message="réponse tronquée (MAX_TOKENS)")

Quatre décisions sont dans ces lignes.

1. Traduire l'usage sans double comptage. Gemini renvoie prompt_token_count (qui inclut les tokens servis depuis le cache de contexte), candidates_token_count (la réponse visible) et thoughts_token_count (la réflexion, facturée au prix de la sortie). Langfuse calcule un coût par clé d'usage :

files/src/assistant/observabilite.py (extrait)
def usage_gemini(u) -> dict[str, int]:
    if u is None:
        return {}
    cache = u.cached_content_token_count or 0
    usage = {"input": (u.prompt_token_count or 0) - cache, "output": u.candidates_token_count or 0,
             "output_reasoning_tokens": u.thoughts_token_count or 0, "input_cached_tokens": cache}
    return {k: v for k, v in usage.items() if v}

Ce découpage n'a de sens que si chaque clé a un prix : la leçon 6 déclare les quatre.

2. Le modèle demandé, pas le modèle servi. Pendant la rédaction, une requête à gemini-3.5-flash est revenue avec model_version = gemini-3.6-flash. Si vous enregistrez le modèle servi comme model, la définition de prix ne correspond plus et le coût tombe à zéro. Le modèle demandé va dans model, le modèle servi dans les métadonnées.

3. Signaler la troncature. finish_reason == MAX_TOKENS passe la génération au niveau WARNING : un filtre sur ce niveau dans l'interface liste toutes les réponses coupées.

4. Fermer proprement. Le SDK envoie les spans par lots en arrière-plan. Un script court qui se termine sans langfuse.flush() (ou shutdown()) perd ses dernières traces. __main__.py appelle shutdown() en fin de commande ; une application web le ferait à l'arrêt du serveur.

Résultat obtenu

make run du 2026-10-15 (extrait, application directe) :

[appli Gemini | prompt v1] Le conseiller ne doit jamais saisir un RIB dicté par téléphone. Le client doit
envoyer son nouveau RIB directement depuis son espace adhérent. La modification sera prise en compte sous 5 jours ouvrés.

La génération lue par l'API Observations v2 (/api/public/v2/observations?fields=core,basic,model,usage,prompt) :

gemini  gemini-3.5-flash-lite  usage {'input': 196, 'output': 47, 'total': 243}
        cost  {'input': 5.88e-05, 'output': 0.0001175, 'total': 0.0001763}
Génération « gemini » dans Langfuse : modèle, prompt v1 relié, instruction système, fiches et question

Capture réelle : la génération porte le modèle, le coût (0,000188 USD), le lien vers conseiller-reponse - v1, et l'entrée exacte envoyée au modèle.

Ce qui casse en production

SymptômeCause constatéeParade
Réponses coupées en milieu de phrasepremière version du code : max_output_tokens=400 avec gemini-3.5-flash, qui a consommé 103 à 213 tokens de réflexion par appel avant d'écrirebudget de sortie qui inclut la réflexion (1 024 ici), alerte sur finish_reason
La clé secrète Langfuse apparaît dans l'entrée de la trace@observe sérialise par défaut tous les arguments ; le client Langfuse passé en paramètre a été enregistré avec ses attributs, clé comprise (constaté sur ce projet)capture_input=False et une entrée choisie ; un test e2e qui cherche la clé dans les traces
Coût à zéro sur certaines générationsmodèle enregistré sous un nom sans définition de prix (modèle servi, préfixe models/, alias)model = nom demandé ; motif de correspondance qui accepte models/
Dernières traces absentes après un traitement par lotprocessus terminé avant l'envoi du lotflush() ou shutdown() en fin de processus
user_id absent des générationsattribut posé sur la trace après la création des observationspropagate_attributes autour du code qui crée les observations

La règle de décision

Instrumentez à la main l'appel de modèle que vous écrivez vous-même, avec le gestionnaire de contexte : vous contrôlez usage, modèle et prompt. Réservez @observe aux fonctions dont les arguments sont des données simples, et passez capture_input=False dès qu'un argument est un objet technique (client, connexion, session HTTP). Surveillez la proportion de générations en WARNING et la part des tokens de réflexion dans le coût.

Pratique

Un développeur d'Arvea propose de remplacer la génération manuelle par GoogleGenAIInstrumentor, « moins de code ». L'application directe et l'agent ADK tournent dans le même processus. Que répondez-vous ?

Le tableau de bord montre 12 % de générations au niveau WARNING depuis le passage à un nouveau modèle, sans changement de prompt. Quelle hypothèse vérifiez-vous en premier ?

Exercice. Ajoutez à repondre une fonction de masquage qui remplace tout numéro de sécurité sociale français (13 chiffres, éventuellement espacés, plus une clé de 2 chiffres) par [NIR] avant envoi à Langfuse, sans modifier ce qui est envoyé à Gemini.

Voir la solution

Le masquage se déclare à la création du client, il s'applique à toutes les entrées, sorties et métadonnées :

import re
from langfuse import Langfuse

NIR = re.compile(r"\b[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}(\s?\d{2})?\b")

def masquer(*, data, **_):
    if isinstance(data, str):
        return NIR.sub("[NIR]", data)
    if isinstance(data, dict):
        return {k: masquer(data=v) for k, v in data.items()}
    if isinstance(data, list):
        return [masquer(data=v) for v in data]
    return data

langfuse = Langfuse(mask=masquer)

Gemini reçoit toujours le texte complet : le masquage n'agit que sur ce qui part vers Langfuse. Ajoutez un test unitaire qui vérifie masquer(data="1 85 05 75 123 456 78").

  • Je sais distinguer trace, observation, génération, session et utilisateur.
  • Je sais enregistrer une génération Gemini avec un usage qui sépare réponse, réflexion et cache.
  • Je sais empêcher @observe de capturer un objet sensible.
  • Je sais repérer une réponse tronquée sans relire les conversations.
التالي