Intelligence Artificielle

Observabilite LLM : tracer vos agents avec Langfuse

Observabilite-Ia Langfuse Tracing-Llm Llmops Opentelemetry Cout-Llm Monitoring-Ia Evaluation-Llm Production-Ia Openai Anthropic Node-Js Typescript Agents-Ia
Observabilite LLM : tracer vos agents avec Langfuse

Tracez vos applications LLM en production avec Langfuse : traces, spans, cout par utilisateur, latence, scores et self-hosting Docker en TypeScript.

Pourquoi les logs classiques ne suffisent pas

En bref : une application LLM est non déterministe, coûteuse à la requête et composée d'étapes imbriquées. Un console.log vous donne la réponse finale ; il ne vous donne ni le prompt réellement assemblé, ni les documents récupérés, ni l'outil appelé, ni le coût, ni la raison pour laquelle la réponse d'hier était bonne et celle d'aujourd'hui ne l'est plus.

Le scénario est toujours le même. Un utilisateur signale que l'assistant « répond n'importe quoi ». Vous rejouez sa question en local : la réponse est parfaite. Vous cherchez dans les logs : vous trouvez un identifiant de requête, un code 200, une durée de 4,2 secondes. Rien qui explique quoi que ce soit, parce que ce qui compte n'est pas dans les logs — c'est le contenu exact du contexte envoyé au modèle à cet instant précis, pour cet utilisateur, avec ces documents récupérés et cet historique de conversation.

Une application traditionnelle est déterministe : mêmes entrées, mêmes sorties. Un test qui passe garantit un comportement. Une application LLM ne fonctionne pas ainsi. La même question, posée deux fois, produit deux réponses différentes ; une mise à jour de modèle côté fournisseur change le comportement sans qu'aucune ligne de votre code n'ait bougé ; un document ajouté à votre base vectorielle modifie silencieusement le contexte de milliers de requêtes.

Les quatre questions auxquelles on ne peut pas repondre sans tracing

Question Sans observabilite Avec tracing
Pourquoi cette reponse est-elle mauvaise ? Impossible de reconstituer le contexte exact Prompt complet, documents recuperes, sortie brute
Ou passe le temps de reponse ? Une duree totale, sans decomposition Duree par etape : recherche, rerank, generation
Qui consomme le budget ? Une facture mensuelle globale Cout par utilisateur, session, fonctionnalite
La qualite se degrade-t-elle ? On l'apprend par les reclamations Scores suivis dans le temps sur du trafic reel

Avant ou apres le deploiement

La confusion la plus fréquente consiste à opposer observabilité et évaluation. Ce sont deux moments distincts d'un même cycle. Promptfoo, dans l'intégration continue, vérifie qu'un changement de prompt ne casse pas les cas connus : c'est un filet de sécurité avant le déploiement, sur des données figées. Le tracing observe ce qui se passe après, sur du trafic réel, avec des questions que personne n'avait imaginées.

Les deux se nourrissent mutuellement, et c'est là que se trouve le vrai gain : chaque trace problématique repérée en production devient un cas de test dans la suite d'évaluation. Au bout de quelques mois, votre jeu de tests ne contient plus des exemples inventés au début du projet, mais les cas qui ont réellement échoué chez vos utilisateurs.

Pourquoi Langfuse plutôt qu'un autre outil dans cet article : il est open source, auto-hébergeable, non lié à un framework particulier, et bâti sur OpenTelemetry — donc compatible avec l'instrumentation que vous avez peut-être déjà. Les principes exposés ici valent pour tous les outils de la catégorie ; seule la syntaxe change.

Trace, observation, generation : le modele de donnees

En bref : une trace représente une interaction utilisateur complète. Elle contient des observations imbriquées : les spans pour les étapes de code, les generations pour les appels de modèle, les events pour les points ponctuels. Les scores s'attachent à une trace pour en qualifier la réussite.

Comprendre cette hiérarchie évite les erreurs de conception les plus coûteuses — typiquement, créer une trace par appel de modèle, ce qui rend impossible de voir qu'une seule question utilisateur a déclenché onze appels.

Concept Represente Exemple concret
TraceUne interaction complete de bout en boutUne question posee dans le chat
SpanUne etape de traitementRecherche vectorielle, reranking, appel d'outil
GenerationUn appel de modele, avec tokens et coutUn appel a gpt-4o ou a Claude
EventUn point sans dureeCache manque, garde-fou declenche
ScoreUne mesure de qualitePouce en l'air, note d'un juge automatique
SessionUn regroupement de tracesUne conversation de dix echanges

La granularité correcte se déduit d'une règle simple : une trace par action utilisateur. Si l'utilisateur pose une question et que votre système effectue une recherche, un reranking, deux appels d'outils et une génération finale, cela fait une trace et cinq observations imbriquées — pas cinq traces.

