Le support de niveau 1, c’est 80 % de questions déjà répondues quelque part dans la documentation. Le candidat idéal pour un agent — à condition qu’il sache quand chercher, qu’il ne réponde pas hors de sa base, qu’il escalade quand il ne sait pas, et surtout qu’on puisse savoir pourquoi il a répondu ce qu’il a répondu. Sans cette dernière propriété, aucun agent ne devrait toucher un vrai client.

Ce cas construit un tel agent de bout en bout : on part des fichiers Markdown de la documentation, on les vectorise, on bâtit l’agent, on l’escalade, on l’instrumente avec LangFuse, on l’évalue sur un dataset, et on l’expose derrière une API. Chaque étape est donnée en code complet et exécutable.

Architecture : un RAG agentique, pas un RAG figé

Un RAG classique est un pipeline rigide : on récupère, on génère, fin. Il récupère même quand c’est inutile (« bonjour »), ne se rattrape pas quand la récupération est mauvaise, et ne sait pas dire « je ne sais pas ».

Notre agent rend ces étapes décidées : c’est le modèle qui choisit d’appeler l’outil de recherche, qui juge si les documents suffisent, et qui décide de reformuler, de répondre, ou d’escalader. La recherche devient un outil parmi d’autres.

Fig.01 · RAG figé vs agentique
figé Question
figé Récupère toujours
figé Génère une fois
Le RAG classique récupère toujours, puis génère. Le RAG agentique décide s'il faut chercher, et peut reboucler pour reformuler ou escalader.

Plutôt que ce pipeline rigide, on adopte la boucle ReAct du premier chapitre, appliquée à la récupération : la recherche et l’escalade deviennent des outils que l’agent appelle — ou non — et peut rappeler après reformulation.

Fig.02 · Boucle de récupération
sinon → STOP tool_calls ToolMessage START modèle llm.invoke() outils ToolNode END
L'agent décide d'appeler chercher_doc (et peut reboucler pour reformuler), de répondre, ou d'escalader. La récupération est pilotée par le modèle.

Construire l’agent, étape par étape

Préparer l’environnement

On fixe les dépendances et les variables d’environnement (clés LLM et LangFuse). On travaille avec un modèle économique pour le support — la simplicité des questions ne justifie pas un modèle haut de gamme.

pip install langgraph langchain langchain-openai langfuse langchain-community

export OPENAI_API_KEY="sk-..."
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_HOST="https://cloud.langfuse.com"

Ingérer la base de connaissances

C’est l’étape qu’on néglige souvent et qui détermine pourtant la qualité de tout le reste : transformer la documentation en index vectoriel. On charge les fichiers Markdown, on les découpe en fragments de taille maîtrisée (le chunking), on les vectorise, et on les range dans un magasin vectoriel.

from pathlib import Path
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import MarkdownTextSplitter
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings

# 1) charger tous les .md de la documentation
docs = []
for f in Path("docs/").rglob("*.md"):
    chargeur = TextLoader(str(f), encoding="utf-8")
    for d in chargeur.load():
        d.metadata["source"] = f.name        # on garde la source pour citer
        docs.append(d)

# 2) découper : des fragments ni trop gros (bruit) ni trop petits (perte de contexte)
splitter = MarkdownTextSplitter(chunk_size=1000, chunk_overlap=150)
fragments = splitter.split_documents(docs)

# 3) vectoriser et indexer
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
store = InMemoryVectorStore.from_documents(fragments, embeddings)
retriever = store.as_retriever(search_kwargs={"k": 4})

print(f"{len(fragments)} fragments indexés depuis {len(docs)} documents.")

Exposer la recherche comme un outil

On donne au modèle l’accès à l’index via un outil. La docstring est lue par le modèle pour décider quand l’appeler — on la soigne comme une spécification. On renvoie aussi les sources, pour pouvoir citer.

from langchain_core.tools import tool

@tool
def chercher_doc(requete: str) -> str:
    """Recherche dans la base de connaissances produit.
    À utiliser pour TOUTE question factuelle sur le fonctionnement, la
    facturation ou la configuration du produit. Renvoie les extraits pertinents
    et leur source."""
    docs = retriever.invoke(requete)
    if not docs:
        return "AUCUN_RESULTAT"
    return "\n\n---\n\n".join(
        f"[source: {d.metadata['source']}]\n{d.page_content}" for d in docs
    )

Câbler l’escalade humaine

« Escalader » doit être une action concrète, pas un vœu pieux. On en fait un outil : l’agent l’appelle quand il sort de son périmètre. Il crée un ticket humain et renvoie une réponse d’attente au client.

@tool
def escalader(motif: str, resume: str) -> str:
    """Transfère la demande à un agent humain. À utiliser quand la base ne contient
    pas la réponse, en cas de réclamation, ou pour toute action sur le compte."""
    ticket = helpdesk.creer_ticket(motif=motif, resume=resume, priorite="n2")
    return f"Demande transférée à un conseiller (ticket {ticket.id})."

