Tenía un agente que revisaba pull requests. Cincuenta líneas de TypeScript, un while, tres tools. Funcionaba.
Hasta que un PR tocó el módulo de autenticación y el agente hizo lo correcto: parar y pedir aprobación humana. El problema es que "parar" significaba dejar un proceso de Node vivo esperando un webhook que llegó dieciocho horas después. El proceso ya no existía. El contexto tampoco.
Reinicié. Volvió a analizar el PR desde cero, volvió a gastar tokens, volvió a pedir aprobación. Mi loop no tenía un bug: tenía un límite arquitectónico.
LangGraph TypeScript existe para ese límite exacto. Y lo adoptas sin comprar la casa entera para usar el garaje.
LangGraph es la librería de orquestación de agentes de LangChain, disponible para TypeScript y Python, que modela un agente como un grafo de estados: los nodos son funciones que reciben y devuelven estado, las aristas deciden qué nodo va después, y un checkpointer persiste el estado tras cada paso. Ese checkpointer es lo que permite pausar una ejecución hoy y reanudarla dentro de tres días, en otra máquina.
El loop explícito resuelve la mayoría de los agentes
Empecemos por lo incómodo: en los proyectos que he tocado, la gran mayoría de los agentes no necesitan un framework de orquestación. Necesitan esto.
type ToolCall = { id: string; name: string; args: unknown };
type Msg =
| { role: "user" | "assistant" | "system"; content: string }
| { role: "assistant"; content: string; toolCalls: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string };
export async function agente(prompt: string): Promise<string> {
const messages: Msg[] = [{ role: "user", content: prompt }];
let turno = 0;
while (turno++ < 10) {
const res = await llm.complete(messages);
if (!res.toolCalls?.length) return res.content;
messages.push({
role: "assistant",
content: res.content,
toolCalls: res.toolCalls,
});
for (const call of res.toolCalls) {
const tool = tools[call.name];
// El nombre de la tool lo elige el modelo. Si alucina uno, se lo devuelves
// como error para que se corrija, en vez de reventar a mitad de ejecución.
const salida = tool
? await tool(call.args)
: `Error: la herramienta "${call.name}" no existe.`;
messages.push({ role: "tool", toolCallId: call.id, content: salida });
}
}
throw new Error("Límite de turnos alcanzado");
}
Eso es un agente. Lo depuras con console.log, lo entiendes entero en treinta segundos y no tiene una capa de orquestación que pueda romperte en la siguiente minor.
Yo defiendo este loop, y lo he defendido por escrito: en multi-agente sin orquestador explico por qué la mayoría de los sistemas "multi-agente" son un for con buen marketing. Sigo pensando lo mismo.
El loop no falla por complejidad. Falla por duración.
Cuándo usar LangGraph en vez de un while loop
Usa LangGraph cuando tu agente cumpla al menos uno de estos tres síntomas. Si no cumple ninguno, quédate con el while. El punto de inflexión no es "mi agente hace muchas cosas": es uno de estos tres, y basta con uno.
- El estado tiene que sobrevivir al proceso. Si tu agente vive más que un request HTTP —minutos, horas, días— el array
messagesen memoria es una bomba de relojería. Un deploy, un reinicio, un pod que se recicla, y perdiste la ejecución. - La ramificación es real, no cosmética. Un
ifdentro del loop está bien. Pero cuando el camino A y el camino B tienen pasos distintos, reintentos distintos y puntos de salida distintos, el loop se convierte en un árbol de condicionales que nadie quiere tocar. - Un humano tiene que decidir en mitad de la ejecución. No al principio ni al final. En mitad. Y puede tardar un día en contestar.
while loop explícito |
LangGraph (StateGraph) |
|
|---|---|---|
| Estado entre pasos | Array en memoria | Campos tipados con reducers |
| Sobrevive a un reinicio | No | Sí, con checkpointer |
| Pausar y reanudar | Lo escribes tú | interrupt() + Command({ resume }) |
| Ramificación | if anidados |
addConditionalEdges tipado |
| Depuración | console.log |
Inspección del grafo y del checkpoint |
| Dependencias | Ninguna | @langchain/langgraph + @langchain/core |
| Coste de entrada | Cero | Una tarde por tramo migrado |
Si no tienes ninguno de los tres, cierra esta pestaña y quédate con tu loop. Hablo en serio. Ese es justamente el argumento que defiendo en el stack de IA agéntica que uso: la capa de orquestación es la última que deberías añadir, no la primera.
Un apunte de vocabulario, porque genera confusión real: aquí "grafo" significa grafo de orquestación —qué nodo se ejecuta después de cuál—. No tiene nada que ver con el grafo de recuperación del que hablé en qué es graph engineering, que va de qué código llega al contexto del modelo. Misma palabra, dos capas distintas del sistema.
El estado primero, el grafo después
En LangGraph el estado no es un detalle de implementación: es el contrato. Y en la v1 —@langchain/langgraph 1.4.10, agosto de 2026— se declara con StateSchema, aceptando esquemas de Zod campo a campo.
import {
StateSchema,
MessagesValue,
ReducedValue,
} from "@langchain/langgraph";
import { z } from "zod";
const RevisionState = new StateSchema({
messages: MessagesValue,
pr: z.string(),
riesgo: z.enum(["bajo", "alto"]).default("bajo"),
hallazgos: new ReducedValue(z.array(z.string()).default(() => []), {
reducer: (actual: string[], nuevo: string[]) => actual.concat(nuevo),
}),
aprobado: z.boolean().default(false),
});
type Revision = typeof RevisionState.State;
Fíjate en ReducedValue. Esa es la pieza que no tiene equivalente limpio en el loop: define cómo se combinan las actualizaciones de un campo. Los nodos devuelven trozos de estado y el reducer decide si se sobrescriben o se acumulan. Sin eso, dos nodos que escriben en hallazgos se pisan.
Y sí, es Zod de verdad: .default(), .enum(), refinamientos. El estado del agente es un contrato de datos como cualquier otro, y aquí es donde se nota tenerlos bien tipados — es el mismo músculo que entreno en el curso de Zod para TypeScript.
Verás mucho tutorial con Annotation.Root({ ... }). Sigue funcionando y sigue compilando, pero es la sintaxis anterior. Si empiezas hoy, empieza con StateSchema.
Nodos, aristas y la decisión que el loop no sabe expresar
Un nodo es una función que recibe el estado y devuelve un trozo de estado. Nada más.
import {
StateGraph, START, END, MemorySaver, interrupt, Command,
} from "@langchain/langgraph";
async function analizar(state: Revision) {
const tocaAuth = state.pr.includes("auth");
return {
hallazgos: ["Cobertura de tests: 62%"],
riesgo: tocaAuth ? ("alto" as const) : ("bajo" as const),
};
}
async function aprobarAuto(_state: Revision) {
return { aprobado: true };
}
function enrutar(state: Revision): "aprobarAuto" | "revisionHumana" {
return state.riesgo === "alto" ? "revisionHumana" : "aprobarAuto";
}
const grafo = new StateGraph(RevisionState)
.addNode("analizar", analizar)
.addNode("aprobarAuto", aprobarAuto)
.addNode("revisionHumana", revisionHumana)
.addEdge(START, "analizar")
.addConditionalEdges("analizar", enrutar, ["aprobarAuto", "revisionHumana"])
.addEdge("aprobarAuto", END)
.addEdge("revisionHumana", END)
.compile({ checkpointer: new MemorySaver() });
addConditionalEdges recibe el nodo origen, la función que decide y la lista de destinos posibles. Esa lista no es decorativa: es lo que hace que el enrutado sea tipado y que el grafo sea inspeccionable antes de ejecutarlo.
Y ahí está compile({ checkpointer }). Esa línea es la que justifica todo lo demás. Sin checkpointer tienes un runner de funciones con sintaxis rara. Con checkpointer tienes una máquina de estados que se puede pausar y reanudar.
MemorySaver es para desarrollo —vive en RAM y muere con el proceso—. Para producción usa los checkpointers persistentes, que van en paquetes aparte: @langchain/langgraph-checkpoint-postgres (1.0.4) o @langchain/langgraph-checkpoint-sqlite (1.0.3).
Human-in-the-loop en LangGraph: la funcionalidad que justifica el cambio
El human-in-the-loop en LangGraph se implementa con interrupt(): el nodo lanza la pausa, el grafo guarda el estado en el checkpointer y la ejecución termina. Cuando llega la respuesta humana, se reanuda con Command({ resume }) sobre el mismo thread_id.
Aquí es donde mi PR de las dieciocho horas deja de ser un problema.
type PeticionRevision = {
pr: string;
hallazgos: string[];
pregunta: string;
};
async function revisionHumana(state: Revision) {
const decision = interrupt<PeticionRevision, { aprobado: boolean }>({
pr: state.pr,
hallazgos: state.hallazgos,
pregunta: "Este PR toca auth. ¿Lo apruebas?",
});
return { aprobado: decision.aprobado };
}
Cuando la ejecución llega a interrupt(), el grafo persiste el estado exacto y para. No bloquea un proceso: termina. El estado queda guardado bajo un thread_id.
const config = { configurable: { thread_id: "pr-482" } };
await grafo.invoke({ pr: "feat/auth-refresh-token" }, config);
const pausa = await grafo.getState(config);
console.log(pausa.next); // [ 'revisionHumana' ]
console.log(pausa.tasks[0]?.interrupts[0]?.value); // el payload de la pregunta
// Horas o días después, otro proceso, otro deploy:
const final = await grafo.invoke(
new Command({ resume: { aprobado: true } }),
config
);
console.log(final.aprobado); // true
Léelo otra vez. El segundo invoke puede ocurrir en otra máquina, la semana siguiente, después de tres despliegues. El grafo continúa donde estaba: analizar no se vuelve a ejecutar y no gastas otra vez esos tokens.
Eso, en el loop, no lo montas en un rato. Escribes tu propio serializador de estado, tu propio registro de "en qué paso iba" y tu propia lógica de reanudación. Es decir: escribes un checkpointer peor.
Tres avisos que cuestan tiempo:
- El nodo que contiene el
interrupt()sí se re-ejecuta entero al reanudar. El grafo no repite los nodos anteriores, pero este arranca otra vez desde su primera línea. Si pones unINSERT, un webhook o un cobro antes de la llamada, ocurre dos veces. Todo efecto secundario va después delinterrupt(), nunca antes. - No envuelvas
interrupt()en untry/catch: señaliza la pausa con una excepción y te la comerías. - Lo que le pasas debe ser serializable a JSON.
Cuándo NO montar el grafo de estados en LangGraph
No lo montes porque el proyecto "va a crecer". Móntalo cuando tengas uno de los tres síntomas delante.
Y no confundas esto con adoptar el ecosistema entero. Ya dejé claro mi veredicto sobre la librería base en el stack de IA agéntica que uso: demasiada abstracción sobre abstracciones. LangGraph es otra cosa. Es una máquina de estados con persistencia, y puedes usarla sin tocar el resto.
Un detalle actual que evita un error frecuente: el prebuilt createReactAgent de @langchain/langgraph/prebuilt está marcado como deprecado. Se movió al paquete langchain como createAgent. Si sigues un tutorial de hace un año, vas a copiar un import obsoleto.
Y antes de dibujar un solo nodo, escribe qué estados existen y qué transiciones son legales. Un grafo mal pensado es peor que un loop, porque además parece serio. Esa disciplina de definir el contrato antes de escribir la implementación es la misma que defiendo en el libro de Spec-Driven Development, y aquí paga doble.
Qué hacer con esto hoy
Abre tu agente y busca una sola cosa: un punto donde la ejecución tenga que sobrevivir a un reinicio. Una aprobación, una espera larga, un proceso por lotes que tarda horas.
Si no lo encuentras, tu loop está bien. Cierra el editor.
Si lo encuentras, no reescribas el agente entero. Extrae solo ese tramo a un StateGraph con checkpointer, deja el resto como está y quédate con las tools intactas. Migrar un tramo cuesta una tarde. Migrar por moda cuesta un trimestre.
Si quieres ver este tipo de decisiones tomadas en proyectos reales —cuándo meter un framework y cuándo no—, es justo lo que trabajo en Construye con IA, y en Dominicode Labs montamos estos grafos con el código completo delante.
Preguntas frecuentes
¿Cuándo usar LangGraph en vez de un while loop?
Cuando la ejecución tenga que sobrevivir al proceso, cuando la ramificación tenga pasos y salidas realmente distintas, o cuando un humano deba decidir en mitad del flujo. Con uno solo de esos tres basta. Si tu agente empieza y termina dentro del mismo request, el while explícito es mejor opción: se depura con console.log y no tiene una capa de orquestación que se rompa en la siguiente minor. LangGraph no gana por complejidad, gana por duración.
¿Necesito un checkpointer para usar interrupt() en LangGraph?
Sí, y no es opcional. interrupt() funciona persistiendo el estado del grafo y terminando la ejecución. Sin un checkpointer en compile() no hay dónde guardar ese estado, así que no hay nada que reanudar. En desarrollo te vale MemorySaver; en producción necesitas uno con almacenamiento real, o perderás las pausas en cada despliegue.
¿StateSchema con Zod sustituye a Annotation.Root en LangGraph TypeScript?
Es la forma actual de declarar el estado y la que deberías usar en código nuevo. Annotation.Root sigue exportándose y sigue compilando, así que no tienes que migrar nada con prisa. La diferencia práctica es que con StateSchema reutilizas esquemas de Zod que probablemente ya tienes en el proyecto, con sus default() y sus validaciones, en lugar de aprender una segunda sintaxis solo para el estado del grafo.
¿Qué checkpointer uso en producción con LangGraph JS?
MemorySaver viene en el paquete principal pero guarda en RAM: sirve para tests y ejemplos, no para producción. Los checkpointers persistentes van en paquetes aparte, @langchain/langgraph-checkpoint-postgres y @langchain/langgraph-checkpoint-sqlite. Si ya tienes Postgres en el stack, esa es la respuesta fácil, porque el estado del agente pasa a ser una tabla más que respaldas y auditas como cualquier otra.
¿Puedo migrar mi while loop a LangGraph sin reescribir las tools?
Sí, y es la vía que recomiendo. Las tools son funciones con un esquema de entrada; no les afecta quién las llama. Lo que cambia es el orquestador: el bucle pasa a ser nodos y aristas, y el array de mensajes pasa a ser un campo del estado. Puedes migrar un solo tramo del flujo —el que necesita pausarse— y dejar el resto del agente exactamente como está.
¿Necesito LangChain para usar LangGraph en TypeScript?
No. @langchain/langgraph declara @langchain/core como peer dependency —de ahí salen los tipos de mensajes y modelos— pero no requiere el paquete langchain ni sus cadenas y abstracciones: una instalación limpia trae @langchain/core, langgraph-checkpoint y langgraph-sdk, y ahí se acaba. Puedes montar un StateGraph llamando dentro de tus nodos al SDK del proveedor que ya uses. Un nodo es una función asíncrona: lo que hagas dentro es cosa tuya.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.

Leave a Reply