# Structure d'une trace typique d'assistant documentaire
Trace  "chat-question"                        4.2 s   0.0038 $
├─ Span        "reformulation-question"       0.4 s   0.0002 $
│  └─ Generation "gpt-4o-mini"                0.4 s   0.0002 $   180 tokens
├─ Span        "recherche-vectorielle"        0.3 s        —     8 documents
├─ Span        "reranking"                    0.6 s   0.0004 $
└─ Generation  "gpt-4o"                       2.9 s   0.0032 $   3 240 tokens

Cette vue répond instantanément à des questions qui prendraient une demi-journée avec des logs plats : la latence vient de la génération finale, pas de la recherche ; le coût est dominé par le contexte injecté ; la reformulation, elle, est presque gratuite.

Installer et brancher le SDK

En bref : le SDK JavaScript récent s'appuie sur OpenTelemetry. On enregistre un LangfuseSpanProcessor dans un fichier d'instrumentation chargé avant le code applicatif, et tout ce qui est instrumenté ensuite remonte automatiquement.

Trois clés suffisent : une clé publique, une clé secrète et l'URL de l'instance. En cloud géré, l'URL dépend de la région ; en auto-hébergé, c'est celle de votre déploiement.

# SDK de tracing base sur OpenTelemetry
npm install @langfuse/tracing @langfuse/otel @langfuse/client
npm install @opentelemetry/sdk-node @opentelemetry/api

# Integrations optionnelles selon votre pile
npm install @langfuse/openai      # enveloppe le SDK OpenAI
npm install @langfuse/langchain   # callback handler LangChain / LangGraph
# .env — jamais commite, jamais expose au navigateur
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
LANGFUSE_BASE_URL=https://cloud.langfuse.com   # ou votre instance auto-hebergee
OPENAI_API_KEY=sk-xxxxxxxx
// instrumentation.ts — DOIT etre importe avant tout code applicatif
import { NodeSDK } from '@opentelemetry/sdk-node';
import { LangfuseSpanProcessor } from '@langfuse/otel';

// Le processeur lit les cles dans l'environnement.
// On peut aussi les passer explicitement, ce qui est preferable
// quand plusieurs environnements cohabitent dans le meme processus.
export const langfuseSpanProcessor = new LangfuseSpanProcessor({
  publicKey: process.env.LANGFUSE_PUBLIC_KEY,
  secretKey: process.env.LANGFUSE_SECRET_KEY,
  baseUrl: process.env.LANGFUSE_BASE_URL,
  // Environnement : indispensable pour ne pas melanger prod et preprod
  environment: process.env.NODE_ENV ?? 'development',
});

const sdk = new NodeSDK({
  spanProcessors: [langfuseSpanProcessor],
});

sdk.start();

Le point d'entrée doit charger ce fichier en premier. Sur un serveur Node classique, c'est un simple import en tête ; sur Next.js, le fichier instrumentation.ts à la racine est chargé automatiquement par le framework.

// server.ts
import './instrumentation'; // TOUJOURS en premier, avant les autres imports
import express from 'express';
import { chatHandler } from './chat';

const app = express();
app.use(express.json());
app.post('/api/chat', chatHandler);
app.listen(3000);
En environnement serverless — fonctions Vercel, Lambda, Cloudflare Workers — le processus peut être gelé avant l'envoi des spans en arrière-plan. Il faut forcer la purge avant de rendre la réponse, sinon les traces disparaissent silencieusement. C'est de très loin la première cause de « Langfuse ne reçoit rien » alors que le code semble correct.
import { langfuseSpanProcessor } from './instrumentation';

