Création d’un agent de recherche ReAct dans LangGraph : cerveau, mains, routeur
Apprenez à mettre en œuvre le cycle ReAct raisonner-agir-observer en tant que sous-graphe LangGraph, avec une réflexion forcée, des budgets d’itération et des recherches parallèles de type scatter-gather.
Une seule requête à un LLM ne peut pas rechercher une question dont il ignore tout. Un agent de recherche utile doit chercher, lire les résultats obtenus, déterminer ce qui manque encore et rechercher à nouveau jusqu’à disposer d’assez d’informations pour répondre. Le modèle ReAct donne à ce comportement une forme précise, et LangGraph vous permet de l’exprimer sous la forme d’un petit graphe explicite plutôt que d’un enchevêtrement de boucles while. À la fin de cette présentation, vous comprendrez chaque nœud d’un sous-graphe de recherche ReAct fonctionnel, saurez où il peut échouer et disposerez d’une liste de mises à jour permettant de l’exécuter en toute sécurité en production.
La conception s’inspire du composant de recherche d’un projet open source nommé deep-research-agent. Au niveau global, un cycle de fonctionnement se déroule comme suit :
- Une question de recherche est soumise par l’utilisateur.
- L’LLM, agissant en tant que « cerveau », réfléchit à la question et choisit l’outil à appeler.
Qu’est-ce que le modèle ReAct, en réalité ?
ReAct signifie « Reason and Act ». Il provient de l’article de recherche « ReAct: Synergizing Reasoning and Acting in Language Models » (publié pour la première fois en 2022 et présenté à l’ICLR 2023). L’observation centrale est que les modèles de langage performent mieux lorsqu’ils alternent deux types d’étapes : réfléchir à ce qu’il convient de faire ensuite, et utiliser un outil pour obtenir des informations réelles. Chacune de ces deux approches échoue à elle seule. Un modèle qui ne raisonne que produira des faits de manière convaincante, car rien ne le fonde. Un modèle qui n’agit que utilisera les outils de manière mécanique, sans interpréter ce qu’ils renvoient.
Les directives architecturales de Google Cloud décrivent ce schéma comme une boucle d’étapes en langage naturel qui se poursuit jusqu’à ce qu’une condition de sortie soit remplie. En pratique, il se divise en trois phases répétitives :
- Réflexion. Le modèle examine tout ce qui a été collecté jusqu’à présent et décide si la demande a déjà reçu une réponse ou quelles actions entreprendre ensuite.
- Action. Sur la base de cette réflexion, il soit utilise un outil pour obtenir plus de données, soit rédige une réponse finale qui met fin au cycle.
- Observation. Les résultats de l’outil reviennent et sont conservés dans la conversation. Comme les observations précédentes restent visibles, le modèle peut s’appuyer sur elles au lieu de répéter des recherches ou d’oublier le contexte.
C’est à peu près ainsi qu’un ingénieur expérimenté aborde un problème inconnu : il cherche des informations, y réfléchit, cherche d’autres informations, et ce n’est qu’alors qu’il formule une conclusion. La documentation d’Anthropic sur l’utilisation des outils décrit le même mécanisme du côté de l’API : le modèle répond par une demande d’utilisation d’un outil, votre application l’exécute et renvoie le résultat, puis le processus se répète. Le cycle est identique que le modèle en question soit Claude, GPT ou Gemini.
C’est le point important à retenir : ReAct est un schéma, et non une fonctionnalité de bibliothèque. LangGraph vous offre par hasard un moyen simple de l’exprimer à l’aide d’un StateGraph, mais vous pouvez mettre en œuvre ce même mécanisme avec n’importe quel modèle et n’importe quel code d’orchestration. Le projet deep-research-agent le présente sous forme de sous-graphique LangGraph, ce qui en fait un élément composable : un système plus grand peut l’appeler comme une seule unité. Si vous souhaitez un rappel conceptuel avant de vous plonger dans le code, consultez notre aperçu sur la manière dont les agents IA combinent le raisonnement et des actions du monde réel.
Les trois composants et l’état qu’ils partagent
Le cycle de recherche est constitué de trois petites fonctions, chacune ayant une tâche précise. Le cerveau (llm_call) lit la conversation en cours et répond soit par du texte brut, soit par des demandes d’appel d’outil. Les mains (tool_node) exécutent tous les appels d’outil demandés et renvoient leurs résultats. Le routeur (should_continue) décide s’il est nécessaire d’enchaîner une nouvelle étape. En séparant ces responsabilités, il devient possible de tester chaque composante individuellement, de remplacer le modèle ou les outils de manière indépendante, et de comprendre le fonctionnement du cycle en examinant simplement trois fonctions courtes.
Définition de l’état du chercheur
Tout ce qui circule à l’intérieur d’un graphe LangGraph existe au sein d’un objet d’état, déclaré sous forme de TypedDict. L’objet d’état du chercheur comporte cinq champs. researcher_messages contient la conversation en cours ; il est enveloppé dans Annotated avec le réducteur add_messages, qui indique à LangGraph de fusionner les nouveaux messages dans la liste existante plutôt que de l’écraser. tool_call_iterations compte les cycles de boucle, research_topic enregistre ce que l’agent étudie, compressed_research reçoit le résumé final, et raw_notes collecte des notes en utilisant operator.add comme réducteur, de sorte que les listes retournées par différents nœuds sont concaténées.
# Define the state that flows through the entire ReAct loop
from typing import Annotated, Sequence, List, TypedDict
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
import operator
class ResearcherState(TypedDict):
# The message history accumulates as the loop runs
researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
# Tracks how many tool call iterations have occurred
tool_call_iterations: int
# The topic this researcher is investigating
research_topic: str
# The final compressed output after the loop ends
compressed_research: str
# Raw notes collected during research
raw_notes: Annotated[List[str], operator.add]
Les réducteurs sont ce qui fait fonctionner la boucle. Chaque fois que le « cerveau » émet un signal ou que les mains renvoient des résultats, un nœud ne renvoie que les nouveaux messages, qui sont ensuite ajoutés par le réducteur. Sans la fonction add_messages, chaque nœud remplacerait l’historique, et le modèle perdrait tout ce qu’il avait appris lors des itérations précédentes.
Le projet définit également un schéma de sortie plus restreint. Il contrôle quels champs quittent le sous-graphe lorsque le graphe parent l’appelle.
# Output schema controls what the parent graph sees
class ResearcherOutputState(TypedDict):
compressed_research: str
raw_notes: Annotated[List[str], operator.add]
researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
Séparer l’état interne de l’état de sortie est une bonne pratique dans LangGraph. Des mécanismes de suivi tels que tool_call_iterations ne sont utiles qu’à l’intérieur de la boucle, de sorte que le graphe parent ne les voit jamais. Le parent reçoit les résultats compressés de la recherche, les notes brutes et les messages, ce qui permet d’assurer une interface entre les graphes simple et bien structurée.
Pourquoi utiliser un TypedDict plutôt qu’un dictionnaire ordinaire ? Il documente précisément les données qui circulent dans le graphe et permet aux vérificateurs de types de détecter les clés mal orthographiées. Le réducteur add_messages ajoute une fonctionnalité supplémentaire : il ajoute de nouveaux messages, et lorsque un message reçu contient l’ID d’un message déjà présent dans la liste, il le remplace au lieu de le dupliquer, ce qui garantit que l’ordre et l’identité restent cohérents.
Le cerveau : llm_call
C’est le cerveau où a lieu le raisonnement. Il construit une requête à partir d’un message système ainsi que de l’ensemble de l’historique des messages, puis demande au modèle quelle action entreprendre ensuite. Notez que la source qualifie ce fragment de JavaScript ; il s’agit en réalité de Python.
# The "brain" of the researcher: analyzes current state and decides next action
from langchain_core.messages import SystemMessage
def llm_call(state: ResearcherState):
# Invoke the LLM with the system prompt and full conversation history
return {
"researcher_messages": [
model_with_tools.invoke(
[SystemMessage(content=research_agent_prompt.format(date=get_today_str()))]
+ state["researcher_messages"]
)
]
}
Trois choses se produisent ici. Premièrement, un SystemMessage est créé à partir de research_agent_prompt, avec la date d’aujourd’hui injectée via get_today_str() afin que le modèle puisse évaluer à quel point ses informations sont à jour. Deuxièmement, ce message système est ajouté en tête de tout le contenu présent dans state["researcher_messages"], de sorte que le modèle voit toujours le contexte complet. Troisièmement, model_with_tools.invoke() envoie tout cela à un modèle doté d’outils. La réponse est soit du texte brut, indiquant que la recherche est terminée, soit une ou plusieurs demandes d’appel d’outil, indiquant qu’une information supplémentaire est nécessaire. La fonction renvoie la réponse encapsulée dans une liste sous researcher_messages, et le réducteur l’ajoute à cette liste.
Le prompt du système est reconstruit à chaque appel plutôt que stocké dans l’état. Cela maintient un historique propre et garantit que les instructions sont toujours prioritaires, même après de nombreuses itérations.
model_with_tools est créé lors de la configuration. Au lieu d’importer une classe fournisseur telle que ChatOpenAI, le projet utilise init_chat_model() de LangChain, qui prend en entrée une chaîne de modèle préfixée par le nom du fournisseur.
# Initialize the model using LangChain's provider-agnostic helper
from langchain.chat_models import init_chat_model
# The project uses different models for different tasks
model = init_chat_model(model="openai:gpt-4o")
# Bind the research tools so the model knows what actions are available
model_with_tools = model.bind_tools([tavily_search, think_tool])
Le préfixe du fournisseur représente un avantage : passer de "openai:gpt-4o" à un modèle Anthropic (une chaîne de caractères de la forme "anthropic:claude-sonnet-4-20250514") constitue un changement de configuration, et non un changement d’importation. Vérifiez la liste actuelle des modèles de votre fournisseur, car les identifiants évoluent avec le temps. La méthode .bind_tools() annonce ensuite les actions disponibles au modèle. Deux outils sont liés : tavily_search, qui effectue des recherches sur le web via l’API Tavily, et think_tool, un outil de réflexion décrit plus loin.
Les « mains » : tool_node
Ces éléments prennent les appels d’outils provenant du dernier message envoyé par le « cerveau » et les exécutent. Ce fragment est également écrit en Python, malgré son étiquette JavaScript.
# The "hands" of the researcher: executes all tool calls from the brain
from langchain_core.messages import ToolMessage
def tool_node(state: ResearcherState):
# Get the tool calls from the last message (the brain's output)
tool_calls = state["researcher_messages"][-1].tool_calls
observations = []
# Execute each tool call and collect raw results
for tool_call in tool_calls:
tool = tools_by_name[tool_call["name"]]
observations.append(tool.invoke(tool_call["args"]))
# Convert raw results into properly formatted ToolMessage objects
tool_outputs = [
ToolMessage(
content=str(observation),
name=tool_call["name"],
tool_call_id=tool_call["id"]
)
for observation, tool_call in zip(observations, tool_calls)
]
return {"researcher_messages": tool_outputs}
La fonction lit les tool_calls de le dernier message, recherche chaque outil demandé par son nom dans le dictionnaire tools_by_name, puis l’appelle avec les arguments fournis par le modèle. C’est la deuxième partie qui est cruciale pour la correction : chaque résultat brut est enveloppé dans un objet ToolMessage comprenant trois champs. content contient le résultat sous forme de chaîne, name indique quel outil l’a généré, et tool_call_id relie ce résultat à la demande précise qui l’a déclenché. Les API de modèle exigent cet ID ; un résultat d’outil qui ne peut pas être associé à une demande est rejeté, et une demande sans résultat correspondant laisse la conversation dans un état invalide.
La correspondance tools_by_name est utilisée mais n’est jamais définie dans le cahier des notes du projet. Vous devrez la créer vous-même, par exemple sous la forme de {"tavily_search": tavily_search, "think_tool": think_tool}, ou à l’aide d’une compréhension de dictionnaire sur la liste des outils afin que les noms restent toujours en synchronisation avec ce qui a été défini.
Lorsque le « cerveau » demande à tavily_search de rechercher « les dernières recherches en IA », les « mains » exécutent la requête et retournent un message ayant cette forme :
ToolMessage(content="Search results for 'latest AI research': ...", name="tavily_search", tool_call_id="call_abc123")
Comme les appels aux outils sont exécutés séquentiellement dans une boucle for simple, un cycle comprenant plusieurs recherches prend autant de temps que la somme de tous ces temps. Cela est acceptable pour une démonstration ; plus tard, nous verrons comment rendre ce nœud plus robuste.
Le routeur : should_continue
Le routeur est la fonction la plus simple et celle qui gère l’ensemble du cycle. Il examine le dernier message et choisit le nœud suivant.
# The "router": determines whether to loop again or finish
from typing import Literal
def should_continue(state: ResearcherState) -> Literal["tool_node", "compress_research"]:
# Check the last message in the conversation
messages = state["researcher_messages"]
last_message = messages[-1]
# If the brain requested tool calls, continue the loop
if last_message.tool_calls:
return "tool_node"
# If no tool calls, the brain is done researching
return "compress_research"
Si le dernier message émis par le cerveau contient des tool_calls, le routeur renvoie "tool_node" et le cycle se poursuit. Si le cerveau n’a produit que du texte, il renvoie "compress_research", ce qui fait sortir du cycle pour passer à l’étape de synthèse. La décision est basée exclusivement sur la sortie du modèle ; le routeur lui-même n’a aucune opinion quant à savoir si la recherche est suffisamment bonne.
Literal["tool_node", "compress_research"] : cette annotation de retour indique à LangGraph quels destins sont possibles. LangGraph l’utilise pour connaître les branches du routage (par exemple lors du dessin du graphe ou en l’absence de mappage explicite), ce qui en fait plus qu’une simple documentation. Cependant, cela n’empêche pas la fonction de retourner une autre chaîne de caractères en temps de exécution ; cela se manifesterait alors comme une erreur lorsque cette branche est choisie.
Pourquoi router vers une étape de compression plutôt que d’arrêter directement ? Parce que ce sous-graphe est conçu pour être appelé par un agent superviseur. Ce dernier a besoin d’une réponse concise et structurée, et non d’un long rapport des recherches, réflexions et données transmises par les outils. La compression à l’intérieur du sous-graphe permet de maintenir un contexte limité pour le superviseur.
Connexion du cycle avec un StateGraph
Avec ces trois fonctions en place, l’étape suivante consiste à les relier. Au lieu d’écrire manuellement le flux de contrôle, on déclare des nœuds et des arêtes, puis LangGraph exécute le graphe. L’exécution commence au cerveau, passe par le routeur, et se dirige soit vers les mains (après quoi elle retourne toujours au cerveau), soit sort par compress_research.
Montage et compilation du graphe
Cet extrait est également en Python, malgré l’étiquette JavaScript.
# Build the ReAct loop as a LangGraph StateGraph
from langgraph.graph import StateGraph, START, END
# Initialize the graph with both input state and output schema
agent_builder = StateGraph(ResearcherState, output_schema=ResearcherOutputState)
# Add the three nodes to the graph
agent_builder.add_node("llm_call", llm_call) # The brain
agent_builder.add_node("tool_node", tool_node) # The hands
agent_builder.add_node("compress_research", compress_research) # The exit point
# Wire the entry point: execution starts at the brain
agent_builder.add_edge(START, "llm_call")
# Wire the router: after the brain thinks, decide what to do next
agent_builder.add_conditional_edges(
"llm_call",
should_continue,
{
"tool_node": "tool_node",
"compress_research": "compress_research",
},
)
# Wire the loop: after the hands act, always go back to the brain
agent_builder.add_edge("tool_node", "llm_call")
# Wire the exit: after compression, end the graph
agent_builder.add_edge("compress_research", END)
# Compile the graph into a runnable agent
researcher_agent = agent_builder.compile()
En le lisant de haut en bas :
StateGraph(ResearcherState, output_schema=ResearcherOutputState)crée le graphe avec l’état interne complet ainsi que le schéma de sortie restreint que les graphes parents verront.- Trois nœuds sont enregistrés :
llm_call,tool_nodeetcompress_research.
add_edge(START, “llm_call”) fait du cerveau le point d’entrée.add_conditional_edges relie le routeur à la sortie du cerveau, en utilisant un dictionnaire qui associe chaque valeur de retour possible à un nœud.tool_node vers llm_call ferme le cycle.add_edge(“compress_research”, END) termine le graphe une fois que le résumé a été écrit..compile() transforme cette déclaration en un objet exécutable. Il est nommé researcher_agent plutôt que simplement agent car, dans le projet complet, il s’agit d’un sous-graphe invoqué par le superviseur.
La mise en correspondance transmise à add_conditional_edges mérite une attention particulière. Ses clés, "tool_node" et "compress_research", doivent correspondre exactement à ce que renvoie should_continue, et leurs valeurs doivent être des noms de nœuds réels. La compilation vérifie que les destinations mappées existent, ce qui permet d’identifier rapidement une erreur de frappe dans un nom de nœud plutôt que d’attendre la moitié du traitement. En revanche, un routeur qui renvoie une valeur absente de la carte ne provoquera une erreur qu’au moment où ce branchement sera exécuté ; il est donc nécessaire de tester le routeur sur les deux branches. Néanmoins, cela reste bien plus facile à valider qu’un cycle while écrit manuellement, où un branchement incorrect se traduit simplement par un comportement anormal.
Visualisation du cycle
Le graphe compilé génère ce flux :
START
│
▼
llm_call (Brain reasons about the query)
│
├── has tool_calls? ──► tool_node (Hands execute tools)
│ │
│ └──► llm_call (Back to brain)
│
└── no tool_calls? ──► compress_research (Summarize and exit)
C’est le cycle classique ReAct. Le cerveau et les mains peuvent alterner autant de fois que le modèle continue de demander des outils. Chaque itération ajoute des observations à l’état, ce qui permet de prendre des décisions plus éclairées à chaque fois.
Ajout du mécanisme de sauvegarde
L’exemple présenté jusqu’à présent concerne un agent autonome fonctionnant en mémoire. Pour tout processus longue durée, on ajoute un mécanisme de sauvegarde au moment de la compilation afin que l’état du graphe soit enregistré après chaque étape.
# Production: add checkpointing for fault tolerance
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
agent = agent_builder.compile(checkpointer=checkpointer)
MemorySaver conserve les points de contrôle en mémoire de processus, ce qui est idéal pour le développement et les tests, mais ils disparaissent à la redémarrage. Pour la production, LangGraph propose des outils de sauvegarde persistants tels que PostgresSaver et SqliteSaver, qui permettent de reprendre une exécution interrompue à partir du dernier état enregistré et de conserver un historique de chaque transition d’état. Un détail pratique : une fois qu’un graphe dispose d’un outil de pointage, chaque appel invoke nécessite un identifiant de thread dans sa configuration (par exemple {"configurable": {"thread_id": "..."}}) afin que LangGraph sache quel dialogue enregistré charger et mettre à jour.
Renforcement du cycle : réflexion, budgets et parallélisme
Un cycle ReAct pur présente deux modes de défaillance qui apparaissent rapidement. Il peut tourner en boucle sans converger, gaspillant des tokens et des crédits API à effectuer recherche après recherche. Ou bien il peut parcourir rapidement les recherches sans vraiment les analyser, produisant des résultats superficiels. Le projet traite ces deux problèmes, puis élargit le cycle grâce à trois ajouts.
Réflexion forcée avec think_tool
L’ajout le plus intéressant est un outil qui ne met en œuvre aucune action externe. Son unique but est de faire en sorte que le modèle s’arrête et réfléchisse par écrit.
# A tool that forces the agent to pause and reflect
from langchain_core.tools import tool
@tool(parse_docstring=True)
def think_tool(reflection: str) -> str:
"""Tool for strategic reflection on research progress and decision-making.
Use this tool after each search to analyze results and plan next steps
systematically. This creates a deliberate pause in the research workflow
for quality decision-making.
Args:
reflection: Your detailed reflection on research progress, findings,
gaps, and next steps.
Returns:
Confirmation that reflection was recorded for decision-making.
"""
return f"Reflection recorded: {reflection}"
think_tool reçoit une chaîne de type reflection et la renvoie avec un préfixe indiquant une confirmation. La longue documentation n’est pas une simple décoration : avec parse_docstring=True, LangChain extrait la description de l’outil ainsi que celle des arguments à partir de cette documentation, et c’est ce texte que le modèle lit lors du choix entre les outils. Cet effet provient de l’instruction système, qui demande à l’agent d’appeler think_tool après chaque recherche. Cela oblige le modèle à indiquer ce qu’il vient d’apprendre, ce qui manque encore et ce qu’il compte faire ensuite.
Sans cette étape, les agents ont tendance à effectuer des recherches successives sans synthétiser quoi que ce soit entre elles. L’équipe d’ingénierie d’Anthropic a fait une observation similaire : les agents capables de revoir et de corriger leurs propres résultats sont plus fiables, car ils détectent les erreurs avant qu’elles ne s’aggravent et peuvent corriger leur trajectoire lorsqu’ils s’écartent du but. think_tool intégre un petit mécanisme d’auto-évaluation à chaque itération. Cela est peu coûteux, car cela ne nécessite que les tokens de la réflexion elle-même ainsi qu’un aller-retour supplémentaire, et cela permet au modèle de rester ancré dans son objectif.
Supposons qu’après une recherche, l’agent constate avoir trouvé trois approches d’indexation RAG mais qu’il manque encore des benchmarks pour les comparer. L’outil se contente de répéter cette réflexion avec son préfixe de confirmation :
Reflection recorded: The search results show three approaches to RAG indexing. I still need to find benchmarks comparing them.
Cette chaîne de caractères est stockée en tant que ToolMessage, de sorte que lors de la prochaine itération, le système lit à nouveau son propre plan. Pourquoi utiliser un outil plutôt que de demander simplement au modèle de « réfléchir étape par étape » ? Une appel à outil constitue un événement discret et visible dans le suivi des actions, il est enregistré dans l’historique des messages sous un format prévisible, et la requête peut exiger son utilisation à un moment précis du cycle.
Contrôles de budget dans la prompt système
La deuxième mesure limite le nombre de recherches effectuées par l’agent :
Budget rules embedded in the system prompt:
- Simple queries: 2 to 3 search calls maximum
- Complex queries: up to 5 search calls maximum
- Always stop after 5 calls if sources are not found
Ces limites sont définies dans la prompt système, et non dans le code. Le modèle sait combien de recherches sont nécessaires pour une requête simple ou complexe, ainsi que quand abandonner. C’est un choix pragmatique : pas de compteurs ni de logique graphique supplémentaire, simplement des instructions auxquelles on fait confiance pour être suivies par le modèle.
Le compromis est réel. Les directives de Google Cloud indiquent que le style itératif entraîne une latence par rapport à une seule requête, et que les résultats dépendent fortement de la qualité du modèle. Un budget de recherche limite directement cette latence en déterminant le nombre maximal d’itérations possibles.
Cependant, les limites basées sur des prompts sont flexibles. Un modèle peut mal évaluer la complexité ou ignorer simplement l’instruction. Le système dispose déjà d’un champ tool_call_iterations, il est donc aisé d’ajouter un plafond strict que le routeur applique. Une configuration robuste utilise les deux approches : les prompts orientent le comportement normal, tandis que du code garantit un plafond maximal. Notre article sur les boucles agentes limitées pour l’utilisation d’outils LLM explore cette même idée en TypeScript.
Collecte dispersée avec un superviseur
La troisième étape considère l’ensemble du cycle ReAct comme un travailleur réutilisable. Un agent superviseur divise une question globale en sous-questions et exécute, en parallèle, un sous-graphe de chercheur distinct pour chacune d’elles.
Supervisor receives: "Compare the economic impact of AI on healthcare vs. education"
Supervisor creates two parallel research tasks:
├── ReAct Agent 1: Research AI impact on healthcare
└── ReAct Agent 2: Research AI impact on education
Both agents run their ReAct loops independently.
Results are gathered and synthesized by the supervisor.
C’est le schéma de dispersion et de collecte : on répartit le travail entre des travailleurs indépendants, puis on rassemble et on fusionne leurs résultats. Chaque chercheur dispose de son propre état, de ses outils et de son budget, de sorte que l’historique long d’une recherche ne contamine jamais le contexte d’une autre. Le superviseur n’aperçoit que les résultats compressés.
Le superviseur est lui-même un petit graphe. Au lieu d’une boucle for fixe sur les sous-questions, il s’appuie sur le type de retour Command de LangGraph, qui permet à un nœud de mettre à jour son état et de désigner le nœud suivant en une seule étape :
Supervisor sub-graph nodes:
├── supervisor (LLM decides what to do next)
├── supervisor_tools (executes supervisor-level tools like ConductResearch)
├── red_team (attacks draft logic to find flaws)
└── context_pruner (clears raw notes to manage context size)
The supervisor_tools node uses Command to route dynamically:
- If research is needed → spawns researcher sub-graphs via ConductResearch tool
- If critique is needed → routes to red_team node
- If context is bloated → routes to context_pruner node
- If research is complete → routes to END
Pour le superviseur, le chercheur représente une boîte noire. Il appelle l’outil ConductResearch, qui à son tour invoque le sous-graphe du chercheur compilé, ce dernier exécutant ensuite en autonomie l’ensemble du cycle ReAct. Autour de celui-ci se trouvent d’autres nœuds spécialisés : un nœud red_team qui attaque le raisonnement du projet afin de détecter des faiblesses, et un context_pruner qui supprime les notes brutes lorsque le contexte devient trop volumineux.
L’utilisation de Command au lieu d’arêtes statiques confère au superviseur une flexibilité en temps de exécution. Après chaque étape, il peut choisir d’envoyer davantage de chercheurs, de soumettre un projet en version préliminaire à l’évaluation, de réduire le contexte ou d’achever le processus, en fonction de l’état actuel. Le inconvénient est que la logique de routage se retrouve dans le code des nœuds, ce qui rend la structure du graphe moins évidente à partir uniquement des déclarations d’arêtes ; une bonne gestion des journaux et du suivi devient alors plus importante.
Suivre une exécution complète
Pour voir comment les éléments fonctionnent ensemble, posez à l’agent une question modérément complexe. Les étiquettes sources désignent cet appel comme du texte brut ; il s’agit en réalité de Python.
# Run the agent with a research question
result = agent.invoke({
"researcher_messages": [
HumanMessage(content="What are the main approaches to reducing hallucination in RAG systems?")
]
})
Deux petites corrections s’imposent si vous exécutez ce code vous-même. Le graphe compilé précédemment s’appelle researcher_agent, donc utilisez ce nom (ou le agent sauvegardé de la section précédente). Et si vous utilisez la version sauvegardée, transmettez un thread_id dans la configuration comme décrit ci-dessus.
Un trace d’exécution illustratif ressemble à ceci. Il s’agit d’un journal plutôt que de code Python.
--- Iteration 1 ---
[Brain] Reasoning: I need to search for approaches to reducing RAG hallucination.
[Brain] Tool call: search_tool(query="reducing hallucination in RAG systems approaches")
[Hands] Executing search_tool...
[Hands] Results: Found 5 relevant articles about RAG hallucination reduction.
[Router] Last message has tool_calls? No (think_tool was called)
--- Iteration 2 ---
[Brain] Tool call: think_tool(reflection="The search results mention three main
approaches: better chunking strategies, re-ranking retrieved documents, and
adding citation verification. I should search for specific implementations.")
[Hands] Executing think_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 3 ---
[Brain] Tool call: search_tool(query="citation verification RAG pipeline implementation")
[Hands] Executing search_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 4 ---
[Brain] Tool call: think_tool(reflection="I now have solid coverage of the three
main approaches with implementation details. I have enough information to
provide a comprehensive answer.")
[Hands] Executing think_tool...
[Router] tool_calls present -> route to tool_node
--- Iteration 5 ---
[Brain] No tool calls. Generating final response.
[Router] No tool_calls -> route to compress_research
[Compress] Summarizing all findings into structured output.
Ce que montre le trace :
- L’agent a effectué deux recherches et deux appels de réflexion, ce qui reste bien dans les limites prévues par la consigne.
- Chaque réflexion a résumé ce qui avait été appris et préparé la recherche suivante, ce qui correspond exactement à l’objectif de la règle forced-reflection.
tool_node chaque fois qu’il y avait des appels à des outils, et vers compress_research lorsqu’il n’y en avait pas.Lisez ce trace comme un schéma, et non comme une sortie de programme littérale. Il nomme l’outil de recherche search_tool alors que l’outil réel est tavily_search ; de plus, la ligne du routeur lors de la première itération indique qu’aucun appel à outil n’a été effectué, bien qu’une recherche ait été demandée, ce qui aurait en réalité dû diriger vers tool_node. Ce qui importe, c’est la structure globale : alternance entre recherche et réflexion jusqu’à ce que le modèle donne une réponse en texte brut.
Mettre la boucle en production
Le sous-graphe de recherche constitue une base solide, mais cinq améliorations en font un outil bien plus fiable dans les déploiements réels :
- Imposer le budget dans le code. Augmenter
tool_call_iterationsà chaque itération et faire en sorte queshould_continueredirige verscompress_researchune fois la limite atteinte, quel que soit le demandé par le modèle. Il s’agit du filet de sécurité derrière les limites souples définies par l’instruction. - Fournir des mises à jour aux utilisateurs en temps réel. La méthode
.astream_events()de LangGraph émet des événements pour les transitions de nœuds et les appels d’outils, permettant ainsi à une interface utilisateur d’afficher des messages tels que "Recherche en cours..." ou ">Analyse des résultats..." pendant que la boucle s’exécute, au lieu d’un indicateur de chargement.
try/except et de renvoyer un objet ToolMessage décrivant l’erreur. Le système central peut alors détecter cet échec et tenter à nouveau avec d’autres paramètres ou modifier sa méthode. La documentation d’Anthropic recommande de signaler les erreurs des outils au modèle de cette manière.PostgresSaver pour les tâches nécessitant de nombreuses itérations, afin que l’exécution interrompue puisse reprendre à partir de l’endroit où elle s’est arrêtée, tout en maintenant la traçabilité de chaque étape de raisonnement et de chaque appel à un outil.Points clés
- ReAct est un cycle de réflexion, d’action et d’observation ; il est indépendant du framework, et LangGraph rend simplement ce cycle explicite et inspectable.
- Trois nœuds à usage unique (cerveau, mains, routeur) ainsi qu’un état géré par un réducteur suffisent pour créer un agent de recherche fonctionnel.
- Associez toujours le résultat de chaque outil à son
tool_call_id, et séparez l’état interne de ce que le sous-graphe expose. - Un outil de réflexion inactif constitue un moyen peu coûteux et visible pour forcer une synthèse entre les recherches.
- Les budgets de prompts influencent le comportement, mais seuls des limites au niveau du code garantissent une fin de traitement.
Lectures complémentaires
- Construire un agent IA de zéro : Patterns, ReAct et LangGraph — Découvrez les concepts fondamentaux des agents IA — planification, utilisation d’outils, réflexion et le pattern ReAct — ainsi que le rôle de LangChain et LangGraph dans leur création manuelle.
- Choisir un framework pour les agents IA en Python en 2026 : Une comparaison pratique — Compare cinq frameworks Python pour agents IA en fonction de leur gestion des erreurs et de la complexité, afin d’aider les lecteurs à sélectionner l’outil adapté à leur workflow.
- Routing, Fan-Out, ReAct, Critique et Approbation : Cinq patterns LangGraph — Découvrez cinq patterns de flux de travail agents dans LangGraph, allant des routeurs et des boucles ReAct aux portes d’évaluation et à l’approbation humaine, ainsi que les contraintes nécessaires pour chacun en environnement de production.
- À l’intérieur d’InMemorySaver de LangGraph : Comment fonctionnent les checkpoints, les écritures et les blobs — Décortiquez les dictionnaires de stockage, d’écritures et de blobs au sein d’InMemorySaver de LangGraph et suivez comment une seule exécution de graphe se transforme en trois checkpoints liés entre eux.
- Agents spécialisés, un routeur de mots-clés et interrupt(): Un coach LangGraph — Concevez un assistant LangGraph avec deux spécialistes, un routeur déterministe, un état partagé qui persiste lors des transferts, ainsi qu’un point de contrôle humain basé sur interrupt et Command.
- De un chatbot à un nœud à un agent géré par MCP dans LangGraph — Construisez une application LangGraph couche par couche : état et réducteurs, arêtes, boucles d’outils, threads avec points de contrôle, trois modes de streaming, et outils fournis via MCP.