GenAINextVercel AI SDKLangchainGoogle Gemini

Arquitecturas agentes avanzadas con LangGraph.js. Parte 2

Antonio Pérez
2025-09-16
12
Arquitecturas agentes avanzadas con LangGraph.js. Parte 2

Desmontando el AgentExecutor: Hacia un control explícito

Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/langgraph-2

Nuestro objetivo es recrear el bucle ReAct (Razonamiento -> Acción -> Observación) que AgentExecutor manejaba por nosotros, pero esta vez, seremos nosotros quienes definamos cada nodo y cada transición en el grafo.

Paso 1: Instalando las dependencias necesarias.

bash
pnpm add @langchain/langgraph

Paso 2: Definir los Nodos del Grafo

Necesitamos dos nodos de trabajo principales, que reflejan las dos fases del ciclo ReAct:

  1. Nodo Agente (callModel): Llama al LLM para decidir el siguiente paso.
  2. Nodo de Acción (callTool): Ejecuta la herramienta que el LLM ha decidido usar.

Crea un archivo src/lib/ai/graph-nodes.ts.

typescriptsrc/lib/ai/graph-nodes.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { AIMessage } from '@langchain/core/messages';
import { AgentState } from './graph-state';
import { langchainTools } from './tools-langchain';
import { ToolNode } from '@langchain/langgraph/prebuilt';

const model = new ChatGoogleGenerativeAI({ model: 'gemini-2.0-flash-001', temperature: 0 });

const prompt = ChatPromptTemplate.fromMessages([
  ["system", `
    Eres un asistente de soporte técnico llamado "ChipiBot". Eres amable y eficiente.
    Utiliza las herramientas disponibles para ayudar al usuario.
    El correo del usuario actual es "antonio@unuko.com".`
  ],
  new MessagesPlaceholder("messages"),
]);

// Vinculamos las herramientas al modelo.
const modelWithTools = model.bindTools(langchainTools);

// 1. Nodo Agente: Llama al LLM
export async function callModel(state: AgentState): Promise<{ messages: AIMessage[] }> {
  console.log('[Graph Node]: Call Model');
  const { messages } = state;
  const chain = prompt.pipe(modelWithTools);
  const response = await chain.invoke({ messages });
  // Devolvemos la respuesta del LLM para añadirla al estado.
  return { messages: [response] };
}

// 2. Nodo de Acción: Ejecuta las herramientas
// Usaremos el ToolNode pre-construido de LangGraph, que simplifica la ejecución de herramientas.
export const callTool = new ToolNode(langchainTools);

async function callModel(state: AgentState): Promise<{ messages: AIMessage[] }>:

  • state: AgentState: Esta es la entrada clave. La función no recibe solo el último mensaje, sino el estado completo del grafo en ese momento. AgentState probablemente se define como un objeto que contiene una lista de mensajes ({ messages: BaseMessage[] }).
  • Promise<{ messages: AIMessage[] }>: Esta es la salida. La función no devuelve un simple mensaje. Devuelve un objeto que representa una actualización parcial del estado. Al retornar { messages: [response] }, le estamos diciendo a LangGraph: "toma este nuevo mensaje (response) y añádelo a la lista de messages del estado principal". LangGraph se encarga de fusionar este cambio.

const { messages } = state;: Extraemos el historial de la conversación del estado que recibimos como argumento.

const chain = prompt.pipe(modelWithTools);: Creamos una cadena de ejecución de LangChain (LCEL).

  1. prompt: Primero, formatea la entrada. Toma el historial de mensajes y lo inserta en la plantilla donde está el MessagesPlaceholder.
  2. .pipe(modelWithTools): El resultado del formateo se pasa directamente al modelo que ya ha sido "vinculado" a las herramientas. Esto le indica al LLM que puede usar las herramientas y que debe formatear su salida de una manera específica si decide hacerlo (incluyendo el campo tool_calls).

const response = await chain.invoke({ messages });: Ejecutamos la cadena. La respuesta (response) será una instancia de AIMessage. Si el modelo decidió usar una herramienta, este objeto AIMessage contendrá una propiedad tool_calls. Si no, será una respuesta de texto normal.