export async function POST(request: Request) {
  const result = await runMyLlmPipeline(await request.json());

  // Purge explicite : sans cela, la fonction peut se terminer
  // avant que les spans aient ete transmis
  await langfuseSpanProcessor.forceFlush();

  return Response.json(result);
}
Version du SDK : le SDK JavaScript a migré vers une architecture OpenTelemetry avec des paquets @langfuse/*. Beaucoup de projets tournent encore sur la génération précédente, un paquet langfuse unique avec un client new Langfuse() et des méthodes trace() / generation(). Les concepts sont identiques, les imports changent : vérifiez la version de la documentation que vous suivez avant de copier du code.

Instrumenter OpenAI, Claude et les autres

En bref : pour OpenAI, une seule ligne suffit : observeOpenAI(new OpenAI()) retourne un client identique qui trace chaque appel. Pour les autres fournisseurs, on crée manuellement une observation de type generation en renseignant le modèle et l'usage de tokens.

L'enveloppe OpenAI est la voie la plus rapide vers une première trace utile. Le client retourné a exactement la même interface : aucun changement dans le reste du code.

import OpenAI from 'openai';
import { observeOpenAI } from '@langfuse/openai';

// Le client enveloppe expose la MEME API que le client d'origine
const openai = observeOpenAI(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));

const completion = await openai.chat.completions.create({
  model: 'gpt-4o-mini',
  messages: [
    { role: 'system', content: 'Tu es un assistant technique concis.' },
    { role: 'user', content: 'Explique la difference entre RAG et fine-tuning.' },
  ],
  temperature: 0.3,
});

// Prompt, reponse, modele, tokens, latence et cout : deja dans Langfuse
console.log(completion.choices[0].message.content);

On peut enrichir l'appel avec des métadonnées propres au métier, ce qui rend le filtrage exploitable ensuite dans l'interface — par fonctionnalité, par version de prompt, par locataire.

const completion = await openai.chat.completions.create(
  {
    model: 'gpt-4o-mini',
    messages,
  },
  {
    // Metadonnees Langfuse, ignorees par l'API OpenAI
    langfuseTraceName: 'support-reponse-auto',
    langfusePrompt: { name: 'support-v3' },
    metadata: {
      feature: 'inbox-suggestion',
      tenantId: 'acme-corp',
      promptVersion: 3,
    },
  }
);

Pour un fournisseur sans intégration dédiée — Anthropic, Mistral, Groq, un modèle local — on décrit soi-même la génération. C'est un peu plus verbeux, mais c'est le mécanisme sous-jacent de toutes les intégrations, et il fonctionne avec n'importe quelle API.

import Anthropic from '@anthropic-ai/sdk';
import { startObservation } from '@langfuse/tracing';

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

async function askClaude(question: string) {
  // asType: 'generation' active le calcul de cout a partir des tokens
  const generation = startObservation(
    'claude-reponse',
    {
      model: 'claude-sonnet-5',
      input: [{ role: 'user', content: question }],
      modelParameters: { temperature: 0.2, max_tokens: 1024 },
    },
    { asType: 'generation' }
  );

  try {
    const message = await anthropic.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 1024,
      temperature: 0.2,
      messages: [{ role: 'user', content: question }],
    });

    const text = message.content[0].type === 'text' ? message.content[0].text : '';

    generation.update({
      output: text,
      usageDetails: {
        input: message.usage.input_tokens,
        output: message.usage.output_tokens,
      },
    });

    return text;
  } catch (error) {
    // Les erreurs aussi doivent etre visibles dans la trace
    generation.update({
      level: 'ERROR',
      statusMessage: error instanceof Error ? error.message : 'unknown',
    });
    throw error;
  } finally {
    generation.end(); // TOUJOURS, sinon le span reste ouvert
  }
}
Le bloc finally n'est pas cosmétique. Un span jamais fermé n'apparaît pas — ou apparaît avec une durée aberrante — et vous perdez précisément la trace de la requête qui a échoué, c'est-à-dire la seule qui vous intéressait. La règle est absolue : tout startObservation() a son end() dans un finally.

Tracer un pipeline RAG etape par etape

En bref : l'intérêt du tracing apparaît vraiment sur un pipeline à plusieurs étages. En enregistrant les documents récupérés à côté de la génération, vous distinguez d'un coup d'œil un échec de recherche — les bons documents n'ont pas été trouvés — d'un échec de génération — les bons documents étaient là, le modèle les a mal exploités.

Cette distinction est le diagnostic le plus important d'un système RAG, et c'est exactement celui qu'aucun log applicatif ne permet de faire. Les deux pannes produisent le même symptôme visible : une réponse fausse.

import { startActiveObservation, startObservation, updateActiveTrace } from '@langfuse/tracing';
import { observeOpenAI } from '@langfuse/openai';
import OpenAI from 'openai';

const openai = observeOpenAI(new OpenAI());

export async function answerQuestion(question: string, userId: string) {
  // startActiveObservation ouvre la trace racine et gere le contexte :
  // toutes les observations creees a l'interieur y sont rattachees
  return startActiveObservation('rag-question', async (root) => {
    updateActiveTrace({
      name: 'rag-question',
      userId,
      input: { question },
      tags: ['rag', 'documentation'],
    });

    // ETAPE 1 — recherche vectorielle
    const retrieval = startObservation('recherche-vectorielle', {
      input: { question, topK: 8 },
    });

    const documents = await vectorStore.search(question, { topK: 8 });

    retrieval.update({
      output: documents.map((d) => ({
        id: d.id,
        title: d.title,
        score: d.score, // le score de similarite est la donnee cle du diagnostic
      })),
      metadata: { count: documents.length, bestScore: documents[0]?.score },
    });
    retrieval.end();

    // ETAPE 2 — construction du contexte
    const context = documents.map((d, i) => `[${i + 1}] ${d.content}`).join('\n\n');

    // ETAPE 3 — generation (tracee automatiquement par observeOpenAI)
    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        {
          role: 'system',
          content:
            'Reponds uniquement a partir du contexte fourni. ' +
            'Si le contexte ne contient pas la reponse, dis-le explicitement. ' +
            'Cite les sources sous la forme [1], [2].',
        },
        { role: 'user', content: `Contexte :\n${context}\n\nQuestion : ${question}` },
      ],
      temperature: 0.1,
    });

    const answer = completion.choices[0].message.content ?? '';

    updateActiveTrace({ output: { answer } });
    return { answer, traceId: root.traceId, documents };
  });
}

Deux détails de cet exemple font toute la différence à l'usage. D'abord, on enregistre les scores de similarité des documents : une trace où le meilleur score plafonne à 0,42 signale immédiatement un problème d'indexation, pas de prompt. Ensuite, on retourne le traceId à l'appelant, ce qui permettra plus loin d'y rattacher le retour de l'utilisateur.

// Instrumenter aussi ce qui ne coute rien mais explique tout
const cacheSpan = startObservation('cache-lookup', { input: { key } });
const cached = await redis.get(key);

cacheSpan.update({
  output: { hit: cached !== null },
  metadata: { ttlRemaining: cached ? await redis.ttl(key) : 0 },
});
cacheSpan.end();

if (cached) {
  // Une trace de reponse en cache doit rester visible et identifiable
  updateActiveTrace({ tags: ['cache-hit'], output: JSON.parse(cached) });
  return JSON.parse(cached);
}
Ce qu'il faut absolument tracer dans un pipeline RAG :
  • La question reformulee si vous en produisez une — c'est une source d'echec majeure et invisible
  • Les identifiants et scores des documents, jamais seulement leur nombre
  • Le prompt final assemble, pas le gabarit avant substitution
  • Les cache hits, sans quoi vos statistiques de latence sont fausses
  • Les erreurs et les retries, avec le message d'origine
  • La version du prompt utilisee, pour correler qualite et deploiement

Tracer un agent LangChain ou LangGraph

En bref : pour LangChain et LangGraph, un CallbackHandler passé dans la configuration d'invocation suffit : chaque nœud, chaque outil et chaque appel de modèle devient une observation imbriquée, sans instrumentation manuelle.

C'est sur les agents que l'observabilité passe du confort à la nécessité. Un graphe LangGraph peut boucler, appeler le même outil quatre fois, se perdre dans une branche conditionnelle — et tout cela est parfaitement invisible depuis l'extérieur, où l'on ne voit qu'une réponse finale et une facture qui grimpe.

import { CallbackHandler } from '@langfuse/langchain';
import { ChatOpenAI } from '@langchain/openai';

const langfuseHandler = new CallbackHandler();

const model = new ChatOpenAI({ model: 'gpt-4o-mini', temperature: 0 });
const app = buildMyGraph(model); // graphe LangGraph compile

const result = await app.invoke(
  { messages: [{ role: 'user', content: 'Compare nos ventes de mars et d\'avril.' }] },
  {
    callbacks: [langfuseHandler],
    // Ces metadonnees remontent au niveau de la trace
    metadata: {
      langfuseUserId: 'user_8412',
      langfuseSessionId: 'session_2b7f',
      langfuseTags: ['agent', 'analytics'],
    },
  }
);

La trace obtenue reflète la structure du graphe, ce qui rend les pathologies classiques immédiatement lisibles.

Trace "agent-analytics"                       18.4 s   0.0412 $
├─ Generation "planification"                  1.2 s   0.0021 $
├─ Span "tool:sql_query"                       0.8 s        —
├─ Generation "analyse-resultat"               2.1 s   0.0055 $
├─ Span "tool:sql_query"                       0.7 s        —      <-- meme requete
├─ Generation "analyse-resultat"               2.3 s   0.0061 $
├─ Span "tool:sql_query"                       0.9 s        —      <-- encore
├─ Generation "analyse-resultat"               2.4 s   0.0068 $
└─ Generation "synthese-finale"                8.0 s   0.0207 $

Trois appels identiques au même outil : l'agent n'a pas compris qu'il disposait déjà de la réponse. Ce diagnostic prend cinq secondes sur une trace, et reste indétectable dans des logs plats. La correction relève ensuite du prompt ou du graphe — mais encore fallait-il voir le problème.

Pour du code qui n'utilise aucun framework, le décorateur observe instrumente une fonction sans en modifier le corps.

import { observe } from '@langfuse/tracing';

// Chaque appel devient une observation, avec entree et sortie capturees
const searchProducts = observe(
  async function searchProducts(query: string, filters: Record<string, unknown>) {
    return await db.products.search(query, filters);
  },
  { name: 'tool:search-products' }
);

const runAgent = observe(
  async function runAgent(userMessage: string) {
    const plan = await planStep(userMessage);
    const products = await searchProducts(plan.query, plan.filters);
    return await answerStep(userMessage, products);
  },
  { name: 'agent-shopping', asType: 'span' }
);

Couts, tokens et attribution par utilisateur

En bref : Langfuse calcule le coût de chaque génération à partir du modèle et des tokens consommés. Attacher un userId, un sessionId et des tags transforme cette donnée brute en réponses métier : quel client coûte le plus cher, quelle fonctionnalité pèse le plus, quel prompt a fait doubler la facture.

Une facture mensuelle de fournisseur LLM est une ligne unique. Elle ne dit ni quelle fonctionnalité l'a générée, ni si dix utilisateurs consomment 80 % du budget, ni si la hausse du mois vient d'une augmentation du trafic ou d'un prompt devenu trop long. L'attribution répond à ces trois questions, et c'est généralement l'argument qui fait accepter l'outil au-delà de l'équipe technique.

import { updateActiveTrace, startActiveObservation } from '@langfuse/tracing';

export async function handleChatRequest(req: Request) {
  const { question, user, tenant, feature } = await parseRequest(req);

  return startActiveObservation('chat', async () => {
    updateActiveTrace({
      // Agregation par utilisateur dans le tableau de bord
      userId: user.id,
      // Regroupe les echanges d'une meme conversation
      sessionId: user.currentConversationId,
      // Filtres transversaux
      tags: [feature, tenant.plan],
      metadata: {
        tenantId: tenant.id,
        plan: tenant.plan,          // free | pro | enterprise
        appVersion: process.env.APP_VERSION,
        promptVersion: 'support-v3',
      },
      input: { question },
    });

    const answer = await runPipeline(question);
    updateActiveTrace({ output: { answer } });
    return answer;
  });
}

Avec ces trois champs renseignés, quatre analyses deviennent immédiates dans l'interface, et elles ont toutes une conséquence directe sur le produit.

Analyse Champ utilise Decision qu'elle permet
Cout par utilisateur userId Fixer un quota, revoir la tarification d'un plan
Cout par fonctionnalite tags Arbitrer entre modele puissant et modele economique
Cout par version de prompt metadata.promptVersion Detecter un prompt devenu trop verbeux
Longueur de conversation sessionId Tronquer l'historique au bon seuil

Le dernier point est celui qui rapporte le plus vite. Dans un chat, chaque tour renvoie tout l'historique : le coût du dixième message est plusieurs fois celui du premier. La courbe de coût par position dans la session montre exactement où placer la troncature ou le résumé de l'historique — et c'est souvent bien plus tôt qu'on ne l'imagine.

Combinez cette mesure avec le prompt caching : les traces distinguent les tokens mis en cache des tokens facturés au plein tarif. C'est le seul moyen de vérifier que votre cache fonctionne réellement, plutôt que de le supposer parce que le code a l'air correct. Un taux de cache effondré après un déploiement se voit en une minute sur ce graphique.
// Renseigner le detail d'usage quand le SDK ne le fait pas tout seul
generation.update({
  usageDetails: {
    input: response.usage.input_tokens,
    output: response.usage.output_tokens,
    cache_read_input_tokens: response.usage.cache_read_input_tokens ?? 0,
    cache_creation_input_tokens: response.usage.cache_creation_input_tokens ?? 0,
  },
  // Pour un modele auto-heberge, le cout unitaire peut etre fourni directement
  costDetails: { total: estimateSelfHostedCost(response.usage) },
});

Scores : feedback utilisateur et qualite

En bref : un score est une mesure attachée à une trace. Il peut venir de l'utilisateur — pouce en l'air, note, correction — d'une règle de code, ou d'un modèle juge. C'est ce qui transforme un journal d'exécution en mesure de qualité suivie dans le temps.

Sans score, une trace ne dit que ce qui s'est passé. Avec des scores, elle dit si c'était bon. Le mécanisme le plus rentable reste le plus simple : le retour explicite de l'utilisateur, à condition d'avoir conservé le traceId côté client.

// 1. Retourner le traceId au navigateur avec la reponse
const { answer, traceId } = await answerQuestion(question, userId);
return Response.json({ answer, traceId });
// 2. Route de feedback — le client renvoie le traceId avec le vote
import { LangfuseClient } from '@langfuse/client';

const langfuse = new LangfuseClient();

export async function POST(request: Request) {
  const { traceId, helpful, comment } = await request.json();

  // Validation : un traceId venant du client n'est pas une donnee de confiance
  if (typeof traceId !== 'string' || !/^[a-f0-9-]{8,64}$/i.test(traceId)) {
    return new Response('traceId invalide', { status: 400 });
  }

  await langfuse.score.create({
    traceId,
    name: 'user-feedback',
    value: helpful ? 1 : 0,
    dataType: 'BOOLEAN',
    comment: typeof comment === 'string' ? comment.slice(0, 1000) : undefined,
  });

  return new Response(null, { status: 204 });
}

À côté du retour utilisateur, les scores calculés en code sont souvent plus fiables — parce que peu de gens cliquent sur les pouces, alors qu'une règle s'applique à 100 % du trafic. Trois mesures automatiques couvrent l'essentiel des dérives sur un système RAG.

import { LangfuseClient } from '@langfuse/client';
const langfuse = new LangfuseClient();

/** Scores deterministes, calcules sur chaque reponse produite. */
async function scoreAnswer(traceId: string, answer: string, documents: Doc[]) {
  // 1. La reponse cite-t-elle au moins une source ?
  const citations = (answer.match(/\[\d+\]/g) ?? []).length;
  await langfuse.score.create({
    traceId,
    name: 'citations',
    value: citations,
    dataType: 'NUMERIC',
  });

  // 2. Le modele a-t-il reconnu ne pas savoir ? Ce n'est pas un echec,
  //    c'est le comportement attendu quand le contexte est insuffisant
  const refused = /je ne (sais|peux) pas|contexte ne (contient|permet)/i.test(answer);
  await langfuse.score.create({
    traceId,
    name: 'abstention',
    value: refused ? 1 : 0,
    dataType: 'BOOLEAN',
  });

  // 3. Qualite de la recherche, independamment de la generation
  await langfuse.score.create({
    traceId,
    name: 'retrieval-confiance',
    value: documents[0]?.score ?? 0,
    dataType: 'NUMERIC',
  });
}

