LangGraph en la práctica: estados, nodos, aristas y cinco patrones de agentes
Aprenda a definir el estado de LangGraph con anotaciones y reductores, a conectar nodos mediante aristas, e implementar los cinco patrones básicos de flujo de trabajo agente en JavaScript.
Fuentes: Agentic AI and LangGraph — Part 2: Building Nodes, Edges, and All 5 Workflow Patterns
Resumen: Donde lo dejamos
En el artículo anterior, presentamos los fundamentos conceptuales: un agente de IA combina un cerebro (el LLM) con un orquestador que coordina herramientas y memoria, todo ello bajo algún tipo de supervisión. También vimos que LangGraph convierte esto en un gráfico: los nodos albergan unidades de trabajo, las aristas controlan el flujo entre ellas, y un objeto estado compartido se desplaza por todo el sistema, siendo fusionado en cada paso por reductores.
Esta vez, pasamos de la teoría a la implementación. Para cuando termines de leer este artículo, podrás:
- Declarar el estado utilizando
Annotation, incluyendo reductores personalizados - Conectar nodos entre sí mediante aristas tanto simples como condicionales
Todo el código a continuación depende de @langchain/langgraph y de JavaScript puro (los mismos patrones se aplican en TypeScript; basta con agregar parámetros de tipo a Annotation<T>).
npm install @langchain/langgraph @langchain/core @langchain/openai
1. Estado y anotaciones: el cuaderno compartido
Antes de que pueda ejecutarse un solo nodo, LangGraph necesita comprender la estructura de los datos que pasan por su grafo. Annotation es la forma en que se describe dicha estructura.
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
const WorkflowState = Annotation.Root({
query: Annotation(),
searchResults: Annotation(),
draft: Annotation(),
finalAnswer: Annotation(),
});
Cada clave que definas dentro de Annotation.Root({...}) se convierte en un campo en tu objeto de estado compartido. Cualquier nodo puede leer estos campos, y cuando devuelve el resultado, solo necesita incluir los campos que realmente está actualizando; LangGraph se encarga de fusionar ese resultado parcial con el estado completo.
Reductores: Decidir cómo se fusionan las actualizaciones
A menos que especifiques lo contrario, el valor de retorno de un nodo sustituye por completo al campo correspondiente. Esto funciona bien para algo como draft, donde solo te interesa la versión más reciente. Pero falla en casos como una lista acumulativa de mensajes, donde sobrescribir borraría todo lo anterior. Aquí es donde ayuda un reductor: es una función que define cómo fusionar el valor existente con una actualización entrante.
const AgentState = Annotation.Root({
// Default behavior: last write wins
draft: Annotation(),
// Custom reducer: append instead of overwrite
messages: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
});
Con esto en su lugar, si nodeA devuelve { messages: ["hi"] } y nodeB devuelve { messages: ["hello"] }, el reducer los fusiona en ["hi", "hello"] en lugar de permitir que uno sobrescriba al otro. Este mecanismo es esencial para ejecutar ramas paralelas de forma segura (ver patrón 3 a continuación): dos nodos pueden escribir en la misma clave de estado simultáneamente sin entrar en conflicto.
2. Nodos: unidades de trabajo
Un nodo no es más que una función —sincrónica o asíncrona— que recibe el estado actual y devuelve una actualización parcial de él.
async function draftNode(state) {
const response = await llm.invoke(`Write a short draft about: ${state.query}`);
return { draft: response.content };
}
Se registra en el grafo utilizando addNode:
const graph = new StateGraph(WorkflowState)
.addNode("draft", draftNode);
Tenga en cuenta que el nodo en sí no tiene conocimiento de la estructura del grafo circundante. Simplemente consume el estado y genera una actualización. Conectar los nodos es exclusivamente tarea de las aristas.
3. Aristas: conectando el flujo
Las aristas determinan el orden de ejecución. LangGraph admite tres tipos:
Aristas normales — un camino fijo e incondicional de un nodo a otro.
graph.addEdge(START, "draft"); // entry point: where the graph starts
graph.addEdge("draft", "polish");
graph.addEdge("polish", END); // exit point: where the graph stops
Aristas condicionales — una función de enrutamiento examina el estado actual y decide qué nodo se ejecutará a continuación.
graph.addConditionalEdges("classify", (state) => {
if (state.category === "billing") return "handleBilling";
if (state.category === "technical") return "handleTechnical";
return "handleGeneral";
});
Aristas paralelas (fan-out) — cuando un único nodo tiene varias aristas normales salientes, todos sus destinos se ejecutan de forma concurrente.
graph.addEdge("orchestrator", "workerA");
graph.addEdge("orchestrator", "workerB");
graph.addEdge("orchestrator", "workerC");
Cada grafo debe incluir al menos un arista que comience en START y una ruta que eventualmente llegue a END; de lo contrario, LangGraph no sabrá dónde debe comenzar o terminar la ejecución.
Ahora que ya se han tratado los estados, nodos y aristas, cuenta con todos los componentes necesarios para construir los cinco patrones de flujo de trabajo presentados anteriormente.
4. Patrón 1 — Encadenamiento de solicitudes
Concepto: dividir una tarea grande en una cadena lineal de llamadas a LLM, donde cada paso se basa en lo anterior.
START → outline → draft → polish → END
const ChainState = Annotation.Root({
topic: Annotation(),
outline: Annotation(),
draft: Annotation(),
final: Annotation(),
});
async function outlineNode(state) {
const res = await llm.invoke(`Create a short outline for: ${state.topic}`);
return { outline: res.content };
}async function draftNode(state) {
const res = await llm.invoke(`Write a draft based on this outline:\n${state.outline}`);
return { draft: res.content };
}async function polishNode(state) {
const res = await llm.invoke(`Polish and tighten this draft:\n${state.draft}`);
return { final: res.content };
}const chainGraph = new StateGraph(ChainState)
.addNode("outline", outlineNode)
.addNode("draft", draftNode)
.addNode("polish", polishNode)
.addEdge(START, "outline")
.addEdge("outline", "draft")
.addEdge("draft", "polish")
.addEdge("polish", END)
.compile();const result = await chainGraph.invoke({ topic: "the history of tea" });
console.log(result.final);
Cada nodo aquí solo lee el campo escrito por el nodo anterior; no hay ramificaciones ni bucles, solo un flujo directo de principio a fin.
5. Patrón 2 — Enrutamiento
Concepto: primero clasificar la entrada recibida y luego enviarla a uno de los varios caminos dedicados para su procesamiento.
┌──> handleBilling ──┐
START → classify ───┼──> handleTechnical ─┼──> END
└──> handleGeneral ──┘
const RouteState = Annotation.Root({
message: Annotation(),
category: Annotation(),
response: Annotation(),
});
async function classifyNode(state) {
const res = await llm.invoke(
`Classify this message as "billing", "technical", or "general". Only reply with one word.\n${state.message}`
);
return { category: res.content.trim().toLowerCase() };
}async function handleBilling(state) {
return { response: `[Billing team] ${await llm.invoke(state.message).then(r => r.content)}` };
}
async function handleTechnical(state) {
return { response: `[Tech support] ${await llm.invoke(state.message).then(r => r.content)}` };
}
async function handleGeneral(state) {
return { response: `[General desk] ${await llm.invoke(state.message).then(r => r.content)}` };
}const routeGraph = new StateGraph(RouteState)
.addNode("classify", classifyNode)
.addNode("handleBilling", handleBilling)
.addNode("handleTechnical", handleTechnical)
.addNode("handleGeneral", handleGeneral)
.addEdge(START, "classify")
.addConditionalEdges("classify", (state) => {
if (state.category === "billing") return "handleBilling";
if (state.category === "technical") return "handleTechnical";
return "handleGeneral";
})
.addEdge("handleBilling", END)
.addEdge("handleTechnical", END)
.addEdge("handleGeneral", END)
.compile();
La función que se pasa a addConditionalEdges debe devolver el nombre del siguiente nodo a visitar; ese es el mecanismo que permite al grafo elegir un camino de forma dinámica en lugar de seguir siempre la misma ruta.
6. Patrón 3 — Paralelización
Concepto: realizar múltiples llamadas a LLM simultáneamente sobre diferentes partes de una tarea y luego combinar los resultados obtenidos.
┌──> summarizeDocA ──┐
START → split ──┼──> summarizeDocB ──┼──> merge → END
└──> summarizeDocC ──┘
const ParallelState = Annotation.Root({
documents: Annotation(),
summaries: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
finalSummary: Annotation(),
});
function splitNode(state) {
return {}; // no-op entry point, just fans out to the summarizers below
}async function summarizeDocA(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[0]}`);
return { summaries: [res.content] };
}
async function summarizeDocB(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[1]}`);
return { summaries: [res.content] };
}
async function summarizeDocC(state) {
const res = await llm.invoke(`Summarize:\n${state.documents[2]}`);
return { summaries: [res.content] };
}async function mergeNode(state) {
const res = await llm.invoke(`Combine these summaries into one:\n${state.summaries.join("\n---\n")}`);
return { finalSummary: res.content };
}const parallelGraph = new StateGraph(ParallelState)
.addNode("split", splitNode)
.addNode("summarizeDocA", summarizeDocA)
.addNode("summarizeDocB", summarizeDocB)
.addNode("summarizeDocC", summarizeDocC)
.addNode("merge", mergeNode)
.addEdge(START, "split")
.addEdge("split", "summarizeDocA")
.addEdge("split", "summarizeDocB")
.addEdge("split", "summarizeDocC")
.addEdge("summarizeDocA", "merge")
.addEdge("summarizeDocB", "merge")
.addEdge("summarizeDocC", "merge")
.addEdge("merge", END)
.compile();
Preste atención al reductor asociado a summaries: sin él, la última rama paralela que se complete simplemente sobrescribiría los resultados anteriores en lugar de permitir que las tres salidas se acumulen de forma segura en el array.
7. Patrón 4 — Orquestador-Trabajador
Concepto: una sola llamada al LLM divide el trabajo general en subtareas más pequeñas y las asigna a un nodo trabajador cada una; luego, un paso final reúne todo en un único resultado. Esto se parece mucho a la paralelización, pero aquí el orquestador decide en tiempo real cuántos trabajadores activar y qué debe hacer cada uno, en lugar de que eso esté fijado de antemano.
START → orchestrator ──> worker(s), one per subtask ──> compile → END
const OrchestratorState = Annotation.Root({
task: Annotation(),
subtasks: Annotation(),
sections: Annotation({
reducer: (current, update) => [...current, ...update],
default: () => [],
}),
report: Annotation(),
});
async function orchestratorNode(state) {
const res = await llm.invoke(
`Break this task into 3-5 short subtasks, one per line:\n${state.task}`
);
const subtasks = res.content.split("\n").filter(Boolean);
return { subtasks };
}async function workerNode(state) {
// In a real app you'd invoke one worker per subtask (e.g. via Send / map-reduce);
// here's the simple per-subtask logic each worker runs:
const results = await Promise.all(
state.subtasks.map((subtask) => llm.invoke(`Write a section about: ${subtask}`))
);
return { sections: results.map((r) => r.content) };
}async function compileNode(state) {
const res = await llm.invoke(`Compile these sections into one report:\n${state.sections.join("\n\n")}`);
return { report: res.content };
}const orchestratorGraph = new StateGraph(OrchestratorState)
.addNode("orchestrator", orchestratorNode)
.addNode("worker", workerNode)
.addNode("compile", compileNode)
.addEdge(START, "orchestrator")
.addEdge("orchestrator", "worker")
.addEdge("worker", "compile")
.addEdge("compile", END)
.compile();
Si necesita una distribución realmente dinámica —es decir, si la cantidad de ramas de trabajadores cambia efectivamente de una ejecución a otra— LangGraph.js ofrece la API Send, que le permite generar una invocación de trabajador por cada subtarea en el momento de la ejecución. Ese mecanismo merece un análisis detallado por sí solo, pero la configuración mostrada aquí ya cubre la gran mayoría de las necesidades cotidianas, como redactar informes o crear agentes de investigación.
8. Patrón 5 — Evaluador-Optimizador
Concepto: un nodo genera una salida, otro nodo la verifica y un enlace condicional redirige la ejecución de vuelta al generador hasta que la salida sea aceptada. Este es el patrón en el que realmente se utiliza la capacidad de LangGraph para manejar ciclos.
START → generate → evaluate ──(fail)──> generate (loop)
│
(pass)
↓
END
const LoopState = Annotation.Root({
prompt: Annotation(),
code: Annotation(),
feedback: Annotation(),
passed: Annotation(),
attempts: Annotation({
reducer: (current, update) => current + update,
default: () => 0,
}),
});
async function generateNode(state) {
const context = state.feedback ? `\nPrevious feedback: ${state.feedback}` : "";
const res = await llm.invoke(`Write code for: ${state.prompt}${context}`);
return { code: res.content, attempts: 1 };
}async function evaluateNode(state) {
const res = await llm.invoke(
`Review this code for bugs. Reply "PASS" if it's correct, otherwise explain what's wrong:\n${state.code}`
);
const passed = res.content.trim().startsWith("PASS");
return { passed, feedback: passed ? null : res.content };
}const loopGraph = new StateGraph(LoopState)
.addNode("generate", generateNode)
.addNode("evaluate", evaluateNode)
.addEdge(START, "generate")
.addEdge("generate", "evaluate")
.addConditionalEdges("evaluate", (state) => {
if (state.passed || state.attempts >= 4) return END; // safety cap on retries
return "generate";
})
.compile();
Obsérvese que el campo attempts utiliza un reductor que suma al total actual en lugar de reemplazarlo; esto también funciona como medida de control. Sin algún tipo de límite en los intentos, un evaluador que nunca apruebe la salida podría mantener el bucle en ejecución indefinidamente.
9. Compilación y ejecución de cualquiera de estos
No importa qué patrón construyas, terminas de la misma manera:
const app = graph.compile();
const finalState = await app.invoke({ /* initial state fields */ });
Llamar a .compile() verifica que se pueda acceder a cada nodo, confirma que START y END estén correctamente conectados, y convierte la descripción de tu grafo en un objeto ejecutable. Luego .invoke() ejecuta ese grafo de principio a fin y devuelve el estado resultante; este es el mismo ciclo de ejecución descrito conceptualmente anteriormente en esta serie.
Conclusión
A lo largo de esta guía, las ideas teóricas se convirtieron en código ejecutable: Annotation.Root para definir el estado, funciones comunes como nodos, addEdge y addConditionalEdges para controlar el flujo, y reducers para evitar que las actualizaciones concurrentes o repetidas se anulen mutuamente. Con solo estos cuatro elementos, cada uno de los cinco patrones de flujo trabajos abordados —encadenamiento, enrutamiento, paralelización, orquestador-trabajador y evaluador-optimizador— resulta ser una combinación diferente de las mismas herramientas subyacentes.
La próxima parte de esta serie introducirá herramientas externas, agregará memoria para que el estado pueda persistir entre sesiones, y incluirá un paso en el que una persona revise y apruebe las acciones antes de que continúen; son los componentes necesarios para transformar estos grafos en un agente real.
Lecturas relacionadas
- Construyendo un agente de IA desde cero: Patterns, ReAct y LangGraph — Aprenda los conceptos fundamentales detrás de los agentes de IA: planificación, uso de herramientas, reflexión y el patrón ReAct—y cómo LangChain y LangGraph se integran en la creación manual de uno.
- Qué realmente automatiza LangChain una vez que has creado un bucle de agente — Explica cómo LangChain, LangGraph y SDK similares envuelven el mismo bucle de agente básico construido desde cero, y cuándo recurrir a un framework ayuda o perjudica.