export const callTool = new ToolNode(langchainTools);:

  • Esto es un atajo muy potente de LangGraph. En lugar de escribir una función que revise el último mensaje, busque llamadas a herramientas, las ejecute en un bucle y formatee los resultados, ToolNode hace todo eso por nosotros.
  • Al inicializarlo con langchainTools, sabe qué herramientas están disponibles.
  • Cuando el grafo ejecute este nodo, ToolNode inspeccionará automáticamente el estado, encontrará el AIMessage con tool_calls, ejecutará las funciones correspondientes y devolverá los resultados como ToolMessage[], que se añadirán al estado para que el callModel pueda verlos en el siguiente ciclo.

Paso 3: Definir la Arista Condicional

Este es el cerebro de nuestro agente. Es la función que decidirá el flujo después de que el LLM haya "hablado".

Crea un archivo src/lib/ai/graph-router.ts.

typescriptsrc/lib/ai/graph-router.ts
import { AgentState } from './graph-state';
import { AIMessage } from '@langchain/core/messages';

// La función de enrutamiento que decide el siguiente paso.
export function shouldContinue(state: AgentState): "tools" | "__end__" {
  console.log('[Graph Router]: Evaluating state...');
  const { messages } = state;
  const lastMessage = messages[messages.length - 1] as AIMessage;

  // Si la última respuesta de la IA contiene llamadas a herramientas, vamos al nodo 'tools'.
  if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0) {
    console.log('[Graph Router]: Decision -> Call Tools');
    return "tools";
  }

  // De lo contrario, hemos terminado.
  console.log('[Graph Router]: Decision -> End');
  return "__end__";
}

function shouldContinue(state: AgentState): "tools" | "__end__":

  • La entrada es, de nuevo, el estado completo del grafo
  • La salida es crucial: es una cadena de texto. El valor de esta cadena debe coincidir con el nombre de una de las "aristas" (conexiones) que definiremos en el grafo.
  • "__end__" es una cadena especial y reservada que le dice a LangGraph que la ejecución ha terminado.

const lastMessage = messages[messages.length - 1] as AIMessage;: Obtenemos el último mensaje del historial. En nuestro flujo, este mensaje siempre será la respuesta que acaba de generar el nodo callModel. Lo forzamos a tipo AIMessage porque sabemos que proviene del modelo.

if (lastMessage.toolcalls && lastMessage.toolcalls.length > 0): Esta es la condición central. Revisa si el último mensaje del LLM contiene la propiedad tool_calls y si no está vacía. Esta propiedad solo existe si el modelo ha decidido explícitamente que necesita ejecutar una o más herramientas.

return "tools";: Si la condición es verdadera, la función devuelve la cadena "tools". Al construir el grafo, crearemos una conexión llamada "tools" que irá desde el nodo del agente hacia el nodo de herramientas.

return "__end__": Si el modelo no solicitó ninguna herramienta, significa que ha generado una respuesta final para el usuario. Devolvemos la cadena especial __end__ para detener el bucle.

Ejercicio: Construyendo y Compilando el Grafo

Ahora que tenemos todos los componentes (estado, nodos, aristas), los ensamblaremos en un StateGraph y lo compilaremos.

Paso 1: Reemplazar el Backend de la API

Modificaremos nuestro endpoint src/app/api/tools-langgraph/route.ts para construir y usar el grafo de LangGraph.

typescriptsrc/app/api/tools-langgraph/route.ts
import { MessagesAnnotation, StateGraph } from '@langchain/langgraph';
import { convertToModelMessages, createUIMessageStreamResponse } from 'ai';
import { callModel, callTool } from '@/lib/ai/graph-nodes';
import { shouldContinue } from '@/lib/ai/graph-router';
import { toUIMessageStream } from '@ai-sdk/langchain';

export const maxDuration = 60;

// Definir el grafo de LangGraph
const workflow = new StateGraph(MessagesAnnotation)
  .addNode("agent", callModel)
  .addNode("tools", callTool);