Le score d'abstention mérite un commentaire, parce qu'il est contre-intuitif. Un système qui refuse de répondre quand il ne sait pas est un bon système. Ce qui doit alerter, c'est la variation : un taux d'abstention qui passe de 8 % à 30 % en une journée signale presque toujours une régression d'indexation, pas un changement de comportement des utilisateurs.

Scores a mettre en place en priorite :
  • Feedback explicite — pouce en l'air / en bas, avec commentaire libre optionnel
  • Feedback implicite — l'utilisateur a-t-il copie, reformule, ou abandonne apres la reponse
  • Conformite de format — la sortie respecte-t-elle le schema attendu
  • Presence de citations — pour tout systeme cense s'appuyer sur des sources
  • Taux d'abstention — surveille en variation, pas en valeur absolue
  • Declenchements de garde-fous — moderation, filtre de donnees personnelles

Evaluations en ligne avec LLM-as-judge

En bref : un évaluateur LLM-as-judge note automatiquement un échantillon du trafic de production selon vos propres critères. Deux règles rendent la chose viable : échantillonner, et juger avec un modèle plus économique que celui qui a produit la réponse.

L'idée est simple : rejouer chaque réponse produite devant un second modèle chargé de la noter. Elle devient coûteuse si on l'applique à tout le trafic — d'où l'échantillonnage, qui suffit largement pour détecter une dérive statistique.