outils = [chercher_doc, escalader]

Cadrer le comportement par le prompt système

Le comportement « ne réponds que depuis la base, sinon escalade » se joue ici. Un agent de support sans garde-fou hallucine des procédures — le pire scénario.

SYSTEME = """Tu es l'assistant de support de niveau 1 de DEEP-5.
Règles impératives :
- Réponds UNIQUEMENT à partir des documents renvoyés par `chercher_doc`.
- Cite la source entre crochets à la fin de chaque affirmation factuelle.
- Si la base ne contient pas la réponse, ou si la demande concerne le compte
  ou une réclamation, appelle `escalader` — n'invente jamais.
- N'invente jamais de procédure, de tarif ou de fonctionnalité.
- Réponds en français, de façon concise et actionnable."""

Assembler l’agent et l’instrumenter pour LangFuse

On assemble l’agent ReAct — et c’est ici que LangFuse entre en scène. Son CallbackHandler s’attache à l’invocation : il intercepte chaque appel LLM et chaque outil pour en faire une trace hiérarchique, sans modifier la logique de l’agent.

from langgraph.prebuilt import create_react_agent
from langchain.chat_models import init_chat_model
from langfuse.langchain import CallbackHandler

modele = init_chat_model("openai:gpt-4o-mini")
agent = create_react_agent(modele, outils, prompt=SYSTEME)

langfuse_handler = CallbackHandler()

def repondre(question: str, ticket_id: str, user_id: str) -> str:
    resultat = agent.invoke(
        {"messages": [{"role": "user", "content": question}]},
        config={
            "callbacks": [langfuse_handler],
            "recursion_limit": 8,                 # borne anti-boucle / coût
            "metadata": {
                "langfuse_session_id": ticket_id,  # regroupe la conversation
                "langfuse_user_id": user_id,       # segmente par client
                "langfuse_tags": ["support-n1"],
            },
        },
    )
    return resultat["messages"][-1].content

Un exemple concret, du message à la réponse

Déroulons une vraie requête pour voir l’agent décider.

print(repondre("Comment activer l'authentification à deux facteurs ?",
               ticket_id="T-4821", user_id="cli_902"))

En coulisses, l’agent franchit trois étapes : il décide d’appeler chercher_doc, reçoit deux fragments pertinents (provenant de securite.md), puis rédige une réponse sourcée. Sortie typique :

Pour activer l'A2F : ouvrez Paramètres > Sécurité > Authentification à deux
facteurs, puis scannez le QR code avec votre application d'authentification.
Conservez les codes de secours affichés. [source: securite.md]

Et sur une question hors base — « Pouvez-vous me rembourser ? » — l’agent n’invente pas : il appelle escalader(motif="réclamation", ...) et répond « Demande transférée à un conseiller (ticket …) ». C’est le comportement qu’on veut, et c’est la trace qui va nous le prouver.

Lire la trace : ce que LangFuse révèle

Ouvrez la trace dans LangFuse. Vous voyez l’arbre complet de la décision :

  • le span racine : l’invocation de l’agent, durée et coût totaux ;
  • une generation : le premier appel LLM, qui décide d’appeler chercher_doc — avec les messages, les tokens consommés et le coût estimé ;
  • un span outil : la recherche, avec la requête exacte et les documents rendus ;
  • une seconde generation : la rédaction finale à partir des documents.
Fig.03 · La trace d'une réponse
TRACE · agent.invoke() 2.4 s · $0.0031 GENERATION · décision gpt-4o-mini · 412 tok SPAN · chercher_doc() 84 ms GENERATION · réponse finale 356 tok
Chaque réponse au client devient un arbre lisible : décision, recherche, rédaction — avec le coût et la latence de chaque étape. C'est ce qui rend l'agent débogable en production.

Mesurer coût et latence

Chaque generation porte son nombre de tokens et son coût. Agrégés dans LangFuse, ils répondent aux questions que tout responsable se pose avant la production :

MétriquePourquoi la suivreSeuil d’alerte indicatif
Coût moyen par ticketDécider si l’automatisation est rentable> 0,02 $
Latence P95Garantir une expérience acceptable> 6 s
Tokens d’entrée moyensRepérer un prompt/contexte qui gonfledérive > 20 %
Taux d’appel outilVérifier que l’agent cherche quand il le faut< 70 %
Taux d’escaladeCalibrer le périmètre confié à l’agentsuivi métier

Évaluer la qualité, en continu

Tracer ne suffit pas : il faut savoir si les réponses sont bonnes. On combine trois sources de score, comme détaillé dans le deep-dive évaluation.

Le LLM-juge de fidélité

