Módulo 3: Memoria: el agente que recuerda

8. Mini-proyecto: agente conversacional con memoria persistente por usuario

Descripción

Al terminar esta lección vas a poder construir, de punta a punta, un agente conversacional que reconoce al mismo cliente en sesiones distintas — hoy y dentro de tres días, en la misma pestaña o en una nueva — y vas a poder demostrarlo con evidencia verificable: filas en una tabla de Postgres que sobreviven un reinicio real del contenedor de n8n, no solo una respuesta que "se ve bien" en el chat.

Esto importa porque el escenario que vas a montar es, casi textual, el que pide cualquier equipo de soporte real: un cliente escribe hoy sobre un pedido, y espera que la próxima vez que hable con el bot — mañana, la próxima semana, desde otro dispositivo — no tenga que repetir todo desde cero. Un agente que solo recuerda dentro de la misma pestaña del navegador no resuelve ese caso, por bien que responda en la demo frente a tu equipo.

Conexión con el módulo: esta es la última lección del módulo, y el objetivo es ensamblar, no aprender pieza nueva. Vas a usar el sessionKey y el contextWindowLength que configuraste en las lecciones 3 y 4, la identidad estable del cliente (lección 4) en vez del id efímero que genera el widget de chat, el criterio de diseño de conversaciones multi-turno de la lección 5, y — al confirmar que la ventana de contexto sigue acotada aunque Postgres guarde todo — el mismo principio que resolviste en las lecciones 6 y 7 sobre no dejar crecer el historial sin control. Nada de esto es teoría nueva: hoy corre en tu propia instancia.

De la llave de habitación a la cuenta de socio

Piensa en un hotel con dos sistemas distintos para llevar cuenta de sus huéspedes. El primero es la llave de la habitación: mientras dura tu estadía, esa llave abre tu puerta y el personal de piso sabe qué llevarte porque sigue la misma reserva. Pero en cuanto haces check-out, esa llave deja de servir para cualquier cosa. Si vuelves el mes que viene, te dan una habitación distinta, una llave distinta, y nadie en recepción reconoce que ya te hospedaste ahí antes — aunque en algún archivero exista una carpeta con miles de estadías guardadas. El segundo sistema es la cuenta del programa de lealtad: un número que te identifica a ti, la persona, sin importar en qué habitación duermas ni cuántas veces regreses. Con ese número, el hotel sí puede decirte "la última vez pediste una almohada extra", aunque hayan pasado tres meses y dos remodelaciones del lobby.

Todo lo que armaste en este módulo hasta ahora son piezas sueltas de esos dos sistemas. Hoy las ensamblas en un solo agente y, lo más importante, vas a verificar con tus propias manos que la pieza persistente funciona como el programa de lealtad y no como la llave de la habitación: vas a apagar el proceso de n8n a la mitad de la prueba, y el agente va a seguir reconociendo al mismo cliente del otro lado. Concretamente, hoy vas a ensamblar:

  • La identidad estable del cliente como sessionKey — el teléfono, no el id efímero que genera el widget en cada visita (lección 4).
  • El nodo Postgres Chat Memory con una tabla real, en vez de Simple Memory viviendo en la RAM del proceso (lección 4).
  • Un contextWindowLength acotado, aplicando el mismo criterio de no dejar crecer el historial sin control que viste en las lecciones 6 y 7.
  • Una conversación de varios turnos que de verdad depende de lo dicho antes, como diseñaste en la lección 5.

Ejemplo trabajado: TuTienda, memoria persistente de punta a punta

Vas a retomar el agente de soporte de TuTienda que vienes siguiendo desde el Módulo 1 — el que responde sobre el pedido #4521 — y lo vas a dejar funcionando con memoria persistente real.