// Definir las aristas que conectan los nodos
workflow.addEdge("__start__", "agent");
workflow.addConditionalEdges("agent", shouldContinue);
workflow.addEdge("tools", "agent");

// Compilar el grafo en un Runnable
const app = workflow.compile();

export async function POST(req: Request) {
  const { messages, id } = await req.json();
  const inputs = {
    messages: convertToModelMessages(messages)
  };

  // Invocamos el grafo compilado.
  // LangGraph se encarga de ejecutar el ciclo completo.
  const stream = app.streamEvents(inputs, { configurable: { sessionId: id }, version: 'v2' })

  return createUIMessageStreamResponse({
    stream: toUIMessageStream(stream),
  })
}

export async function GET() {
  const representation = await app.getGraphAsync();
  return new Response(await representation.drawMermaidPng());
}

Análisis de la Arquitectura del Grafo:

  • new StateGraph({ channels: agentState }): Inicializamos el grafo, pasándole la definición de nuestro estado.
  • .addNode("agent", callModel): Registramos nuestro nodo callModel con el nombre "agent".
  • .addNode("tools", callTool): Registramos nuestro ToolNode con el nombre "tools".
  • .addEdge("_start_", "agent"): Definimos el punto de entrada. Cuando el grafo se inicie, el primer nodo en ejecutarse será "agent".
  • .addConditionalEdges("agent", shouldContinue, ...): Aquí conectamos nuestra lógica de enrutamiento. Después de que el nodo "agent" se ejecute, se llamará a la función shouldContinue. Dependiendo de si devuelve "tools" o "_end_", el grafo sabrá a dónde ir.
  • .addEdge("tools", "agent"): Esta es la arista que crea el "bucle". Después de que el nodo "tools" se ejecute, el control siempre volverá al nodo "agent" para que el LLM pueda procesar los resultados de la herramienta.
  • .compile(): Este método finaliza la definición del grafo y lo convierte en un Runnable ejecutable.

El resto del endpoint es notablemente similar a lo que teníamos con AgentExecutor. Usamos toUIMessageStream para adaptar la salida del grafo al formato que nuestro frontend espera.

Ademas hemos creado un nuevo endpoint por get que nos permite visualizar el grafo.

El objetivo de este código es simple pero increíblemente poderoso: generar una representación visual de la arquitectura de tu agente bajo demanda.

Cuando estás construyendo flujos complejos, es fácil perderse en el código. Este endpoint te permite ver un diagrama de flujo claro y preciso de lo que has construido, directamente en tu navegador.

**

typescript
export async function GET() {
  const representation = await app.getGraphAsync();
  return new Response(await representation.drawMermaidPng());
}

alt text

Ejecución y verificación del flujo explícito

Por último crearemos una copia de src\app\tools-langchain\page.tsx con un sólo cambio, pasaremos por props apiEndpoint="/api/tools-langchain"

typescriptsrc\app\tools-langgraph\page.tsx
import { Chatbot } from "@/components/ui/Chatbot/Chatbot";
import { BotMessageSquare, UserIcon } from 'lucide-react';

// Avatares (podrían estar en un archivo compartido)
const AssistantAvatar = () => (
  <div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
    <BotMessageSquare className="text-white w-5 h-5" />
  </div>
);

const UserAvatar = () => (
  <div className="w-8 h-8 bg-slate-500 rounded-full flex items-center justify-center flex-shrink-0">
    <UserIcon className="text-white w-5 h-5" />
  </div>
);

export default function PdfChatPage() {
  return (
    <Chatbot
      apiEndpoint="/api/tools-langgraph"
      assistantName="Gestión de Tickets"
      assistantDescription="Gestiona tus tickets desde este asistente."
      assistantAvatar={<AssistantAvatar />}
      userAvatar={<UserAvatar />}
      initialMessageTitle="Hola, ¿en qué puedo ayudarte?"
      initialMessageDescription="Tengo información sobre el estado de todos tus tickets."
      inputPlaceholder="Escribe tu consulta sobre los tickets..."
      fileSupport={false}
    />
  );
}

