Skip to content

Memory & State Management ​

Enabling persistent, contextual, and adaptive agent behavior


Learning Objectives ​

By the end of this module, you will be able to:

  • Implement short-term and long-term memory systems for agents
  • Design episodic memory for experience-based learning
  • Build state management systems for complex multi-turn interactions
  • Use vector databases for semantic memory retrieval
  • Evaluate trade-offs between different memory architectures

Why Memory Matters for Agents ​

Core Insight

Memory transforms stateless LLMs into contextual, learning agents. Without memory, every interaction starts from zero. With memory, agents accumulate knowledge, learn from experience, and maintain coherent long-term behavior.

Memory's Role in Agent Behavior ​


Memory Types ​

Memory Type Comparison ​

TypeContentDurationAccess PatternStorage
Short-termCurrent conversationSessionSequentialContext window
EpisodicPast experiencesPersistentSimilarity searchVector DB
SemanticFacts, knowledgePersistentKey-value lookupDatabase
ProceduralSkills, workflowsPersistentPattern matchCode/Prompts

Short-Term / Working Memory ​

Conversation Buffer Implementation ​

python
from collections import deque
from dataclasses import dataclass
from typing import Optional
import tiktoken

@dataclass
class Message:
    role: str
    content: str
    timestamp: float
    metadata: dict = None

class ConversationMemory:
    """
    Short-term memory using a sliding window approach.
    Manages conversation history within token limits.
    """

    def __init__(
        self,
        max_tokens: int = 4000,
        max_messages: int = 50,
        model: str = "gpt-4"
    ):
        self.max_tokens = max_tokens
        self.max_messages = max_messages
        self.messages: deque[Message] = deque(maxlen=max_messages)
        self.encoder = tiktoken.encoding_for_model(model)

    def add_message(self, role: str, content: str, metadata: dict = None):
        """Add a message to memory."""
        import time
        message = Message(
            role=role,
            content=content,
            timestamp=time.time(),
            metadata=metadata or {}
        )
        self.messages.append(message)
        self._enforce_token_limit()

    def _count_tokens(self) -> int:
        """Count total tokens in memory."""
        total = 0
        for msg in self.messages:
            total += len(self.encoder.encode(msg.content))
            total += 4  # Role tokens overhead
        return total

    def _enforce_token_limit(self):
        """Remove oldest messages to stay within token limit."""
        while self._count_tokens() > self.max_tokens and len(self.messages) > 1:
            self.messages.popleft()

    def get_messages(self) -> list[dict]:
        """Get messages in API format."""
        return [
            {"role": msg.role, "content": msg.content}
            for msg in self.messages
        ]

    def get_summary(self, client) -> str:
        """Generate a summary of the conversation so far."""
        if len(self.messages) < 5:
            return ""

        conversation = "\n".join([
            f"{msg.role}: {msg.content}"
            for msg in self.messages
        ])

        response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=[{
                "role": "user",
                "content": f"Summarize this conversation in 2-3 sentences:\n\n{conversation}"
            }],
            temperature=0.3
        )

        return response.choices[0].message.content

    def clear(self):
        """Clear all messages."""
        self.messages.clear()


# Usage
memory = ConversationMemory(max_tokens=4000)
memory.add_message("user", "My name is Alex and I work at TechCorp")
memory.add_message("assistant", "Nice to meet you, Alex! What do you do at TechCorp?")
memory.add_message("user", "I'm a software engineer focusing on ML systems")

Windowed Memory with Summarization ​