Paso 1 — agrega Postgres a tu instancia self-hosted. Ya tienes corriendo la instancia self-hosted de la lección 7 del Módulo 1, con su docker-compose.yml y su N8N_ENCRYPTION_KEY fija. Agrégale un servicio de Postgres dedicado a guardar el historial de chat — no reuses la base de datos interna de n8n, que es para su propio funcionamiento, no para tus datos de aplicación:

# docker-compose.yml — el mismo de la lección 7 del Módulo 1, con Postgres agregado
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - GENERIC_TIMEZONE=America/Mexico_City
      - TZ=America/Mexico_City
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_RUNNERS_ENABLED=true
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      - postgres

  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER=n8n_memory
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=chat_memory
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  n8n_data:
  postgres_data:

Agrega la contraseña al mismo .env donde ya tienes N8N_ENCRYPTION_KEY:

# .env
N8N_ENCRYPTION_KEY=la_que_ya_tenías
POSTGRES_PASSWORD=pon_aquí_una_contraseña_propia
docker compose up -d

Qué esperar: Docker descarga la imagen oficial de Postgres y levanta un segundo contenedor junto al de n8n, sin tocar el volumen n8n_data que ya tenías. Nota que el servicio postgres no publica ningún puerto hacia tu máquina (ports:) — a propósito. Solo n8n necesita hablarle, y lo hace dentro de la red privada que Docker Compose crea automáticamente entre los servicios de un mismo archivo. Ahí, cada servicio puede resolver a los demás por su nombre, como si fuera una libreta de contactos interna: el contenedor de n8n va a poder alcanzar al de Postgres usando literalmente el hostname postgres, no localhostlocalhost, visto desde dentro del contenedor de n8n, se refiere al propio contenedor de n8n, no al de al lado.

Paso 2 — crea la credencial de Postgres en n8n. En el editor, ve a Credentials → New → Postgres, y completa:

Host      = postgres          # el nombre del servicio en docker-compose.yml, no "localhost"
Database  = chat_memory
User      = n8n_memory
Password  = la que pusiste en .env
Port      = 5432               # el puerto interno del contenedor, no necesitas exponerlo
SSL       = Disable            # red privada de Docker, no expuesta a internet

Paso 3 — construye la tool get_order_status sin depender de ninguna API externa. Para este mini-proyecto no necesitas la API real de TuTienda (no existe) ni ninguna cuenta de terceros — usa un nodo Code Tool con datos de prueba, conectado al puerto ai_tool del AI Agent:

# Nodo: Code Tool
name          = "get_order_status"
description   = "Usa esta herramienta cuando el cliente dé un número de pedido
                 y pregunte por su estado o fecha de entrega. Pásale como
                 entrada solo el número de pedido, sin texto adicional —
                 por ejemplo: 4521. No la uses para preguntas sobre política
                 de cambios o devoluciones."
language      = JavaScript
// query llega como texto plano: el número de pedido que decidió mandar el modelo
const mockOrders = {
  "4521": { status: "en tránsito", eta: "24 de julio" },
  "4522": { status: "entregado", eta: "20 de julio" },
};

const order = mockOrders[query.trim()];

if (!order) {
  return `No encontré ningún pedido con el número ${query}.`;
}

return `Pedido #${query}: estado ${order.status}, entrega estimada ${order.eta}.`;

Paso 4 — conecta el resto del agente. Un Chat Trigger, el mismo Chat Model que ya tienes configurado desde el Módulo 1 (claude-sonnet-5 o el proveedor que uses), y el System Message de TuTienda que ya conoces:

# Nodo: AI Agent
prompt.systemMessage = "Eres el asistente de soporte de TuTienda. Responde en
                        español, en tono cercano y directo. Si no tienes un
                        dato, dilo — no inventes números de pedido ni fechas
                        de entrega."

# CONEXIÓN ai_memory -> nodo: Postgres Chat Memory
memory.credential           = la credencial de Postgres del Paso 2
memory.sessionKey           = "{{ $json.customerPhone }}"   # NO el sessionId del widget
memory.tableName            = "n8n_chat_histories"
memory.contextWindowLength  = 10