Ejecuta la aplicación (pnpm dev) y navega a http://localhost:3000/tools-langgraph. La interfaz no ha cambiado, pero el motor que la impulsa es ahora mucho más explícito y potente.

Prueba los mismos escenarios que antes:

  1. "¿Podrías darme todos mis tickets?"
  2. "Necesito ayuda. No puedo acceder a mi cuenta. Por favor, crea un ticket de soporte."
  3. "¿Podrías revisar si tengo algún ticket de soporte abierto?"

Observa la Consola del Servidor:

Ahora, los logs que verás serán los nuestros. Para la primera pregunta, la secuencia será:

javascript
[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> Call Tools
[DB] Buscando usuario por email: antonio@unuko.com
[DB] Buscando tickets para el email: antonio@unuko.com

[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> End
[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> Call Tools
[DB] Buscando usuario por email: antonio@unuko.com
[DB] Creando ticket para antonio@unuko.com con asunto: Problema de acceso a la cuenta
[DB] Buscando usuario por email: antonio@unuko.com
[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> End

[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> Call Tools
[DB] Buscando usuario por email: antonio@unuko.com
[DB] Buscando tickets para el email: antonio@unuko.com
[Graph Node]: Call Model
[Graph Router]: Evaluating state...
[Graph Router]: Decision -> End

Esta traza explícita es oro puro para la depuración. Cada decisión, cada paso del flujo, está claramente registrado por nuestra propia lógica, dándonos una visibilidad y un control que antes estaban ocultos dentro del AgentExecutor.

alt text

Anatomía de una traza de LangGraph en LangSmith

alt text

La integración con LangSmith que hicimos en el capítulo anterior nos da una visibilidad total sobre la ejecución de nuestro grafo, convirtiendo el proceso interno del agente en algo transparente y fácil de depurar.

La captura de pantalla muestra una traza de ejecución, que es el historial visual de las decisiones del agente.

Análisis rápido de la traza:

La vista en cascada (panel central) nos muestra el ciclo ReAct (Razonamiento -> Acción -> Observación) que hemos construido con el código:

  1. agent (Razonamiento): El LLM recibe la petición del usuario ("Necesito ayuda...") y decide que debe usar la herramienta createsupportticket.
  2. tools (Acción): El ToolNode se activa y ejecuta la herramienta createsupportticket.
  3. agent (Observación): El control vuelve al LLM, que ahora "observa" el resultado de la creación del ticket y formula la respuesta final para el usuario.

Ventajas clave de usar LangSmith con LangGraph:

  • Visibilidad total: Elimina la "caja negra", mostrando cada llamada al LLM, cada decisión de enrutamiento y cada ejecución de herramienta.
  • Depuración sencilla: Permite identificar al instante por qué un agente tomó una decisión incorrecta o en qué paso falló una ejecución.
  • Análisis de rendimiento: Desglosa la latencia y el coste de tokens de cada paso, ayudando a optimizar los flujos.

Conclusión de la práctica

Hemos completado una refactorización de gran calado. Hemos pasado de usar una abstracción de alto nivel (AgentExecutor) a construir nuestro propio motor de agente con LangGraph.js. Aunque el comportamiento externo del agente es el mismo, hemos ganado un control inmenso sobre su funcionamiento interno.

Ahora que el flujo es explícito, podemos empezar a modificarlo de formas que antes eran imposibles. ¿Queremos añadir un paso de validación humana antes de ejecutar una herramienta de escritura? Simplemente añadimos un nuevo nodo y una nueva arista condicional. ¿Queremos que dos herramientas se ejecuten en paralelo? LangGraph lo permite.

Este control explícito es la base para construir los sistemas de IA verdaderamente complejos y fiables que el futuro demanda. En el próximo capítulo, pondremos a prueba esta flexibilidad construyendo una interfaz de "Generative UI", donde el agente no solo generará texto, sino los propios componentes de la interfaz de usuario.

Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/langgraph-2