python
class SummarizingMemory:
    """
    Memory that automatically summarizes old conversations
    to maintain context while staying within limits.
    """

    def __init__(self, model: str = "gpt-4"):
        self.client = OpenAI()
        self.model = model
        self.recent_messages: list[Message] = []
        self.summary: str = ""
        self.summary_threshold = 10  # Messages before summarizing

    def add_message(self, role: str, content: str):
        """Add message, potentially triggering summarization."""
        self.recent_messages.append(Message(
            role=role,
            content=content,
            timestamp=time.time()
        ))

        if len(self.recent_messages) >= self.summary_threshold:
            self._summarize_and_trim()

    def _summarize_and_trim(self):
        """Summarize older messages and keep only recent ones."""
        # Keep last 3 messages
        to_summarize = self.recent_messages[:-3]
        self.recent_messages = self.recent_messages[-3:]

        # Generate summary
        conversation = "\n".join([
            f"{msg.role}: {msg.content}"
            for msg in to_summarize
        ])

        prompt = f"""Previous summary: {self.summary if self.summary else 'None'}

New conversation to incorporate:
{conversation}

Create an updated summary that captures key information, user preferences, and important context. Be concise but comprehensive."""

        response = self.client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3
        )

        self.summary = response.choices[0].message.content

    def get_context(self) -> list[dict]:
        """Get full context for API call."""
        messages = []

        if self.summary:
            messages.append({
                "role": "system",
                "content": f"Conversation summary: {self.summary}"
            })

        messages.extend([
            {"role": msg.role, "content": msg.content}
            for msg in self.recent_messages
        ])

        return messages

Long-Term Memory with Vector Stores ​

Implementation Note

Vector stores enable semantic search over large memory collections, finding relevant past experiences and knowledge based on meaning rather than keywords.

Vector Memory Architecture ​

Vector Memory Implementation ​

python
from openai import OpenAI
import numpy as np
from dataclasses import dataclass, field
from typing import Optional
import uuid
import json

@dataclass
class MemoryEntry:
    id: str
    content: str
    embedding: list[float]
    timestamp: float
    memory_type: str  # "episodic", "semantic", "user_fact"
    metadata: dict = field(default_factory=dict)
    importance: float = 1.0

class VectorMemory:
    """
    Long-term memory using vector embeddings for semantic retrieval.
    """

    def __init__(self, model: str = "text-embedding-3-small"):
        self.client = OpenAI()
        self.embedding_model = model
        self.memories: dict[str, MemoryEntry] = {}
        self.embedding_dim = 1536

    def _get_embedding(self, text: str) -> list[float]:
        """Generate embedding for text."""
        response = self.client.embeddings.create(
            model=self.embedding_model,
            input=text
        )
        return response.data[0].embedding

    def add_memory(
        self,
        content: str,
        memory_type: str = "episodic",
        metadata: dict = None,
        importance: float = 1.0
    ) -> str:
        """Add a new memory."""
        import time

        memory_id = str(uuid.uuid4())
        embedding = self._get_embedding(content)

        entry = MemoryEntry(
            id=memory_id,
            content=content,
            embedding=embedding,
            timestamp=time.time(),
            memory_type=memory_type,
            metadata=metadata or {},
            importance=importance
        )

        self.memories[memory_id] = entry
        return memory_id

    def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
        """Compute cosine similarity between two vectors."""
        a_np = np.array(a)
        b_np = np.array(b)
        return np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np))

    def search(
        self,
        query: str,
        top_k: int = 5,
        memory_type: Optional[str] = None,
        min_similarity: float = 0.5
    ) -> list[tuple[MemoryEntry, float]]:
        """Search for relevant memories."""
        query_embedding = self._get_embedding(query)

        results = []
        for memory in self.memories.values():
            # Filter by type if specified
            if memory_type and memory.memory_type != memory_type:
                continue

            similarity = self._cosine_similarity(query_embedding, memory.embedding)

            # Apply importance weighting
            weighted_score = similarity * memory.importance

            if weighted_score >= min_similarity:
                results.append((memory, weighted_score))

        # Sort by score and return top-k
        results.sort(key=lambda x: x[1], reverse=True)
        return results[:top_k]

    def forget(self, memory_id: str):
        """Remove a memory."""
        if memory_id in self.memories:
            del self.memories[memory_id]

    def decay_memories(self, decay_rate: float = 0.99):
        """Apply time-based decay to memory importance."""
        import time
        current_time = time.time()

        for memory in self.memories.values():
            age_hours = (current_time - memory.timestamp) / 3600
            decay_factor = decay_rate ** age_hours
            memory.importance *= decay_factor


