Les points de contrôle d’approbation dans LangGraph.js : suspension des agents avec interrupt() et Command
Créez une porte d’approbation minimale LangGraph.js qui s’arrête avant un effet secondaire, collecte une décision humaine dans la console, et reprend en toute sécurité à partir d’un point de contrôle.
Certaines actions des agents ont des conséquences trop importantes pour être exécutées sans surveillance : envoyer un e-mail au nom de quelqu’un, supprimer une entrée, approuver un paiement. Dans ces cas, il est souhaitable que l’agent propose l’action, s’arrête et attende une réponse affirmative ou négative de la part d’une personne. Cette démarche illustre comment ce comportement peut être implémenté dans LangGraph.js avec le graphe le plus simple possible, afin de montrer précisément comment interrupt(), un checkpointer, un thread_id et Command({ resume }) coopèrent pour suspendre une exécution puis la reprendre ultérieurement.
Aucune orchestration multi-agents ni appel à un LLM n’est utilisé ici, intentionnellement. Tout le processus se résume en une phrase : suspendre, laisser un humain décider, reprendre.
Lorsqu’un agent ne devrait pas avoir le dernier mot
L’autonomie est précieuse lorsque l’on est à l’aise pour laisser l’agent agir selon son propre jugement. De nombreuses actions ne remplissent pas cette condition, et une étape de révision par un humain vaut la peine des complications supplémentaires. Parmi les exemples typiques on trouve :
- envoyer un e-mail ou un message de chat pour un utilisateur
- modifier ou supprimer une entrée dans une base de données
- approuver un paiement
- déployer du code
- supprimer des ressources cloud
- élever un ticket de support
- publier du contenu généré par l’IA
Dans chaque cas, l’objectif reste le même. L’agent effectue toujours la réflexion et prépare l’action, mais il présente son intention et laisse la décision finale à une personne avant que quoi que ce soit d’irréversible ne se produise. C’est ce que signifie en pratique le concept de Human-in-the-Loop (HITL).
Comment interrupt() et Command s’articulent-ils ?
En résumé, HITL dans LangGraph fonctionne de la manière suivante : le graphe s’arrête temporairement pendant son exécution, attend des entrées externes, puis reprend son fonctionnement en utilisant ces données.
Cet arrêt est réalisé grâce à la fonction interrupt(). Lorsqu’un nœud l’appelle, LangGraph met fin à l’exécution en cours et sauvegarde l’état du graphe via le point de contrôle configuré, afin que cette même exécution puisse être reprise ultérieurement. Votre application reçoit la valeur que vous avez transmise à interrupt(), la montre à une personne, collecte une réponse, puis reprend l’exécution du graphe en l’appelant avec un objet Command contenant cette réponse.
La séquence globale est la suivante :
Graph starts
↓
Agent decides to send email
↓
⏸ interrupt()
↓
Human reviews the action
↓
Approve / Reject
↓
Command({ resume: ... })
↓
Graph continues
Gardez cette structure en tête ; chaque partie du code ci-dessous correspond à une flèche dans ce schéma.
Le scénario : un e-mail nécessitant une validation
L’exemple utilise un e-mail. L’agent décide de vouloir envoyer ce message :
Meeting at 5 PM with Aman
User
↓
Agent decides to send email
↓
⏸ Human approval
↓
┌───────────────┐
│ Approve │ → Send email
│ Reject │ → Stop
└───────────────┘
Afin de maintenir l’attention sur la mécanique d’arrêt et de reprise, aucun fournisseur de messagerie réel n’est impliqué. Le nœud qui « envoie » l’e-mail se contente d’afficher du texte dans la terminal. L’intégration ultérieure d’une API réelle ne modifie en rien le flux de contrôle.
Construction du graphe étape par étape
Installer LangGraph et préparer les importations
Commencez par un projet Node.js vide et ajoutez LangGraph :
npm install @langchain/langgraph
En fonction de la version que vous installez, LangGraph.js peut également exiger @langchain/core comme dépendance collatérale ; si npm en donne un avertissement ou que les importations échouent, ajoutez également ce paquet et consultez la documentation d’installation correspondante.
Les entrées fournies par l’utilisateur proviendront de la terminal via le module intégré de Node readline/promises, il n’est donc pas nécessaire d’installer de package supplémentaire à cette fin. Les imports chargent le constructeur de graphe, l’outil d’annotation d’état, les sentinelles START et END, la fonction interrupt, le point de contrôle en mémoire ainsi que la classe Command, en plus des éléments liés à readline :
import {
StateGraph,
Annotation,
START,
END,
interrupt,
MemorySaver,
Command,
} from "@langchain/langgraph";
import readline from "node:readline/promises";
import {
stdin as input,
stdout as output,
} from "node:process";
Le fichier utilise la syntaxe des modules ES (import), il faut donc soit lui donner une extension .mjs, soit définir "type": "module" dans package.json, et utiliser une version de Node prenant en charge await au niveau du module, car le code d’exécution utilisera ultérieurement await au niveau du module.
Définir l’état que porte le graphe
Dans LangGraph, l’état est un objet partagé qui circule à travers le graphe. Chaque nœud en lit le contenu et renvoie des mises à jour partielles. Ce graphe nécessite uniquement deux champs :
message, le texte de l’e-mail proposé par l’agentdecision, la réponse donnée par l’utilisateur
const StateAnnotation = Annotation.Root({
message: Annotation,
decision: Annotation,
});
Annotation.Root() définit la structure de l’état. En l’absence de réducteur spécifié, chaque champ prend simplement la valeur la plus récente qui y a été écrite. Le nœud de l’agent remplira message ; le nœud d’approbation remplira decision une fois que l’utilisateur aura répondu.
Rédigez l’action que vous souhaitez protéger
Vient ensuite le nœud qui représente l’opération à risque. En environnement de production, celui-ci appellerait une API d’e-mail. Ici, il se contente de journaliser :
function sendEmail(state) {
console.log(`\n📧 Email sent: "${state.message}"`);
return {};
}
La fonctionnalité de cette fonction est presque insignifiante. Ce qui compte, c’est quand elle s’exécute : jamais avant que quelqu’un n’ait donné son accord. Le reste du graphe a pour but d’imposer cet ordre. Notez qu’elle renvoie un objet vide, ce qui signifie que l’état reste inchangé.
Remplacer la décision de l’agent
Dans un système réel, c’est ici qu’un LLM lirait la demande de l’utilisateur et déciderait qu’un e-mail est nécessaire, probablement en utilisant des outils. Ajouter un modèle à cet endroit ne ferait que détourner l’attention des mécanismes HITL ; donc une simple fonction remplit le rôle de l’agent et renvoie le message qu’il a « choisi » :
function agent() {
return {
message: "Meeting at 5 PM with Aman",
};
}
Interprétez ce nœud comme l’agent annonçant son intention : il s’agit de l’e-mail qu’il souhaite envoyer. Si vous le remplacez plus tard par un LLM et une logique d’appel d’outils, le mécanisme de validation qui l’entoure reste essentiellement identique.
Pause pour un humain avec interrupt()
C’est le cœur du schéma. Le nœud d’approbation appelle interrupt() avec un en-tête décrivant ce qui nécessite une décision, et renvoie la valeur reçue comme nouvelle decision:
function humanApproval(state) {
const decision = interrupt({
message: state.message,
question: "Do you want to send this email?",
});
return {
decision,
};
}
Dès que l’exécution atteint cette appel
interrupt(...)
le graphe s’arrête. L’objet transmis devient l’en-tête d’interruption que l’application appelante peut lire. Dans ce cas, il s’agit de :
{
message: "Meeting at 5 PM with Aman",
question: "Do you want to send this email?"
}
L’application affiche cet en-tête à une personne, attend une réponse et reprend l’exécution du graphe. Le détail essentiel est que la valeur fournie lors de la reprise devient la valeur de retour de interrupt(). Ainsi, cette ligne
const decision = interrupt(...);
devient en fait la suivante une fois que l’utilisateur a donné son accord :
const decision = "approve";
Cette valeur est enregistrée dans l’état sous la forme de decision. Une fonction de routage choisit ensuite l’étape suivante à partir de celle-ci :
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
"approve" entraîne l’appel de sendEmail ; tout autre résultat met fin à l’exécution. Considérer toute réponse autre qu’une approbation comme un signal d’arrêt constitue une valeur par défaut judicieuse pour une barrière de sécurité : si une valeur inattendue arrive, le graphe échoue sans exécuter l’action correspondante.
Ajouter un checkpointer et connecter le graphe
Un élément supplémentaire est nécessaire avant de compiler : un checkpointer. Comme l’exécution doit s’arrêter puis reprendre, LangGraph doit conserver l’état d’exécution au moment de l’interruption. Sans checkpointer, il n’y a rien à reprendre. Pour une démonstration, l’implémentation en mémoire suffit :
const checkpointer = new MemorySaver();
Enregistrez maintenant les trois nœuds, connectez START à l’agent et l’agent à l’étape d’approbation, ajoutez une arête conditionnelle depuis l’étape d’approbation contrôlée par routeAfterApproval, puis compilez avec le checkpointer :
const graph = new StateGraph(StateAnnotation)
.addNode("agent", agent)
.addNode("humanApproval", humanApproval)
.addNode("sendEmail", sendEmail)
.addEdge(START, "agent")
.addEdge("agent", "humanApproval")
.addConditionalEdges(
"humanApproval",
routeAfterApproval,
{
sendEmail: "sendEmail",
[END]: END,
}
)
.compile({
checkpointer,
});
Le troisième argument de addConditionalEdges associe chaque valeur que le routeur peut retourner à un nœud de destination, ce qui permet également à LangGraph de dessiner le graphe correctement. La topologie résultante :
START
↓
agent
↓
humanApproval
↓
┌──────────────┐
│ │
approve reject
│ │
↓ ↓
sendEmail END
│
↓
END
MemorySaver stocke les points de contrôle en mémoire de processus, ce qui est idéal pour les expériences mais inutile une fois le processus terminé. Pour des déploiements réels, il convient d’utiliser un pointeur de contrôle persistant soutenu par une base de données, afin que l’exécution suspendue survive aux redémarrages et puisse être reprise depuis un autre processus ou serveur, ce qui est la situation normale lorsque l’approbation arrive via une interface web des heures plus tard. Pour en savoir davantage sur la manière dont les points de contrôle sont stockés en interne, consultez comment le mécanisme de sauvegarde en mémoire de LangGraph organise les points de contrôle et effectue les écritures.
L’autre élément clé de la persistance est le thread_id. Il identifie quel exécution sauvegardée est concernée. La suspension et la reprise doivent utiliser le même thread_id ; sinon, LangGraph ne peut pas retrouver l’exécution enregistrée.
Démarrer le flux depuis la terminal
Démarrer l’exécution et détecter l’interruption
const rl = readline.createInterface({
input,
output,
});
L’objet de configuration contient le thread_id sous la clé configurable. Tout ce qui concerne cette exécution, que ce soit l’appel initial ou la reprise, doit utiliser cet même objet (ou au moins le même identifiant) :
const config = {
configurable: {
thread_id: "thread-1",
},
};
Démarrer le graphe avec un état initial. Le nœud agent écrasera la valeur vide de message :
const stream = await graph.stream(
{
message: "",
},
config
);
L’exécution se déroule via agent jusqu’à humanApproval, où interrupt() l’arrête. Le flux génère ensuite un bloc contenant une clé __interrupt__. La valeur de sa première entrée correspond au payload transmis à interrupt(), que la boucle affiche pour le réviseur :
for await (const chunk of stream) {
if (chunk.__interrupt__) {
const interruptValue =
chunk.__interrupt__[0].value;
console.log(
"\n⏸ Waiting for human approval...\n"
);
console.log(
"The agent wants to send this email:"
);
console.log(`"${interruptValue.message}"`);
console.log(
`\n${interruptValue.question}`
);
}
}
La sortie du terminal ressemble à ceci. La première ligne représente la demande initiale de l’utilisateur pour fournir du contexte ; le code affiché ci-dessus ne la met pas en évidence :
User: Send an email to Aman about the 5 PM meeting
⏸ Waiting for human approval...
The agent wants to send this email:
"Meeting at 5 PM with Aman"
Do you want to send this email?
À ce moment-là, le graphe est en pause et aucun e-mail n’a été envoyé. L’exécution est bloquée dans le point de contrôle, en attente.
Demander une décision et la valider
Interrogez maintenant le réviseur. La boucle continue de demander jusqu’à obtenir l’une des deux réponses acceptées, en normalisant d’abord les espaces blancs et la casse :
let humanAnswer;
while (true) {
humanAnswer = (
await rl.question("\nApprove or reject: ")
)
.trim()
.toLowerCase();
if (
humanAnswer === "approve" ||
humanAnswer === "reject"
) {
break;
}
console.log(
'Please type "approve" or "reject".'
);
}
Le terminal affiche Approuver ou rejeter : puis bloque. La saisie de approve interrompt la boucle, permettant de reprendre le graphe. Valider les entrées avant de reprendre vaut la peine des quelques lignes supplémentaires : la valeur que vous renvoyez est exactement celle que votre logique de routage verra.
Reprendre avec une commande
Reprendre signifie invoquer à nouveau le graphe, mais au lieu d’une entrée fraîche, on passe un Command dont le champ resume contient la réponse de l’utilisateur :
await graph.invoke(
new Command({
resume: humanAnswer,
}),
config
);
La même config est réutilisée, ce qui implique le même thread_id, et c’est ainsi que LangGraph localise l’exécution interrompue. La valeur resume est renvoyée en tant que valeur de retour de interrupt(). Lorsque approve est saisi, l’appel à l’intérieur de humanApproval
const decision = interrupt(...);
produit maintenant approve. Le nœud le renvoie sous la forme de decision, et le routage s’exécute :
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
Puisque decision est égal à "approve", le contrôle passe à sendEmail, et la console affiche :
📧 Email sent: "Meeting at 5 PM with Aman"
L’e-mail n’a été « envoyé » qu’après approbation explicite. Lorsque vous avez terminé, appelez rl.close() afin que l’interface readline libère stdin et que le processus puisse s’arrêter.
À quoi ressemble un rejet
Réexécutez le script. Comme MemorySaver reste en mémoire, un nouveau processus démarre avec un stockage de points de contrôle vide ; si vous le réexécutez dans le même processus, utilisez un nouveau thread_id pour ne pas reprendre une exécution déjà terminée. Lorsque la demande apparaît,
Approve or reject:
réponse :
reject
Le graphe est repris avec cette valeur. Le fragment ci-dessous présente littéralement la valeur pour plus de clarté ; dans le script, il s’agit simplement de humanAnswer:
await graph.invoke(
new Command({
resume: "reject",
}),
config
);
Cette fois, state.decision contient "reject", ce qui fait que le routeur renvoie END et que sendEmail n’est jamais planifié :
Agent wants to send email
↓
⏸ Paused
↓
Human: reject
↓
END
Cette distinction est importante. Le graphe ne se contente pas de générer un message différent en cas de rejet ; le nœud qui exécute l’effet secondaire ne s’exécute jamais du tout. C’est ce qui fait de l’étape d’approbation une véritable protection plutôt qu’une simple mesure esthétique.
Le tableau complet
En réunissant tout cela, le graphe complet ressemble à ceci :
┌─────────────┐
│ START │
└──────┬──────┘
↓
┌─────────────┐
│ Agent │
└──────┬──────┘
↓
┌───────────────────┐
│ Human Approval │
│ │
│ ⏸ interrupt() │
└─────────┬─────────┘
↓
Human decides
/ \
/ \
approve reject
↓ ↓
┌────────────┐ END
│ sendEmail │
└──────┬─────┘
↓
END
En une phrase : l’agent prend une décision, le graphe s’arrête, un humain examine la situation, le graphe reprend son cours, et ce n’est qu’alors que l’action est exécutée.
Le piège de la réexécution : conserver les effets secondaires après l’interruption
Un comportement de interrupt() prête à confusion pour beaucoup. En résumé, LangGraph ne reprend pas l’exécution à la ligne suivant interrupt(). Il exécute à nouveau le nœud contenant l’interruption depuis sa première ligne. La différence lors de la deuxième exécution est que l’appel à interrupt() renvoie immédiatement la valeur de reprise plutôt que de suspendre l’exécution.
Cela a une conséquence directe sur les effets secondaires. Tout ce qui se trouve avant interrupt() dans le même nœud s’exécute une fois lorsque le graphe est suspendu et à nouveau lorsqu’il reprend. C’est ce schéma qu’il faut éviter :
function humanApproval(state) {
saveSomethingToDatabase();
const decision = interrupt("Approve?");
return { decision };
}
Ici, saveSomethingToDatabase() s’exécuterait deux fois pour une seule approbation. La solution est structurelle : il faut maintenir le nœud d’approbation sans effets secondaires, et placer chaque action réelle dans un nœud ultérieur qui ne s’exécute qu’après que l’utilisateur ait répondu. C’est ainsi que l’exemple est organisé :
humanApproval
↓
interrupt()
↓
human response
↓
sendEmail
Si vous devez vraiment effectuer des tâches avant une interruption dans le même nœud, faites-en une opération idempotente (sécurisée pour être répétée, par exemple une mise à jour conditionnelle identifiée par un ID stable) ou déplacez-la dans son propre nœud précédent, dont le résultat final a déjà été enregistré et ne sera pas exécuté à nouveau.
Que se passe-t-il en arrière-plan, pas à pas
Lorsque le code n’est plus un obstacle, le cycle de vie est court :
- Le graphe commence à s’exécuter.
- Le nœud agent décide de vouloir envoyer un e-mail.
- L’exécution atteint la fonction
interrupt(). - LangGraph arrête l’exécution.
- L’état actuel est enregistré par le mécanisme de vérification.
- L’application reçoit la charge utile de l’interruption.
- Une personne examine l’action proposée.
- Cette personne prend une décision.
- L’application reprend l’exécution du graphe avec la commande
Command, en utilisant le mêmethread_id.
interrupt() renvoie la réponse de la personne.Les appels API constituent la partie simple ; c’est le schéma de pause et de reprise qui est l’idée à bien maîtriser :
Graph
│
▼
Agent decision
│
▼
interrupt()
│
│
┌───┴───┐
│ Human │
└───┬───┘
│
approve/reject
│
▼
resume
│
▼
Continue
Lorsque ce flux devient clair, HITL cesse de paraître mystérieux. Il s’agit simplement d’une pause marquée par des points de contrôle, suivie d’une réponse saisie à la fin.
Vérifier votre propre implémentation
- Approuvez une fois et assurez-vous que l’action s’exécute exactement une fois.
- Rejetez et vérifiez que le nœud d’action ne s’exécute jamais, et non seulement que la sortie diffère.
- Saisissez une réponse invalide et assurez-vous que la demande se répète plutôt que de reprendre avec des données erronées.
- Reprenez l’exécution avec un
thread_iddifférent et observez que la première exécution n’est pas affectée.
Où s’applique la même étape de contrôle
L’e-mail n’est qu’une démonstration pratique. Cette structure identique convient à toute action nécessitant une supervision humaine : envoi de messages, mise à jour ou suppression d’enregistrements, approbation de paiements, déploiement, désinstallation de ressources cloud, publication de contenu généré ou escalade des demandes d’assistance. Le nœud d’action change ; l’étape de contrôle qui le précède reste la même. Si vous souhaitez voir les étapes d’approbation aux côtés d’autres modèles d’orchestration tels que le routage et la diffusion, cet aperçu des cinq modèles LangGraph les présente côte à côte.
Points clés
interrupt()met en pause le graphique et transmet un chargement à votre application ; la valeur avec laquelle vous reprenez l’exécution devient sa valeur de retour.Command({ resume: ... })renvoie la réponse de l’utilisateur dans l’exécution en pause.- thread_id doit être utilisé pour mettre en pause et reprendre une exécution donnée.
- interrupt() est exécuté à nouveau depuis le début lors de la reprise, il convient donc de placer les effets secondaires dans des nœuds ultérieurs ou de les rendre idempotents.
Vous n’avez pas besoin d’un flux de travail complexe pour mettre un humain en contrôle d’un agent. Une pause bien placée avant l’étape décisive permet à l’agent d’effectuer la majeure partie du travail tandis qu’une personne conserve le dernier mot.
Lectures complémentaires
- Les agents contrôlés par approbation dans LangGraph : interrupt(), Checkpoints et un stockage — Construire un agent LangGraph étape par étape : un graph ReAct explicite, une approbation humaine via interrupt(), ainsi qu’une mémoire inter-thread avec un stockage, pour aboutir à un assistant de boîte de réception qui demande l’avis en premier.