Building Agents with Long-Term Memory
Agent Memory Architecture â Production Flow
What is Agent Memory?
Agent memory systems enable LLMs to maintain context across interactions, learn from past experiences, and build persistent knowledge. Without memory, each conversation starts from scratch, losing all previous context and learned information.
Memory types mirror human cognition:
- Short-term (working memory): Current conversation context and recent messages
- Long-term memory: Persistent facts, preferences, and experiences stored in vector databases
- Summary memory: Periodic conversation summaries for compression
- Episodic memory: Specific past interactions and outcomes
The key challenge is balancing memory retention with context window limits. Effective memory systems use retrieval-augmented approaches: store everything in vector databases, then retrieve only the most relevant memories for each query.
Why This Matters
Without memory, an agent is like a goldfish â it forgets everything between interactions. Memory enables agents to:
- Remember user preferences and past decisions
- Build on previous conversations
- Accumulate knowledge over time
- Provide personalized experiences
Real-world analogy: Think of agent memory as a human's brain. Working memory is what you're thinking about right now. Long-term memory is everything you've ever learned. Summary memory is like taking notes to remember key points from a long meeting.
Memory Types Comparison
| Memory Type | Persistence | Retrieval | Use Case | Token Cost |
|---|---|---|---|---|
| Working | Current session | Direct access | Conversation context | High |
| Summary | Current session | Direct access | Compressed history | Medium |
| Long-term | Permanent | Semantic search | User preferences, facts | Low (retrieved) |
| Episodic | Permanent | Time-based | Past interactions | Low (retrieved) |
Project Overview
We will build an agent with three memory layers:
- Working Memory: Sliding window of recent messages
- Long-Term Memory: ChromaDB vector store of all past interactions
- Summary Memory: Periodic conversation summaries for compression
Expected outcome: An agent that remembers user preferences, past conversations, and accumulated knowledge across sessions.
Difficulty: Advanced (requires understanding of vector databases, embeddings, and memory management strategies)
Tools & Setup
| Tool | Version | Purpose |
|---|---|---|
| Python | 3.11+ | Core language |
| ChromaDB | 0.4+ | Vector memory store |
| OpenAI | 1.0+ | Embeddings + LLM |
| tiktoken | 0.5+ | Token counting |
| pydantic | 2.0+ | Data models |
Step 1: Environment Setup
python -m venv venv
source venv/bin/activate
pip install chromadb openai tiktoken pydantic
export OPENAI_API_KEY="sk-your-key"
Step 2: Working Memory
# memory/working_memory.py
from __future__ import annotations
from dataclasses import dataclass
import tiktoken
import time
import logging
logger = logging.getLogger(__name__)
@dataclass
class Message:
"""A single message in working memory."""
role: str
content: str
timestamp: float
tokens: int = 0
class WorkingMemory:
"""
Sliding window of recent messages.
Maintains context within token limits by evicting
oldest messages when capacity is exceeded.
Args:
max_tokens: Maximum tokens to retain
max_messages: Maximum number of messages
"""
def __init__(self, max_tokens: int = 4000, max_messages: int = 20):
self.max_tokens = max_tokens
self.max_messages = max_messages
self.messages: list[Message] = []
self.enc = tiktoken.get_encoding("cl100k_base")
def add(self, role: str, content: str) -> None:
"""Add a message to working memory."""
tokens = len(self.enc.encode(content))
msg = Message(
role=role,
content=content,
timestamp=time.time(),
tokens=tokens,
)
self.messages.append(msg)
self._trim()
logger.debug(f"Added message ({tokens} tokens), total: {self.get_total_tokens()}")
def get_context(self) -> list[dict[str, str]]:
"""Get messages in LLM format."""
return [{"role": m.role, "content": m.content} for m in self.messages]
def get_total_tokens(self) -> int:
"""Get total tokens in working memory."""
return sum(m.tokens for m in self.messages)
def clear(self) -> None:
"""Clear all messages from working memory."""
self.messages.clear()
logger.debug("Working memory cleared")
def _trim(self) -> None:
"""Remove oldest messages to stay within limits."""
while (
len(self.messages) > self.max_messages
or self.get_total_tokens() > self.max_tokens
):
if self.messages:
removed = self.messages.pop(0)
logger.debug(f"Evicted message: {removed.content[:50]}...")
else:
break
def get_recent(self, n: int = 5) -> list[dict[str, str]]:
"""Get the N most recent messages."""
return [
{"role": m.role, "content": m.content}
for m in self.messages[-n:]
]
Step 3: Long-Term Memory
# memory/long_term_memory.py
from __future__ import annotations
import chromadb
from openai import OpenAI
from typing import List, Dict, Optional
import time
import logging
logger = logging.getLogger(__name__)
class LongTermMemory:
"""
Persistent vector store for all past interactions.
Uses ChromaDB for semantic search over stored memories.
Supports different content types (interactions, facts, etc.)
"""
def __init__(self, collection_name: str = "agent_memory"):
self.client = chromadb.PersistentClient(path="./memory_db")
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"},
)
self.openai = OpenAI()
self._id_counter = 0
def _get_embedding(self, text: str) -> List[float]:
"""Generate embedding for text."""
response = self.openai.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return response.data[0].embedding
def store(
self,
content: str,
metadata: Optional[Dict] = None,
content_type: str = "interaction",
) -> str:
"""
Store a memory in long-term storage.
Args:
content: Text content to store
metadata: Additional metadata
content_type: Type of content (interaction, fact, summary)
Returns:
Memory ID
"""
self._id_counter += 1
memory_id = f"mem_{self._id_counter}_{int(time.time())}"
embedding = self._get_embedding(content)
meta = {
"content_type": content_type,
"timestamp": time.time(),
"content_length": len(content),
**(metadata or {}),
}
self.collection.add(
ids=[memory_id],
embeddings=[embedding],
documents=[content],
metadatas=[meta],
)
logger.debug(f"Stored memory: {memory_id}")
return memory_id
def retrieve(
self,
query: str,
n_results: int = 5,
content_type: Optional[str] = None,
) -> List[Dict]:
"""
Retrieve relevant memories using semantic search.
Args:
query: Search query
n_results: Number of results to return
content_type: Filter by content type
Returns:
List of retrieved memories
"""
query_embedding = self._get_embedding(query)
kwargs = {
"query_embeddings": [query_embedding],
"n_results": n_results,
}
if content_type:
kwargs["where"] = {"content_type": content_type}
results = self.collection.query(**kwargs)
memories = []
for i in range(len(results["documents"][0])):
memories.append({
"id": results["ids"][0][i],
"content": results["documents"][0][i],
"metadata": results["metadatas"][0][i],
"score": 1 - results["distances"][0][i],
})
return memories
def store_interaction(self, user_msg: str, agent_msg: str) -> None:
"""Store a user-agent interaction."""
combined = f"User: {user_msg}\nAgent: {agent_msg}"
self.store(
combined,
metadata={"user_message": user_msg[:200]},
content_type="interaction",
)
def store_fact(self, fact: str, source: str = "conversation") -> None:
"""Store a factual claim."""
self.store(fact, metadata={"source": source}, content_type="fact")
def search_facts(self, query: str, n: int = 5) -> List[str]:
"""Search for factual memories."""
results = self.retrieve(query, n_results=n, content_type="fact")
return [r["content"] for r in results]
Step 4: Summary Memory
# memory/summary_memory.py
from __future__ import annotations
from openai import OpenAI
from typing import List, Dict
import json
import logging
logger = logging.getLogger(__name__)
SUMMARY_PROMPT = """Summarize the following conversation, extracting:
1. Key topics discussed
2. User preferences mentioned
3. Important facts or decisions
4. Action items or follow-ups
Conversation:
{conversation}
Provide a concise summary (200-300 words):"""
FACT_EXTRACTION_PROMPT = """Extract all factual claims from this text.
Return as a JSON array of strings, each being a standalone fact.
Text: {text}
Facts (JSON array):"""
class SummaryMemory:
"""
Compressed conversation summaries.
Periodically summarizes working memory to preserve key information
while reducing token count.
"""
def __init__(self):
self.client = OpenAI()
self.summaries: List[str] = []
self.extracted_facts: List[str] = []
def summarize_conversation(self, messages: List[Dict]) -> str:
"""
Summarize a conversation.
Args:
messages: List of message dictionaries
Returns:
Summary text
"""
conversation = "\n".join(
f"{m['role']}: {m['content']}" for m in messages
)
try:
response = self.client.chat.completions.create(
model="gpt-4-turbo-preview",
messages=[
{"role": "system", "content": "You are a precise summarizer."},
{"role": "user", "content": SUMMARY_PROMPT.format(
conversation=conversation
)},
],
temperature=0.0,
max_tokens=500,
)
summary = response.choices[0].message.content
self.summaries.append(summary)
logger.info(f"Generated summary ({len(summary)} chars)")
return summary
except Exception as e:
logger.error(f"Summarization failed: {e}")
return ""
def extract_facts(self, text: str) -> List[str]:
"""
Extract factual claims from text.
Args:
text: Text to extract facts from
Returns:
List of facts
"""
try:
response = self.client.chat.completions.create(
model="gpt-4-turbo-preview",
messages=[
{"role": "system", "content": "Extract facts as JSON array."},
{"role": "user", "content": FACT_EXTRACTION_PROMPT.format(
text=text
)},
],
temperature=0.0,
max_tokens=1000,
)
facts = json.loads(response.choices[0].message.content)
self.extracted_facts.extend(facts)
logger.info(f"Extracted {len(facts)} facts")
return facts
except json.JSONDecodeError:
logger.warning("Failed to parse facts as JSON")
return []
except Exception as e:
logger.error(f"Fact extraction failed: {e}")
return []
def get_all_summaries(self) -> str:
"""Get all summaries joined."""
return "\n\n---\n\n".join(self.summaries)
Step 5: Memory Manager
# memory/memory_manager.py
from __future__ import annotations
from memory.working_memory import WorkingMemory
from memory.long_term_memory import LongTermMemory
from memory.summary_memory import SummaryMemory
from typing import List, Dict
import logging
logger = logging.getLogger(__name__)
class MemoryManager:
"""
Orchestrates all memory layers.
Builds context for LLM by combining:
- Working memory (recent messages)
- Summary memory (compressed history)
- Long-term memory (relevant past interactions)
"""
def __init__(
self,
working_token_limit: int = 4000,
summary_threshold: int = 20,
):
self.working = WorkingMemory(max_tokens=working_token_limit)
self.long_term = LongTermMemory()
self.summary = SummaryMemory()
self.summary_threshold = summary_threshold
self.message_count = 0
def add_user_message(self, content: str) -> None:
"""Add a user message and trigger consolidation if needed."""
self.working.add("user", content)
self.message_count += 1
if self.message_count >= self.summary_threshold:
self._consolidate()
def add_assistant_message(self, content: str) -> None:
"""Add an assistant message."""
self.working.add("assistant", content)
def get_context_for_llm(self) -> List[Dict]:
"""
Build complete context for LLM.
Combines summaries, retrieved memories, and working memory
into a single context list.
"""
context = []
# Add summaries if available
if self.summary.summaries:
summary_text = self.summary.get_all_summaries()
context.append({
"role": "system",
"content": f"Previous conversation summaries:\n{summary_text}",
})
# Add relevant memories
relevant_memories = self._retrieve_relevant()
if relevant_memories:
memory_text = "\n".join(
f"- {m['content'][:200]}" for m in relevant_memories
)
context.append({
"role": "system",
"content": f"Relevant memories:\n{memory_text}",
})
# Add working memory
context.extend(self.working.get_context())
return context
def search_memories(self, query: str, n: int = 5) -> List[Dict]:
"""Search long-term memories."""
return self.long_term.retrieve(query, n_results=n)
def store_fact(self, fact: str) -> None:
"""Store a fact in long-term memory."""
self.long_term.store_fact(fact)
def _retrieve_relevant(self) -> List[Dict]:
"""Retrieve memories relevant to current context."""
if not self.working.messages:
return []
last_msg = self.working.messages[-1].content
return self.long_term.retrieve(last_msg, n_results=3)
def _consolidate(self) -> None:
"""Consolidate working memory into summaries and long-term storage."""
messages = self.working.get_context()
if len(messages) >= 5:
logger.info("Consolidating memory...")
# Generate summary
self.summary.summarize_conversation(messages)
# Store interactions in long-term memory
for msg in messages:
if msg["role"] == "user":
self.long_term.store_interaction(msg["content"], "See summary")
# Extract and store facts
facts = self.summary.extract_facts(
" ".join(m["content"] for m in messages)
)
for fact in facts:
self.long_term.store_fact(fact)
# Clear working memory
self.working.clear()
self.message_count = 0
logger.info(f"Consolidation complete: {len(facts)} facts extracted")
Mathematical Foundation
Memory Relevance Scoring:
Where each parameter means:
- , , â weight coefficients (typically 0.6, 0.2, 0.2)
- â cosine similarity between memory and query embeddings
- â exponential decay based on memory age
- â estimated importance score
Intuition: Balances how relevant, recent, and important each memory is.
Memory Compression Ratio:
Intuition: Measures how much memory is compressed through summarization. Typical ratios of 5-10x are achievable while preserving key information.
Recency Decay:
Where:
- â decay rate (higher = faster decay)
- â time since memory was created
Intuition: Recent memories are more relevant than old ones, but important facts persist.
Performance Metrics
| Metric | Value | Notes |
|---|---|---|
| Retrieval Latency | 50-100ms | ChromaDB with HNSW |
| Embedding Speed | 1000 texts/min | text-embedding-3-small |
| Compression Ratio | 5-10x | Summary vs full conversation |
| Memory Precision@5 | 0.85+ | Relevant memory retrieval |
| Storage per 1K msgs | ~50MB | With embeddings |
Real-World Examples
Example 1: Personal Assistant
An agent that remembers user preferences:
memory = MemoryManager()
# User mentions preferences
memory.add_user_message("I prefer Python over JavaScript")
memory.add_assistant_message("Got it, I'll focus on Python examples")
# Later, agent retrieves this memory
context = memory.get_context_for_llm()
# Includes: "User prefers Python over JavaScript"
Example 2: Customer Support
An agent that remembers past issues:
# Store customer interactions
memory.store_fact("Customer had billing issue on 2024-01-15")
memory.store_fact("Customer prefers email communication")
# Retrieve relevant context
memories = memory.search_memories("customer billing")
# Returns relevant past interactions
Common Pitfalls & Solutions
| Pitfall | Solution |
|---|---|
| Context overflow | Use sliding window + summarization |
| Stale memories | Implement memory decay and refresh cycles |
| Retrieval noise | Use hybrid search + relevance thresholds |
| Privacy concerns | Implement memory deletion and consent |
| Storage bloat | Regular cleanup of low-value memories |
| Slow retrieval | Use HNSW index + caching |
| Fact inconsistency | Deduplicate facts before storage |
| Token waste | Limit retrieved memories by token budget |
Security Considerations
Critical security measures for memory systems:
- User Consent: Inform users about memory storage and retrieval
- Memory Deletion: Provide API to delete user memories (GDPR compliance)
- Data Retention Limits: Auto-expire memories after configurable period
- PII Detection: Filter personally identifiable information before storage
- Encryption: Encrypt memories at rest
- Access Controls: Restrict memory access by user ID
# Example: Memory with privacy controls
class PrivateMemoryManager(MemoryManager):
def store_interaction(self, user_id: str, user_msg: str, agent_msg: str):
# Filter PII before storage
filtered_msg = self._filter_pii(user_msg)
super().store_interaction(filtered_msg, agent_msg)
def delete_user_memories(self, user_id: str):
# Delete all memories for a user
self.long_term.delete_by_user(user_id)
Summary with Key Takeaways
- Three-layer memory (working, long-term, summary) provides comprehensive context
- Vector-based retrieval enables relevant memory access without context overflow
- Periodic summarization compresses conversations while preserving key information
- Fact extraction enables structured knowledge accumulation
- Memory consolidation should run periodically, not on every message
- Privacy controls are essential for user trust and compliance
- Token budget management prevents context window overflow
Interview Questions
1. What is the difference between working memory and long-term memory?
Answer: Working memory is a sliding window of recent messages (typically 4K-8K tokens) that provides immediate conversation context. It's fast to access but limited in size and cleared after summarization. Long-term memory is a persistent vector store (ChromaDB) that stores all past interactions and facts. It's searched semantically for relevant memories. Working memory handles current context; long-term memory handles historical knowledge. The Memory Manager orchestrates both layers.
2. How does memory consolidation work?
Answer: Memory consolidation periodically compresses working memory into summaries: 1) After N messages (e.g., 20), summarize the conversation, 2) Extract key facts from the conversation, 3) Store summaries and facts in long-term memory, 4) Clear working memory. This maintains context while preventing token overflow. The summarization preserves key information while reducing token count by 5-10x. Facts are stored separately for precise retrieval.
3. Why is semantic search important for memory retrieval?
Answer: Semantic search uses embeddings to find memories that are conceptually similar to the current query, not just keyword-matched. For example, if a user asks about "project deadlines", semantic search retrieves memories about "delivery dates", "timelines", and "due dates" even without exact keyword matches. This enables the agent to find relevant context across different phrasings and topics. Cosine similarity measures the angle between embedding vectors.
4. How do you handle memory privacy concerns?
Answer: Privacy strategies: 1) User consent â Explicitly inform users about memory storage, 2) Memory deletion â Provide API to delete user memories, 3) Data retention limits â Auto-expire memories after configurable period, 4) PII detection â Filter personally identifiable information before storage, 5) Encryption â Encrypt memories at rest, 6) Access controls â Restrict memory access by user ID. Implement a memory management API that allows users to view, export, and delete their memories.
5. What is the token budget problem in memory systems?
Answer: As conversations grow, the combined context (working memory + summaries + retrieved memories) approaches the model's context window limit. Solutions: 1) Prioritize working memory â Most recent messages are most relevant, 2) Limit retrieved memories â Top 3-5 most relevant, 3) Summarize aggressively â Compress old conversations, 4) Dynamic allocation â Adjust working/summary ratio based on query complexity, 5) Token counting â Track total tokens and warn when approaching limits. The MemoryManager should enforce token budgets across all layers.
6. How do you evaluate memory system quality?
Answer: Key metrics: 1) Retrieval precision â % of retrieved memories that are relevant, 2) Retrieval recall â % of relevant memories that are retrieved, 3) Fact accuracy â % of stored facts that are correct, 4) Compression quality â Information preserved after summarization, 5) Latency â Time to retrieve memories, 6) User satisfaction â Does the agent remember important information? Use test conversations with known facts and evaluate retrieval accuracy.
7. When should you trigger memory consolidation?
Answer: Consolidation triggers: 1) Message count â After N messages (e.g., 20), 2) Token threshold â When working memory exceeds 80% capacity, 3) Time-based â After 30 minutes of conversation, 4) Topic change â When conversation shifts to new topic, 5) User request â Explicit "save this" command. Avoid consolidating too frequently (wastes computation) or too infrequently (causes context overflow). A hybrid approach combining message count and token threshold works best.
8. How would you implement cross-session memory?
Answer: Cross-session memory requires: 1) User identification â Associate memories with user IDs, 2) Session persistence â Store session state in database, 3) Memory indexing â Organize memories by user, topic, and time, 4) Context loading â Retrieve relevant memories at session start, 5) Memory decay â Reduce relevance of old memories over time. Use ChromaDB with user ID metadata filtering. At session start, retrieve top-K memories for the user. Store new interactions with user ID tags for future retrieval.
KnowledgeCheck
-
What is the primary purpose of working memory?
- a) Store all past conversations
- b) Maintain current conversation context within token limits
- c) Generate embeddings
- d) Summarize old messages
-
What is the typical compression ratio for summary memory?
- a) 1-2x
- b) 5-10x
- c) 50-100x
- d) 1000x
-
Why is semantic search important for memory retrieval?
- a) It's faster than keyword search
- b) It finds conceptually similar memories
- c) It uses less storage
- d) It doesn't need embeddings
-
When should memory consolidation be triggered?
- a) After every message
- b) When message count or token threshold is reached
- c) Only at session end
- d) Never
-
What is the Memory Manager's role?
- a) Generate embeddings
- b) Orchestrate all memory layers and build context
- c) Store facts
- d) Delete old memories
-
How should user privacy be handled in memory systems?
- a) Store all data without restrictions
- b) Implement deletion, encryption, and consent controls
- c) Only use working memory
- d) Disable memory features
Answers: 1-b, 2-b, 3-b, 4-b, 5-b, 6-b