# Integration with agent
class MemoryAugmentedAgent:
    """Agent that uses long-term memory for context."""

    def __init__(self):
        self.client = OpenAI()
        self.memory = VectorMemory()
        self.conversation = ConversationMemory()

    def _build_context(self, user_message: str) -> str:
        """Build context from relevant memories."""
        relevant_memories = self.memory.search(user_message, top_k=3)

        if not relevant_memories:
            return ""

        context_parts = []
        for mem, score in relevant_memories:
            context_parts.append(f"[Relevance: {score:.2f}] {mem.content}")

        return "Relevant context from memory:\n" + "\n".join(context_parts)

    def chat(self, user_message: str) -> str:
        """Process user message with memory augmentation."""
        # Add to conversation memory
        self.conversation.add_message("user", user_message)

        # Get relevant long-term memories
        memory_context = self._build_context(user_message)

        # Build messages
        messages = [
            {
                "role": "system",
                "content": f"""You are a helpful assistant with access to conversation history and long-term memory.

{memory_context if memory_context else 'No relevant memories found.'}

Use this context to provide personalized, informed responses."""
            }
        ]
        messages.extend(self.conversation.get_messages())

        # Generate response
        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            temperature=0.7
        )

        assistant_message = response.choices[0].message.content

        # Store interaction in long-term memory if significant
        self._maybe_store_memory(user_message, assistant_message)

        # Add to conversation memory
        self.conversation.add_message("assistant", assistant_message)

        return assistant_message

    def _maybe_store_memory(self, user_message: str, assistant_message: str):
        """Decide whether to store this interaction in long-term memory."""
        # Heuristics for what to remember
        importance_keywords = [
            "remember", "my name", "i work", "i live", "i prefer",
            "important", "always", "never", "deadline"
        ]

        combined = (user_message + assistant_message).lower()
        if any(kw in combined for kw in importance_keywords):
            self.memory.add_memory(
                content=f"User said: {user_message}\nAssistant responded: {assistant_message}",
                memory_type="episodic",
                importance=1.5
            )

Episodic Memory ​

Definition

Episodic memory stores specific experiences and events, enabling agents to learn from past interactions and apply lessons to similar situations.

Episodic Memory System ​

python
from dataclasses import dataclass
from typing import Optional
import json

@dataclass
class Episode:
    id: str
    situation: str  # What was the context
    action: str     # What action was taken
    outcome: str    # What was the result
    lesson: str     # What was learned
    success: bool   # Was it successful
    timestamp: float
    embedding: list[float]

class EpisodicMemory:
    """
    Memory system for storing and retrieving past experiences.
    Enables learning from success and failure.
    """

    def __init__(self):
        self.client = OpenAI()
        self.episodes: list[Episode] = []

    def _extract_lesson(self, situation: str, action: str, outcome: str, success: bool) -> str:
        """Extract a generalizable lesson from an episode."""
        prompt = f"""Analyze this experience and extract a brief, generalizable lesson.

Situation: {situation}
Action taken: {action}
Outcome: {outcome}
Success: {success}

Provide a one-sentence lesson that could apply to similar future situations."""

        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3
        )

        return response.choices[0].message.content

    def store_episode(
        self,
        situation: str,
        action: str,
        outcome: str,
        success: bool
    ) -> str:
        """Store a new episode with extracted lesson."""
        import time
        import uuid

        lesson = self._extract_lesson(situation, action, outcome, success)

        # Create embedding from situation for similarity search
        embedding_response = self.client.embeddings.create(
            model="text-embedding-3-small",
            input=situation
        )
        embedding = embedding_response.data[0].embedding

        episode = Episode(
            id=str(uuid.uuid4()),
            situation=situation,
            action=action,
            outcome=outcome,
            lesson=lesson,
            success=success,
            timestamp=time.time(),
            embedding=embedding
        )

        self.episodes.append(episode)
        return episode.id

    def retrieve_relevant_episodes(
        self,
        current_situation: str,
        top_k: int = 3,
        success_only: bool = False
    ) -> list[Episode]:
        """Find episodes similar to current situation."""
        # Get embedding for current situation
        embedding_response = self.client.embeddings.create(
            model="text-embedding-3-small",
            input=current_situation
        )
        query_embedding = embedding_response.data[0].embedding

        # Calculate similarities
        results = []
        for episode in self.episodes:
            if success_only and not episode.success:
                continue

            similarity = self._cosine_similarity(query_embedding, episode.embedding)
            results.append((episode, similarity))

        results.sort(key=lambda x: x[1], reverse=True)
        return [ep for ep, _ in results[:top_k]]

    def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
        a_np = np.array(a)
        b_np = np.array(b)
        return np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np))

    def get_lessons_for_situation(self, situation: str) -> list[str]:
        """Get applicable lessons for a new situation."""
        relevant_episodes = self.retrieve_relevant_episodes(situation, top_k=5)

        lessons = []
        for ep in relevant_episodes:
            prefix = "[SUCCESS]" if ep.success else "[FAILURE]"
            lessons.append(f"{prefix} {ep.lesson}")

        return lessons


