LlamaIndex Plus LangGraph : un modèle propre pour les agents RAG
Comment créer une base de connaissances avec LlamaIndex et la mettre à disposition d’un agent LangGraph en tant qu’outil, avec une explication de la persistance, de Chroma et du mode uniquement de récupération.
Sources: RAG Without the Guesswork: A Standardized LangGraph + LlamaIndex Pattern.
Le point aveugle et la division du travail
Un agent LangGraph ne connaît que ce que son LLM a appris pendant l’entraînement. Les questions concernant une politique de tarification interne, le rapport financier du dernier trimestre ou un manuel terminé la semaine précédente suscitent soit une reconnaissance d’ignorance, soit, pire encore, une fabrication assurée.
Retrieval-Augmented Generation (RAG) comble cette lacune. Lorsqu’une requête est soumise, il récupère des extraits pertinents à partir de vos propres documents et les transmet à l’LLM en tant que contexte avant qu’il ne réponde, permettant ainsi au modèle de lire plutôt que de deviner. RAG est une fonctionnalité ajoutée à un agent unique. Il ne s’agit pas d’un nouveau schéma d’orchestration, donc les superviseurs, les sous-graphes et Send restent en dehors de ce processus. Seule est requise une certaine connaissance de la structure d’un agent LangGraph et de la manière de lui fournir des outils.
Il est possible de construire manuellement le pipeline au sein de LangGraph : il suffit d’écrire son propre outil de segmentation, les appels pour les embeddings, ainsi que le code d’indexation et de récupération des vecteurs. De nombreuses équipes le font, mais il s’agit d’une tâche complexe pour un problème qui est déjà résolu. LlamaIndex a été conçu pour reprendre cette charge de travail. C’est sans aucun doute l’option la plus mature et la plus populaire pour la gestion des données dans les applications d’IA, couvrant le chargement, la segmentation, les embeddings, l’indexation et la récupération. La partie d’orchestration, c’est-à-dire la gestion de l’état, du flux de contrôle et du raisonnement multi-étapes, relève de LangGraph.
Cette séparation donne naissance à la structure utilisée par la plupart des systèmes RAG en production : LlamaIndex crée et gère la base de connaissances, tandis que LangGraph la considère comme un outil et décide quand l’appeler.
Un modèle mental pour LlamaIndex
Pensez à LlamaIndex comme à un bibliothécaire pour votre LLM. Le LLM ressemble à un assistant de recherche brillant qui a lu une grande partie d’Internet mais n’a jamais consulté les fichiers de votre entreprise, et personne ne peut lui demander de mémoriser un dossier de 10 000 pages. C’est alors que le bibliothécaire intervient : il lit chaque document, le décompose en petites fiches indexables (des fragments), stocke ces fiches là où la recherche par thème est rapide (un index vectoriel), et retire uniquement les 3 à 5 fiches les plus pertinentes chaque fois que l’assistant pose une question. L’assistant ne mémorise jamais rien. Quelques extraits arrivent et le raisonnement s’effectue à partir d’eux.
Le pipeline en cinq étapes
Tout système RAG, quel que soit son framework, passe par les mêmes cinq étapes, et LlamaIndex propose une abstraction claire pour chacune d’elles.
1. LOAD → Read raw files (PDF, Word, web pages, databases) into Documents
2. CHUNK → Split Documents into small, retrievable Nodes
3. EMBED → Convert each Node's text into a vector (a list of numbers
representing meaning)
4. STORE → Save those vectors in a Vector Index for fast lookup
5. RETRIEVE → At query time, embed the user's question, find the most
similar Nodes, and return them as context
Les quatre premières étapes s’exécutent une seule fois, lors de la création de la base de connaissances, ce qui est appelé indexation. La cinquième étape a lieu chaque fois qu’un utilisateur pose une question, ce qui est appelé recherche. Le rapport entre le temps d’indexation et le temps de recherche constitue probablement le modèle mental le plus important dans RAG, et il vaut la peine de s’en souvenir.
Le vocabulaire
Documentdésigne un élément brut de contenu source, tel qu’un PDF, une page web ou une ligne de base de données, ainsi que ses métadonnées. Il s’agit de l’unité non traitée et intégrale.Nodecorrespond à un morceau extrait d’unDocument. Les modèles LLM et les modèles d’incorporation fonctionnent mieux sur des textes ciblés et de petite taille que sur des documents entiers ; c’est pourquoi LlamaIndex divise chaqueDocumenten de nombreux objetsNode. LesNodetransmettent des métadonnées à leur élément parent, ce qui permet de retracer l’origine de n’importe quel morceau de texte.
SimpleDirectoryReader est le chargeur habituel : il suffit de le diriger vers un dossier pour qu’il lise des PDF, du texte, des fichiers Word et d’autres formats, générant un ou plusieurs objets Document par fichier.VectorStoreIndex est la classe principale d’indexation. À partir d’objets Document ou Node, elle les intègre et crée un index vectoriel consultable, soit en mémoire, soit à l’intérieur d’une base de données vectorielle externe si celle-ci est configurée.Settings est un objet de configuration globale. Le modèle LLM et le modèle d’incorporation y sont définis une seule fois, et chaque composant les récupère automatiquement, ce qui évite de devoir les transmettre à chaque appel.QueryEngine est l’élément auquel on pose réellement des questions. Il provient de .as_query_engine() sur un index, et une seule appelation y insère la requête, exécute une recherche de similarité et transmet le contexte obtenu au LLM pour obtenir une réponse finale.Retriever provient de .as_retriever() sur un index et ne gère que l’étape de recherche. Il renvoie les objets Node correspondants bruts sans générer de réponse, ce qui convient aux cas où la récupération et la génération doivent être des étapes distinctes et contrôlables. C’est précisément la situation lorsque LangGraph entre en jeu.Le pipeline est d’abord construit sans LangGraph.
Un pipeline fonctionnel en environ dix lignes
La mise en place a lieu en premier.
# Core package + OpenAI LLM and embedding integrations (the common starting setup)
pip install llama-index-core llama-index-llms-openai llama-index-embeddings-openai
# Readers for common file types (PDF, Word, etc.)
pip install llama-index-readers-file pypdf
LlamaIndex est livré intentionnellement sous forme de nombreux petits paquets. llama-index-core contient la logique du framework, tandis que le choix du LLM, du modèle d’embedding, de la base de données vectorielle et du lecteur de fichiers se trouve dans des paquets distincts qui ne sont installés que si nécessaire. Cela permet de garder les installations légères.
Un petit fichier de configuration définit les paramètres par défaut globaux.
# ── llamaindex_config.py ─────────────────────────────────────
import os
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# Settings is global - configure once, used everywhere in LlamaIndex
Settings.llm = OpenAI(
model="gpt-4o-mini", # Used for generating final answers from retrieved context
temperature=0.1, # Low temperature: factual, not creative
)
Settings.embed_model = OpenAIEmbedding(
model="text-embedding-3-small", # Used to convert text into vectors
)
# Controls how documents are split into Nodes (chunks)
Settings.chunk_size = 512 # Max tokens per chunk
Settings.chunk_overlap = 50 # Overlap between consecutive chunks, to preserve context across boundaries
Concernant chunk_size : des blocs trop gros contiennent tellement de texte que la récupération devient floue, car un bloc récupéré n’est peut-être pertinent que à 10 %. Des blocs trop petits éliminent le contexte environnant dont le LLM a besoin pour comprendre un fait. Une valeur de départ de 512 tokens avec une superposition de 50 tokens est courante et judicieuse, puis on l’ajuste en fonction des documents concernés.
Puis il y a la base de connaissances elle-même :
# ── build_knowledge_base.py ──────────────────────────────────
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
# ── LOAD: Read all files in a folder into Document objects ──
documents = SimpleDirectoryReader("./data").load_data()
print(f"Loaded {len(documents)} documents")
# ── CHUNK + EMBED + STORE: all three happen inside this one call ──
# VectorStoreIndex automatically:
# 1. Splits each Document into Nodes (using Settings.chunk_size)
# 2. Embeds each Node (using Settings.embed_model)
# 3. Stores the vectors in an in-memory index
index = VectorStoreIndex.from_documents(documents, show_progress=True)
# ── RETRIEVE + GENERATE: ask a question ──────────────────────
query_engine = index.as_query_engine(
similarity_top_k=3, # Retrieve the 3 most relevant chunks for each query
)
response = query_engine.query("What is our refund policy for enterprise customers?")
print(response)
Environ dix lignes suffisent pour créer un système RAG fonctionnel, qui constitue le « hello world » de ce framework et une démonstration convaincante de son utilité. Le faire manuellement impliquerait l’utilisation d’un séparateur personnalisé, d’une boucle appelant le modèle d’incorporation, d’une recherche du voisin le plus proche ainsi que d’un modèle de prompt.
Stockage de l’index
Lors de la création de l’index, chaque morceau de texte est traité par le modèle d’incorporation, ce qui coûte de l’argent et du temps ; donc, reconstruire l’index à chaque démarrage de l’application est gaspilleur. En enregistrant l’index sur disque et en le chargant ultérieurement, on évite ce problème.
# ── Save the index after building it ─────────────────────────
index.storage_context.persist(persist_dir="./storage")
# ── Load it back later without re-embedding anything ──────────
from llama_index.core import StorageContext, load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)
storage_context.persist() stocke les données vectorielles, les métadonnées des nœuds ainsi que le stock de documents dans un dossier local, tandis que load_index_from_storage() recrée l’index identique à partir de ces fichiers, sans ré-encodage et sans appels API. Il suit la même logique de création une seule fois, de stockage persistant et de chargement peu coûteux que celle d’un checkpointer LangGraph.
Une véritable base de données vectorielle
Un index en mémoire convient pour les prototypes. En environnement de production, on a généralement besoin d’une base de données vectorielle dédiée, conçue pour gérer des millions de vecteurs, des lectures simultanées et un filtrage de métadonnées à grande échelle. Chroma en est un exemple.
# ── Using Chroma as a persistent, production-grade vector store ──
# pip install llama-index-vector-stores-chroma chromadb
import chromadb
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.core import StorageContext, VectorStoreIndex
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("my_knowledge_base")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
# Build the index directly into Chroma
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context
)
# Later, in a different process, reconnect without re-indexing:
index = VectorStoreIndex.from_vector_store(vector_store=vector_store)
Cette substitution ressemble à celle des points de contrôle. On échange un seul élément (le stockage en mémoire de ChromaVectorStore, tout comme MemorySaver cède la place à SqliteSaver), tandis que le moteur de requêtes et la logique de récupération restent inchangés. Pinecone, Qdrant, Weaviate ainsi que la plupart des autres bases de données vectorielles prises en charge par LlamaIndex fonctionnent de la même manière.
Relier les deux frameworks
Voici l’essentiel. Ces frameworks utilisent des langages différents : un QueryEngine de LlamaIndex n’est pas un outil LangGraph, car un agent LangGraph attend des outils construits avec le décorateur @tool de LangChain.
Le pont est simple. Il suffit de définir une fonction Python ordinaire, d’y ajouter @tool, et de faire en sorte que son corps appelle query_engine.query(...). C’est toute l’intégration nécessaire. LangGraph n’a pas besoin de connaître les détails internes de LlamaIndex, car il voit simplement un outil qui prend une chaîne de caractères et en retourne une autre, comme n’importe quel autre outil.
# ── MODULE 3: TOOLS (LlamaIndex-backed) ─────────────────────
from langchain_core.tools import tool
# The query_engine built in Part B - created once, at startup
# (In a real app, you'd load this from persisted storage, not rebuild it every time)
@tool
def search_knowledge_base(query: str) -> str:
"""Search the internal knowledge base for company policies, product
documentation, and internal procedures. Use this whenever the user asks
a question that might be answered by internal company documents rather
than general knowledge.
Args:
query: A natural-language question to search for.
Returns:
A synthesized answer based on the most relevant retrieved documents.
"""
response = query_engine.query(query)
return str(response)
La documentation est tout aussi importante que pour n’importe quel outil. Le LLM de l’agent la lit pour décider quand appeler cet outil, plutôt que de répondre en s’appuyant sur ses propres connaissances ou d’appeler un autre outil.
L’agent complet
Voici le modèle d’agent complet avec un outil RAG basé sur LlamaIndex intégré dans le module des outils. Rien d’autre ne change.
# ============================================================
# LANGGRAPH + LLAMAINDEX RAG AGENT — COMPLETE TEMPLATE
# Extends: Part 1 (core structure)
# ============================================================
# ── MODULE 1: IMPORTS & CONFIGURATION ───────────────────────
import os
from typing import Literal
# LangChain / LangGraph imports (the orchestration layer)
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
# LlamaIndex imports (the retrieval / data layer)
from llama_index.core import (1
Settings, SimpleDirectoryReader, VectorStoreIndex,
StorageContext, load_index_from_storage
)
from llama_index.llms.openai import OpenAI as LlamaOpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# LangGraph's chat model - used by the agent's reasoning
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# LlamaIndex's model config - used internally by the query engine
# Note: these are SEPARATE from the LangGraph llm above. Each framework
# manages its own model instances; they don't share state.
Settings.llm = LlamaOpenAI(model="gpt-4o-mini", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
Settings.chunk_size = 512
# ── MODULE 2: STATE ──────────────────────────────────────────
class State(MessagesState):
pass # messages field inherited; extend if your agent needs more
# ── MODULE 3: TOOLS (RAG-backed) ─────────────────────────────
# Build or load the LlamaIndex knowledge base ONCE, at startup
PERSIST_DIR = "./storage"
if os.path.exists(PERSIST_DIR):
# Reload existing index - no re-embedding, fast startup
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
else:
# First run - build the index and persist it
documents = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(documents, show_progress=True)
index.storage_context.persist(persist_dir=PERSIST_DIR)
query_engine = index.as_query_engine(similarity_top_k=3)
@tool
def search_knowledge_base(query: str) -> str:
"""Search internal company documents for policies, product specs,
procedures, and other domain-specific information. Use this for any
question that requires knowledge specific to this organization rather
than general world knowledge."""
response = query_engine.query(query)
return str(response)
tools = [search_knowledge_base]
llm_with_tools = llm.bind_tools(tools)
tool_node = ToolNode(tools)
# ── MODULE 4: NODES ──────────────────────────────────────────
def agent_node(state: State) -> dict:
"""The reasoning node. Decides whether to answer directly or
search the knowledge base first."""
system_prompt = SystemMessage(content=(
"You are a helpful assistant with access to an internal knowledge base. "
"Use the search_knowledge_base tool when the user asks about company-specific "
"information. For general questions, answer directly."
))
messages = [system_prompt] + state["messages"]
response = llm_with_tools.invoke(messages)
return {"messages": [response]}
# ── MODULE 5: ROUTING ────────────────────────────────────────
def should_continue(state: State) -> Literal["tools", "__end__"]:
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return "__end__"
# ── MODULE 6: GRAPH ASSEMBLY ─────────────────────────────────
graph_builder = StateGraph(State)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges(
"agent", should_continue,
{"tools": "tools", "__end__": END}
)
graph_builder.add_edge("tools", "agent")
graph = graph_builder.compile(checkpointer=MemorySaver())
# ── MODULE 7: ENTRYPOINT ──────────────────────────────────────
if __name__ == "__main__":
config = {"configurable": {"thread_id": "session-001"}}
print("RAG agent ready. Ask about your documents, or anything else.\n")
while True:
user_text = input("You: ").strip()
if not user_text or user_text.lower() == "exit":
break
response = graph.invoke(
{"messages": [HumanMessage(content=user_text)]},
config=config
)
print(f"Agent: {response['messages'][-1].content}\n")
Que se passe-t-il pour une seule requête
Le suivi d’une seule demande permet de clarifier la répartition des tâches.
User: "What's our policy on remote work?"
↓
[agent_node] — LangGraph's LLM reads the message, recognizes this needs
internal info, decides to call search_knowledge_base
↓
[tools] — ToolNode executes search_knowledge_base("What's our policy on remote work?")
↓
Inside the tool: query_engine.query(...) runs —
this is 100% LlamaIndex, invisible to LangGraph:
1. Embeds the query
2. Searches the vector index for the 3 closest chunks
3. Feeds those chunks + the question to Settings.llm
4. Returns a synthesized answer string
↓
[agent_node] — LangGraph's LLM receives the tool's string result,
and crafts the final response shown to the user
↓
Response to user
La frontière entre les deux systèmes est claire. LangGraph ne rencontre jamais de Node, d’embedding ou de vecteur, mais uniquement des outils qui renvoient du texte, sans différence avec search_web ou calculate. LlamaIndex, quant à lui, ne traite jamais de messages, de state ni de thread_id ; il s’agit simplement d’une chaîne de requête entrante et d’une réponse sortante. Chaque framework mise sur ses forces, et une seule appel de fonction constitue la liaison entre eux.
Mode uniquement retriever pour plus de contrôle
Le modèle query_engine.query() représente l’intégration la plus simple et convient à la plupart des cas. Parfois, un contrôle plus poussé est utile, par exemple lorsque l’LLM intégré à l’agent LangGraph doit rédiger la réponse finale en utilisant le prompt système existant ainsi que le contexte de la conversation, et non les paramètres internes Settings.llm de LlamaIndex, qui génèrent une réponse distincte et déconnectée.
Dans ce cas, utilisez un Retriever à la place d’un QueryEngine. Il ne fait que l’étape de recherche et renvoie des fragments bruts ; ce qui se passe ensuite dépend de l’agent.
# ── Retriever-only tool: returns raw chunks, not a synthesized answer ──
retriever = index.as_retriever(similarity_top_k=3)
@tool
def retrieve_documents(query: str) -> str:
"""Retrieve relevant document excerpts from the internal knowledge base.
Returns raw excerpts for you to read and reason over yourself -
use this when you need to cite specific sources or combine information
from multiple documents."""
nodes = retriever.retrieve(query)
# Format each retrieved chunk with its source for transparency
formatted_chunks = []
for i, node in enumerate(nodes):
source = node.metadata.get("file_name", "unknown source")
formatted_chunks.append(f"[Excerpt {i+1} from {source}]\n{node.text}")
return "\n\n---\n\n".join(formatted_chunks)
Avec cette configuration, l’appel à l’LLM effectué par le agent_node lui-même, en utilisant llm_with_tools ainsi que l’historique complet des conversations et le prompt système, lit les extraits bruts et rédige la réponse. Cela apporte des avantages réels : l’agent peut indiquer d’où provient un fait, il peut mélanger les extraits récupérés avec les échanges précédents, et les utilisateurs entendent une seule voix cohérente, et non deux LLMs aux styles différents.
Lequel choisir
Choisissez QueryEngine pour une intégration la plus simple, lorsque le LLM de LlamaIndex peut produire une réponse basée sur la recherche sous forme de chaîne autonome. Cela convient à la plupart des chatbots et assistants.
Choisissez Retriever lorsque des citations sources sont nécessaires, lorsque le LLM principal de l’agent doit maintenir une voix et un style de raisonnement constants tant pour les réponses extraites que pour celles qui ne le sont pas, ou lorsque les fragments extraits doivent être combinés avec d’autres résultats d’outils avant de produire la réponse finale.
Référence aux mots-clés
Chargement et indexation. Un Document correspond à une source chargée, qu’il s’agisse d’un fichier ou d’une page. Un Node est un fragment de cette source, l’unité pouvant être récupérée, accompagnée de métadonnées indiquant son origine. La méthode SimpleDirectoryReader("./path").load_data() charge tous les fichiers pris en charge dans un dossier en tant qu’objets Document. Settings représente le singleton de configuration globale, et les valeurs de Settings.llm, Settings.embed_model ainsi que Settings.chunk_size sont définies une seule fois pour l’ensemble des composants. La méthode VectorStoreIndex.from_documents(documents) effectue en une seule opération le découpage, l’incorporation de données et le stockage.
Requête. index.as_query_engine(similarity_top_k=3) génère un QueryEngine qui récupère les chunks les plus pertinents (top-k) et écrit également une réponse synthétisée. index.as_retriever(similarity_top_k=3) produit un Retriever qui ne récupère que les chunks top-k, ce qui convient aux configurations où votre propre LLM effectue la synthèse. query_engine.query("...") exécute l’ensemble du processus de récupération suivie de génération et renvoie un objet Response, tandis que str(response) extrait le texte. retriever.retrieve("...") ne fait que la récupération et renvoie des objets Node présentant les propriétés .text et .metadata.
Persistance. La commande index.storage_context.persist(persist_dir="./storage") enregistre l’index afin que les redémarrages n’aient pas besoin de le recharger. La fonction StorageContext.from_defaults(persist_dir="./storage") combinée à load_index_from_storage(...) le recharge. ChromaVectorStore, PineconeVectorStore et QdrantVectorStore sont des backends externes qui remplacent le stockage en mémoire par défaut, dans le même esprit que les mécanismes de sauvegarde de LangGraph.
Le pont. Une fonction @tool dont le corps appelle query_engine.query(...) constitue l’intégration complète : LangGraph perçoit un outil ordinaire et LlamaIndex reste encapsulé. Deux configurations distinctes de LLM existent également. Settings.llm correspond au modèle interne de LlamaIndex, utilisé uniquement au sein du moteur de recherche, tandis que le llm de LangGraph de l’agent gère le raisonnement principal. Ils fonctionnent de manière indépendante et ne partagent ni historique ni état.
Avez-vous besoin de cela ?
- Si l’agent a besoin uniquement de connaissances générales, de mathématiques ou de recherches en ligne en temps réel, LlamaIndex n’est pas nécessaire, et le modèle de base avec des outils standard suffit.
- Si celui-ci doit répondre à partir d’un ensemble fixe de documents tels que des politiques, des manuels ou des rapports qui changent rarement, il est tout à fait approprié d’encapsuler un
QueryEngineen tant qu’outil.
Retriever.interrupt() de LangGraph et placez l’appel de récupération dans un nœud qui attend cette approbation, comme pour tout outil sensible.Deux frameworks et une interface
La leçon à retenir est que fournir à un agent des connaissances du monde réel n’est pas un problème propre à LangGraph. C’est un problème de données, et un cadre de travail dédié aux données est l’outil adapté. Un système multi-agents complexe est en réalité composé de plusieurs graphes d’agents individuels assemblés ensemble, et l’accès aux connaissances suit le même principe modulaire.
LlamaIndex s’occupe de l’indexation et de la récupération, tandis que LangGraph gère le raisonnement et l’orchestration. Toute la connexion se fait via une fonction décorée avec @tool. Ce n’est pas une limitation, mais plutôt un niveau de couplage approprié, car chaque cadre de travail reste dans son domaine et chacune des parties peut être mise à jour indépendamment. La base de données vectorielle peut être remplacée sans toucher au graphe, et un modèle de supervision peut être ajouté sans affecter la logique de récupération.
Lectures complémentaires
- MCP pour les agents LangGraph, étape par étape : serveurs, transports et le pont d’adaptation — Découvrez le Model Context Protocol en abordant les rôles hôte, client et serveur, un serveur FastMCP, stdio versus HTTP, langchain-mcp-adapters ainsi que les pièges courants en environnement de production dans LangGraph.
- Créer ShopBot : un agent de support LangGraph doté de mémoire, d’outils et de capacité d’approbation de remboursement — Construisez un agent de support client module par module dans LangGraph : état, outils, nœuds, routage, résumés dynamiques et une fonction d’interruption permettant d’attendre l’approbation d’un remboursement.