El campo que más fácil se pasa por alto: el selector de origen del Session Key en el nodo Postgres Chat Memory viene, por defecto, tomando el id que arma el Chat Trigger para cada conversación — exactamente el alcance efímero que falló en la Configuración B de la lección 1. Tienes que cambiarlo a modo expresión manual y escribir {{ $json.customerPhone }} a propósito. Conectar el nodo correcto no basta si dejas ese campo en su valor por defecto — vas a comprobarlo en el Ejercicio 1.

Como el customerPhone no lo genera el widget del chat (solo genera chatInput y sessionId), vas a activar el workflow y mandarle los mensajes por HTTP directamente, con ese campo agregado a mano en el cuerpo de la petición. Activa el workflow (interruptor arriba a la derecha del canvas) y abre el nodo Chat Trigger: copia el valor que te muestra en el campo Chat URL, pestaña Production — no la reconstruyas de memoria, el identificador es único por workflow.

Paso 5 — turno 1 y turno 2, mismo día, mismo cliente:

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-a1b2c3",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "¿Dónde está mi pedido #4521?"
  }'

Qué esperar:

{ "output": "Tu pedido #4521 está en tránsito, con entrega estimada el 24 de julio." }

Quince segundos después, mismo sessionId, mismo customerPhone:

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-a1b2c3",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "¿Y cuándo llega?"
  }'
{ "output": "Llega el 24 de julio — es el mismo pedido #4521 del que hablamos hace un momento." }

Hasta aquí no probaste nada nuevo: es la misma continuidad de turno a turno que ya viste en la lección 5 del Módulo 1. Lo que sigue es la parte que sí es nueva.

Paso 6 — simula "al día siguiente" con un reinicio real, no con un supuesto. Reinicia únicamente el contenedor de n8n (Postgres queda corriendo, como seguiría corriendo tu servidor de base de datos en producción aunque redespliegues tu aplicación):

docker compose restart n8n

Espera a que vuelva a estar arriba (docker compose logs -f n8n, hasta ver de nuevo la línea de "Editor is now accessible"), y manda el turno 3 con un sessionId nuevo — simulando que el cliente abrió una pestaña distinta o volvió al día siguiente — pero el mismo customerPhone:

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-x9y8z7",
    "customerPhone": "+52-55-8811-2299",
    "chatInput": "Hola, ¿ya llegó mi pedido?"
  }'

Qué esperar:

{ "output": "Hola de nuevo. Según lo que hablamos, tu pedido #4521 seguía en tránsito, entrega estimada el 24 de julio. ¿Quieres que lo revise otra vez para confirmarte el estado actual?" }

El sessionId de este turno nunca existió antes — el proceso de n8n que lo generó ni siquiera es el mismo proceso que atendió los turnos 1 y 2, porque lo reiniciaste en el paso anterior. Lo único que conectó esta conversación con la de "ayer" fue el customerPhone, leído desde una tabla de Postgres que nunca se apagó. Nota también algo que ya viste como error conceptual en la lección 1: el agente responde con lo que se dijo, no vuelve a llamar la tool para verificar el estado actual — por eso ofrece revisarlo de nuevo en vez de asegurar que sigue en tránsito. Vuelve a ese punto en el cierre de esta lección.

Paso 7 — confirma el aislamiento entre clientes. Manda un turno con un customerPhone distinto, cualquier sessionId:

curl -X POST "<tu Chat URL, pestaña Production>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-nuevo",
    "customerPhone": "+52-55-0000-1111",
    "chatInput": "¿Ya llegó mi pedido?"
  }'

Qué esperar:

{ "output": "Con gusto te ayudo. ¿Me compartes el número de pedido para revisarlo?" }

