LangChain.js: El siguiente nivel de abstracción. Parte 2

Preparando el entorno para LangChain.js
Nuestro objetivo es reconstruir el backend del agente de soporte (src/app/api/rag/route.ts
que en el capítulo anterior convertimos en un agente con herramientas) usando LangChain, mientras mantenemos el frontend (src/app/rag/page.tsx
y sus componentes) prácticamente sin cambios. Esto demostrará la potencia del desacoplamiento entre el backend y el frontend.
Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/langchain-2
Paso 1: Instalar dependencias de LangChain
Necesitaremos varios paquetes del ecosistema LangChain para construir nuestra cadena y agente.
pnpm install langchain @langchain/core @langchain/google-genai @langchain/community @ai-sdk/langchain
-
langchain
: El paquete principal que reexporta funcionalidades comunes. -
@langchain/core
: Contiene las abstracciones base comoRunnable
, prompts y parsers. -
@langchain/google-genai
: El proveedor específico para los modelos Gemini de Google (para mantener la consistencia, aunque podríamos usar@langchain/anthropic
). -
@langchain/community
: Contiene diversas integraciones, incluyendo laChatMessageHistory
. -
@ai-sdk/langchain
: Nos permite integrar Vercel AI SDK con Langchain.js con el métodotoUIMessageStream
Paso 2: Re-configurar herramientas para LangChain
LangChain tiene su propia forma de definir herramientas. Aunque el concepto es idéntico, la implementación es ligeramente diferente. Refactorizaremos nuestro archivo de herramientas para usar las abstracciones de LangChain.
Crearemos el fichero src/lib/ai/tools-langchain.ts
para que se vea así:
import { z } from 'zod';
import { db } from '../db/mock-db';
import { DynamicStructuredTool } from "@langchain/core/tools";
// Herramienta de solo lectura
export const findUserTicketsTool = new DynamicStructuredTool({
name: 'find_user_tickets',
description: 'Busca los tickets de soporte para un usuario específico usando su dirección de correo electrónico.',
schema: z.object({
email: z.string().email({ message: "Se requiere un correo electrónico válido." }),
}),
func: async ({ email }) => {
try {
const user = await db.findUserByEmail(email);
if (!user) {
return `El usuario con el email ${email} no existe.`;
}
const tickets = await db.findTicketsByUserEmail(email);
if (tickets.length === 0) {
return `El usuario ${email} no tiene tickets.`;
}
// Devolvemos un string JSON para que el LLM pueda interpretarlo fácilmente
return JSON.stringify(tickets);
} catch (error) {
return 'Ha ocurrido un error al buscar los tickets.';
}
},
});
// Herramienta de escritura
export const createSupportTicketTool = new DynamicStructuredTool({
name: 'create_support_ticket',
description: 'Crea un nuevo ticket de soporte para un usuario.',
schema: z.object({
userEmail: z.string().email({ message: "Se requiere un correo electrónico válido." }),
subject: z.string().min(10, { message: "El asunto debe tener al menos 10 caracteres." }),
}),
func: async ({ userEmail, subject }) => {
try {
const user = await db.findUserByEmail(userEmail);
if (!user) {
return `No se puede crear un ticket para ${userEmail} porque el usuario no existe.`;
}
const newTicket = await db.createTicket(subject, userEmail);
return `Ticket creado con éxito. ID: ${newTicket.id}, Estado: ${newTicket.status}.`;
} catch (error) {
return 'No se pudo crear el ticket de soporte.';
}
},
});
export const updataeSupportTicket = new DynamicStructuredTool({
name: 'update_support_ticket',
description: 'Actualiza el estado de un ticket de soporte existente.',
schema: z.object({
ticketId: z.string().min(5, { message: "El ID del ticket debe tener al menos 5 caracteres." }),
status: z.enum(['open', 'closed', 'in-progress'], { message: "El estado debe ser 'open', 'closed' o 'in-progress'." })
}),
func: async ({ ticketId, status }) => {
try {
await db.updateTicketStatus(ticketId, status);
return `El ticket ${ticketId} ha sido actualizado a estado ${status}.`;
} catch (error) {
return 'No se pudo actualizar el ticket de soporte.';
}
}
})
export const assignTicketToUser = new DynamicStructuredTool({
name: 'assign_ticket_to_user',
description: 'Asigna un ticket de soporte a un usuario específico mediante su correo electrónico.',
schema: z.object({
ticketId: z.string().min(5, { message: "El ID del ticket debe tener al menos 5 caracteres." }),
userEmail: z.string().email({ message: "Se requiere un correo electrónico válido." }),
}),
func: async ({ ticketId, userEmail }) => {
try {
const user = await db.findUserByEmail(userEmail);
if (!user) {
return `No se puede asignar el ticket al email ${userEmail} porque el usuario no existe.`;
}
await db.assignTicketToUser(ticketId, userEmail);
return `El ticket ${ticketId} ha sido asignado al usuario con email ${userEmail}.`;
} catch (error) {
return 'No se pudo asignar el ticket de soporte.';
}
}
})
export const langchainTools = [
findUserTicketsTool,
createSupportTicketTool,
updataeSupportTicket,
assignTicketToUser
];
Análisis de la refactorización de herramientas:
-
DynamicStructuredTool
: Usamos esta clase de LangChain para crear nuestras herramientas a partir de un esquema Zod, similar a como lo hacíamos contool
del AI SDK. - Salidas de Texto: Nota un cambio sutil pero importante: las funciones (
func
) ahora devuelven strings simples o strings JSON. En elfunction calling
, es una buena práctica que las herramientas devuelvan datos serializables como texto, que el LLM puede luego interpretar. El LLM es excelente para convertir un JSON string en una frase bonita.
Ejercicio 1: Refactorizando con agentes de LangChain
Ahora viene el cambio principal. Reemplazaremos nuestra lógica de orquestación manual en src/app/api/rag/route.ts
(que ahora llamaremos src/app/api/tools-langchain/route.ts
para claridad) con un AgentExecutor
de LangChain.
Paso 1: Crear el Nuevo Endpoint de API
Crearemos src/app/api/tools-langchain/route.ts
.
Paso 2: Implementar el Agente con LangChain
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatMessageHistory } from "langchain/memory";
import { langchainTools } from '@/lib/ai/tools-langchain';
import { toUIMessageStream } from '@ai-sdk/langchain';
import { createUIMessageStreamResponse } from 'ai'
export const maxDuration = 30;
const prompt = ChatPromptTemplate.fromMessages([
["system", `
Eres un asistente de soporte técnico para una plataforma digital.
Tu nombre es "ChipiBot". Eres amable, extremadamente servicial y eficiente.
Utiliza las herramientas disponibles para responder a las preguntas del usuario y realizar tareas.
Cuando busques información de un usuario, siempre dirígete a él por su nombre si lo encuentras.
El correo del usuario actual es "antonio@unuko.com". Solo puedes operar sobre este usuario a menos que se te proporcione otro correo explícitamente.
`
],
new MessagesPlaceholder("chat_history"),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const model = new ChatGoogleGenerativeAI({ model: 'gemini-2.5-flash', temperature: 0 });
const agent = createToolCallingAgent({
llm: model,
tools: langchainTools,
prompt,
});
const agentExecutor = new AgentExecutor({
agent,
tools: langchainTools,
});
// Sesiones en memoria para el historial. En producción, usaríamos Redis o similar.
const sessionHistories: Record<string, ChatMessageHistory> = {};
const agentWithHistory = new RunnableWithMessageHistory({
runnable: agentExecutor,
getMessageHistory: async (sessionId) => {
if (sessionHistories[sessionId] === undefined) {
sessionHistories[sessionId] = new ChatMessageHistory();
}
return sessionHistories[sessionId];
},
inputMessagesKey: "input",
historyMessagesKey: "chat_history",
});
export async function POST(req: Request) {
const { messages, id } = await req.json();
const lastUserMessage = messages[messages.length - 1].parts.filter((part: any) => part.type === 'text').map((part: any) => part.text).join(' ');
const stream = await agentWithHistory.streamEvents(
{ input: lastUserMessage },
{ configurable: { sessionId: id }, version: 'v2' }
);
return createUIMessageStreamResponse({
stream: toUIMessageStream(stream),
})
}
Análisis de la Arquitectura con LangChain:
ChatPromptTemplate
: Define la estructura de la conversación que se enviará al modelo de lenguaje (LLM). Es como un guion que el agente debe seguir.
-
["system", ...]
establece las instrucciones y la personalidad base del "ChipiBot". -
new MessagesPlaceholder("chat_history")
es un espacio reservado donde se inyectará automáticamente el historial de la conversación pasada para que el bot tenga contexto. -
["human", "{input}"]
representa el mensaje más reciente del usuario. -
new MessagesPlaceholder("agent_scratchpad")
es un espacio de trabajo interno crucial. Aquí, el agente anota los pasos que está tomando: qué herramienta decide llamar, con qué argumentos y cuál fue el resultado de esa herramienta. Esto es fundamental para su proceso de razonamiento.
createToolCallingAgent
: Es la función que crea el "cerebro" del agente. Vincula tres elementos esenciales:
- El LLM (
model
): El modelo de lenguaje que toma las decisiones. - Las herramientas (
langchainTools
): Las capacidades que el agente puede ejecutar (como buscar tickets). - El prompt (
prompt
): Las instrucciones sobre cómo debe comportarse y razonar.
El resultado es un Runnable
que, al recibir el estado actual de la conversación, decide si debe responder directamente al usuario o si necesita usar una de sus herramientas.
AgentExecutor
: Es el "ejecutor" o el motor del agente. Orquesta el ciclo de trabajo:
- Recibe la decisión del
agent
(el cerebro). - Si el agente decidió llamar a una herramienta, el
AgentExecutor
la ejecuta con los argumentos proporcionados. - Toma el resultado de la herramienta y se lo devuelve al agente (a través del
agent_scratchpad
). - Repite este proceso hasta que el agente decide que ya tiene suficiente información y genera una respuesta final para el usuario.
RunnableWithMessageHistory
: Es un "envoltorio" (wrapper) que dota de memoria al agente. Conecta el AgentExecutor
con un sistema para guardar y recuperar el historial de conversaciones.
-
runnable: agentExecutor
: Le indica que el componente que necesita memoria es elAgentExecutor
. -
getMessageHistory
: Es la función que gestiona la persistencia. En tu código, utiliza un objeto simple en memoria (sessionHistories
) para guardar las conversaciones, usando unsessionId
único para cada una. En un entorno de producción, esta función se modificaría para conectarse a una base de datos persistente como Redis o PostgreSQL. -
inputMessagesKey
yhistoryMessagesKey
: Son "etiquetas" que conectan los datos (input
del usuario ychat_history
de la memoria) con losMessagesPlaceholder
correspondientes en elChatPromptTemplate
. Así es como el prompt se llena con la información correcta en cada turno.
Función POST
y Streaming con Vercel AI SDK:
- Esta función es tu endpoint de API. Recibe los mensajes de la interfaz de usuario.
-
agentWithHistory.streamEvents(...)
: En lugar de esperar una respuesta final, este método ejecuta el agente y devuelve un flujo (stream
) de todos los eventos que ocurren durante el proceso de razonamiento: qué herramienta se está llamando, los resultados intermedios, los fragmentos de la respuesta final, etc. -
toUIMessageStream
ycreateUIMessageStreamResponse
: Son adaptadores del Vercel AI SDK. Toman el flujo de eventos detallado de LangChain y lo transforman en un formato estándar que la interfaz de usuario (probablemente usando el hookuseChat
) puede entender y renderizar en tiempo real. Esto permite mostrar al usuario no solo la respuesta final, sino también los pasos que el agente está tomando, mejorando la experiencia de usuario.
Paso 3: Adaptar el Frontend para la Nueva API
Necesitamos que el frontend envíe un sessionId
. Haremos una pequeña modificación en nuestro hook useCustomChat
y en la página /rag
para que apunte al nuevo endpoint.
12. Crear la página src/app/tools-langchain/page.tsx
:
Simplemente cambiamos el apiEndpoint
para que apunte a nuestra nueva API.
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-langchain"
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}
/>
);
}
Ejecución y Verificación
Ejecuta la aplicación (pnpm dev
) y navega a http://localhost:3000/tools-langchain
.
Prueba los mismos escenarios que en el capítulo anterior:
-
"¿Podrías revisar si tengo algún ticket de soporte abierto?"
2. "Necesito ayuda. No puedo acceder a mi cuenta. Por favor, crea un ticket de soporte."
3. Después de la primera pregunta, pregunta: "¿Cuál era el estado del primer ticket que mencionaste?"
Notarás que el comportamiento es idéntico al de nuestra implementación manual. Sin embargo, nuestro código de backend es ahora significativamente más corto, más declarativo y más robusto. El AgentExecutor
maneja la lógica del bucle, y RunnableWithMessageHistory
maneja la memoria de la conversación.
Explora LangSmith:
Si tienes LangSmith configurado, esta es la verdadera recompensa. Cada llamada al agente ahora producirá un "trace" increíblemente detallado. Podrás ver:
- El
AgentExecutor
como un span padre. - Dentro de él, cada llamada al LLM (
ChatGemini
) como un span hijo. - Cada llamada a una herramienta (
findusertickets
) como otro span hijo. - Podrás inspeccionar las entradas y salidas de cada paso, viendo exactamente cómo el agente formula sus llamadas a herramientas y cómo interpreta los resultados.
Este nivel de observabilidad es extremadamente difícil de lograr con una orquestación manual y es una de las mayores ventajas de adoptar un framework como LangChain.
Conclusión de la práctica
Hemos refactorizado con éxito nuestro agente, reemplazando la lógica de orquestación imperativa por el enfoque declarativo y componible de LangChain.js.
- Código más limpio: Nuestra API es ahora más delgada. La lógica del bucle y la gestión de la memoria están encapsuladas por abstracciones de LangChain.
- Memoria stateful: Hemos implementado una memoria de conversación stateful real, aunque en memoria, que es el patrón para sistemas de producción.
- Observabilidad de nivel superior: Gracias a la integración nativa de LCEL con LangSmith, hemos ganado una visibilidad sin precedentes en el proceso de "pensamiento" de nuestro agente.
Hemos aprendido que no se trata de "Vercel AI SDK vs. LangChain", sino de usar la herramienta adecuada para el trabajo adecuado. En el próximo capítulo, llevaremos esta composición al límite con LangGraph.js, donde retomaremos el control total sobre el flujo del agente, pero de una manera estructurada y visualizable que un AgentExecutor
estándar no permite.
Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/langchain-2