# Usage example
episodic = EpisodicMemory()

# Store a successful episode
episodic.store_episode(
    situation="User asked for help debugging a Python recursion error",
    action="First checked base case, found it was missing, added base case",
    outcome="Bug was fixed, user was satisfied",
    success=True
)

# Store a failed episode
episodic.store_episode(
    situation="User asked to optimize a slow database query",
    action="Added more indexes without analyzing query plan first",
    outcome="Query got slower due to index overhead on writes",
    success=False
)

# Later, retrieve lessons for similar situation
lessons = episodic.get_lessons_for_situation(
    "User needs help with a function that's causing stack overflow"
)

State Management for Complex Tasks ​

Task State Machine ​

python
from enum import Enum
from dataclasses import dataclass, field
from typing import Any, Optional
import json

class TaskState(Enum):
    INITIALIZED = "initialized"
    PLANNING = "planning"
    EXECUTING = "executing"
    WAITING_USER = "waiting_user"
    COMPLETED = "completed"
    FAILED = "failed"

@dataclass
class TaskStep:
    id: str
    description: str
    status: str = "pending"
    result: Any = None
    error: Optional[str] = None

@dataclass
class TaskContext:
    task_id: str
    description: str
    state: TaskState = TaskState.INITIALIZED
    steps: list[TaskStep] = field(default_factory=list)
    current_step_index: int = 0
    variables: dict = field(default_factory=dict)
    history: list[dict] = field(default_factory=list)
    created_at: float = 0
    updated_at: float = 0

