← Notes

Observabilité d'agents : une trace par tâche, un coût par étape

Un agent non tracé est une boîte noire qui facture. Le modèle de trace que j'utilise en production sur un agent juridique : Langfuse pour les appels LLM, le coût calculé à côté, et un traçage qui ne peut pas faire tomber la production.

  • agents
  • observabilité
  • Langfuse
  • coûts

Prenons un message type, de ceux qui arrivent un jour sur tout agent en production : « l’agent a mis trois minutes sur le contrat de ce matin, et il est passé à côté de la clause de responsabilité. » Vous ouvrez le code. Entre le fichier déposé et le document rendu, il y a une chaîne d’étapes : extraction, triage, vérification d’ancrage, correction, relecture, commentaire, résumé. Laquelle a été lente ? Laquelle a échoué en silence ? Laquelle pèse le plus lourd sur la facture du mois ?

Sans traces, aucune réponse : vous relancez l’agent à la main, ajoutez des print et espérez reproduire, pendant qu’il continue de tourner en production.

Quatre symptômes d’un agent non observé

  • La panne est racontée, pas mesurée. Le seul signal est un utilisateur mécontent, plusieurs heures après.
  • L’échec est global. « L’audit a échoué », mais extraction, triage, correction et résumé sont des étapes distinctes, avec des causes distinctes.
  • La facture est un total mensuel. Vous savez ce que le projet coûte, jamais quelle étape le coûte, ni si c’est le prompt changé mardi qui a doublé la note.
  • La qualité ne se compare pas. Deux versions de prompt cohabitent, personne ne peut dire laquelle a tourné sur quels dossiers.

Le point commun : l’unité d’observation est le service, alors que l’unité qui intéresse le métier est la tâche : un contrat audité, une demande traitée.

Le modèle de trace : tâche, étape, appel

Trois niveaux suffisent, et ils se calquent sur le vocabulaire de Langfuse, l’outil de traçage des appels LLM que j’utilise :

  • Trace = une tâche métier complète. Sur l’agent de relecture contractuelle des notes précédentes, c’est un audit de contrat : audit:<nom du fichier>, avec le modèle utilisé et la provenance du document en métadonnées.
  • Span (un segment de trace) = une étape, y compris celles qui n’appellent aucun modèle, avec sa durée, son statut et sa version de prompt.
  • Generation (le type que Langfuse réserve aux appels de modèle) = un appel LLM, avec son prompt, sa sortie, son modèle et ses tokens.

L’arbre d’un audit, simplifié :

trace  audit:<fichier>                       model=… · source=…
├─ span        audit.stage extraction        extract-v2
├─ generation  audit_extract_chunk_k_of_n    extraction, une par segment de contrat
├─ span        audit.stage triage            triage-v2.1
├─ generation  audit_triage                  verdict clause par clause
├─ span        audit.stage anchor            déterministe : contrôlées, rejetées
├─ generation  audit_reanchor                micro-passe bornée de ré-ancrage
├─ span        audit.stage fix               fix-v6
├─ generation  audit_fix_batch ×N            corrections, en parallèle
├─ generation  audit_placement_decider       emplacement des clauses absentes, si besoin
├─ span        audit.stage wording           wording-v1
├─ generation  audit_wording_batch ×N        relecture de la langue
├─ span        audit.stage client_comment    comment-v1
├─ generation  audit_comment_retry           commentaire client
├─ span        audit.stage aggregate         aggregate-v1
└─ generation  audit_aggregate               résumé et email
output = { ok, error, stages[{stage, ok, n}], truncated }

Chaque generation porte en métadonnée la version du prompt qui l’a produite. Comparer deux versions se fait donc dans la trace, sans repasser par le harnais d’évaluation.

Le nœud intéressant de cet arbre est celui qui a longtemps manqué. La porte d’ancrage est le garde-fou qui refuse une correction dont la citation n’est pas retrouvable mot à mot dans le contrat. Sans token consommé, elle ne produit aucune generation, et dans la première version de cette instrumentation, aucun span non plus : elle ne remontait que dans le bilan stages[] de la racine, et sa durée n’existait que dans une ligne de log. Elle a maintenant son span, avec le nombre de clauses contrôlées et rejetées. C’est ce qui évite d’accuser le modèle à tort quand un audit revient presque vide, et ce qui alimente le taux de rejet des garde-fous plus bas. Une étape déterministe mérite son span au même titre qu’un appel LLM.

L’instrumentation, en pratique

Toute l’instrumentation LLM tient à un seul endroit : la fonction qui appelle le modèle. Version simplifiée, fidèle à celle qui tourne :