import OpenAI from 'openai';
import { LangfuseClient } from '@langfuse/client';

const openai = new OpenAI();          // NON instrumente : le juge ne se trace pas lui-meme
const langfuse = new LangfuseClient();

const JUDGE_SCHEMA = {
  type: 'object',
  required: ['fidelite', 'pertinence', 'justification'],
  additionalProperties: false,
  properties: {
    fidelite: { type: 'integer', minimum: 1, maximum: 5 },
    pertinence: { type: 'integer', minimum: 1, maximum: 5 },
    justification: { type: 'string', maxLength: 300 },
  },
};

export async function judgeAnswer(traceId: string, question: string, context: string, answer: string) {
  // Echantillonnage : 10 % du trafic suffit pour suivre une tendance
  if (Math.random() > 0.1) return;

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini', // juge economique, tache simple et cadree
    temperature: 0,
    response_format: { type: 'json_schema', json_schema: { name: 'verdict', schema: JUDGE_SCHEMA, strict: true } },
    messages: [
      {
        role: 'system',
        content:
          'Tu evalues une reponse d\'assistant documentaire sur deux axes, de 1 a 5. ' +
          'Fidelite : la reponse est-elle entierement soutenue par le contexte fourni ? ' +
          'Pertinence : repond-elle reellement a la question posee ? ' +
          'Une reponse qui invente une information non presente dans le contexte ' +
          'obtient obligatoirement 1 en fidelite.',
      },
      {
        role: 'user',
        content: `Question :\n${question}\n\nContexte :\n${context}\n\nReponse :\n${answer}`,
      },
    ],
  });

  const verdict = JSON.parse(completion.choices[0].message.content ?? '{}');

  // Les deux scores sont rattaches a la trace d'origine
  await langfuse.score.create({
    traceId, name: 'fidelite', value: verdict.fidelite,
    dataType: 'NUMERIC', comment: verdict.justification,
  });
  await langfuse.score.create({
    traceId, name: 'pertinence', value: verdict.pertinence, dataType: 'NUMERIC',
  });
}