Pour le RAG, la métrique reine est la fidélité : la réponse s’appuie-t-elle vraiment sur les documents, sans inventer ? On l’implémente avec un juge à sortie structurée.

from pydantic import BaseModel, Field
from langfuse import Langfuse

langfuse = Langfuse()

class Fidelite(BaseModel):
    fidele: bool = Field(description="La réponse est-elle justifiée par les sources ?")
    raison: str

def juger_fidelite(trace_id: str, sources: str, reponse: str):
    juge = init_chat_model("openai:gpt-4o").with_structured_output(Fidelite)
    v = juge.invoke(
        f"Sources :\n{sources}\n\nRéponse de l'agent :\n{reponse}\n\n"
        f"La réponse est-elle ENTIÈREMENT justifiée par les sources ? Sois strict."
    )
    langfuse.create_score(trace_id=trace_id, name="fidelite",
                          value=int(v.fidele), comment=v.raison)
    return v

Les autres sources complètent le tableau : un score humain (un conseiller note la réponse) et un score implicite (le client a-t-il rouvert le ticket ?).

langfuse.create_score(trace_id=trace_id, name="utile", value=1)        # humain
langfuse.create_score(trace_id=trace_id, name="ticket_rouvert", value=0)  # métier

Le dataset de non-régression

Un test manuel sur trois questions ne prouve rien. On constitue un dataset de cas réels — dont des pièges hors périmètre où l’on attend une escalade — et on rejoue l’agent à chaque changement.

langfuse.create_dataset(name="support-qa")
langfuse.create_dataset_item(
    dataset_name="support-qa",
    input={"question": "Comment activer l'A2F ?"},
    expected_output="Paramètres > Sécurité > A2F, scanner le QR code.",
    metadata={"piege": False},
)
langfuse.create_dataset_item(
    dataset_name="support-qa",
    input={"question": "Remboursez-moi."},
    expected_output="[escalade]",
    metadata={"piege": True},        # on attend une escalade, pas une réponse
)

# rejouer l'agent sur tout le dataset et scorer chaque cas
dataset = langfuse.get_dataset("support-qa")
for item in dataset.items:
    with item.run(run_name="prompt-v4") as root:
        reponse = repondre(item.input["question"], ticket_id=f"eval-{item.id}", user_id="eval")
        a_escalade = "transférée à un conseiller" in reponse
        root.score_trace(name="escalade_correcte",
                         value=int(a_escalade == item.metadata["piege"]))

Exposer l’agent derrière une API

Dernière étape : rendre l’agent accessible. On l’expose en HTTP, en streamant idéalement, ici en version simple.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Demande(BaseModel):
    question: str
    ticket_id: str
    user_id: str

@app.post("/support")
def support(d: Demande):
    return {"reponse": repondre(d.question, d.ticket_id, d.user_id)}

Mise en production : la checklist

Bonnes pratiques & pièges

Bonnes pratiques
  • Soignez l'ingestion : le chunking et la qualité de l'index gouvernent toute la suite.
  • Cadrez le périmètre dans le prompt système : répondre UNIQUEMENT depuis les documents.
  • Faites de l'escalade un outil concret, appelé sur toute incertitude, et tracez-la.
  • Citez les sources : c'est vérifiable, et le juge de fidélité s'en sert.
  • Regroupez par session_id et segmentez par user_id dans LangFuse.
  • Évaluez sur un dataset à pièges (cas hors périmètre) ; mesurez le taux d'escalade correcte.
Pièges à éviter
  • Bâcler le chunking : des fragments mal coupés produisent des récupérations inutiles.
  • Laisser l'agent répondre hors base : il invente des procédures — le pire en support.
  • Tester à la main sur trois questions et conclure que « ça marche ».
  • Ignorer la courbe de tokens par ticket : une re-recherche en boucle triple le coût en silence.
  • Confondre « la trace existe » et « la qualité est mesurée » : il faut des scores, pas que des traces.

Ce qu’il faut retenir

  • Tout commence par l’ingestion : charger, chunker, vectoriser, indexer — la qualité de l’index plafonne celle de l’agent.
  • Un RAG agentique rend la récupération décidée : l’agent cherche quand il faut, reformule, et escalade au lieu d’inventer.
  • LangFuse s’attache via un simple callback ; session_id et user_id rendent les traces exploitables.
  • La trace transforme le débogage en lecture ; coût, latence et fidélité se mesurent en continu sur un dataset à pièges.
  • Une API et une checklist de production referment la boucle, du concept au client.

Cet agent réunit tout le parcours : la boucle ReAct de l’initiation, la rigueur de conception du deep-dive, et l’observabilité qui rend le tout exploitable. Pour aller plus loin, comparez-le à l’assistant de recherche multi-agents et au RAG agentique correctif.

#cas-pratique#rag#langfuse#observabilité#support