def traced_generation(*, name, model, prompt_input, call, trace=None, metadata=None):
    """Une generation Langfuse par appel modèle. Ne doit JAMAIS faire échouer l'appel."""
    client = _get_client() if langfuse_enabled() else None  # clés ou SDK absents, SDK cassé
    if client is None:
        return call()
    try:
        # Parenté explicite : le contexte implicite ne traverse pas les pools de threads.
        owner = trace.root_span if (trace and trace.root_span) else client
        span = owner.start_observation(as_type="generation", name=name, model=model,
                                       input=prompt_input, metadata=metadata or {})
    except Exception:
        return call()  # traçage cassé : l'appel modèle part quand même
    try:
        response = call()
    except Exception as exc:
        try:
            span.update(level="ERROR", status_message=f"{type(exc).__name__}: {exc}")
            span.end()
        except Exception:
            pass
        raise
    try:
        span.update(output=getattr(response, "text", None),
                    usage_details=_usage(response))  # tokens entrée / sortie / total
        span.end()
    except Exception:
        pass  # une réponse déjà obtenue ne se perd jamais pour une erreur de trace
    return response

Ce code tient sur trois choix.

Le traçage s’efface au moindre problème. Sans clés, sans SDK, ou si le SDK lève, à l’ouverture du span comme à sa fermeture, l’appel modèle part et la réponse revient. La pire panne d’observabilité est celle qui emporte le service qu’elle observe.

La parenté est explicite. Le pipeline traite les lots de corrections et les segments d’extraction sur plusieurs fils d’exécution. Le contexte de trace ambiant ne les suit pas tout seul : sans passer la trace en paramètre, et sans recopier le contexte dans chaque fil, un audit se retrouve éclaté en traces orphelines, et le coût par tâche devient incalculable.

Sur Cloud Run, il faut forcer l’envoi des traces. Le SDK exporte en arrière-plan ; Cloud Run, par défaut, ne vous alloue le CPU que pendant la requête. Le fil d’export peut ne jamais tourner, et les traces disparaissent. On force donc l’envoi (flush) en fin de pipeline, y compris quand une étape bloquante échoue : c’est précisément la trace d’un audit raté qu’on veut lire. Et on déploie avec --no-cpu-throttling quand des traitements continuent après la réponse, en sachant que ce réglage fait facturer le CPU hors requête.

Le coût se calcule à côté, pas dans l’outil de trace

Langfuse reçoit les tokens. Le prix, lui, reste chez moi : une table modèle → tarif, résolue dans l’ordre variable d’environnement, document de configuration rechargeable à chaud, table par défaut. C’est elle qui me permet de dire qu’un audit coûte environ 0,60 € sur cet agent, et surtout de savoir où part cet argent, étape par étape.

Pourquoi séparer ?

Les tarifs changent plus vite que le code. Basculer un modèle un lundi matin ne doit pas demander un redéploiement pour que le coût affiché redevienne juste.

La facturation a des subtilités que la trace ignore. Les tokens de raisonnement des modèles récents sont facturés au tarif de sortie : je les ajoute aux tokens de sortie dans le calcul de coût, tout en les exposant à part pour l’analyse. Côté trace, ils ne remontent pas en tant que tels : ils sont noyés dans le total.

Le coût est une donnée sensible. Le détail en euros par étape est exposé derrière une liste d’autorisation propre, qui peut se séparer de celle des administrateurs sans toucher au code. À l’écran, une donnée absente s’affiche par un tiret, jamais par un zéro rassurant.

Un piège à connaître : un modèle sans tarif connu est compté zéro dans le total. Si rien ne le signale, un nouveau modèle passe en production et le coût total continue d’afficher un chiffre crédible, mais faux. Ici, le coût de l’audit est alors marqué incomplet, le total du tableau de bord s’affiche comme un minimum (« ≥ ») avec les modèles en cause, et un avertissement part dans les logs.

Langfuse ne voit pas tout : la couche infrastructure

Langfuse voit les appels de modèle. Il ne voit pas la requête HTTP qui a déclenché l’audit, le temps passé à attendre une instance, ni l’erreur d’API levée avant le premier prompt. L’architecture complète a donc trois couches, reliées par un même identifiant :

  • Cloud Trace pour le parcours de la requête dans le service, de l’entrée HTTP à la réponse ;
  • Cloud Logging pour des logs structurés, dont chaque ligne porte l’identifiant de trace ;
  • Langfuse pour le détail des appels LLM, rattachés à la même tâche.

Le raccord tient à un détail d’OpenTelemetry, le standard de traçage sur lequel Langfuse est bâti. Au démarrage, Langfuse installe son propre fournisseur de traces s’il n’en trouve pas. J’installe donc le mien avant lui : Langfuse le réutilise, ses generations deviennent des spans de la requête HTTP, et la trace Langfuse prend le même identifiant que la trace Cloud Trace. Les logs JSON lisent ce même identifiant sur le span courant. Deux règles complètent le dispositif. Les spans partent vers Cloud Trace expurgés de leur contenu : prompts et réponses restent dans Langfuse. Et les spans HTTP ne vont pas à Langfuse, sinon chaque rafraîchissement de statut y deviendrait une trace.