Langfuse permet également de configurer ces évaluateurs directement dans l'interface, sans code : on choisit un gabarit, un modèle juge, un filtre de traces et un taux d'échantillonnage. C'est plus rapide à mettre en place et cela évite de déployer à chaque ajustement de critère. Le code garde l'avantage quand le critère dépend de données métier que Langfuse ne connaît pas — un identifiant de commande à vérifier en base, par exemple.

Un juge est un modèle : il se trompe, il a des biais — préférence pour les réponses longues, pour son propre style de formulation. Avant de piloter quoi que ce soit avec ses notes, calibrez-le : annotez cinquante réponses à la main et comparez. Un juge dont l'accord avec vos annotations est faible ne mesure pas la qualité, il mesure son propre biais, et vous conduira à optimiser dans le vide.

Le cycle vertueux se referme ici. Les traces mal notées en production alimentent un jeu de données ; ce jeu de données devient une suite d'évaluation exécutée à chaque changement de prompt dans la CI ; les régressions détectées ne reviennent plus en production. C'est le même mouvement que celui d'un test de non-régression écrit après un bug — appliqué à un système non déterministe.

Self-hosting, donnees sensibles et RGPD

En bref : une trace contient les prompts complets, donc potentiellement des données personnelles ou confidentielles. Deux réponses, cumulables : masquer les données sensibles avant l'envoi, et héberger l'instance vous-même.

