TL;DR
La memoria de un agente LLM en producción no es una cosa: son tres. Working memory vive en Redis con TTL corto y formato compacto — es lo que el agente lee antes de cada turno. Historial auditable vive en Langfuse sessions con trazas completas — es lo que miras cuando algo falla. Memoria semántica de largo plazo vive en Mem0 o Zep sobre Qdrant — es lo que recupera cuando el usuario dice "¿recuerdas lo que hablamos la semana pasada?". Confundir las tres capas es la causa #1 de agentes que alucinan "preferencias" o pierden el hilo a los 10 mensajes. Este artículo muestra el patrón tiered memory con código Go ejecutable sobre ese stack.
El anti-patrón: "mandarle todo el historial al LLM"
La mayoría de agentes que revisamos en 2025 tenían el mismo bug: enviaban el transcript completo de la conversación en cada request. Funciona para 5 turnos; rompe a los 50. Tres problemas:
- Costos lineales por turno. Un agente de soporte con 200 turnos y 2000 tokens por mensaje gasta 400k tokens de input en el último turno. A precio Opus, son 6 USD por sesión solo en leer el historial.
- Latencia crece. El time-to-first-token sube 2-4× cuando el input pasa de 8k a 80k tokens.
- Degradación de recall. Los modelos olvidan lo que está al medio del contexto ("lost in the middle") — y la información crítica suele estar ahí.
El patrón correcto es memoria por capas, inspirado en cómo funciona la memoria humana: sensorial (segundos), working (minutos), largo plazo (indefinida).
Las tres capas
Capa 1 — Working memory (Redis)
- Qué: estado inmediato de la conversación (últimos N turnos, variables de flujo, slot-filling).
- TTL: 1 hora por defecto, extensible a 24 h si el usuario sigue activo.
- Formato: JSON compacto + turnos resumidos cuando supera umbral.
- Latencia: <5 ms lectura.
- Dónde vive: Redis 8 en la misma VPC que el orquestador.
Capa 2 — Historial auditable (Langfuse sessions)
- Qué: cada turno completo con input, output, tool calls, latencia, costo, evaluaciones.
- TTL: retención configurable (90 días por defecto, indefinido para clientes regulados).
- Formato: traces + spans normalizados por Langfuse.
- Latencia: escritura async vía worker; no bloquea al agente.
- Dónde vive: Langfuse self-hosted (Postgres + ClickHouse).
Capa 3 — Memoria semántica (Mem0 o Zep sobre Qdrant)
- Qué: hechos consolidados sobre el usuario ("prefiere videollamadas por la tarde", "cliente enterprise, RFC XAXX010101000").
- TTL: indefinido con "olvido" semántico basado en contradicciones.
- Formato: embedding + metadatos filtrables.
- Latencia: 20-50 ms búsqueda top-k.
- Dónde vive: Qdrant en el mismo droplet que el orquestador.
Diagrama del flujo por turno
Usuario envía mensaje
│
▼
┌─────────────────────────────────────────┐
│ 1. Redis.GET session:{id}:working │ ← ~3 ms
│ → últimos 6 turnos + resumen parcial │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 2. Mem0.search(user_id, query) │ ← ~30 ms
│ → top 5 hechos semánticamente rel. │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 3. Construir prompt: │
│ [system + memory facts + working │
│ + current message] │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 4. LiteLLM → Claude/GPT/Ollama │
│ (traza automática a Langfuse) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 5. Redis.APPEND turno a working │
│ Langfuse.addToSession(trace) │
│ Mem0.extractFacts(async) │
└─────────────────────────────────────────┘
│
▼
Responder al usuario
Implementación en Go
Estructura del paquete:
internal/memory/
├── memory.go // interfaces y tipos
├── working.go // Redis
├── audit.go // Langfuse
├── semantic.go // Mem0 sobre Qdrant
└── compactor.go // heurísticas de resumen
Tipos base
package memory
import "time"
type Turn struct {
Role string `json:"role"` // "user" | "assistant" | "tool"
Content string `json:"content"`
Tokens int `json:"tokens"`
CreatedAt time.Time `json:"created_at"`
}
type SessionState struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Turns []Turn `json:"turns"` // ventana rolling
Summary string `json:"summary"` // resumen de lo expirado
Slots map[string]string `json:"slots"` // slot-filling
TokenBudget int `json:"token_budget"`
}
Working memory en Redis
type WorkingMemory struct {
rdb *redis.Client
ttl time.Duration
windowSize int // N turnos sin comprimir
}
func (w *WorkingMemory) Load(ctx context.Context, sessionID string) (*SessionState, error) {
data, err := w.rdb.Get(ctx, key(sessionID)).Bytes()
if errors.Is(err, redis.Nil) {
return &SessionState{SessionID: sessionID}, nil
}
if err != nil {
return nil, err
}
var s SessionState
return &s, json.Unmarshal(data, &s)
}
func (w *WorkingMemory) Append(ctx context.Context, s *SessionState, t Turn) error {
s.Turns = append(s.Turns, t)
if len(s.Turns) > w.windowSize {
if err := w.compact(ctx, s); err != nil {
return err
}
}
b, _ := json.Marshal(s)
return w.rdb.Set(ctx, key(s.SessionID), b, w.ttl).Err()
}
Heurística de compactación
Cuando la ventana excede windowSize, compactamos los turnos más antiguos en un resumen:
func (w *WorkingMemory) compact(ctx context.Context, s *SessionState) error {
oldest := s.Turns[:len(s.Turns)-w.windowSize]
s.Turns = s.Turns[len(s.Turns)-w.windowSize:]
// Llamada barata a un modelo pequeño vía LiteLLM
newSummary, err := w.summarizer.Summarize(ctx, SummarizeInput{
PreviousSummary: s.Summary,
NewTurns: oldest,
})
if err != nil {
return err
}
s.Summary = newSummary
return nil
}
El resumidor usa claude-haiku-4-5 o llama-local vía LiteLLM — es un paso barato (costo <0.5 ¢) que se ejecuta async cuando es posible.
Capa 2: Langfuse session tracking
Langfuse se integra de forma transparente si LiteLLM está configurado con success_callback: ["langfuse"] (ver artículo del stack self-hosted). Para agregar metadata de sesión y usuario:
import "github.com/langfuse/langfuse-go"
func (a *Agent) traceTurn(ctx context.Context, s *SessionState, input, output string) {
trace := a.lf.Trace(&langfuse.TraceInput{
SessionID: s.SessionID,
UserID: s.UserID,
Metadata: map[string]any{
"tenant_id": s.TenantID,
"turn_idx": len(s.Turns),
},
})
trace.Generation(&langfuse.GenerationInput{
Name: "agent-turn",
Input: input,
Output: output,
Model: "claude-sonnet-4-6",
})
}
En el dashboard de Langfuse obtienes /sessions/{session_id} con la conversación completa reproducible.
Capa 3: memoria semántica con Mem0
Mem0 corre como servicio HTTP (o embebido como Python/TS SDK). Expone dos operaciones clave: add (extrae hechos de una conversación) y search (recupera hechos relevantes a la query actual).
type SemanticMemory struct {
mem0URL string
http *http.Client
}
func (s *SemanticMemory) Search(ctx context.Context, userID, query string) ([]Fact, error) {
body, _ := json.Marshal(map[string]any{
"user_id": userID,
"query": query,
"limit": 5,
})
// POST $mem0URL/v1/memories/search ...
}
func (s *SemanticMemory) AddAsync(ctx context.Context, userID string, conv []Turn) {
go func() {
// Mem0 extrae hechos del par (turno usuario, turno agente)
// y los vectoriza a Qdrant internamente.
}()
}
Ensamblar el prompt
func (a *Agent) BuildPrompt(ctx context.Context, s *SessionState, userMsg string) (string, error) {
facts, _ := a.semantic.Search(ctx, s.UserID, userMsg)
var b strings.Builder
b.WriteString(a.systemPrompt)
b.WriteString("\n\n## Hechos sobre el usuario\n")
for _, f := range facts {
fmt.Fprintf(&b, "- %s (confianza: %.2f)\n", f.Content, f.Score)
}
if s.Summary != "" {
b.WriteString("\n\n## Resumen de la conversación previa\n")
b.WriteString(s.Summary)
}
b.WriteString("\n\n## Últimos turnos\n")
for _, t := range s.Turns {
fmt.Fprintf(&b, "[%s] %s\n", t.Role, t.Content)
}
fmt.Fprintf(&b, "\n[user] %s", userMsg)
return b.String(), nil
}
Cuándo expirar, cuándo resumir, cuándo vectorizar
La parte difícil no es el código; es decidir qué va a dónde. Las heurísticas que nos han funcionado en producción:
| Señal | Acción |
|---|---|
| Turno común (pregunta, confirmación) | Working memory |
| "Siempre" / "prefiero" / "soy X" | Extraer a Mem0 inmediatamente |
| Dato estructurado (email, RFC, fecha) | Slot del working state |
| >6 turnos desde último mensaje | Comprimir a resumen |
| Sesión cerrada (inactividad >1 h) | Flush a Langfuse; extract a Mem0 |
| Usuario dice "olvídalo" | Borrar fact en Mem0 |
Cache semántico: la capa opcional
Redis 8 con RedisVL habilita caché por similitud: antes de llamar al LLM, buscamos si ya respondimos algo "suficientemente parecido".
func (c *SemanticCache) GetOrCompute(ctx context.Context, prompt string, compute func() (string, error)) (string, error) {
if hit, ok := c.lookup(ctx, prompt, 0.92); ok {
c.metrics.IncHit()
return hit, nil
}
answer, err := compute()
if err != nil {
return "", err
}
c.store(ctx, prompt, answer)
return answer, nil
}
En agentes de soporte con FAQs repetitivas vemos 40-60% de hit rate. A 2 USD por mil llamadas, eso son 800-1200 USD/mes ahorrados en un agente con 1 millón de interacciones.
Observabilidad: qué mirar cada semana
En Langfuse, tres dashboards que usamos religiosamente:
- Token volume por capa: working / memory facts / system. Si "working" supera 10k tokens, hay que bajar
windowSize. - Cache hit rate: debe crecer monotónicamente las primeras semanas y estabilizarse.
- "Memory drift": casos donde el agente contradice un fact — signal de que Mem0 necesita rerunning o de que el fact tenía baja confianza.
Anti-patrones que vimos en clientes
- Vectorizar cada turno. Satura Qdrant y llena el contexto de ruido. Vectoriza hechos, no transcripciones.
- Resumir con Opus. Usa un modelo pequeño. El resumen no merece tokens caros.
- Compartir working memory entre sesiones. Colisiona flows.
session_iddebe ser granular (no por usuario, sino por conversación). - No limpiar memory en tests. Test flaky garantizado. Usa
tenant_id = "test-{uuid}"y limpia al final.
Impacto de negocio
Qué vende el retrofit
La mayoría de equipos con agente productivo queman dinero por context sobredimensionado. Un retrofit de 6 semanas a tiered memory corta el gasto de inferencia a la mitad o más y además sube calidad. Esa combinación rara se paga en meses, y deja al equipo con un mejor modelo para features nuevas.
Agente de soporte mid-market con 80k conversaciones / mes
Retrofit de tiered memory (12 meses)
| Retrofit (one-time) | −$14,500 |
| Retainer (12 mo × $600) | −$7,200 |
| Ahorro LLM (12 × $13.5k) | +$162,000 |
| Retención impulsada por calidad | +$58,000 |
| Contribución neta año 1 | +$198,300 |
- Sampling + análisis de trazas
- Descomposición de gasto en tokens
- Eval de calidad en turnos de memoria
- Roadmap priorizado
- Redis working memory
- Wiring sessions Langfuse
- Mem0 o Zep sobre Qdrant
- Runbook + training
- Guarantee 90 días
- Review mensual de trazas
- Políticas de expiration + summarization
- Manejo de migraciones de modelo
- Canal Slack
FAQ
¿Por qué no guardar todo en Postgres como tabla de mensajes?Postgres no tiene TTL nativo, la escritura es más lenta, y la recuperación semántica requiere extensión vectorial. Redis + Qdrant es un orden de magnitud más rápido para este caso.
¿Mem0 vs Zep vs construir propio?Mem0 es más maduro en español y tiene OSS. Zep tiene mejor soporte comercial. Construir propio sobre Qdrant tiene sentido cuando necesitas lógica custom (p.ej. memorias con expiración regulatoria).
¿Cómo maneja el AI Act los logs de memoria?Requiere que el usuario pueda ver y borrar sus memorias. Implementamos endpoints /memory/{user_id} GET y DELETE que barren Langfuse + Mem0 + Redis en una sola transacción.
¿Funciona con agentes multi-turno largos (horas/días)?Sí, pero hay que poner Temporal o Inngest encima para persistir el estado del orquestador. Redis no es suficiente para durabilidad de días.
¿Cómo mido si la memoria está "funcionando"?Suite de evals en Langfuse con casos tipo "el usuario dijo X en el turno 5, pregúntale sobre X en el turno 30". Si el agente no recuerda, Mem0 no está extrayendo o el prompt no lo está incluyendo.
Próximos pasos
El código de este artículo está en github.com/numoru-ia/agent-memory-go como librería importable. La próxima pieza de la serie cubre evals automatizados sobre este patrón de memoria: cómo asegurar en CI/CD que cambios en el prompt no rompen el recall.