Puertas de aprobación en LangGraph.js: Pausar agentes con interrupt() y Command
Construir una puerta de aprobación mínima en LangGraph.js que haga una pausa antes de un efecto secundario, recopile la decisión humana en la terminal y reanude de forma segura a partir de un punto de control.
Algunas acciones de los agentes tienen consecuencias demasiado importantes como para ejecutarse sin supervisión: enviar un correo en nombre de alguien, eliminar un registro o aprobar un pago. En estos casos, se desea que el agente proponga la acción, se detenga y espere a que una persona responda sí o no. Esta guía muestra cómo implementar este comportamiento en LangGraph.js con el grafo más pequeño posible, para que pueda ver exactamente cómo interrupt(), un checkpointer, un thread_id y Command({ resume }) colaboran para pausar una ejecución y reanudarla posteriormente.
A propósito, no hay orquestación de múltiples agentes ni llamadas a LLM aquí. Todo el patrón cabe en una sola oración: pausar, dejar que una persona decida y reanudar.
Cuándo un agente no debería tener la última palabra
La autonomía es valiosa cuando uno está dispuesto a permitir que el agente actúe según su propio juicio. Muchas acciones no cumplen con ese requisito, y un paso de revisión humana merece el esfuerzo adicional. Algunos ejemplos típicos son:
- enviar un correo electrónico o un mensaje de chat en nombre de un usuario
- modificar o eliminar un registro de la base de datos
- aprobar un pago
- desplegar código
- eliminar recursos en la nube
- escalar un ticket de soporte
- publicar contenido generado por IA
En cada caso el objetivo es el mismo. El agente sigue siendo quien piensa y prepara la acción, pero presenta su intención y deja la decisión final en manos de una persona antes de que ocurra algo irreversible. Ese arreglo es lo que en la práctica significa Human-in-the-Loop (HITL).
Cómo se relacionan interrupt() y Command
En esencia, HITL en LangGraph funciona de la siguiente manera: el grafo se detiene a mitad de ejecución, espera entrada desde el exterior y luego continúa utilizando esa entrada.
La detención se realiza mediante interrupt(). Cuando un nodo lo llama, LangGraph detiene la ejecución actual y guarda el estado del grafo a través del checkpointer configurado, de modo que se pueda continuar con esa misma ejecución más tarde. Su aplicación recibe el valor que pasó a interrupt(), lo muestra a una persona, recoge una respuesta y luego reanuda el grafo invocándolo con un objeto Command que contiene la respuesta.
La secuencia general es la siguiente:
Graph starts
↓
Agent decides to send email
↓
⏸ interrupt()
↓
Human reviews the action
↓
Approve / Reject
↓
Command({ resume: ... })
↓
Graph continues
Tenga en cuenta esta estructura; cada fragmento de código a continuación corresponde a una flecha en ella.
El escenario: un correo electrónico que necesita aprobación
El ejemplo utiliza un correo electrónico. El agente decide que desea enviar este mensaje:
Meeting at 5 PM with Aman
Antes de que ese mensaje vaya a ningún lado, alguien debe revisarlo y aprobarlo o rechazarlo. La estructura de ramificación es la siguiente:
User
↓
Agent decides to send email
↓
⏸ Human approval
↓
┌───────────────┐
│ Approve │ → Send email
│ Reject │ → Stop
└───────────────┘
Para mantener el enfoque en la mecánica de pausa y reanudación, no se utiliza ningún proveedor de correo real. El nodo que “envía” el correo simplemente lo imprime en la terminal. Reemplazarlo por una API real más adelante no cambia nada en el flujo de control.
Construyendo el grafo paso a paso
Instalar LangGraph y preparar las importaciones
Comience con un proyecto vacío de Node.js e incorpore LangGraph:
npm install @langchain/langgraph
Dependiendo de la versión que instale, LangGraph.js también puede requerir @langchain/core como dependencia par. Si npm emite una advertencia al respecto o las importaciones fallan, agregue también ese paquete y consulte la documentación de instalación actual.
La entrada humana provendrá de la terminal a través del módulo integrado de Node readline/promises, por lo que no se necesita ningún paquete adicional para ello. Las importaciones incluyen el generador del grafo, la herramienta de anotación de estado, las sentinelas START y END, la función interrupt, el comprobador en memoria y Command, además de las componentes relacionadas con 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";
El archivo utiliza sintaxis de módulo ES (import), por lo que debe nombrarse con la extensión .mjs o establecerse "type": "module" en package.json, y se requiere una versión de Node que soporte await en nivel superior, ya que el código a ejecutar utilizará await más adelante a nivel de módulo.
Definir el estado que lleva el grafo
El estado en LangGraph es un objeto compartido que fluye a través del grafo. Cada nodo lee de él y devuelve actualizaciones parciales. Este grafo solo necesita dos campos:
message, el texto del correo electrónico que propone el agentedecision, la respuesta que da el humano
const StateAnnotation = Annotation.Root({
message: Annotation,
decision: Annotation,
});
Annotation.Root() declara la estructura del estado. Al no especificarse ningún reductor, cada campo simplemente toma el valor más reciente que se le ha escrito. El nodo del agente completará message; el nodo de aprobación completará decision una vez que el humano haya respondido.
Escribe la acción que deseas proteger
A continuación está el nodo que representa la operación de riesgo. En producción esto llamaría a una API de correos electrónicos. Aquí solo registra:
function sendEmail(state) {
console.log(`\n📧 Email sent: "${state.message}"`);
return {};
}
Lo que hace esta función es casi irrelevante. Lo importante es cuándo se ejecuta: nunca antes de que un humano lo apruebe. El resto del grafo existe para garantizar ese orden. Obsérvese que devuelve un objeto vacío, lo que significa que deja el estado sin cambios.
Sustituto de la decisión del agente
En un sistema real, aquí es donde un LLM leería la solicitud del usuario y decidiría que se necesita un correo electrónico, probablemente mediante llamadas a herramientas. Agregar un modelo aquí solo distraería de la mecánica HITL, por lo que una función sencilla desempeña el papel del agente y devuelve el mensaje que “elegió”:
function agent() {
return {
message: "Meeting at 5 PM with Aman",
};
}
Interprete este nodo como el agente anunciando su intención: este es el correo electrónico que desea enviar. Si más adelante lo reemplaza por un LLM y lógica de llamadas a herramientas, la maquinaria de aprobación que lo rodea permanecerá esencialmente igual.
Pausa para un humano con interrupt()
Este es el corazón del patrón. El nodo de aprobación llama a interrupt() con un payload que describe qué necesita una decisión, y devuelve cualquier valor que regrese como la nueva decision:
function humanApproval(state) {
const decision = interrupt({
message: state.message,
question: "Do you want to send this email?",
});
return {
decision,
};
}
En cuanto la ejecución llega a esa llamada
interrupt(...)
el grafo se detiene. El objeto pasado se convierte en el payload de interrupción que la aplicación llamante puede leer. En este caso es:
{
message: "Meeting at 5 PM with Aman",
question: "Do you want to send this email?"
}
La aplicación muestra ese payload a una persona, espera una respuesta y reanuda el grafo. El detalle clave es que cualquier valor que se proporcione al reanudar se convierte en el valor de retorno de interrupt(). Por lo tanto, la línea
const decision = interrupt(...);
se convierte efectivamente en lo siguiente una vez que la persona aprueba:
const decision = "approve";
Ese valor se escribe en el estado como decision. Luego, una función de enrutamiento elige el siguiente paso a partir de él:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
Una respuesta "approve" conduce a sendEmail; cualquier otra cosa detiene la ejecución. Tratar toda respuesta que no sea de aprobación como una señal de detención es un valor por defecto razonable para una barrera de seguridad: si llega algún valor inesperado, el proceso falla sin realizar la acción correspondiente.
Añadir un checkpointer e conectar el grafo
Se necesita otro elemento antes de compilar: un checkpointer. Dado que la ejecución se detendrá y luego continuará, LangGraph debe guardar el estado de ejecución en el momento de la interrupción. Sin un checkpointer no hay nada del que poder reanudar. Para una demostración, la implementación en memoria es suficiente:
const checkpointer = new MemorySaver();
Ahora registre los tres nodos, conecte START con el agente y al agente con el paso de aprobación, agregue un borde condicional desde el paso de aprobación controlado por routeAfterApproval, y compile con el 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,
});
El tercer argumento de addConditionalEdges asocia cada valor que puede devolver el enrutador a un nodo de destino, lo cual también permite que LangGraph dibuje el grafo correctamente. La topología resultante:
START
↓
agent
↓
humanApproval
↓
┌──────────────┐
│ │
approve reject
│ │
↓ ↓
sendEmail END
│
↓
END
MemorySaver almacena puntos de control en la memoria del proceso, lo cual es ideal para experimentos pero resulta inútil una vez que el proceso finaliza. Para implementaciones reales, se debe utilizar un punto de control persistente respaldado por una base de datos, de modo que una ejecución pausada sobreviva a los reinicios y pueda reanudarse desde un proceso o servidor diferente, que es la situación habitual cuando una aprobación llega a través de una interfaz web horas después. Para conocer en detalle cómo se almacenan internamente los puntos de control, consulte cómo organiza y escribe LangGraph su salvador en memoria.
La otra parte importante de la persistencia es thread_id. Este identifica qué ejecución con puntos de control se está mencionando. Para pausar y reanudar, es necesario utilizar el mismo thread_id; de lo contrario, LangGraph no tiene forma de encontrar la ejecución guardada.
Ejecutar el flujo desde la terminal
Iniciar la ejecución y detectar la interrupción
Una interfaz readline convierte a la terminal en el revisor humano:
const rl = readline.createInterface({
input,
output,
});
El objeto de configuración contiene el thread_id bajo la clave configurable. Todo lo relacionado con esta ejecución, tanto la llamada inicial como la reanudación, debe pasar por este mismo objeto (o al menos el mismo ID):
const config = {
configurable: {
thread_id: "thread-1",
},
};
Se inicia el grafo con un estado inicial. El nodo del agente sobrescribirá el message vacío:
const stream = await graph.stream(
{
message: "",
},
config
);
La ejecución fluye a través de agent hacia humanApproval, donde interrupt() la detiene. A continuación, el flujo genera un bloque que contiene una clave __interrupt__. El value de su primera entrada es el payload pasado a interrupt(), el cual el bucle imprime para que lo vea el revisor:
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 salida de la terminal se parece a la siguiente. La primera línea representa la solicitud original del usuario para dar contexto; el código mostrado arriba no la imprime:
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?
En este momento el gráfico está en pausa y no se ha enviado ningún correo electrónico. La ejecución se encuentra en el punto de verificación, esperando.
Solicitar una decisión y validarla
Ahora pregunte al revisor. El bucle sigue preguntando hasta obtener una de las dos respuestas aceptadas, normalizando primero los espacios en blanco y la mayúsculas/minúsculas:
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".'
);
}
La terminal muestra Aprobar o rechazar: y luego se bloquea. Al escribir approve se interrumpe el bucle y se puede reanudar el gráfico. Validar la entrada antes de reanudar merece los pocos líneas adicionales: el valor que se devuelve es exactamente lo que verá la lógica de enrutamiento.
Reanudar con comando
Reanudar significa invocar el grafo nuevamente, pero en lugar de datos de entrada nuevos se pasa un Command cuyo campo resume contiene la respuesta del usuario:
await graph.invoke(
new Command({
resume: humanAnswer,
}),
config
);
Se reutiliza la misma config, lo que implica el mismo thread_id; así es como LangGraph localiza la ejecución interrumpida. El valor de resume se entrega como el valor de retorno de interrupt(). Al introducir approve, la llamada dentro de humanApproval
const decision = interrupt(...);
ahora devuelve approve. El nodo lo devuelve como decision, y el enrutador ejecuta:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
Dado que decision es igual a "approve", el control pasa a sendEmail, y la terminal imprime:
📧 Email sent: "Meeting at 5 PM with Aman"
El correo electrónico solo se “envió” después de una aprobación explícita. Cuando haya terminado, llame a rl.close() para que la interfaz readline libere stdin y el proceso pueda finalizar.
Qué aspecto tiene el rechazo
Ejecute la secuencia de comandos nuevamente. Dado que MemorySaver permanece en memoria, un proceso nuevo inicia con un almacén de puntos de control vacío; si vuelve a ejecutarse dentro del mismo proceso, utilice un thread_id nuevo para no reanudar una ejecución que ya finalizó. Cuando aparezca el prompt,
Approve or reject:
respuesta:
reject
El gráfico se reanuda con ese valor. El fragmento a continuación muestra el texto literal para mayor claridad; en la secuencia de comandos es simplemente humanAnswer:
await graph.invoke(
new Command({
resume: "reject",
}),
config
);
Esta vez state.decision contiene "reject", por lo que el router devuelve END y sendEmail nunca se programa:
Agent wants to send email
↓
⏸ Paused
↓
Human: reject
↓
END
Esa distinción es importante. El grafo no solo genera un mensaje diferente en caso de rechazo; el nodo que ejecuta los efectos secundarios nunca se ejecuta en absoluto. Eso es lo que convierte el paso de aprobación en una verdadera medida de seguridad y no solo en algo superficial.
La imagen completa
Al combinar todo, el grafo completo se ve así:
┌─────────────┐
│ START │
└──────┬──────┘
↓
┌─────────────┐
│ Agent │
└──────┬──────┘
↓
┌───────────────────┐
│ Human Approval │
│ │
│ ⏸ interrupt() │
└─────────┬─────────┘
↓
Human decides
/ \
/ \
approve reject
↓ ↓
┌────────────┐ END
│ sendEmail │
└──────┬─────┘
↓
END
En una sola frase: el agente toma la decisión, el grafo se pausa, una persona revisa, el grafo vuelve a ejecutarse y solo entonces se lleva a cabo la acción.
El problema de la reejecución: mantener los efectos secundarios después de la interrupción
Un comportamiento de interrupt() confunde a muchas personas. En resumen, LangGraph no continúa desde la línea siguiente a interrupt(); en su lugar, vuelve a ejecutar el nodo que contiene la interrupción desde su primera línea. La diferencia en la segunda pasada es que la llamada a interrupt() devuelve inmediatamente el valor de reanudación en lugar de pausar.
Eso tiene una consecuencia directa en los efectos secundarios. Cualquier elemento colocado antes de interrupt() en el mismo nodo se ejecuta una vez cuando el grafo se pausa y nuevamente cuando se reanuda. Este es el patrón que debe evitarse:
function humanApproval(state) {
saveSomethingToDatabase();
const decision = interrupt("Approve?");
return { decision };
}
Aquí saveSomethingToDatabase() se ejecutaría dos veces para una sola aprobación. La solución es estructural: mantener el nodo de aprobación libre de efectos secundarios y colocar todas las acciones reales en un nodo posterior que se ejecute únicamente después de que la persona responda. Así está organizado el ejemplo:
humanApproval
↓
interrupt()
↓
human response
↓
sendEmail
Si realmente es necesario realizar una tarea antes de una interrupción en el mismo nodo, hágala idempotente (segura para repetir, por ejemplo, un upsert con clave basada en un identificador estable) o muévala a su propio nodo anterior, cuyo resultado ya está guardado como punto de control y no se ejecutará nuevamente.
Qué sucede en el fondo, paso a paso
Una vez eliminado el código, el ciclo de vida es breve:
- El grafo comienza a ejecutarse.
- El nodo agente decide enviar un correo electrónico.
- La ejecución llega a
interrupt(). - LangGraph detiene la ejecución.
- El estado actual es guardado por el punto de control.
- La aplicación recibe la carga útil de la interrupción.
- Alguien revisa la acción propuesta.
- Esa persona toma una decisión.
- La aplicación reanuda la ejecución del grafo con
Command, utilizando el mismothread_id.
interrupt() devuelve la respuesta de la persona.Las llamadas a la API son la parte sencilla; el patrón de pausa y reanudación es la idea que vale la pena interiorizar:
Graph
│
▼
Agent decision
│
▼
interrupt()
│
│
┌───┴───┐
│ Human │
└───┬───┘
│
approve/reject
│
▼
resume
│
▼
Continue
Una vez que ese flujo queda claro, HITL deja de parecer misterioso. Es simplemente una pausa con puntos de control y una respuesta escrita al final.
Verificar tu propia implementación
Antes de confiar en una puerta de aprobación, realiza algunas pruebas rápidas:
- Aproba una vez y confirma que la acción se ejecute exactamente una vez.
- Rechaza y confirma que el nodo de acción nunca se ejecute, no solo que la salida sea diferente.
- Escribe una respuesta inválida y confirma que el mensaje se repita en lugar de reanudarse con datos incorrectos.
- Reanuda con un
thread_iddiferente y observa que la ejecución original no se ve afectada.
Dónde se aplica la misma puerta de control
El correo electrónico es solo una demostración práctica. La estructura idéntica se adapta a cualquier acción que requiera supervisión humana: mensajería, actualizaciones o eliminación de registros, aprobaciones de pagos, despliegues, desmontaje de recursos en la nube, publicación de contenido generado o escalado de solicitudes de soporte. El nodo de acción cambia; la puerta de control que lo precede no. Si desea ver los pasos de aprobación junto con otros patrones de orquestación como el enrutamiento y la distribución, esta visión general de cinco patrones LangGraph los muestra uno al lado del otro.
Puntos clave
interrupt()pausa el gráfico y entrega un payload a su aplicación; el valor con el que se reanuda se convierte en su valor de retorno.Command({ resume: ... })devuelve la respuesta del usuario al proceso pausado.- Es obligatorio utilizar un checkpointer para la pausa; en entornos de producción, emplee uno persistente para que las aprobaciones puedan llegar tras los reinicios.
- Se debe utilizar el mismo
thread_idpara pausar y reanudar un proceso determinado. - El nodo que contiene
interrupt()se vuelve a ejecutar desde el principio al reanudarse, por lo que los efectos secundarios deben colocarse en nodos posteriores o hacerse idempotentes. - Dirija todo lo que no sea una aprobación explícita hacia un estado de detención, de modo que la compuerta se cierre automáticamente.
No se necesita un flujo de trabajo complejo para poner a una persona al mando de un agente. Una pausa bien colocada antes del paso decisivo permite que el agente realice la mayor parte del trabajo mientras una persona mantiene la última palabra.
Lecturas relacionadas
- Agentes con control de aprobación en LangGraph: interrupt(), puntos de control y un almacén — Construir un agente de LangGraph paso a paso: un grafo ReAct explícito, aprobación humana mediante interrupt(), y memoria entre hilos con un almacén, todo ello culminando en un asistente de bandeja de entrada que pregunta primero.