C'est le point qu'il faut traiter avant la mise en production, pas après. Envoyer chez un tiers l'intégralité des messages de vos utilisateurs, avec leur identifiant, mérite au minimum une mention au registre des traitements — et souvent une conversation avec le service juridique.

Masquer avant l'envoi

Le masquage s'applique au niveau du processeur de spans : il s'exécute avant toute transmission réseau, ce qui garantit que la donnée brute ne quitte jamais votre processus.

import { LangfuseSpanProcessor } from '@langfuse/otel';

const PATTERNS: Array<[RegExp, string]> = [
  [/[\w.+-]+@[\w-]+\.[\w.]+/g, '[EMAIL]'],
  [/\+?\d[\d\s.-]{8,}\d/g, '[TELEPHONE]'],
  [/\b(?:\d[ -]*?){13,16}\b/g, '[CARTE]'],
  [/\b\d{13}\b/g, '[NIR]'],
];

function maskSensitive(value: unknown): unknown {
  if (typeof value === 'string') {
    return PATTERNS.reduce((text, [pattern, token]) => text.replace(pattern, token), value);
  }
  if (Array.isArray(value)) return value.map(maskSensitive);
  if (value && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, maskSensitive(item)])
    );
  }
  return value;
}

export const langfuseSpanProcessor = new LangfuseSpanProcessor({
  // Applique a toutes les entrees et sorties avant transmission
  mask: ({ data }) => maskSensitive(data),
});
Un masquage par expressions régulières attrape les formats structurés — adresses e-mail, numéros de carte, identifiants — et rate tout le reste : un nom propre au milieu d'une phrase, une adresse postale rédigée librement, un détail médical. Ne le présentez jamais comme une anonymisation. Pour les domaines réellement sensibles, la seule réponse solide reste l'auto-hébergement.

Auto-heberger l'instance

# Deploiement local complet : Langfuse, PostgreSQL, ClickHouse, Redis, MinIO
git clone https://github.com/langfuse/langfuse.git
cd langfuse
docker compose up -d

# Interface disponible sur http://localhost:3000
# Creer le premier compte, puis un projet pour recuperer les cles

docker compose logs -f langfuse-web   # diagnostic au premier demarrage

L'architecture mérite d'être comprise avant un déploiement en production : ce n'est pas une application monolithique posée sur une base de données. PostgreSQL stocke les données transactionnelles, ClickHouse encaisse le volume analytique des traces, Redis gère la file d'ingestion et le cache, un stockage compatible S3 accueille les charges utiles volumineuses.

# Variables essentielles en production
DATABASE_URL=postgresql://user:password@postgres:5432/langfuse
CLICKHOUSE_URL=http://clickhouse:8123
REDIS_CONNECTION_STRING=redis://redis:6379

# Secrets : generer des valeurs aleatoires distinctes, jamais celles de la doc
NEXTAUTH_SECRET=$(openssl rand -base64 32)
SALT=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)

NEXTAUTH_URL=https://langfuse.mon-domaine.fr
TELEMETRY_ENABLED=false
Avant d'ouvrir l'instance a l'equipe :
  • Sauvegarder PostgreSQL et ClickHouse — les traces sont des donnees de production
  • Fixer une politique de retention — le volume croit vite, et conserver un an rarement utile
  • Restreindre l'acces reseau — l'interface expose les prompts en clair
  • Separer les projets prod et preprod — ou au minimum le champ environment
  • Documenter le traitement — registre RGPD, duree de conservation, finalite
  • Prevoir la suppression sur demande — une trace porte un userId, donc une demande d'effacement s'y applique

Alternatives et arbre de decision

En bref : Langfuse n'est pas seul. Le choix se joue sur trois critères : votre pile est-elle déjà LangChain, avez-vous besoin d'auto-héberger, et acceptez-vous d'écrire de l'instrumentation ou préférez-vous un proxy sans code.
Outil Approche Auto-hebergeable Choisir quand
Langfuse SDK + OpenTelemetry Oui, open source Pile heterogene, besoin de maitriser les donnees
LangSmith SDK, integration native LangChain Offre entreprise uniquement Tout est deja en LangChain / LangGraph
Helicone Proxy : une URL de base a changer Oui Demarrage immediat, sans toucher au code
Arize Phoenix OpenTelemetry, oriente evaluation Oui, execution locale possible Travail d'analyse hors ligne sur les traces
OpenLLMetry Instrumentation OTel pure Selon le backend choisi Une stack observabilite existante a reutiliser

