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.
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:
- 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.
- Latency grows. Time-to-first-token rises 2-4× as input moves from 8k to 80k tokens.
- 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:
| Signal | Action |
|---|---|
| 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 message | Compress 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:
- Token volume per layer: working / memory facts / system. If "working" tops 10k tokens, lower
windowSize. - Cache hit rate: should grow monotonically in the first weeks and stabilize.
- "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_idmust 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
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.
Mid-market customer-support agent processing 80k conversations / mo
Tiered memory retrofit (12 months)
| 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 |
- Trace sampling + analysis
- Token-spend decomposition
- Quality eval on memory turns
- Priority fix roadmap
- Redis working memory
- Langfuse sessions wiring
- Mem0 or Zep over Qdrant
- Runbook + training
- 90-day guarantee
- 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.