class StatefulAgent:
    """
    Agent with explicit state management for complex multi-step tasks.
    Supports persistence, resumption, and rollback.
    """

    def __init__(self, model: str = "gpt-4"):
        self.client = OpenAI()
        self.model = model
        self.tasks: dict[str, TaskContext] = {}

    def create_task(self, description: str) -> str:
        """Initialize a new task."""
        import time
        import uuid

        task_id = str(uuid.uuid4())
        task = TaskContext(
            task_id=task_id,
            description=description,
            created_at=time.time(),
            updated_at=time.time()
        )
        self.tasks[task_id] = task

        self._log_event(task, "task_created", {"description": description})
        return task_id

    def plan_task(self, task_id: str) -> list[TaskStep]:
        """Generate execution plan for task."""
        task = self.tasks[task_id]
        task.state = TaskState.PLANNING

        prompt = f"""Create a step-by-step plan for this task:
{task.description}

Output a JSON array of steps, each with:
- id: step identifier (step_1, step_2, etc.)
- description: what this step accomplishes

Keep steps atomic and actionable."""

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3
        )

        steps_data = json.loads(response.choices[0].message.content)
        task.steps = [
            TaskStep(id=s["id"], description=s["description"])
            for s in steps_data
        ]

        self._log_event(task, "plan_created", {"step_count": len(task.steps)})
        task.state = TaskState.EXECUTING
        return task.steps

    def execute_step(self, task_id: str) -> dict:
        """Execute the current step."""
        task = self.tasks[task_id]

        if task.current_step_index >= len(task.steps):
            task.state = TaskState.COMPLETED
            return {"status": "completed", "message": "All steps completed"}

        step = task.steps[task.current_step_index]

        # Build context from previous steps
        context = {
            "task": task.description,
            "current_step": step.description,
            "previous_results": {
                s.id: s.result
                for s in task.steps[:task.current_step_index]
                if s.result
            },
            "variables": task.variables
        }

        prompt = f"""Execute this step and provide the result.

Context:
{json.dumps(context, indent=2)}

If you need user input, respond with:
{{"needs_input": true, "question": "your question"}}

Otherwise respond with:
{{"result": "step result", "variables_to_store": {{"key": "value"}}}}"""

        try:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[{"role": "user", "content": prompt}],
                temperature=0.5
            )

            result = json.loads(response.choices[0].message.content)

            if result.get("needs_input"):
                task.state = TaskState.WAITING_USER
                return {"status": "waiting", "question": result["question"]}

            # Store result
            step.result = result.get("result")
            step.status = "completed"

            # Update variables
            if "variables_to_store" in result:
                task.variables.update(result["variables_to_store"])

            # Move to next step
            task.current_step_index += 1
            self._log_event(task, "step_completed", {"step_id": step.id})

            return {"status": "success", "result": step.result}

        except Exception as e:
            step.status = "failed"
            step.error = str(e)
            task.state = TaskState.FAILED
            return {"status": "failed", "error": str(e)}

    def provide_input(self, task_id: str, user_input: str):
        """Provide user input for a waiting task."""
        task = self.tasks[task_id]

        if task.state != TaskState.WAITING_USER:
            raise ValueError("Task is not waiting for input")

        step = task.steps[task.current_step_index]
        step.result = f"User provided: {user_input}"
        step.status = "completed"

        task.current_step_index += 1
        task.state = TaskState.EXECUTING

        self._log_event(task, "input_received", {"input": user_input})

    def _log_event(self, task: TaskContext, event_type: str, data: dict):
        """Log a task event."""
        import time
        task.history.append({
            "timestamp": time.time(),
            "event": event_type,
            "data": data
        })
        task.updated_at = time.time()

    def get_task_status(self, task_id: str) -> dict:
        """Get current task status."""
        task = self.tasks[task_id]
        return {
            "task_id": task.task_id,
            "state": task.state.value,
            "progress": f"{task.current_step_index}/{len(task.steps)}",
            "current_step": task.steps[task.current_step_index].description
                if task.current_step_index < len(task.steps) else None,
            "variables": task.variables
        }

    def serialize_task(self, task_id: str) -> str:
        """Serialize task for persistence."""
        task = self.tasks[task_id]
        return json.dumps({
            "task_id": task.task_id,
            "description": task.description,
            "state": task.state.value,
            "steps": [
                {"id": s.id, "description": s.description, "status": s.status, "result": s.result}
                for s in task.steps
            ],
            "current_step_index": task.current_step_index,
            "variables": task.variables,
            "history": task.history
        })

Interview Q&A ​

Q1: How do you handle the trade-off between memory capacity and retrieval accuracy? ​

Strong Answer:

"This is a fundamental tension in memory system design. Here's my approach:

1. Hierarchical Memory Structure

                    Hot (Fast, Limited)
                         ↑
Recent Context  ─────────┼───────────  High Accuracy
                         ↓
                    Cold (Slow, Large)
Long-term Store ─────────────────────  Lower Accuracy

2. Tiered Retrieval Strategy

python
def retrieve(query, context):
    # Tier 1: Check working memory (exact match)
    if match := working_memory.exact_match(query):
        return match

    # Tier 2: Recent context (high relevance)
    recent = recent_memory.search(query, threshold=0.8)
    if recent:
        return recent

    # Tier 3: Long-term with re-ranking
    candidates = vector_store.search(query, top_k=20)
    reranked = rerank_with_llm(query, candidates)
    return reranked[:5]