Le cas du proxy mérite une nuance qui décide souvent du choix. Une solution comme Helicone s'installe en changeant l'URL de base du client OpenAI : c'est imbattable en temps de mise en œuvre. Mais un proxy ne voit que les appels réseau vers le modèle. Il ignore tout ce qui les entoure — la recherche vectorielle, le reranking, les appels d'outils, la logique du graphe. Sur un pipeline à plusieurs étages, c'est précisément le contexte manquant qui explique les échecs.

Comment trancher :
  • Un seul appel de modele par requete — un proxy suffit, ne compliquez pas
  • Pipeline RAG ou agent multi-etapes — il faut un SDK qui trace la structure
  • Donnees reglementees — auto-hebergement obligatoire, ce qui elimine plusieurs options
  • Deja tout en LangChain — comparez avec LangSmith avant de choisir
  • OpenTelemetry deja en place — privilegiez un outil compatible pour ne pas doubler la stack
Ne branchez pas deux outils d'observabilité LLM en parallèle « pour comparer ». Vous doublez le coût d'instrumentation, les traces divergent sur les détails et personne ne sait plus laquelle fait foi. Choisissez-en un, instrumentez sérieusement, et changez plus tard si besoin : l'instrumentation basée sur OpenTelemetry se reporte largement d'un backend à l'autre.

Conclusion

L'observabilité LLM n'est pas un raffinement d'équipe mature : c'est la condition pour pouvoir corriger quoi que ce soit. Sans elle, chaque incident se traite par supposition, chaque optimisation de coût se fait à l'aveugle, et chaque dégradation de qualité s'apprend par les réclamations des utilisateurs. Avec elle, un problème signalé devient une trace ouverte en trente secondes, où l'on voit le prompt exact, les documents récupérés, l'étape qui a dérapé et ce que ça a coûté.

Le déploiement se fait par paliers, et le premier rapporte déjà l'essentiel. Enveloppez votre client de modèle, ajoutez userId et sessionId sur les traces, et vous avez la visibilité sur les coûts et les latences en une petite heure de travail. Découpez ensuite votre pipeline en spans pour distinguer les échecs de recherche des échecs de génération. Ajoutez enfin les scores — feedback utilisateur d'abord, règles automatiques ensuite, juge LLM échantillonné en dernier — pour passer de « ce qui s'est passé » à « est-ce que c'était bon ».

Reste la question qu'aucun outil ne tranche à votre place : que faire des traces. Un tableau de bord que personne ne regarde ne vaut rien. Le rituel qui marche est simple et tient en vingt minutes par semaine — ouvrir les dix traces les moins bien notées, les lire vraiment, et décider pour chacune s'il s'agit d'un problème de données, de prompt ou d'attente irréaliste. C'est cette lecture régulière, pas l'outil, qui fait progresser un système LLM.

Recapitulatif :
  • Une trace par action utilisateur, jamais une trace par appel de modele
  • instrumentation.ts charge en premier, et forceFlush() obligatoire en serverless
  • observeOpenAI() pour demarrer, startObservation() pour les autres fournisseurs
  • Tout startObservation() a son end() dans un finally
  • Tracer les scores de similarite des documents, pas seulement leur nombre
  • userId, sessionId et tags : sans eux, les couts restent une facture opaque
  • Juge LLM echantillonne a 10 %, calibre sur des annotations humaines
  • Masquage avant envoi, et auto-hebergement pour les donnees reglementees

Sources et références officielles

Questions fréquentes

Elle enregistre chaque appel de modèle avec son entrée, sa sortie, sa latence, ses tokens et son coût, reliés dans une trace unique. Sans elle, une réponse dégradée en production est impossible à reproduire : vous voyez le résultat final, jamais le prompt réellement envoyé ni l'étape qui a échoué.

Promptfoo teste vos prompts avant le déploiement, dans la CI, sur des jeux de données figés. Langfuse observe ce qui se passe après, sur le trafic réel des utilisateurs. Les deux sont complémentaires : l'un empêche les régressions connues, l'autre révèle les problèmes que vous n'aviez pas anticipés.

Attachez un userId et un sessionId à la trace avec updateActiveTrace(). Langfuse calcule le coût de chaque génération à partir des tokens et du modèle, puis agrège par utilisateur, session ou tag dans le tableau de bord.

Oui, Langfuse est open source et s'auto-héberge avec Docker Compose : les données de vos prompts et de vos utilisateurs ne quittent alors jamais votre infrastructure. L'installation requiert PostgreSQL, ClickHouse, Redis et un stockage compatible S3.

Partager