Todas las contribuciones
Ingenieríalangfuseredismem0

Langfuse + Redis + Mem0 como memoria de agentes en producción: patrón tiered memory

Patrón de memoria por capas para agentes LLM: Redis para working memory (TTL 1h), Langfuse sessions para historia auditable, Mem0 para memoria semántica de largo plazo. Código Go, cuándo expirar, cuándo resumir, cuándo vectorizar.

Numoru EngineeringPublicado el 7 de junio de 202614 min de lectura
Compartir
Propuesta de implementacióngithub.com/numoru-ia/agent-memory-go

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.

-55% a -80%
Costo de tokens vs history naive
Conversaciones 50 turnos
~2× más rápido
Latencia por turno
Working memory compactada
+34%
Accuracy recuerdo de preferencias
Vs embeber transcript completo
$6-18k
Retrofit de arquitectura
Ticket típico

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:

  1. 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.
  2. Latencia crece. El time-to-first-token sube 2-4× cuando el input pasa de 8k a 80k tokens.
  3. 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ñalAcció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 mensajeComprimir 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:

  1. Token volume por capa: working / memory facts / system. Si "working" supera 10k tokens, hay que bajar windowSize.
  2. Cache hit rate: debe crecer monotónicamente las primeras semanas y estabilizarse.
  3. "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_id debe 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

Business & commercial impact

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.

Illustrative caseSaaS customer support · 60 ingenieros · $24M ARR · México + EE.UU.

Agente de soporte mid-market con 80k conversaciones / mes

Baseline
Agente naive mandando transcript completo. Context promedio 11,500 tokens / turno. Gasto mensual LLM: $22,000. Quejas sobre agente que "olvida" después del turno 18.
Intervention
Numoru entregó memoria tiered Redis + Langfuse + Mem0 en 6 semanas. Working memory comprimida a 1,400 tokens / turno; facts long-term vectorizados en Qdrant.
Projected outcome (12 mo)
Context promedio por turno
11.5k → 1.4k tokens
-88%
Costo mensual LLM
$22k → $8.5k
-61%
Tests de preferencia pasando
58% → 92%
+34 pts
Costo del retrofit
$14,500
One-time
Ahorro LLM anualizado
+$162,000
$13.5k × 12
Payback
< 1 mes
Sell claro al CFO
Deltas desde engagement data; comparación contra rate cards públicos Anthropic + OpenAI Q1 2026. Caso sintético.

Retrofit de tiered memory (12 meses)

Payback: < 2
Assumptions
Conversaciones / mes80,000
Turnos por conversación14 prom
Tokens ahorrados promedio / turno10,100
Precio token input$0.003 / 1k
Ahorro por mes~$13,500
Costo retrofit one-time$14,500
Retainer$600 / mes
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
Auditoría memoria
$3,500one-time
Auditoría + plan de 2 semanas.
  • Sampling + análisis de trazas
  • Descomposición de gasto en tokens
  • Eval de calidad en turnos de memoria
  • Roadmap priorizado
Retrofit completo
$14,500one-time
6 semanas a tiered memory.
  • Redis working memory
  • Wiring sessions Langfuse
  • Mem0 o Zep sobre Qdrant
  • Runbook + training
  • Guarantee 90 días
Evolucionar & operar
$600 – 1,800/ mes
Tuning + eval en curso.
  • 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.

¿Quieres resultados así para tu empresa?

Iniciar conversación
Compartir