3. Adaptive Thresholds

  • Adjust similarity thresholds based on query confidence
  • Lower threshold for rare queries, higher for common ones

4. Memory Compression

  • Summarize old memories while preserving key facts
  • Use hierarchical summarization for very old content

5. Periodic Maintenance

  • Consolidate similar memories
  • Prune low-value entries
  • Update embeddings when model changes

The key insight is that perfect recall isn't always necessary - we optimize for the memories most likely to be useful."


Q2: How would you implement memory that persists across agent restarts? ​

Strong Answer:

"I'd implement a persistence layer with these components:

1. Serialization Format

python
class PersistentMemory:
    def serialize(self) -> dict:
        return {
            'version': '1.0',
            'timestamp': time.time(),
            'short_term': {
                'messages': [m.to_dict() for m in self.messages],
                'summary': self.summary
            },
            'long_term': {
                'entries': [e.to_dict() for e in self.entries],
                'index_config': self.index_config
            },
            'episodic': {
                'episodes': [ep.to_dict() for ep in self.episodes]
            }
        }

2. Storage Backend Options

BackendUse CaseProsCons
SQLiteSingle userSimple, portableLimited scale
PostgreSQLMulti-userACID, pgvectorSetup complexity
RedisSession stateFast, TTL supportVolatile
PineconeVector searchManaged, scalableCost

3. Checkpoint Strategy

  • Checkpoint after significant events (not every message)
  • Background async writes to avoid blocking
  • WAL for crash recovery

4. Versioning and Migration

python
def migrate_v1_to_v2(old_data):
    # Handle schema evolution
    ...

5. Warm-up on Restart

  • Load summary first for fast startup
  • Lazy-load detailed memories
  • Pre-warm frequently accessed memories"

Q3: How do you prevent memory systems from surfacing outdated or incorrect information? ​

Strong Answer:

"This is critical for production systems. My approach:

1. Temporal Metadata

python
@dataclass
class MemoryEntry:
    content: str
    created_at: datetime
    last_accessed: datetime
    source_reliability: float  # 0-1
    verification_status: str   # 'verified', 'unverified', 'deprecated'
    valid_until: Optional[datetime]  # For time-sensitive info

2. Retrieval-Time Filtering

python
def retrieve_with_freshness(query, max_age_days=30):
    candidates = vector_search(query)

    # Filter by freshness
    fresh = [c for c in candidates
             if (now - c.created_at).days <= max_age_days
             or c.verification_status == 'verified']

    # Add recency boost to ranking
    scored = [(c, score * recency_boost(c)) for c, score in fresh]
    return sorted(scored, key=lambda x: x[1], reverse=True)

3. Contradiction Detection

  • When storing new memories, check for contradictions
  • Flag and resolve or version the information

4. User Correction Handling

python
def handle_correction(old_fact, new_fact, source='user'):
    # Don't delete - mark as superseded
    old_memory = find_memory(old_fact)
    old_memory.verification_status = 'superseded'
    old_memory.superseded_by = store_memory(new_fact, source)

5. Periodic Validation

  • Background job to validate factual memories against current sources
  • Decay confidence over time for unverified facts

6. Source Attribution

  • Track where each memory came from
  • Higher weight for verified sources"

Summary ​

Memory TypeImplementationRetrievalBest For
Short-termSliding windowSequentialCurrent conversation
SummarizedLLM compressionPrepended contextLong conversations
VectorEmbeddings + similarityTop-k searchSemantic retrieval
EpisodicExperience tuplesSituation matchingLearning from past
StatefulState machineTask contextComplex workflows

Sources ​

  • Park et al., "Generative Agents: Interactive Simulacra of Human Behavior" (2023)
  • Lewis et al., "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks" (2020)
  • Zhang et al., "Memory and Knowledge Augmented Language Models" (2023)
  • LangChain Memory Documentation
  • Weaviate Vector Database Documentation