All contributions
Engineeringlangfuseredismem0

Langfuse + Redis + Mem0 as production agent memory: the tiered memory pattern

Tiered memory pattern for LLM agents: Redis for working memory (1h TTL), Langfuse sessions for auditable history, Mem0 for long-term semantic memory. Go code, when to expire, summarize, or vectorize.

Numoru EngineeringPublished on June 7, 202614 min read
Share
Implementation proposalgithub.com/numoru-ia/agent-memory-go

TL;DR

Production LLM agent memory is not one thing: it's three. Working memory lives in Redis with a short TTL and compact format — it's what the agent reads before each turn. Auditable history lives in Langfuse sessions with complete traces — it's what you look at when something fails. Long-term semantic memory lives in Mem0 or Zep on top of Qdrant — it's what surfaces when the user says "remember what we discussed last week?". Confusing the three layers is the #1 cause of agents that hallucinate "preferences" or lose the thread at turn 10. This article shows the tiered memory pattern with executable Go code on that stack.

-55% to -80%
Token cost vs naive history
At 50-turn conversations
~2× faster
Per-turn latency
Compacted working memory
+34%
User preference recall accuracy
Vs embedding full transcript
$6-18k
Memory architecture retrofit
Typical engagement ticket

The anti-pattern: "send the whole history to the LLM"

Most agents we reviewed in 2025 had the same bug: they sent the complete conversation transcript on every request. Works for 5 turns; breaks at 50. Three problems:

  1. Linear cost per turn. A support agent with 200 turns and 2,000 tokens per message spends 400k input tokens in the last turn. At Opus pricing that's $6 per session just to read history.
  2. Latency grows. Time-to-first-token rises 2-4× as input moves from 8k to 80k tokens.
  3. Recall degradation. Models forget what's in the middle of context ("lost in the middle") — and critical information tends to be there.

The correct pattern is layered memory, inspired by how human memory works: sensory (seconds), working (minutes), long-term (indefinite).

The three layers

Layer 1 — Working memory (Redis)

  • What: immediate state of the conversation (last N turns, flow variables, slot-filling).
  • TTL: 1 hour default, extendable to 24h if the user stays active.
  • Format: compact JSON + summarized turns when threshold is exceeded.
  • Latency: <5 ms read.
  • Where it lives: Redis 8 in the same VPC as the orchestrator.

Layer 2 — Auditable history (Langfuse sessions)

  • What: every complete turn with input, output, tool calls, latency, cost, evaluations.
  • TTL: configurable retention (90 days default, indefinite for regulated clients).
  • Format: Langfuse-normalized traces + spans.
  • Latency: async write via worker; does not block the agent.
  • Where it lives: Langfuse self-hosted (Postgres + ClickHouse).

Layer 3 — Semantic memory (Mem0 or Zep on Qdrant)

  • What: consolidated facts about the user ("prefers afternoon video calls", "enterprise client, RFC XAXX010101000").
  • TTL: indefinite with semantic "forgetting" based on contradictions.
  • Format: embedding + filterable metadata.
  • Latency: 20-50 ms top-k search.
  • Where it lives: Qdrant on the same droplet as the orchestrator.

Per-turn flow diagram

User sends message
        │
        ▼
┌─────────────────────────────────────────┐
│ 1. Redis.GET session:{id}:working       │ ← ~3 ms
│    → last 6 turns + partial summary     │
└─────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────┐
│ 2. Mem0.search(user_id, query)          │ ← ~30 ms
│    → top 5 semantically relevant facts  │
└─────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────┐
│ 3. Build prompt:                        │
│    [system + memory facts + working     │
│     + current message]                  │
└─────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────┐
│ 4. LiteLLM → Claude/GPT/Ollama          │
│    (auto-traces to Langfuse)            │
└─────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────┐
│ 5. Redis.APPEND turn to working         │
│    Langfuse.addToSession(trace)         │
│    Mem0.extractFacts(async)             │
└─────────────────────────────────────────┘
        │
        ▼
Reply to user

Implementation in Go

Package structure:

internal/memory/
├── memory.go        // interfaces and types
├── working.go       // Redis
├── audit.go         // Langfuse
├── semantic.go      // Mem0 over Qdrant
└── compactor.go     // summarization heuristics

Base types

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"`         // rolling window
    Summary     string    `json:"summary"`       // summary of expired turns
    Slots       map[string]string `json:"slots"` // slot-filling
    TokenBudget int       `json:"token_budget"`
}

Working memory in Redis

type WorkingMemory struct {
    rdb        *redis.Client
    ttl        time.Duration
    windowSize int // N uncompressed turns
}

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()
}

Compaction heuristic

When the window exceeds windowSize, we compact the oldest turns into a summary:

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:]

    // Cheap call to a small model via LiteLLM
    newSummary, err := w.summarizer.Summarize(ctx, SummarizeInput{
        PreviousSummary: s.Summary,
        NewTurns:        oldest,
    })
    if err != nil {
        return err
    }
    s.Summary = newSummary
    return nil
}

The summarizer uses claude-haiku-4-5 or llama-local via LiteLLM — a cheap step (cost <0.5 cents) that runs async when possible.

Layer 2: Langfuse session tracking

Langfuse integrates transparently if LiteLLM is configured with success_callback: ["langfuse"] (see the self-hosted stack article). To add session and user metadata:

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",
    })
}

In the Langfuse dashboard you get /sessions/{session_id} with the full reproducible conversation.

Layer 3: semantic memory with Mem0

Mem0 runs as an HTTP service (or embedded as a Python/TS SDK). It exposes two key operations: add (extracts facts from a conversation) and search (retrieves facts relevant to the current query).

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 extracts facts from the (user turn, agent turn) pair
        // and vectorizes them to Qdrant internally.
    }()
}

