Créer ShopBot : un agent de support LangGraph doté de mémoire, d’outils et de capacité d’approbation des remboursements
Construisez un agent de support client dans le module LangGraph module par module : état, outils, nœuds, routage, résumés dynamiques et une interruption qui attend l’approbation du remboursement.
Sources: Your First Real LangGraph Project: Building a Customer Support Agent.
Ce projet intègre trois concepts de LangGraph dans un seul agent : une architecture graphique, une mémoire de conversation et une approbation humaine. Cet agent est un bot de support client nommé ShopBot, destiné à un magasin en ligne imaginaire. Les clients peuvent poser des questions sur leurs commandes, demander des remboursements et faire monter leurs plaintes en grade. Le bot se souvient de la conversation, consulte les données des commandes à l’aide d’outils réels et attend l’approbation d’une personne avant que tout remboursement ne soit effectué. Le code est développé module par module, chaque partie étant expliquée en détail, et aucune expérience préalable avec LangGraph n’est requise.
Planifiez d’abord le graphique
Une erreur courante chez les débutants est d’ouvrir un fichier vide et de commencer à taper. LangGraph encourage d’abord à dessiner le graphe, que ce soit sur papier ou dans son esprit. Sur papier, ShopBot est simple : l’étape de l’agent, l’exécution de l’outil, la révision du remboursement et le résuméforment les nœuds, deux branches déterminent le flux, une seule instruction interrupt() provoque une pause en attente d’approbation, et un déclencheur active le résumé. Le code se contente de retranscrire cette représentation.
Préparation
Créez un fichier nommé shopbot.py, ou ouvrez un notebook Colab. Installez les dépendances nécessaires :
pip install langgraph langchain langchain-openai langgraph-checkpoint-sqlite python-dotenv
Créez ensuite un fichier .env dans le même dossier pour y stocker la clé :
OPENAI_API_KEY=your-key-here
Module 1 : importations et configuration
Toutes les importations pour tout le fichier se trouvent en haut, sans aucune importation à mi-parcours. La configuration se trouve également ici.
# ============================================================
# MODULE 1: IMPORTS & CONFIGURATION
# ============================================================
import os
import sqlite3
from typing import Annotated, Literal
from datetime import datetime
from dotenv import load_dotenv
# LangChain - the AI layer
from langchain_openai import ChatOpenAI
from langchain_core.messages import (
HumanMessage,
AIMessage,
SystemMessage,
BaseMessage,
RemoveMessage,
)
from langchain_core.tools import tool
# LangGraph - the graph layer
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
load_dotenv()
# ── The LLM ─────────────────────────────────────────────────
# temperature=0 means deterministic - the agent behaves
# consistently, which is what you want for a support bot.
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
Le modèle choisi est gpt-4o-mini. Il est suffisamment performant pour les tâches d’assistance, suffisamment peu coûteux pour être utilisé fréquemment, et suffisamment rapide pour que les utilisateurs ne remarquent pas de retards. Un système de production pourrait passer à gpt-4o pour des tâches nécessitant davantage de raisonnement, mais il est judicieux de commencer par un modèle plus simple.
Module 2 : état
L’état représente la mémoire de travail de l’agent ; tout ce qui circule dans le graphe doit y être stocké. Déterminez ce que l’agent doit savoir à chaque étape.
# ============================================================
# MODULE 2: STATE
# ============================================================
class SupportState(MessagesState):
# MessagesState already gives us:
# messages: Annotated[list[BaseMessage], add_messages]
# We add three more fields for our specific needs:
# The running summary of the conversation (Part 2 pattern).
# Starts empty. Gets written by summarize_node when conversation gets long.
summary: str
# The name of the customer, extracted early in the conversation.
# Used to personalise every response. Starts empty.
customer_name: str
# Tracks the current ticket category, set by the agent.
# Helps the human reviewer understand context during escalation.
# Values: "order_inquiry" | "refund_request" | "complaint" | "general"
ticket_category: str
Il existe trois champs supplémentaires en dehors de l’historique des messages. summary contient un enregistrement compressé des échanges précédents : une fois que la conversation dépasse six messages, l’agent condense l’historique dans ce champ et supprime les anciens messages bruts, afin que les coûts en tokens restent stables. customer_name est un champ pratique pour presque tout agent de support. Avec le nom en mémoire, l’agent peut saluer les personnes personnellement sans que chaque nœud doive relire la conversation pour le trouver. ticket_category permet le routage conditionnel, car c’est l’agent qui le définit et une fonction de routage le lit pour décider s’il faut activer la procédure d’approbation humaine.
Module 3 : outils
Les outils sont les mains de l’agent. Sans eux, il ne peut que parler, tandis qu’avec eux il peut rechercher des informations et effectuer des actions. ShopBot dispose de trois outils, soutenus par une base de données en mémoire fictive :
# ============================================================
# MODULE 3: TOOLS
# ============================================================
# ── Fake Database ────────────────────────────────────────────
# In a real project, these would be database queries or API calls.
# For learning purposes, we use a simple Python dictionary.
ORDERS_DB = {
"ORD-001": {
"customer": "Alex",
"product": "Wireless Headphones",
"status": "Delivered",
"amount": 89.99,
"delivery_date": "2025-06-10",
},
"ORD-002": {
"customer": "Sam",
"product": "Phone Case",
"status": "In Transit",
"amount": 14.99,
"delivery_date": "Expected 2025-06-18",
},
"ORD-003": {
"customer": "Jordan",
"product": "Laptop Stand",
"status": "Processing",
"amount": 45.00,
"delivery_date": "Expected 2025-06-20",
},
}
@tool
def lookup_order(order_id: str) -> str:
"""Look up the details of a customer's order by order ID.
Use this when the customer provides an order number and wants
to know the status, product name, or delivery date of their order.
Args:
order_id: The order ID string, e.g. 'ORD-001'
Returns:
A formatted string with full order details, or an error message
if the order is not found.
"""
order = ORDERS_DB.get(order_id.upper())
if not order:
return f"No order found with ID '{order_id}'. Please double-check the order number."
return (
f"Order {order_id.upper()}: {order['product']} | "
f"Status: {order['status']} | "
f"Amount: ${order['amount']:.2f} | "
f"Delivery: {order['delivery_date']}"
)
@tool
def check_refund_eligibility(order_id: str) -> str:
"""Check whether an order is eligible for a refund.
Use this BEFORE processing any refund request. An order is eligible
for a refund only if its status is 'Delivered'. Orders Fthat are
'In Transit' or 'Processing' cannot be refunded yet.
Args:
order_id: The order ID string, e.g. 'ORD-001'
Returns:
A string stating whether the order is eligible and why.
"""
order = ORDERS_DB.get(order_id.upper())
if not order:
return f"Cannot check refund: order '{order_id}' not found."
if order["status"] == "Delivered":
return (
f"Order {order_id.upper()} IS eligible for a refund. "
f"Product: {order['product']}, Amount: ${order['amount']:.2f}. "
f"Proceed to refund processing."
)
else:
return (
f"Order {order_id.upper()} is NOT eligible for a refund yet. "
f"Current status: {order['status']}. Refunds are only available "
f"for delivered orders."
)
@tool
def process_refund(order_id: str, reason: str) -> str:
"""Process a refund for a delivered order.
IMPORTANT: This tool actually issues the refund. It should only be
called AFTER human approval has been obtained. Never call this tool
without prior confirmation.
Args:
order_id: The order ID to refund
reason: The customer's stated reason for the refund
Returns:
A confirmation string with the refund reference number.
"""
order = ORDERS_DB.get(order_id.upper())
if not order:
return f"Refund failed: order '{order_id}' not found."
# In a real system, this would hit your payments API.
refund_ref = f"REF-{order_id.upper()}-{datetime.now().strftime('%H%M%S')}"
return (
f"Refund APPROVED and PROCESSED. Reference: {refund_ref}. "
f"${order['amount']:.2f} will be returned to the original payment method "
f"within 3–5 business days. Reason logged: '{reason}'."
)
# ── Collect tools and bind to LLM ───────────────────────────
# All three tools in one list.
tools = [lookup_order, check_refund_eligibility, process_refund]
# llm_with_tools = the LLM that KNOWS about the tools and can decide to call them.
# This is what we use inside agent_node.
llm_with_tools = llm.bind_tools(tools)
# tool_node = the pre-built node that EXECUTES whatever tool the LLM chose.
# This is what we register in Module 6.
tool_node = ToolNode(tools)
Examinez attentivement la documentation de process_refund : elle indique que cet outil effectue réellement le remboursement et ne doit être appelé qu’après approbation humaine. Le modèle lit ces documents et s’en sert pour décider de ses actions. Rédigez-les comme si vous briefiez un stagiaire intelligent qui ne connaît pas encore vos règles métier, car c’est en effet ce qui se passe.
Module 4 : nœuds
Les nœuds effectuent le travail. Chacun reçoit l’état actuel, effectue une action et renvoie un dictionnaire des mises à jour, rien de plus. ShopBot dispose de trois nœuds que vous devez écrire, ainsi que du nœud tool_node prédéfini du module précédent. Ces nœuds comprennent l’instruction système, l’étape principale de l’agent, l’étape de révision du remboursement avec interrupt() et le résumateur :
# ============================================================
# MODULE 4: NODES
# ============================================================
# ── The System Prompt ────────────────────────────────────────
# Written once, used in every call to the LLM from agent_node.
# This is the personality and rulebook of your agent.
SYSTEM_PROMPT = """You are ShopBot, a friendly and professional customer support \
agent for an e-commerce store.
Your capabilities:
- Look up order details using the lookup_order tool
- Check if an order qualifies for a refund using check_refund_eligibility
- Process approved refunds using the process_refund tool
Your rules:
- Always greet the customer by name once you know it
- Always check refund eligibility BEFORE attempting to process a refund
- For refund requests, set ticket_category to "refund_request" in your reasoning
- Be empathetic, clear, and concise
- If you cannot help, offer to escalate to a human agent
Important: The process_refund tool requires prior human approval. Do not call it \
unless the conversation shows that a human has already approved the refund."""
# ── Node 1: agent_node ──────────────────────────────────────
def agent_node(state: SupportState) -> dict:
"""The brain of the operation. Reads state, calls the LLM, and decides
whether to use a tool, give a final answer, or do something else.
This node handles two cases:
1. Normal conversation - just call the LLM and respond
2. Long conversation - if a summary exists, prepend it so the LLM
has context without seeing all the raw messages
"""
# Part 2 pattern: check for an existing summary
summary = state.get("summary", "")
if summary:
# Build context: system prompt + compressed history + recent messages
system_with_summary = SystemMessage(
content=f"{SYSTEM_PROMPT}\n\nSummary of conversation so far:\n{summary}"
)
messages_to_send = [system_with_summary] + state["messages"]
else:
# No summary yet - full history is short enough to send as-is
system_msg = SystemMessage(content=SYSTEM_PROMPT)
messages_to_send = [system_msg] + state["messages"]
# Call the LLM. It sees tools and can choose to call one.
response = llm_with_tools.invoke(messages_to_send)
# Detect ticket category from the response for routing purposes.
# A smarter version would have the LLM explicitly set this -
# for now, we scan for keywords.
content_lower = response.content.lower() if response.content else ""
updates: dict = {"messages": [response]}
if "refund" in content_lower or (
hasattr(response, "tool_calls")
and any("refund" in str(tc).lower() for tc in (response.tool_calls or []))
):
updates["ticket_category"] = "refund_request"
return updates
# ── Node 2: review_refund ───────────────────────────────────
def review_refund(state: SupportState) -> dict:
"""The human approval gate. Pauses execution, shows the pending refund
details to a human agent, and waits for their decision.
This implements the Part 3 interrupt() pattern. Execution stops here
until someone calls graph.invoke(Command(resume=...), config).
Three outcomes the human can choose:
- "approve" → let the refund tool call proceed unchanged
- "reject" → cancel the refund, send a message to the customer
- "escalate" → hand the entire ticket to a human support agent
"""
last_message = state["messages"][-1]
# Find the refund-related tool call in the last AI message.
# We look for process_refund specifically - the "real action" tool.
refund_tool_call = None
if hasattr(last_message, "tool_calls"):
for tc in last_message.tool_calls:
if "refund" in tc["name"].lower():
refund_tool_call = tc
break
# Surface the context to the human reviewer via interrupt().
# Everything in this dict is what the human sees before deciding.
human_decision = interrupt({
"message": "⚠️ Refund approval required",
"customer_name": state.get("customer_name", "Unknown"),
"tool_being_called": refund_tool_call["name"] if refund_tool_call else "refund tool",
"arguments": refund_tool_call["args"] if refund_tool_call else {},
"conversation_summary": state.get("summary", "No summary yet"),
"options": ["approve", "reject", "escalate"],
})
# ── Handle the human's decision ─────────────────────────
if human_decision == "approve":
# Do nothing to state - let tool_node execute the tool call as-is
return {}
elif human_decision == "reject":
# Cancel the tool call. The LLM will see a ToolMessage explaining why,
# and generate a polite response to the customer.
from langchain_core.messages import ToolMessage
return {
"messages": [
ToolMessage(
content=(
"Refund request was reviewed and declined by our support team. "
"Please inform the customer politely and offer alternatives."
),
tool_call_id=refund_tool_call["id"] if refund_tool_call else "unknown",
)
]
}
elif human_decision == "escalate":
# Signal escalation - in a real system you'd open a ticket,
# ping Slack, or transfer to a live agent queue.
from langchain_core.messages import ToolMessage
return {
"messages": [
ToolMessage(
content=(
"This ticket has been escalated to a senior support agent. "
"Inform the customer that a human agent will contact them "
"within 2 business hours."
),
tool_call_id=refund_tool_call["id"] if refund_tool_call else "unknown",
)
]
}
# Fallback - treat as approve
return {}
# ── Node 3: summarize_node ──────────────────────────────────
def summarize_node(state: SupportState) -> dict:
"""Triggered when the conversation exceeds 6 messages. Compresses the
full message history into a short summary, then deletes old raw messages.
This is the rolling summary pattern from Part 2. The summary grows
richer turn by turn. Token costs stay nearly flat no matter how long
the conversation runs.
"""
existing_summary = state.get("summary", "")
if existing_summary:
# Extend the existing summary with new messages
summary_instruction = (
f"Current summary:\n{existing_summary}\n\n"
"Extend this summary with the new messages above. "
"Keep it under 5 sentences. Focus on: the customer's name, "
"their issue, any orders mentioned, and what actions were taken."
)
else:
# First time summarising
summary_instruction = (
"Summarise this customer support conversation in under 5 sentences. "
"Include: the customer's name (if mentioned), their issue, "
"any order numbers discussed, and what actions were taken so far."
)
messages = state["messages"] + [HumanMessage(content=summary_instruction)]
response = llm.invoke(messages) # Plain llm, no tools needed here
# Delete all but the 2 most recent messages.
# The summary now holds everything that was in the deleted messages.
messages_to_delete = [
RemoveMessage(id=m.id) for m in state["messages"][:-2]
]
return {
"summary": response.content,
"messages": messages_to_delete,
}
Module 5 : arêtes et routage
Les fonctions de routage déterminent où aller ensuite. Elles examinent l’état et retournent une chaîne de caractères, sans jamais modifier cet état, car c’est la tâche des nœuds. Deux fonctions sont nécessaires :
# ============================================================
# MODULE 5: EDGES & ROUTING
# ============================================================
def route_after_agent(state: SupportState) -> Literal[
"review_refund", "tools", "summarize_node", "__end__"
]:
"""Called after agent_node runs. Decides what happens next.
Four possible routes:
1. The LLM wants to call process_refund → must go through human review first
2. The LLM wants to call any other tool → go directly to tool_node
3. The LLM gave a plain text answer AND the conversation is long → summarise
4. The LLM gave a plain text answer and conversation is short → we're done
"""
last_message = state["messages"][-1]
has_tool_calls = hasattr(last_message, "tool_calls") and bool(last_message.tool_calls)
if has_tool_calls:
# Check if ANY of the tool calls is the sensitive process_refund tool
tool_names = [tc["name"] for tc in last_message.tool_calls]
if "process_refund" in tool_names:
return "review_refund" # → Pause for human approval first
return "tools" # → Safe tool, run it directly
# No tool call - the LLM gave a plain response.
# Check if the conversation is long enough to need summarisation.
if len(state["messages"]) > 6:
return "summarize_node"
return "__end__" # → Conversation turn is complete
def route_after_review(state: SupportState) -> Literal["tools", "agent_node"]:
"""Called after review_refund runs (i.e., after the human has decided).
Two routes:
1. Human approved or escalated → run the tool (tool_node handles the call)
2. Human rejected → the review node already added a ToolMessage cancelling
the tool call, so skip tool_node and go back to agent_node to respond
"""
last_message = state["messages"][-1]
# If the last message is a ToolMessage, the review node cancelled the call.
# Go back to agent_node so it can generate a customer-facing response.
from langchain_core.messages import ToolMessage
if isinstance(last_message, ToolMessage):
return "agent_node"
# Otherwise, the review node returned {} (approved) - proceed to tools.
return "tools"
Module 6 : assemblage du graphe
Tous les éléments sont maintenant disponibles, et l’assemblage suit toujours cinq étapes : initialiser, enregistrer les nœuds, définir le point d’entrée, ajouter les arêtes et compiler.
# ============================================================
# MODULE 6: GRAPH ASSEMBLY
# ============================================================
# ── Step 1: Initialize ──────────────────────────────────────
graph_builder = StateGraph(SupportState)
# ── Step 2: Register All Nodes ──────────────────────────────
# Format: add_node("string_name", function)
# The string name is what you use in every edge definition below.
graph_builder.add_node("agent_node", agent_node)
graph_builder.add_node("tools", tool_node) # Pre-built from Module 3
graph_builder.add_node("review_refund", review_refund)
graph_builder.add_node("summarize_node", summarize_node)
# ── Step 3: Set Entry Point ─────────────────────────────────
# The first node that runs when a user sends a message.
graph_builder.add_edge(START, "agent_node")
# ── Step 4: Wire the Edges ──────────────────────────────────
# After agent_node: conditional - depends on what the LLM decided
graph_builder.add_conditional_edges(
"agent_node", # Source
route_after_agent, # Router function from Module 5
{
"review_refund": "review_refund", # Refund tool → human review first
"tools": "tools", # Other tools → run directly
"summarize_node": "summarize_node", # Long conversation → summarise
"__end__": END, # Plain answer → done
}
)
# After review_refund: conditional - depends on human's decision
graph_builder.add_conditional_edges(
"review_refund",
route_after_review,
{
"tools": "tools", # Approved → execute the tool
"agent_node": "agent_node", # Rejected → back to agent to respond
}
)
# After tools run: always go back to agent_node
# (the ReAct loop - agent sees tool result, decides what to do next)
graph_builder.add_edge("tools", "agent_node")
# After summarization: conversation turn is done
graph_builder.add_edge("summarize_node", END)
# ── Step 5: Compile ─────────────────────────────────────────
# Using MemorySaver for development.
# For production, swap this one line to SqliteSaver or PostgresSaver.
memory = MemorySaver()
shopbot = graph_builder.compile(checkpointer=memory)
Dans Colab ou Jupyter, vous pouvez afficher le graphique sous forme de diagramme :
from IPython.display import display, Image
from langchain_core.runnables.graph import MermaidDrawMethod
display(Image(
shopbot.get_graph().draw_mermaid_png(
draw_method=MermaidDrawMethod.API
)
))
Voir un diagramme de flux réel de ce que vous avez construit est l’une des meilleures fonctionnalités de LangGraph pour les apprenants. Si le diagramme semble incorrect, c’est que les arêtes sont erronées.
Module 7 : point d’entrée
C’est ici que l’agent s’exécute réellement. Dans un script, c’est le bloc if __name__ == "__main__", et dans un serveur API, c’est le gestionnaire de route, bien que le schéma soit identique dans les deux cas.
# ============================================================
# MODULE 7: ENTRYPOINT
# ============================================================
def run_shopbot():
"""
Interactive command-line session with ShopBot.
Demonstrates: multi-turn conversation, tool use, and human-in-the-loop.
"""
print("=" * 55)
print(" ShopBot - Customer Support Agent")
print(" Powered by LangGraph")
print("=" * 55)
print("Type your message below. Type 'exit' to quit.")
print("Type 'state' to inspect what ShopBot currently remembers.\n")
# One config per session.
# thread_id is the session key - same ID = same memory thread.
# Change the ID to start a completely fresh conversation.
config = {"configurable": {"thread_id": "customer-session-001"}}
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() == "exit":
print("ShopBot: Thank you for contacting support. Have a great day!")
break
# ── Debug: inspect current state ────────────────────
if user_input.lower() == "state":
snapshot = shopbot.get_state(config)
print("\n[DEBUG] Current State:")
print(f" Messages in state : {len(snapshot.values.get('messages', []))}")
print(f" Customer name : {snapshot.values.get('customer_name', '(not set)')}")
print(f" Ticket category : {snapshot.values.get('ticket_category', '(not set)')}")
print(f" Summary : {snapshot.values.get('summary', '(none yet)')}")
print(f" Next node(s) : {snapshot.next}\n")
continue
# ── Normal message: invoke the graph ─────────────────
result = shopbot.invoke(
{"messages": [HumanMessage(content=user_input)]},
config=config,
)
# ── Check if graph paused for human approval ─────────
# This is how you detect that interrupt() was called inside review_refund.
while "__interrupt__" in result:
interrupt_data = result["__interrupt__"][0].value
print("\n" + "=" * 55)
print(" HUMAN APPROVAL REQUIRED")
print("=" * 55)
print(f" Customer : {interrupt_data.get('customer_name', 'Unknown')}")
print(f" Action : {interrupt_data.get('tool_being_called', 'refund')}")
print(f" Arguments : {interrupt_data.get('arguments', {})}")
print(f" Context : {interrupt_data.get('conversation_summary', 'N/A')}")
print("=" * 55)
print("Options: [a] Approve [r] Reject [e] Escalate")
human_choice = input("Your decision: ").strip().lower()
if human_choice == "a":
resume_value = "approve"
elif human_choice == "r":
resume_value = "reject"
elif human_choice == "e":
resume_value = "escalate"
else:
print("Invalid choice. Defaulting to reject.")
resume_value = "reject"
# Resume the graph with the human's decision.
# Command(resume=...) answers the pending interrupt() call.
result = shopbot.invoke(
Command(resume=resume_value),
config=config,
)
# ── Print the agent's final response ─────────────────
last_message = result["messages"][-1]
print(f"\nShopBot: {last_message.content}\n")
if __name__ == "__main__":
run_shopbot()
Une session réelle
Un exemple de conversation montre à quoi s’attendre lors de son exécution :
=======================================================
ShopBot — Customer Support Agent
Powered by LangGraph
=======================================================
Type your message below. Type 'exit' to quit.
Type 'state' to inspect what ShopBot currently remembers.
You: Hi, my name is Alex and I need help with order ORD-001
ShopBot: Hi Alex! I'd be happy to help you with order ORD-001.
I can see your order:
- Product: Wireless Headphones
- Status: Delivered on 2025-06-10
- Amount: $89.99
What can I help you with regarding this order?
You: I want to return it and get a refund
ShopBot: I've checked your order ORD-001 and it's eligible for a refund
since it's been delivered. Let me process that for you right away.
=======================================================
HUMAN APPROVAL REQUIRED
=======================================================
Customer : Alex
Action : process_refund
Arguments : {'order_id': 'ORD-001', 'reason': 'Customer requested return'}
Context : No summary yet
=======================================================
Options: [a] Approve [r] Reject [e] Escalate
Your decision: a
ShopBot: Great news, Alex! Your refund has been successfully processed.
Refund Reference: REF-ORD-001-143022
Amount: $89.99 will be returned to your original payment method within
3–5 business days.
Is there anything else I can help you with?
You: state
[DEBUG] Current State:
Messages in state : 6
Customer name : (not set)
Ticket category : refund_request
Summary : (none yet)
Next node(s) : ()
You: exit
ShopBot: Thank you for contacting support. Have a great day!
Ce que le projet démontre
La structure à sept modules montre l’architecture de bout en bout : StateGraph, add_node, add_conditional_edges et compile, tous dans le bon ordre. En ce qui concerne la mémoire, on trouve le champ summary, la fonction summarize_node ainsi que le modèle de résumé itératif dans lequel les nouveaux messages sont compressés et les anciens supprimés, en plus d’un point de contrôle MemorySaver qui maintient l’historique actif au fil des tours. Pour l’intervention humaine, le nœud review_refund est mis en pause avec interrupt(), le point d’entrée reprend avec Command(resume=...), et une boucle while "__interrupt__" in result gère plusieurs approbations en une seule fois, permettant d’approuver, de rejeter ou d’escalader la demande.
Divers éléments proviennent des outils. Les outils réels sont déclarés avec @tool ; le ToolNode préconstruit les exécute, et bind_tools les lie au modèle. La boucle ReAct alterne entre outils, nœud d’agent et à nouveau outils. Le routage traite les outils différemment : ceux qui sont sûrs sont envoyés directement à tool_node, tandis que ceux qui sont sensibles passent d’abord par review_refund.
Où aller ensuite
Le projet est délibérément complet mais minimal. Trois extensions, en commençant par les plus simples :
- Une meilleure détection de catégorie. Actuellement,
ticket_categoryprovient du balayage des mots-clés dansagent_node. Une approche plus propre consiste à demander au modèle de catégoriser explicitement le ticket avec une sortie structurée ; examinez doncwith_structured_output(). - Persistance avec SQLite. Remplacez
MemorySaver()parSqliteSaver— une modification en une seule ligne dans le Module 6 — et ajoutez une question lors du démarrage pour savoir s’il s’agit de la même session ou d’une nouvelle. Les conversations survivront ainsi aux redémarrages. - Voie d’escalade des plaintes. Ajoutez un quatrième nœud,
escalate_node, qui est activé lorsqueticket_category == "complaint"et que l’émotion du client est"FRUSTRATED". Il appelleinterrupt()pour alerter un agent humain et se relie àroute_after_agenten tant que nouvelle branche.
Référence rapide
De nouveaux éléments font leur apparition dans ce projet : quelques modèles prédéfinis. ToolNode(tools) est un nœud prédéveloppé qui exécute les appels d’outils présents dans le dernier message du modèle. llm.bind_tools(tools) relie la liste des outils au modèle, permettant ainsi à ce dernier de demander un outil en retournant un champ tool_calls au lieu de texte brut. @tool transforme une fonction Python en quelque chose que le modèle peut appeler, en utilisant la documentation associée pour déterminer quand l’utiliser. La vérification hasattr(message, "tool_calls") and message.tool_calls constitue la méthode standard pour savoir si le dernier message contient un appel d’outil, et hasattr doit être utilisé en premier avant d’accéder à l’attribut. La boucle while "__interrupt__" in result gère un ou plusieurs interruptions en attente, étant donné que chaque Command(resume=...) répond à une interruption et peut déclencher la suivante. Enfin, shopbot.get_state(config) affiche l’état actuel complet à tout moment : .values fournit le dictionnaire d’état et .next indique ce qui sera exécuté ensuite, ce qui est d’une grande aide pour le débogage.
Il existe bien un écart entre la compréhension d’un concept et la rédaction de code pour le mettre en œuvre, et un petit projet qui tient dans l’esprit tout en utilisant tous les outils disponibles permet de combler cette lacune. Ici, on dispose d’un point de contrôle, de vrais outils, d’une étape d’approbation et d’un déclencheur de synthèse qui fonctionnent ensemble au sein d’un même agent. Construisez-le, brisez-le délibérément, corrigez-le, puis étendez-le.
Lectures complémentaires
- Portes de validation et routage multi-agents pour un bot de support LangGraph — Ajoutez des portes de logique métier déterministes, un cycle de validation auto-corrigé ainsi qu’un superviseur doté de trois sous-graphes spécialisés à un agent LangGraph destiné au e-commerce.
- Tri des demandes de support multi-agents avec LangGraph et Ollama — Créez un graphique de tri des demandes utilisant trois agents avec LangGraph : un classificateur, un répondant et une porte humaine, fonctionnant sur Ollama local ou via une API, avec une seule ligne modifiée.