Cero rastro del pedido #4521. Es el resultado correcto: este es un cliente distinto, con un customerPhone distinto, y la memoria persistente no mezcla el historial de uno con el del otro — el problema de privacidad que la lección 5 del Módulo 1 advertía sobre un sessionKey mal elegido.

Cómo confirmar que la memoria sobrevivió, y qué no garantiza

Las respuestas del chat son un buen indicio, pero no son la prueba — exactamente el mismo principio del mini-proyecto del Módulo 1: una respuesta que "suena bien" no confirma qué pasó por dentro. La prueba real está en la base de datos.

docker compose exec postgres psql -U n8n_memory -d chat_memory -c "\dt"

Qué esperar: una tabla llamada n8n_chat_histories — el nombre que pusiste en memory.tableName — creada automáticamente por el nodo la primera vez que corrió, sin que tuvieras que escribir ningún CREATE TABLE.

docker compose exec postgres psql -U n8n_memory -d chat_memory -c "SELECT * FROM n8n_chat_histories ORDER BY id;"

Qué esperar: una fila por cada mensaje guardado — el del cliente y el del agente cuentan como filas separadas. Mira la columna que identifica la sesión: para los turnos 1, 2 y 3 (los tres del mismo customerPhone), esa columna trae el mismo valor — +52-55-8811-2299 — sin importar que el sessionId haya cambiado entre el turno 2 y el turno 3. Para el turno del Paso 7, vas a ver un valor distinto en esa misma columna. Esa es tu confirmación directa, en la fuente de datos, de que el alcance quedó bien configurado — no una suposición basada en que el chat respondió como esperabas.

Queda una verificación más, y conecta directo con las lecciones 6 y 7. Con contextWindowLength = 10, la tabla puede seguir creciendo sin límite — cada mensaje nuevo se guarda siempre — pero eso no significa que el modelo reciba, en cada llamada, todos los mensajes guardados. Si abres el panel de ejecución de ese turno y miras el nodo del Chat Model conectado, el arreglo de mensajes que le llegó nunca va a traer más de 10 turnos anteriores, sin importar si la tabla ya acumuló 30. Esa es la diferencia entre almacenamiento (sin límite, lo resolviste en la lección 4) y ventana de contexto (acotada a propósito, el criterio de las lecciones 6 y 7) — las dos decisiones funcionando juntas, no una sustituyendo a la otra.

Errores comunes

Confundir "Postgres guarda todo" con "el modelo ve todo" (conceptual). Qué pasa: alguien revisa la tabla n8n_chat_histories, ve que tiene cientos de filas de una conversación larga, y da por hecho que el modelo está razonando sobre el historial completo en cada turno — y le sorprende que el agente "se le olvide" algo que el cliente dijo hace 20 turnos. Por qué pasa: almacenamiento y ventana de contexto resuelven problemas distintos, aunque ambos vivan en el mismo nodo de memoria. Postgres Chat Memory guarda cada mensaje sin límite porque ese es su trabajo — persistir; pero el contextWindowLength decide cuántos de esos mensajes se reinyectan en la llamada de este turno, y ese número sí tiene un techo fijo. Cómo detectarlo: compara el número de filas en la tabla con el número de mensajes que de verdad llegan al Chat Model en el panel de ejecución — casi nunca van a coincidir en una conversación larga, y está bien que no coincidan. Cómo corregirlo: si el agente necesita recordar algo de hace 20 turnos, la respuesta no es subir contextWindowLength sin límite (eso reintroduce el problema de la lección 6) — es el criterio de resumir que ya viste en la lección 7.

