IA 13 nov 2026 · 7 min de lectura

El agente que decide en bucle vs. el grafo con estados: por qué dejé de confiar en el while(true)

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

Casi todos los tutoriales de agentes enseñan el mismo patrón: un while (true) que le pasa al modelo la conversación y todas las herramientas, ejecuta lo que pida, le devuelve el resultado y repite hasta que el modelo deja de pedir herramientas. Funciona en la demo y es una forma honesta de empezar. El problema llega cuando alguien te pregunta "¿en qué paso está esta conversación?", "¿por qué ha llamado tres veces al buscador?" o "¿podemos pedir aprobación antes de publicar?", y la única respuesta que tienes es "depende de lo que decida el modelo". En Jxbs la capa de agentes está construida con LangGraph alrededor de un nodo supervisor que enruta mensajes por intención, y la lección que más me ha servido es esta: el modelo debería decidir lo menos posible sobre el flujo, y lo más posible sobre el contenido.

Lo que no te dice el bucle

El bucle libre tiene esta forma, más o menos:

while (true) {
  const res = await llm.invoke(messages, { tools: TODAS_LAS_HERRAMIENTAS });
  messages.push(res);
  if (!res.tool_calls?.length) break;
  for (const call of res.tool_calls) {
    messages.push(await ejecutar(call));
  }
}

No hay nada incorrecto en estas diez líneas. Lo que falta es todo lo que rodea a un sistema en producción:

  • No hay estado, solo historial. Lo único que sabes de la conversación es una lista de mensajes. Si quieres saber si el usuario ya confirmó la vacante, tienes que releer el historial o preguntárselo al modelo.
  • El coste no tiene techo. El número de vueltas lo decide el modelo. Le pones un maxIteraciones = 10 y ese número no sale de ningún razonamiento, sale del miedo.
  • No se puede testear por partes. El único test posible es de punta a punta, con un modelo no determinista en medio.
  • No hay sitio para un humano. Pausar a mitad, esperar una aprobación y retomar horas después no encaja en un bucle que vive en memoria dentro de un request.

El estado es el contrato

Un grafo de estados empieza por la pregunta que el bucle evita: ¿qué necesito saber de esta conversación en cada momento? En LangGraph eso es el estado, un objeto tipado que todos los nodos leen y al que devuelven actualizaciones parciales:

import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
import type { BaseMessage } from '@langchain/core/messages';

const Estado = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: (prev, nuevos) => prev.concat(nuevos),
    default: () => [],
  }),
  intent: Annotation<'editar_vacante' | 'buscar_candidatos' | 'fuera_de_ambito' | null>(),
  bloqueado: Annotation<boolean>(),
  borrador: Annotation<Record<string, unknown> | null>(),
});

Fíjate en lo que acaba de pasar: intent y bloqueado ya no son algo que el modelo "recuerda", son campos que puedes leer, loguear y testear. El reducer de messages dice explícitamente cómo se combinan las actualizaciones. Y cuando alguien pregunte en qué punto está una conversación, la respuesta es un objeto, no una interpretación.

Las aristas las decide tu código

La segunda decisión es quién elige el siguiente paso. En el bucle, siempre el modelo. En el grafo, el flujo lo deciden aristas, y la mayoría pueden ser código normal:

const grafo = new StateGraph(Estado)
  .addNode('guardrails', revisarEntrada)
  .addNode('supervisor', clasificarIntent)
  .addNode('editor', editarVacante)
  .addNode('matching', buscarCandidatos)
  .addNode('rechazo', responderFueraDeAmbito)
  .addEdge(START, 'guardrails')
  .addConditionalEdges('guardrails', (s) => (s.bloqueado ? 'rechazo' : 'supervisor'))
  .addConditionalEdges('supervisor', (s) => s.intent ?? 'fuera_de_ambito', {
    editar_vacante: 'editor',
    buscar_candidatos: 'matching',
    fuera_de_ambito: 'rechazo',
  })
  .addEdge('editor', END)
  .addEdge('matching', END)
  .addEdge('rechazo', END)
  .compile();

El supervisor sigue usando un modelo, pero para algo acotado: clasificar la intención con una salida estructurada cuyo único valor posible es uno de tres strings. Eso es una decisión que puedes evaluar con un dataset de mensajes reales y una matriz de confusión. "Elige libremente entre doce herramientas en un bucle" no lo es.

Y el nodo de guardrails va antes que cualquier llamada con herramientas, siempre, porque es una arista y no una sugerencia en el prompt. En un bucle, que el filtro se ejecute primero depende de que nadie toque el orden del código; en un grafo, está dibujado.

Lo que te llevas gratis

Una vez que el flujo es un grafo, aparecen cosas que en el bucle tendrías que construir a mano:

Persistencia y reanudación. Compilas con un checkpointer y cada paso queda guardado por thread_id. La conversación sobrevive a un reinicio del servidor y puedes inspeccionar el estado de cualquier hilo.

const app = grafo.compile({ checkpointer: new MemorySaver() }); // PostgresSaver en producción
await app.invoke({ messages: [entrada] }, { configurable: { thread_id: conversacionId } });

Pausas para un humano. Un nodo puede llamar a interrupt() antes de una acción con efectos — publicar, enviar, cobrar — y el grafo se detiene ahí con su estado guardado. Cuando llega la aprobación, retomas el mismo hilo. Esto da para un post entero, y es el de mañana.

Streaming por nodo. Puedes emitir al frontend en qué paso está el grafo además de los tokens, que es lo que hace que un chat en streaming se sienta vivo en vez de colgado.

Tests por nodo. clasificarIntent es una función que recibe un estado y devuelve un estado. Se testea sola, con fixtures, sin levantar el resto.

Cuándo sí el bucle

No todo cabe en un grafo cerrado. Hay tareas genuinamente abiertas — investigar algo, explorar un repositorio, depurar — donde no sabes de antemano cuántos pasos harán falta, y forzarlas en un diagrama de cajas es peor que dejar decidir al modelo.

La salida no es elegir uno de los dos, es meter el bucle dentro de un nodo del grafo: un subagente tipo ReAct con un conjunto pequeño de herramientas, solo de lectura cuando se pueda, y un límite de pasos que esta vez sí sale de algo medido. LangGraph lo expresa con recursionLimit en la configuración de la llamada. El grafo de fuera sigue sabiendo dónde empieza y dónde acaba esa exploración, cuánto ha costado y qué devolvió.

Mi recomendación

Antes de escribir el primer prompt, dibuja el flujo en papel. Cada caja que puedas describir sin decir "el modelo decide" es un nodo de código normal; cada flecha que dependa de un if sobre el estado es una arista condicional sin modelo. Lo que quede — las cajas donde de verdad hace falta juicio — es donde pones el LLM, con la salida más estrecha posible.

Si el dibujo te sale como una sola caja con una flecha que vuelve a sí misma, empieza con el bucle. Pero en el momento en que alguien te pida saber en qué paso está una conversación, o aprobar algo antes de que pase, ya sabes que el bucle se te ha quedado pequeño.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

IA

Tu API no es un servidor MCP: diseñar herramientas que un agente sepa usar

IA

Embeddings más baratos: recortar dimensiones y cuantizar sin perder recall