Cómo construir un agente de IA con LangChain.js y NestJS: tutorial completo
3 julio 2026 · 12 min de lectura
Hay dos maneras de construir un agente de IA en Node.js. La primera: pegar la API de OpenAI en un controlador Express, sin caché, sin colas, sin tools. Funciona en demo y explota en producción el día que llegan 200 peticiones concurrentes. La segunda: arquitectura real con LangChain.js sobre NestJS, colas con BullMQ, memoria con Redis y tools tipadas. Este tutorial cubre la segunda.
Si no tienes claro por qué la IA necesita arquitectura antes que código, lee IA sin contexto es un loro caro y vuelve. Aquí asumimos que ya sabes por qué.
Arquitectura del agente
El flujo es este: el usuario manda un mensaje al endpoint de NestJS. NestJS encola el trabajo en BullMQ (Redis). Un worker procesa la cola, llama al agente LangChain con las tools disponibles, persiste la conversación en PostgreSQL y devuelve la respuesta vía WebSocket o polling. El modelo LLM es stateless — el contexto vive en tu base de datos, no en la memoria del modelo.
Cliente (Nuxt/React)
│ POST /agents/:id/messages
▼
┌──────────────────────┐
│ NestJS Controller │ valida, auth, rate-limit
└──────────┬───────────┘
│ enqueue(job)
▼
┌──────────────────────┐
│ BullMQ Queue (Redis) │ colas, reintentos, prioridad
└──────────┬───────────┘
│ process(job)
▼
┌──────────────────────┐
│ Agent Worker │ LangChain agent + tools
│ - chat history (PG) │
│ - tools (DB, API) │
│ - model (OpenAI/etc) │
└──────────┬───────────┘
│ ws.emit / db.save
▼
Cliente recibe respuesta
Cada pieza tiene una razón. La cola evita que 200 usuarios simultáneos tiren el servicio. El worker separa la latencia del LLM del request HTTP. La persistencia del historial en PostgreSQL permite auditar cada conversación y reanudar contextos.
Paso 1: instalar dependencias
npm i @nestjs/common @nestjs/core @nestjs/platform-fastify \
@langchain/core @langchain/openai @langchain/community \
langchain bullmq ioredis @prisma/client
Usamos el adapter de Fastify en NestJS por rendimiento. Si vienes de Express, la migración es trivial y ya cubrimos el porqué en NestJS vs Express.
Paso 2: el módulo del agente
El módulo agrupa el servicio del agente, la cola y el worker. Registro el worker como provider para que el lifecycle de NestJS lo gestione:
import { Module } from '@nestjs/common';
import { AgentService } from './agent.service';
import { AgentQueue } from './agent.queue';
import { AgentWorker } from './agent.worker';
@Module({
providers: [AgentService, AgentQueue, AgentWorker],
exports: [AgentService],
})
export class AgentModule {}
Paso 3: el controlador
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AgentService } from './agent.service';
import { JwtGuard } from '../auth/jwt.guard';
class SendMessageDto {
conversationId: string;
content: string;
}
@Controller('agents')
@UseGuards(JwtGuard)
export class AgentsController {
constructor(private readonly agent: AgentService) {}
@Post('messages')
async send(@Body() dto: SendMessageDto) {
const jobId = await this.agent.enqueue(dto);
return { jobId, status: 'queued' };
}
}
El controlador no llama al LLM. Solo encola. La respuesta llega asíncrona. Esto es la diferencia entre un agente de demo y uno de producción: el usuario no espera 30 segundos con el request abierto.
Paso 4: la cola con BullMQ
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
@Injectable()
export class AgentQueue implements OnModuleInit {
private queue: Queue;
onModuleInit() {
const connection = new IORedis(process.env.REDIS_URL, {
maxRetriesPerRequest: null,
});
this.queue = new Queue('agent-jobs', { connection });
}
async add(data: SendMessageDto) {
return this.queue.add('run-agent', data, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
Tres reintentos con backoff exponencial. Si el LLM falla por rate-limit, BullMQ reintenta sin perder el mensaje. Sin cola, un timeout del proveedor te cuesta una conversación entera.
Paso 5: el agente con LangChain
Ahora el worker. Aquí vive la lógica: recupera el historial de la conversación, configura las tools, ejecuta el agente y guarda el resultado.
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Worker, Job } from 'bullmq';
import { ChatOpenAI } from '@langchain/openai';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts';
import { searchCatalogTool } from './tools/search-catalog.tool';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AgentWorker implements OnModuleInit, OnModuleDestroy {
private worker: Worker;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
this.worker = new Worker(
'agent-jobs',
async (job: Job) => this.handle(job),
{ connection: { url: process.env.REDIS_URL } },
);
}
onModuleDestroy() { return this.worker.close(); }
private async handle(job: Job) {
const { conversationId, content } = job.data;
// 1. Historial desde PostgreSQL
const history = await this.prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
take: 20,
});
const model = new ChatOpenAI({
model: 'gpt-4o-mini',
temperature: 0.2,
});
// 2. Tools con acceso a tu sistema
const tools = [searchCatalogTool(this.prisma)];
// 3. Prompt con instrucciones de marca
const prompt = ChatPromptTemplate.fromMessages([
['system',
'Eres un asistente de ventas de SOM-OS. ' +
'Responde en español, en segunda persona. ' +
'Usa las tools para buscar en el catálogo antes de responder. ' +
'Si no lo sabes, di que no lo sabes. No inventes.'],
new MessagesPlaceholder('history'),
['human', '{input}'],
new MessagesPlaceholder('agent_scratchpad'),
]);
const agent = await createToolCallingAgent({
llm: model,
tools,
prompt,
});
const executor = new AgentExecutor({ agent, tools });
// 4. Ejecutar
const result = await executor.invoke({
input: content,
history: history.map((m) => [m.role, m.content]),
});
// 5. Persistir
await this.prisma.message.createMany({
data: [
{ conversationId, role: 'human', content },
{ conversationId, role: 'ai', content: result.output },
],
});
return { conversationId, output: result.output };
}
}
Cinco pasos: historial, modelo, tools, prompt, persistencia. El agente tiene contexto de los últimos 20 mensajes, tools para acceder al catálogo real, instrucciones de marca y guarda cada intercambio para auditar.
Paso 6: definir una tool
Una tool es una función tipada que el agente decide cuándo usar. Aquí una que busca productos en el catálogo:
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
export const searchCatalogTool = (prisma) =>
tool(
async ({ query }) => {
const products = await prisma.product.findMany({
where: { name: { contains: query, mode: 'insensitive' } },
take: 5,
});
return JSON.stringify(products);
},
{
name: 'search_catalog',
description:
'Busca productos en el catálogo por nombre. ' +
'Devuelve hasta 5 resultados con id, nombre, precio y stock.',
schema: z.object({
query: z.string().describe('término de búsqueda'),
}),
},
);
El esquema con Zod le dice al modelo qué argumentos acepta la tool. LangChain lo convierte a function calling automáticamente. El agente decide si llamarla según el contexto del usuario.
Paso 7: despliegue
El despliegue mínimo en producción: NestJS en un contenedor, Redis en otro, PostgreSQL gestionado (Supabase, Neon o RDS). Variables de entorno: REDIS_URL, DATABASE_URL, OPENAI_API_KEY. Un Dockerfile base:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/main.js"]
Despliega en Fly.io, Railway o Coolify sobre tu VPS. Lo importante: Redis y el worker viven en el mismo contenedor o separados, pero la cola es el punto de sincronización. No pongas el worker en serverless — los jobs largos no sobreviven a cold starts.
Lo que no te dice el tutorial rápido
Tres cosas que aprendimos en producción y que ningún "construye un agente en 10 minutos" menciona:
- Rate limits del LLM. OpenAI te corta a 500 RPM en tier 1. Si tu agente usa 2 tools por mensaje, una sola conversación consume 3 requests. Sin cola y sin backoff, mueres el día del lanzamiento.
- Context window. 20 mensajes ocupan ~4.000 tokens. A los 50, te pasas del límite del modelo barato. Necesitas un resumen automático o truncado inteligente del historial — no cargar todo cada vez.
- Coste. Un agente con gpt-4o-mini y 2 tools por mensaje cuesta ~0,002€ por interacción. Parece poco hasta que tienes 1.000 usuarios con 10 conversaciones diarias. Son 20€/día solo en tokens. Mide antes de escalar.
Un agente de IA en producción no es un endpoint. Es un sistema: cola, persistencia, tools, rate-limit y coste controlado. Sin eso, tienes un loro caro que se cae el día del lanzamiento.
Por qué NestJS y no Express aquí
Podrías hacer esto en Express. Pero un agente con tools, colas, websockets y persistencia necesita módulos, inyección de dependencias y estructura. Express te deja improvisar — NestJS te obliga a organizar. La comparativa completa está en NestJS vs Express, pero el resumen es: si tu IA va a crecer más allá de una demo, NestJS ahorra refactor.
Si quieres ver cómo queda un agente con contexto real en producción, mira el caso de Sando Capital: un agente que cualifica leads financieros con catálogo cargado y reglas de encaje. Y si tu negocio aún no tiene los datos conectados que un agente necesita, empieza por el Mapa del Caos antes que por LangChain.