15. Construire votre premier graphe avec LangGraph
Dans la partie IV, nous avons défini des outils, les avons connectés à un LLM, et complété une boucle d'agent qui répète le cycle décider-et-exécuter. Piloter la boucle, exécuter les outils lorsque le LLM les demandait, savoir quand s'arrêter — nous avons codé chaque partie de ce flux à la main.
Dans ce chapitre, nous allons construire le même agent d'une manière totalement différente. Au lieu d'écrire le flux directement, nous allons enregistrer des étapes (nœuds) et des règles de connexion (arêtes) auprès du framework LangGraph et le laisser gérer l'exécution. Le comportement est identique à celui du chapitre 14, mais la façon dont nous le construisons change.
Ce chapitre couvre quatre concepts fondamentaux — StateGraph, nœuds, arêtes et State — puis refactorise la boucle d'agent du chapitre 14 en un graphe LangGraph. Dans les chapitres suivants, le chapitre 16 traite du routage conditionnel et des composants préconstruits, et le chapitre 17 traite de la persistance de l'état qui permet à un agent de reprendre là où il a été interrompu.
15.1) Pourquoi des graphes ?
15.1.1) Limites de la boucle d'agent existante
Revisitons la boucle d'agent du chapitre 14. En retirant la gestion des erreurs et d'autres détails, la structure de base ressemblait à ceci :
# Boucle d'agent du chapitre 14 — structure de base (simplifiée)
messages = [
SystemMessage(content="Vous êtes un assistant utile."),
HumanMessage(content=user_input),
]
for step in range(max_steps):
# Demande au LLM de décider de la prochaine action
ai_message = llm_with_tools.invoke(messages)
messages.append(ai_message)
# Si aucun appel d'outil n'est demandé, renvoie la réponse finale
if not ai_message.tool_calls:
return ai_message.content
# Exécute les outils demandés
for tool_call in ai_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
messages.append(tool_message)Ce code ne couvre que les bases et rien d'autre. Dans un véritable environnement de production, cependant, il faut beaucoup plus. Voici quelques exemples.
- Récupération après plantage — Si un agent plante à l'étape 7 d'une tâche de recherche en 10 étapes, il devrait pouvoir reprendre à l'étape 7 au lieu de recommencer depuis le début.
- Demandes d'approbation — Avant qu'un agent n'effectue une opération critique, il devrait pouvoir se mettre en pause et demander à un humain « Est-il correct de continuer ? ».
- Surveillance en temps réel — Les utilisateurs devraient pouvoir voir ce que l'agent est en train de faire et quels outils il appelle.
- Visualisation et débogage — Un diagramme montrant le fonctionnement de l'agent devrait être disponible afin que, lorsque des problèmes surviennent, vous puissiez identifier quelle étape a mal tourné.
Implémenter ces fonctionnalités vous-même n'est pas impossible, mais ce n'est pas facile non plus. La seule récupération après plantage nécessite d'écrire du code pour sérialiser l'état à chaque étape, l'enregistrer sur disque, le restaurer et reprendre à la position exacte. Vous pourriez finir avec plus de code d'infrastructure que de logique métier.
LangGraph a été conçu pour fournir ces fonctionnalités au niveau du framework. Récupération après plantage, demandes d'approbation, surveillance, visualisation — le framework gère tout cela. Mais il y a une exigence : vous devez construire votre agent dans une structure que le framework peut comprendre.
La boucle d'agent du chapitre 14 gère toute la logique directement, il n'y a donc rien à quoi le framework puisse se raccorder. Pour tirer parti de ce que LangGraph offre, nous devons reconstruire l'agent dans une structure que LangGraph comprend — un graphe. C'est le sujet de ce chapitre.
15.1.2) Qu'est-ce que LangGraph ?
LangGraph est un framework d'orchestration qui définit et exécute des workflows d'agent sous forme de graphes. Un graphe désigne ici une structure où chaque nœud (étape) que l'agent effectue est connecté par des arêtes (règles de connexion).
Dans LangGraph, vous décomposez le workflow en nœuds indépendants et vous les connectez avec des arêtes. LangGraph parcourt ensuite le graphe, exécutant chaque nœud au fur et à mesure. Voici à quoi ressemble la boucle d'agent du chapitre 14 exprimée sous forme de graphe :
Les boîtes rectangulaires sont des nœuds, et les flèches sont des arêtes. Le losange représente une arête conditionnelle qui bifurque vers différents chemins en fonction d'une condition.
Dans le chapitre 14, l'ensemble du workflow vivait dans des boucles for, des vérifications if et d'autre code écrit à la main. Avec LangGraph, vous définissez ce que fait chaque nœud et vous reliez les nœuds entre eux avec des arêtes. En bref, vous passez du codage du workflow à sa déclaration en tant que structure.
LangGraph ne remplace rien de ce que vous avez appris dans les chapitres 12 à 14. Les définitions d'outils, bind_tools(), tool_calls, ToolMessage — tout cela est toujours utilisé à l'intérieur des nœuds, exactement comme avant.
La section suivante couvre les composants fondamentaux de LangGraph — StateGraph, State, nœuds et arêtes — un par un.
15.2) Composants de LangGraph : StateGraph, State, nœuds, arêtes
Cette section parcourt les quatre composants fondamentaux de LangGraph un par un. Nous commencerons par StateGraph — la classe qui regroupe State, nœuds et arêtes en un graphe — puis nous couvrirons chacune des parties (State, nœuds, arêtes) qui se trouvent à l'intérieur.
15.2.1) StateGraph
StateGraph est la classe utilisée pour construire des graphes dans LangGraph. Vous spécifiez le State que le graphe va gérer, vous ajoutez des nœuds, vous les connectez avec des arêtes, puis vous compilez pour produire un graphe exécutable.
Voyons comment cela fonctionne.
Créer une instance de StateGraph
Appelez le constructeur StateGraph pour créer une instance. Vous devez passer le schéma du State (la classe elle-même) en paramètre. Ici, nous utilisons MessagesState, un State prédéfini que LangGraph fournit pour gérer les listes de messages. Nous en verrons les détails en 15.2.2.
from langgraph.graph import StateGraph, MessagesState
builder = StateGraph(MessagesState)Ajouter des nœuds
Utilisez add_node() pour enregistrer un nœud. Un nœud est une fonction Python qui prend le State actuel et renvoie les parties qu'elle souhaite modifier. Nous couvrirons les fonctions de nœud en détail en 15.2.3.
def say_hello(state: MessagesState):
return {"messages": [{"role": "ai", "content": "hello world"}]}
builder.add_node(say_hello) # le nom du nœud devient "say_hello"Connecter des arêtes
Utilisez add_edge(source, target) pour connecter des nœuds. source est le point de départ de l'arête ; target est sa destination. START et END sont des marqueurs spéciaux pour les points d'entrée et de sortie du graphe. Nous couvrirons les arêtes en 15.2.4.
from langgraph.graph import START, END
builder.add_edge(START, "say_hello") # le graphe démarre → exécute say_hello
builder.add_edge("say_hello", END) # say_hello se termine → termine le grapheCompiler et exécuter
Appeler compile() valide la structure du graphe et produit un objet exécutable. Vous exécutez le graphe compilé avec invoke(), en passant les valeurs initiales du State.
graph = builder.compile()
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)Maintenant, rassemblons tout cela et construisons un graphe simple :
from langgraph.graph import StateGraph, MessagesState, START, END
def say_hello(state: MessagesState):
return {"messages": [{"role": "ai", "content": "hello world"}]}
builder = StateGraph(MessagesState)
builder.add_node(say_hello)
builder.add_edge(START, "say_hello")
builder.add_edge("say_hello", END)
graph = builder.compile()
initial_state = {"messages": [{"role": "user", "content": "hi!"}]}
result = graph.invoke(initial_state)
print(result["messages"][-1].content)Sortie :
hello worldLorsque vous appelez invoke(), le graphe s'exécute dans l'ordre START → say_hello → END. say_hello a renvoyé un dictionnaire avec messages comme clé, et cette valeur a été ajoutée à la liste messages dans MessagesState. Nous approfondirons le fonctionnement de ce mécanisme en 15.2.2. Le résultat est que l'extraction du contenu du dernier message nous donne "hello world".
15.2.2) State : les données qui circulent à travers le graphe
State représente les données que chaque nœud du graphe partage. Lorsqu'un nœud s'exécute, il reçoit le State actuel, effectue son travail, et renvoie uniquement les parties qu'il souhaite modifier. LangGraph réintègre ces changements dans le State et transmet la version mise à jour au nœud suivant.
Définir le State
Vous définissez le State en créant une sous-classe de TypedDict. Choisissez les champs et les types qui correspondent à ce que votre agent doit suivre. Voici un exemple simple :
from typing_extensions import TypedDict
class AgentState(TypedDict):
messages: list # liste de messages
llm_calls: int # nombre d'appels au LLMÀ partir de là, vous passez AgentState lors de la création d'un StateGraph et vous l'utilisez comme indice de type pour vos fonctions de nœud.
Reducers
Lorsqu'un nœud renvoie une valeur, le champ correspondant du State est mis à jour. Le comportement par défaut est le remplacement — si un nœud renvoie {"llm_calls": 3}, llm_calls devient simplement 3, quelle que soit sa valeur précédente.
Mais certains champs nécessitent un ajout, et non un remplacement. Que se passe-t-il si messages est remplacé ? Chaque fois qu'un nœud renvoie un nouveau message, tout l'historique de conversation disparaît. Pour messages, l'ajout est le bon comportement.
LangGraph vous permet de définir une stratégie de mise à jour différente par champ grâce à une fonction reducer. Vous spécifiez le reducer comme deuxième argument dans Annotated :
from typing_extensions import TypedDict, Annotated
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages] # reducer : ajout
llm_calls: int # pas de reducer : remplacementadd_messages est un reducer fourni par LangGraph. Au lieu de remplacer la liste, il ajoute les nouveaux messages à ceux qui existent déjà. Comme ce reducer est défini sur le champ messages, toute valeur qu'un nœud renvoie pour messages est ajoutée. llm_calls n'a pas de reducer, donc les valeurs renvoyées remplacent simplement ce qui s'y trouvait auparavant.
C'est exactement pour cette raison que le message renvoyé par say_hello en 15.2.1 a été ajouté à messages plutôt que de le remplacer — le reducer s'en est chargé.
MessagesState
LangGraph est livré avec un State prédéfini appelé MessagesState. Voici à quoi il ressemble en interne :
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]La même structure que nous venons de voir — un champ messages avec le reducer add_messages déjà câblé.
Si vous avez besoin de champs supplémentaires, il suffit de créer une sous-classe :
from langgraph.graph import MessagesState
class AgentState(MessagesState):
llm_calls: int # comportement de remplacement par défaut15.2.3) Nœuds : des fonctions qui mettent à jour le State
Un nœud est une fonction Python qui effectue une tâche unique et spécifique à l'intérieur du graphe.
def say_hello(state: MessagesState):
return {"messages": [{"role": "ai", "content": "hello world"}]}Deux choses à savoir lors de l'écriture de fonctions de nœud :
Règle 1 : elle reçoit le State actuel comme argument. LangGraph transmet l'objet State actuel lorsqu'il exécute le nœud.
Règle 2 : elle ne renvoie que les parties qu'elle souhaite modifier, pas le State complet. Les nœuds ne modifient pas le State directement. Renvoyez simplement les champs que vous souhaitez mettre à jour, et LangGraph les fusionne dans le State existant selon les règles de reducer de chaque champ.
Utilisez add_node() pour ajouter un nœud au StateGraph :
builder.add_node(say_hello) # le nom de fonction "say_hello" devient le nom du nœud
builder.add_node("my_node", my_func) # vous pouvez aussi spécifier le nom explicitement15.2.4) Arêtes : des règles qui connectent les nœuds
Une arête détermine « après que ce nœud a terminé, qu'est-ce qui s'exécute ensuite ? ». Il existe deux types.
Arêtes normales
Une arête normale câble un « aller vers » fixe entre deux nœuds. Utilisez add_edge(source, target) — source est le nœud de départ, target est la destination.
builder.add_edge(START, "say_hello") # quand le graphe démarre, exécute say_hello
builder.add_edge("say_hello", "llm_call") # après say_hello, exécute llm_call
builder.add_edge("llm_call", END) # après llm_call, termine le grapheArêtes conditionnelles
Une arête conditionnelle choisit le nœud suivant à l'exécution en fonction du State actuel. Utilisez add_conditional_edges(source, routing_function) — source est le nœud de départ, et routing_function est une fonction qui prend le State actuel et renvoie le nom du nœud suivant :
from langgraph.graph import END
def should_continue(state: AgentState):
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_node" # appel d'outil demandé → va vers tool_node
return END # pas d'appel d'outil → termineadd_conditional_edges("llm_call", should_continue) indique à LangGraph : « quand llm_call se termine, appelle should_continue pour décider ce qui s'exécute ensuite ». should_continue route vers "tool_node" si le dernier message a des tool_calls, ou vers END s'il n'en a pas. En pratique, cela signifie que le graphe continue vers le nœud d'exécution d'outil lorsque le LLM demande un appel d'outil, et se termine lorsqu'il ne le fait pas.
Maintenant que nous avons couvert les quatre composants, la section suivante les utilise pour refactoriser la boucle d'agent du chapitre 14 en un graphe LangGraph.
15.3) Refactoriser la boucle d'agent en un graphe
Reconstruisons la boucle d'agent du chapitre 14 en utilisant LangGraph. Le comportement est identique à celui du chapitre 14 — le LLM décide, les outils s'exécutent en fonction des demandes du LLM, et le cycle se répète jusqu'à l'achèvement. La seule chose qui change, c'est la manière dont nous structurons ce flux.
Voici à quoi ressemblera le graphe terminé :
Le graphe fait des cycles entre llm_call et tool_node jusqu'à ce que le LLM cesse de demander des appels d'outils, moment auquel il sort vers END. Construisons-le étape par étape.
15.3.1) Définir le State
Nous créons une sous-classe de MessagesState vue en 15.2.2 pour définir le State de l'agent. Le champ messages est hérité de MessagesState, et nous ajoutons un champ llm_calls pour suivre le nombre d'appels au LLM.
from langgraph.graph import MessagesState
class AgentState(MessagesState):
llm_calls: int # nombre d'appels au LLM (remplacement)La liste messages accumulera les entrées utilisateur (HumanMessage), les réponses du LLM (AIMessage) et les résultats d'exécution d'outils (ToolMessage) dans l'ordre.
15.3.2) Construire les nœuds
Tout d'abord, configurons les outils et le modèle du chapitre 14 :
from langchain_openai import ChatOpenAI
from langchain_core.messages import ToolMessage
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Obtient la météo actuelle pour une ville."""
fake_data = {"Tokyo": "18°C, nuageux", "Le Caire": "31°C, ensoleillé"}
return fake_data.get(city, f"Pas de données météo pour {city}.")
@tool
def calculate(expression: str) -> str:
"""Calcule une expression arithmétique simple. Exemple : '3 * 21'."""
return str(eval(expression)) # Attention : eval() est un risque de sécurité. Ne pas utiliser en production.
tools = [get_weather, calculate]
tool_map = {t.name: t for t in tools}
llm = ChatOpenAI(model="gpt-5-mini")
model_with_tools = llm.bind_tools(tools)Maintenant, écrivons les deux fonctions de nœud.
Nœud llm_call — Appelle le LLM et renvoie la réponse :
def llm_call(state: AgentState):
"""Appelle le LLM et renvoie la réponse."""
response = model_with_tools.invoke(state["messages"])
return {
"messages": [response],
"llm_calls": state.get("llm_calls", 0) + 1,
}model_with_tools.invoke() appelle le LLM, et la réponse est empaquetée sous la clé messages dans le dictionnaire de retour. Le reducer l'ajoute aux messages existants dans AgentState. llm_calls renvoie le compte actuel plus 1, remplaçant la valeur précédente.
Nœud tool_node — Exécute les outils demandés par le LLM et renvoie les résultats :
def tool_node(state: AgentState):
"""Exécute les outils demandés par le LLM."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
selected_tool = tool_map[tool_call["name"]]
tool_message = selected_tool.invoke(tool_call)
results.append(tool_message)
return {"messages": results}Comme tool_node s'exécute toujours juste après llm_call, le dernier message dans messages est garanti d'être l'AIMessage que le LLM vient de produire. Le champ tool_calls de ce message contient les appels d'outils demandés par le LLM. Le nœud exécute chaque outil, collecte les résultats dans results, et les renvoie sous la clé messages — le reducer se charge de les ajouter à la liste existante.
15.3.3) Arête conditionnelle
Une fois que llm_call a terminé, nous avons besoin d'une arête conditionnelle pour décider s'il faut exécuter tool_node ou terminer le graphe. Cela suit le même schéma qu'en 15.2.4 :
from typing import Literal
from langgraph.graph import END
def should_continue(state: AgentState) -> Literal["tool_node", "__end__"]:
"""Décide s'il faut exécuter les outils ou terminer le graphe."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_node"
return ENDSi last_message.tool_calls est présent, le LLM demande un appel d'outil, donc nous routons vers tool_node. Sinon, nous routons vers END et le graphe se termine.
L'indice de type de retour
Literal["tool_node", "__end__"]déclare les destinations possibles que cette fonction peut renvoyer. LangGraph a besoin de cet indice pour dessiner correctement les chemins d'arête conditionnelle dans les visualisations de graphe. Il n'a aucun effet sur le comportement à l'exécution.
"__end__"est la valeur de chaîne sous-jacente deEND. CommeLiteraln'accepte que des littéraux de chaîne, nous écrivons"__end__"au lieu deEND.
15.3.4) Assembler et exécuter le graphe
Il est temps de tout câbler ensemble. Assemblons le State, les nœuds et l'arête conditionnelle dans un StateGraph et compilons :
from langgraph.graph import StateGraph, START, END
builder = StateGraph(AgentState)
builder.add_node("llm_call", llm_call)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_call") # start → llm_call
builder.add_conditional_edges("llm_call", should_continue) # llm_call → tool_node ou END
builder.add_edge("tool_node", "llm_call") # tool_node → llm_call (boucle)
agent = builder.compile()L'arête de tool_node retournant vers llm_call crée une boucle. L'exécution continue de tourner jusqu'à ce que le LLM réponde avec une réponse finale au lieu de demander un autre appel d'outil, moment auquel la boucle se termine.
Exécutons-le :
from langchain_core.messages import HumanMessage
result = agent.invoke({
"messages": [HumanMessage(content="Obtiens la température au Caire, puis multiplie le nombre par 3.")],
"llm_calls": 0,
})
print(result["messages"][-1].content)
print(f"\nNombre total d'appels au LLM : {result['llm_calls']}")Sortie :
Température actuelle au Caire : 31°C. Multipliée par 3 = 93.
Nombre total d'appels au LLM : 3L'agent a appelé get_weather("Cairo"), a vu le résultat, a appelé calculate("31 * 3"), et a produit la réponse finale — le même résultat que nous avons obtenu au chapitre 14.
L'inspection de l'historique complet des messages montre chaque étape enregistrée dans messages, dans l'ordre :
for message in result["messages"]:
message.pretty_print()Sortie :
================================ Human Message =================================
Obtiens la température au Caire, puis multiplie le nombre par 3.
================================== Ai Message ==================================
Tool Calls:
get_weather (call_DiL9WF)
Args:
city: Cairo
================================= Tool Message =================================
Name: get_weather
31°C, sunny
================================== Ai Message ==================================
Tool Calls:
calculate (call_wa6RqWST)
Args:
expression: 31 * 3
================================= Tool Message =================================
Name: calculate
93
================================== Ai Message ==================================
Température actuelle au Caire : 31°C. Multipliée par 3 = 93.15.3.5) Visualisation du graphe
Dans un notebook Jupyter, agent.get_graph().draw_mermaid_png() rend la structure du graphe sous forme d'image directement dans la sortie de la cellule.
from IPython.display import Image, display
display(Image(agent.get_graph().draw_mermaid_png()))Dans un environnement de terminal, enregistrez-le plutôt sous forme de fichier PNG.
agent.get_graph().draw_mermaid_png(output_file_path="agent_graph.png")Image générée :
Les lignes pleines sont des arêtes normales et les lignes pointillées sont des arêtes conditionnelles. Ce diagramme est généré automatiquement à partir du code.
15.3.6) Limite de récursion
Tout comme nous avons utilisé max_steps pour nous prémunir contre les boucles infinies au chapitre 14, LangGraph dispose d'un filet de sécurité intégré. Chaque fois qu'un nœud s'exécute pendant l'exécution du graphe, un compteur interne s'incrémente de un. Lorsque ce compteur dépasse la limite configurée, LangGraph lève une GraphRecursionError.
Pour voir comment le décompte fonctionne, regardez l'exécution précédente. L'appel à la fois de get_weather et de calculate a visité les nœuds dans cet ordre :
llm_call(1) → tool_node(2) → llm_call(3) → tool_node(4) → llm_call(5) → END
Cela fait 5 visites de nœud au total. Si vous définissez recursion_limit à 3, la limite entre en jeu à la 3e visite et l'exécution est écourtée :
from langgraph.errors import GraphRecursionError
try:
result = agent.invoke(
{"messages": [HumanMessage(content="Obtiens la température au Caire, puis multiplie le nombre par 3.")],
"llm_calls": 0},
config={"recursion_limit": 3},
)
except GraphRecursionError:
print("L'agent a atteint la limite de récursion — arrêt de l'exécution.")Sortie :
L'agent a atteint la limite de récursion — arrêt de l'exécution.Définissez la limite en passant config={"recursion_limit": nombre} à invoke(). La bonne valeur dépend de votre cas d'usage et de la complexité de votre graphe. Commencez par un nombre généreux et ajustez-le par des tests.