Poner localhost como Host en la credencial de Postgres (práctico). Qué pasa: guardas la credencial, la conectas al nodo Postgres Chat Memory, y la primera ejecución falla con un error de conexión rechazada, aunque el contenedor de Postgres esté corriendo y saludable. Por qué pasa: localhost, evaluado desde dentro del contenedor de n8n, apunta al propio contenedor de n8n — que no tiene ningún servidor Postgres escuchando en el puerto 5432. Los dos contenedores son máquinas distintas dentro de la red de Docker Compose, aunque corran en tu misma laptop. Cómo detectarlo: el mensaje de error suele decir algo como "connection refused" apuntando al host y puerto configurados — revisa cuál nombre de host pusiste antes que cualquier otra cosa. Cómo corregirlo: usa el nombre del servicio tal como aparece en docker-compose.yml (postgres en el ejemplo de esta lección) — ese es el hostname que la red interna de Compose resuelve.

Dejar el Session Key del nodo Postgres Chat Memory en su valor por defecto (práctico, y el que más se parece a un fallo de memoria "misteriosa"). Qué pasa: armaste todo correctamente — Postgres corriendo, credencial válida, tabla creándose sola — pero el turno 3 (sessionId nuevo, mismo cliente) igual le pide el número de pedido otra vez, como si la memoria persistente no hubiera servido de nada. Por qué pasa: si nunca cambiaste el selector de origen del Session Key, el nodo sigue tomando el sessionId efímero del Chat Trigger en vez de tu expresión {{ $json.customerPhone }} — exactamente el mismo error de alcance de la Configuración B de la lección 1, solo que ahora corriendo sobre un backend que sí es persistente. Cómo detectarlo: revisa la tabla con psql — vas a ver que sí se guardaron filas para el turno 3, pero bajo un valor de sesión distinto al de los turnos 1 y 2 (el sessionId, no el teléfono). Cómo corregirlo: abre el nodo Postgres Chat Memory y confirma explícitamente que el Session Key está en modo expresión manual apuntando a customerPhone, no en modo automático conectado al Chat Trigger.

Ejercicios

Ejercicio 1 — Diagnóstico con evidencia contradictoria. Armaste tu agente exactamente como en esta lección. Turno 1 y 2 funcionan bien. Reinicias n8n. Turno 3, con un sessionId nuevo y el mismo customerPhone, el agente responde pidiendo el número de pedido otra vez — como si nunca hubiera hablado con este cliente. Revisas la tabla con psql y sí encuentras las filas de los turnos 1 y 2, guardadas correctamente. ¿Qué sospechas primero, y qué campo del nodo Postgres Chat Memory revisarías?

Ver solución

Sospecha principal: el Session Key del nodo Postgres Chat Memory sigue en su valor por defecto (tomado del Chat Trigger) en vez de apuntar a {{ $json.customerPhone }}. La pista está en que la tabla sí tiene los datos — el almacenamiento funciona, así que no es un problema de "la memoria no persiste". El problema es de alcance: si el Session Key usó el sessionId de los turnos 1 y 2, esas filas quedaron indexadas bajo ese valor efímero. El turno 3 llega con un sessionId distinto, busca bajo esa nueva clave, no encuentra nada, y el agente arranca de cero — aunque el historial de ayer siga perfectamente guardado bajo la clave equivocada.

Por qué funciona: es el mismo diagnóstico de la lección 1 aplicado con datos reales delante — separar "¿el dato está guardado?" (almacenamiento, se confirma con psql) de "¿está guardado bajo la identidad correcta?" (alcance, se confirma revisando el Session Key del nodo). Un backend persistente no protege contra un sessionKey mal elegido.

Ejercicio 2 — Leer la tabla para confirmar el alcance, sin adivinar. Después de correr los turnos 1, 2, 3 y el turno del cliente distinto (Paso 7) de esta lección, ¿cuántos valores distintos esperarías ver en la columna que identifica la sesión, si el Session Key quedó bien configurado apuntando a customerPhone? ¿Y si, por el error del Ejercicio 1, hubiera quedado apuntando al sessionId del Chat Trigger?

Ver solución