Avec cet identifiant commun, on passe d’une alerte à la requête, de la requête à ses logs, des logs à l’appel modèle fautif, sans chercher. Sur le premier audit tracé de bout en bout, la requête, ses vingt-quatre lignes de log et ses neuf appels LLM portaient le même identifiant dans les trois outils, y compris les lots traités en parallèle. Le chat de l’agent suit le même chemin : chaque tour a sa trace, et ses tokens entrent dans le calcul de coût.

Quatre écrans, trois alertes

  1. Coût par tâche et par étape, avec la courbe jour par jour et le cumul du mois. Quand un agent double de prix, c’est en général une seule étape qui dérape.
  2. Latence par étape, au 95e centile (p95), pas en moyenne : la moyenne cache exactement les cas dont les utilisateurs parlent. Sur le premier audit tracé, un seul lot de relecture a pris 95 secondes sur un audit de cinq minutes : c’est ce genre de cas que l’écran doit faire sortir.
  3. Taux d’échec par étape, comparé d’une version de prompt à l’autre.
  4. Taux de rejet des garde-fous. Une porte qui rejette tout signale un problème d’ancrage ou de format ; une porte qui ne rejette plus rien signale qu’elle est cassée.

Le premier vit dans l’application, les trois autres dans Cloud Monitoring, calculés à partir des logs structurés. Les définitions (métriques, tableau de bord, alertes) sont versionnées avec le code et appliquées par un script, pas cliquées dans une console.

Les trois alertes : taux d’échec des audits au-delà d’un seuil sur une heure, latence p95 d’une étape au-delà d’un plafond, coût journalier au-delà d’un budget. Chacune a été déclenchée une fois pour de vrai, en injectant des lignes de log synthétiques sur le service : métrique, incident, puis e-mail reçu. Une alerte jamais déclenchée en test n’est pas une alerte : c’est une hypothèse.

Les trous que je connais

Côté données, le partage est explicite : prompts et sorties vont à l’outil de trace, et à lui seul ; les logs applicatifs ne portent que des métadonnées d’étape, jamais de contenu contractuel.

Reste ce que je ne prétends pas avoir réglé. Les seuils des alertes sont des premières valeurs, pas encore calibrées sur des semaines d’historique : le test prouve que la chaîne d’alerte fonctionne, pas que le seuil est le bon. Le coût du chat entre dans l’alerte de budget, mais pas encore dans le tableau de bord de l’application, qui ne compte que les audits. Langfuse peut afficher une trace avec une dizaine de minutes de retard, alors que Cloud Trace et les logs sont quasi immédiats : pendant un incident, ce sont eux qu’on ouvre en premier. Et le taux de corrections effectivement appliquées par les juristes, la mesure qui dit si l’agent sert vraiment, reste une ligne de backlog, pas un écran.

Avant de mettre un agent en production

  • Une trace = une tâche métier, nommée avec l’objet métier, pas avec la route HTTP.
  • Un span par étape, y compris les étapes déterministes : portes, validations, vérifications programmatiques.
  • Un traçage qui s’efface sans jamais lever d’erreur, ouverture et fermeture comprises, prouvé par un test, avec une parenté explicite dès qu’il y a du parallélisme.
  • Envoi forcé des traces (flush) en fin de traitement sur une plateforme qui bride le CPU hors requête.
  • Tokens capturés à la source, coût calculé hors de l’outil de trace, tarifs modifiables sans redéploiement, modèles non tarifés listés à part plutôt que comptés zéro.
  • Un identifiant de trace commun à la requête, aux logs et aux appels LLM, et la version de prompt en métadonnée de chaque étape.
  • Le contenu client ne va qu’à un seul endroit, l’outil de trace, et ce choix est écrit. Les logs ne portent que des métadonnées, des identifiants et des compteurs.
  • Quatre écrans : coût, latence p95, échecs, rejets de garde-fous. Trois alertes, versionnées avec le code et testées au moins une fois pour de vrai.

Une trace avant tout débat sur le modèle

Quand on me montre un agent existant, je demande une trace complète d’une tâche réelle. Pas une capture d’écran : une trace. Si elle n’existe pas, ou si elle s’arrête au premier appel modèle, la discussion sur la qualité du modèle est prématurée : vous ne pouvez pas encore prouver ce que l’agent fait, ni ce qu’il vous coûte.

Dans un sprint, cette instrumentation n’est pas une finition : elle part avec la première version. Chaque appel tracé, chaque étape chiffrée, les garde-fous visibles dans la trace, et un tableau de bord que votre équipe sait lire le jour où je passe la main.