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
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.
Trace, observation, generation : le modele de donnees
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 |
|---|---|---|
| Trace | Une interaction complete de bout en bout | Une question posee dans le chat |
| Span | Une etape de traitement | Recherche vectorielle, reranking, appel d'outil |
| Generation | Un appel de modele, avec tokens et cout | Un appel a gpt-4o ou a Claude |
| Event | Un point sans duree | Cache manque, garde-fou declenche |
| Score | Une mesure de qualite | Pouce en l'air, note d'un juge automatique |
| Session | Un regroupement de traces | Une 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
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);
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);
}
@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
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
}
}
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
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);
}
- 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
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
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.
// 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
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.
- 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
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.
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
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),
});
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
- 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
| 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.
- 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
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.
- Une trace par action utilisateur, jamais une trace par appel de modele
instrumentation.tscharge en premier, etforceFlush()obligatoire en serverlessobserveOpenAI()pour demarrer,startObservation()pour les autres fournisseurs- Tout
startObservation()a sonend()dans unfinally - Tracer les scores de similarite des documents, pas seulement leur nombre
userId,sessionIdet 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
- Langfuse Documentation — Langfuse
- Langfuse JS/TS SDK — Langfuse
- Langfuse Self-Hosting — Langfuse
- Langfuse Scores — Langfuse
- Semantic conventions for generative AI — OpenTelemetry
- OpenTelemetry JavaScript — OpenTelemetry
- Langfuse GitHub repository — GitHub