Assembling the 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## Facts about the user\n")
    for _, f := range facts {
        fmt.Fprintf(&b, "- %s (confidence: %.2f)\n", f.Content, f.Score)
    }

    if s.Summary != "" {
        b.WriteString("\n\n## Prior conversation summary\n")
        b.WriteString(s.Summary)
    }

    b.WriteString("\n\n## Recent turns\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
}

When to expire, summarize, vectorize

The hard part isn't the code; it's deciding what goes where. Heuristics that work for us in production:

SignalAction
Ordinary turn (question, confirmation)Working memory
"Always" / "prefer" / "I am X"Extract to Mem0 immediately
Structured data (email, RFC, date)Working-state slot
>6 turns since last messageCompress to summary
Session closed (idle >1h)Flush to Langfuse; extract to Mem0
User says "forget it"Delete fact in Mem0

Semantic cache: the optional layer

Redis 8 with RedisVL enables similarity caching: before calling the LLM, we check if we already answered something "sufficiently similar."

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
}

In support agents with repetitive FAQs we see 40-60% hit rate. At $2 per thousand calls, that's $800-1,200/month saved on an agent with 1 million interactions.

Observability: what to watch each week

In Langfuse, three dashboards we use religiously:

  1. Token volume per layer: working / memory facts / system. If "working" tops 10k tokens, lower windowSize.
  2. Cache hit rate: should grow monotonically in the first weeks and stabilize.
  3. "Memory drift": cases where the agent contradicts a fact — a signal that Mem0 needs re-running or the fact had low confidence.

Anti-patterns we've seen in clients

  • Vectorize every turn. Saturates Qdrant and fills context with noise. Vectorize facts, not transcripts.
  • Summarize with Opus. Use a small model. Summary doesn't deserve expensive tokens.
  • Share working memory across sessions. Crashes flows. session_id must be granular (not per user, but per conversation).
  • Don't clean memory in tests. Guaranteed flaky tests. Use tenant_id = "test-{uuid}" and clean up at the end.

Business & commercial impact

Business & commercial impact

What the memory retrofit sells

Most teams with a production agent already burn money on oversized context. A 6-week retrofit to tiered memory cuts inference spend by half or more while also raising quality. That's a rare offer: it pays for itself in months, and it gives the engineering team a better handoff model for new features.

Illustrative caseCustomer support SaaS · 60 engineers · $24M ARR · Mexico + USA

Mid-market customer-support agent processing 80k conversations / mo

Baseline
Naive agent sending whole transcript. Average context 11,500 tokens / turn. Monthly LLM spend: $22,000. Complaints about agent "forgetting" above turn 18.
Intervention
Numoru delivered Redis + Langfuse + Mem0 tiered memory in 6 weeks. Working memory compressed to 1,400 tokens / turn; long-term facts vectorised in Qdrant.
Projected outcome (12 mo)
Average context per turn
11.5k → 1.4k tokens
-88%
Monthly LLM cost
$22k → $8.5k
-61%
Preference-recall tests passing
58% → 92%
+34 pts
Retrofit cost
$14,500
One-time
Annualised LLM savings
+$162,000
$13.5k × 12
Payback
< 1 month
Clear CFO sell
Cost / quality deltas from our engagement data; compare to public Anthropic + OpenAI rate cards Q1 2026. Synthetic case.

Tiered memory retrofit (12 months)

Payback: < 2
Assumptions
Conversations / month80,000
Turns per conversation14 avg
Avg tokens saved per turn10,100
Input token price$0.003 / 1k
Savings per month~$13,500
Retrofit one-time cost$14,500
Retainer$600 / mo
Retrofit (one-time)−$14,500
Retainer (12 mo × $600)−$7,200
LLM cost savings (12 × $13.5k)+$162,000
Quality-driven retention lift+$58,000
Net year-1 contribution+$198,300
Memory audit
$3,500one-time
2-week audit + plan.
  • Trace sampling + analysis
  • Token-spend decomposition
  • Quality eval on memory turns
  • Priority fix roadmap
Full retrofit
$14,500one-time
6 weeks to tiered memory.
  • Redis working memory
  • Langfuse sessions wiring
  • Mem0 or Zep over Qdrant
  • Runbook + training
  • 90-day guarantee
Evolve & operate
$600 – 1,800/ mo
Ongoing tuning + eval.
  • Monthly trace review
  • Expiration + summarization policies
  • Model-migration handling
  • Slack channel

FAQ

Why not store everything in Postgres as a message table?Postgres has no native TTL, writes are slower, and semantic recall requires a vector extension. Redis + Qdrant is an order of magnitude faster for this case.

Mem0 vs Zep vs build your own?Mem0 is more mature in Spanish and has an OSS version. Zep has better commercial support. Building your own on Qdrant makes sense when you need custom logic (e.g. memories with regulatory expiry).

How does the AI Act handle memory logs?Requires the user to view and delete their memories. We implement /memory/{user_id} GET and DELETE endpoints that sweep Langfuse + Mem0 + Redis in a single transaction.

Does it work with long multi-turn agents (hours/days)?Yes, but you need Temporal or Inngest on top to persist the orchestrator's state. Redis alone isn't sufficient for day-long durability.

How do I measure whether memory is "working"?Eval suite in Langfuse with cases like "the user said X at turn 5, ask them about X at turn 30". If the agent doesn't remember, either Mem0 isn't extracting or the prompt isn't including it.

Next steps

The article's code lives at github.com/numoru-ia/agent-memory-go as an importable library. The next piece of the series covers automated evals on this memory pattern: how to ensure in CI/CD that prompt changes don't break recall.

Want results like these for your company?

Start a conversation
Share