Bien configurado (Session Key = customerPhone): dos valores distintos en total — uno para +52-55-8811-2299 (agrupa los turnos 1, 2 y 3, aunque el sessionId haya cambiado entre ellos) y otro para +52-55-0000-1111 (el cliente del Paso 7). El número de sesiones distintas coincide con el número de clientes reales, no con el número de veces que alguien abrió una pestaña nueva.

Mal configurado (Session Key = sessionId del Chat Trigger): tres valores distintos — uno por cada sessionId usado (tab-a1b2c3 para los turnos 1 y 2, tab-x9y8z7 para el turno 3, y el del Paso 7), aunque dos de esos tres correspondan al mismo cliente real.

Por qué funciona: contar valores distintos en esa columna es una forma directa de verificar el alcance sin depender de si la respuesta del chat "sonó" correcta — si el número de sesiones distintas es mayor al número de clientes reales que probaste, el Session Key está agrupando por algo más efímero que la identidad del cliente.

Ejercicio 3 — Aplica el criterio completo a un caso nuevo. Vas a construir un agente interno de RR. HH. que responde preguntas sobre el saldo de vacaciones de cada empleado. Los empleados escriben desde Slack, que le da a cada persona un user_id estable — el mismo hoy, la próxima semana y desde cualquier canal donde le escriban al bot. Diseña, con el criterio completo de este módulo: (a) qué usarías como sessionKey y por qué, (b) qué tipo de memoria conectarías (¿Simple Memory o algo respaldado por base de datos?) y por qué.

Ver solución

(a) El user_id estable que da Slack — cumple el mismo rol que el customerPhone de TuTienda en esta lección: una identidad real de la persona, que no cambia entre una conversación y la siguiente, a diferencia de un id de sesión o de canal que sí podría cambiar.

(b) Memoria persistente respaldada por base de datos (Postgres Chat Memory u otra opción equivalente), no Simple Memory. El criterio de la lección 1 aplica directo: un empleado que pregunta hoy por su saldo y vuelve a preguntar la próxima semana espera que el bot recuerde el contexto de esa conversación anterior — eso es exactamente el caso donde memoria de sesión no alcanza, sin importar qué tan bien configures el contextWindowLength.

Por qué funciona: el criterio no cambia entre TuTienda y RR. HH. — sigue siendo "¿existe una identidad estable del mundo real que el agente necesita reconocer entre sesiones distintas?". Cambia el dominio, no el razonamiento.

Resumen y siguiente paso

Hoy armaste, de punta a punta, el agente que el mapa de la lección 1 prometía: identidad estable de cliente como sessionKey, historial en una tabla de Postgres que sobrevivió un reinicio real del contenedor de n8n, y una ventana de contexto que se mantiene acotada aunque el almacenamiento siga creciendo. Y lo verificaste con evidencia — filas en una base de datos y un contenedor apagado de por medio — no con una respuesta que sonó razonable en el chat.

Antes de avanzar deberías poder: explicar, señalando el campo exacto del nodo Postgres Chat Memory, por qué un agente puede tener memoria persistente conectada y aun así fallar con el mismo síntoma que un agente sin memoria; leer una tabla de historial de chat y contar cuántas sesiones reales representa, sin adivinar a partir de las respuestas del bot; y diseñar, para un caso nuevo, qué identidad usar como sessionKey y qué tipo de almacenamiento le corresponde.

Hay algo que este mini-proyecto no prueba, a propósito. Cuando el cliente vuelve al día siguiente y el agente repite "tu pedido está en tránsito", ese dato viene de la memoria — de lo que se dijo ayer —, no de una consulta nueva a ningún sistema real. Si el pedido ya se entregó esta mañana, tu agente seguiría sin saberlo, porque la memoria guarda conversación, no estado del sistema. Esa es exactamente la frontera que vas a trazar en el Módulo 4, cuando conectes memoria y tools reales trabajando juntas: un agente que recuerda lo que se habló, pero que también sabe cuándo volver a preguntarle al sistema real en vez de confiar en lo que ya dijo.

Recursos