ai_ml / context engineering / 02_memory_strategies_deep.md

Memory Strategies for LLM Conversations and Agents

6 interview angles 7 min read source

Memory Strategies for LLM Conversations and Agents

How to give an LLM “memory” across turns and sessions. The naive answer is “send the whole history” — works until you hit the context window. Real systems use a layered approach.

The context-window problem

Modern LLMs have 8k–1M-token context windows. “Just include everything” works for short chats; falls apart for:

  • Long conversations (1000+ turns).
  • Multi-session memory (user comes back tomorrow).
  • Agents accumulating tool results.
  • Multi-user / multi-tenant context per request.

Cost also matters: a 100k-token prompt to GPT-4 is ~$1 per call. At scale, context bloat is real money.

The memory taxonomy

Type Lifetime Mechanism
Working memory within one conversation conversation history in the prompt
Episodic memory across sessions, per user vector store keyed by user_id
Semantic memory across all sessions, all users knowledge base / RAG corpus
Procedural memory encoded behavior the model’s training + system prompts

You’ll mix several in any non-trivial agent.

Strategy 1: Sliding window

Keep the last N turns in the prompt; drop older ones.

def trim_history(messages, max_messages=10):
    return messages[-max_messages:]

Pros: dirt simple. Cons: anything earlier is gone forever. Bad UX when the user references “what we talked about earlier.”

Strategy 2: Token budget

Trim history to fit within a token budget; preserve recency.

import tiktoken
enc = tiktoken.encoding_for_model("gpt-4")

def trim_to_budget(messages, max_tokens=4000):
    out = []
    used = 0
    for msg in reversed(messages):
        cost = len(enc.encode(msg["content"])) + 4
        if used + cost > max_tokens:
            break
        out.append(msg)
        used += cost
    return list(reversed(out))

Production-grade variant: weight system messages as high-priority; trim earlier user/assistant pairs first.

Strategy 3: Summarization

When history exceeds threshold, summarize older turns into a single message. Newer turns stay verbatim.

async def maybe_summarize(messages):
    if total_tokens(messages) < THRESHOLD:
        return messages

    keep = messages[-6:]                  # last 6 turns verbatim
    older = messages[:-6]

    summary = await llm.summarize(
        "Summarize this conversation, preserving names, decisions, "
        "and any open questions:\n\n" + format(older)
    )

    return [
        {"role": "system", "content": f"Previous conversation summary: {summary}"},
        *keep,
    ]

LangChain’s ConversationSummaryBufferMemory does this. Trade-off: summary fidelity. Summaries lose detail; multiple rounds of summarizing compound the loss.

Strategy 4: Vector store retrieval

Store each turn (or chunk of turns) as embeddings. At each new turn, retrieve the K most relevant past turns to include.

async def get_relevant_history(query, user_id, k=5):
    query_embedding = await embed(query)
    results = await vector_store.search(
        query_embedding,
        filter={"user_id": user_id},
        top_k=k,
    )
    return [r.metadata["message"] for r in results]

Pros: scales to unbounded history; recall is by relevance, not recency. Cons: misses cross-references the query doesn’t surface (“what was the third item I mentioned?”). Expensive (embedding + vector search per turn).

ConversationVectorStoreMemory in LangChain.

Strategy 5: Hybrid recency + relevance

Combine: keep the last N turns verbatim (recency) + retrieve K relevant older turns (semantic).

async def build_context(query, user_id, history):
    recent = history[-6:]
    relevant_older = await retrieve_relevant_older(query, user_id, history[:-6], k=5)

    return recent + relevant_older

Most production agent memories end up here. Recency for “what just happened”; semantic for “things I should remember.”

Strategy 6: Entity / structured memory

Extract structured facts as the conversation progresses; store them separately.

{
  "user_id": 42,
  "name": "Alice",
  "preferences": {"language": "Python", "tone": "concise"},
  "open_topics": ["payment integration", "deployment"],
  "decisions": [{"topic": "DB", "value": "Postgres", "date": "2024-03-12"}]
}

Extract via LLM:

async def extract_facts(turn):
    prompt = f"Extract any user preferences, decisions, or important facts mentioned. Return JSON.\n\nTurn: {turn}"
    facts = await llm.parse(prompt, output_schema=FactExtraction)
    update_user_state(user_id, facts)

Include the structured state in every prompt:

system_msg = f"""You are an assistant. Here's what we know about this user:
{json.dumps(user_state)}

Conversation:
"""

LangChain’s ConversationEntityMemory operationalizes this. Production agents (Anthropic’s Computer Use, OpenAI’s memory feature) use variants of this pattern.

Strategy 7: Multi-tier memory

Different stores for different lifetimes:

Tier Store Use
Working in-process / Redis this conversation
Episodic (short) vector store last 30 days per user
Episodic (long) summarized + structured DB 30+ days, compressed
Semantic RAG corpus shared knowledge

A turn might pull from all four:

  • Working: last 6 messages.
  • Episodic (short): 3 relevant turns from the past week.
  • Episodic (long): structured facts about the user.
  • Semantic: 5 chunks from the knowledge base relevant to the question.

Assembled into one prompt within the context budget.

Memory eviction

Vector stores grow forever; need eviction.

Strategies:

  • TTL — drop messages older than N days.
  • LRU — drop messages with no recent retrieval.
  • Importance scoring — LLM-assigned importance per turn; drop low-importance.
  • Summarize-and-evict — compress old turns into summaries, evict the originals.

For per-user memory, total size is usually bounded (a user has finite interaction history); the bigger issue is unlimited free trial users accumulating storage.

Multi-user / multi-tenant

Vector store filter by user_id always:

results = await vector_store.search(
    query_embedding,
    filter={"user_id": current_user_id},
    top_k=k,
)

Or one collection per tenant — depending on the store. Don’t accidentally retrieve another user’s memory; that’s a privacy bug.

Memory and tool use

Agent tools (web search, function calls) generate context that should be remembered:

turn_history = [
  {"role": "user", "content": "Look up the current weather in Berlin"},
  {"role": "assistant", "tool_calls": [{"name": "weather", "args": {"city": "Berlin"}}]},
  {"role": "tool", "content": "{\"temp\": 15, \"conditions\": \"cloudy\"}"},
  {"role": "assistant", "content": "It's 15°C and cloudy in Berlin."}
]

You can summarize tool outputs aggressively — usually only the assistant’s interpretation matters, not the raw tool response. Reduces token cost.

LangGraph’s state pattern

LangGraph (agentic workflow framework) uses a typed state passed across nodes. Memory becomes part of the state, with reducer functions:

class State(TypedDict):
    messages: Annotated[list, add_messages]
    user_facts: dict
    decisions: list

# Each node may update state; reducers merge updates
def remember_decision(state):
    return {"decisions": [{"topic": "...", "value": "..."}]}

State persists across turns via checkpointer (Postgres, SQLite). Cleaner than hand-rolled “load memory, run, save memory” loops.

Cost-aware memory

Token cost matters. Strategies:

  • Cache the summary — only recompute when history grows; otherwise reuse.
  • Smaller model for summarization — use Haiku / GPT-4o-mini for summarizing; bigger model for actual responses.
  • Prompt caching — Anthropic + OpenAI support caching static prefixes; put the long system prompt + user facts at the start to benefit.
  • Aggressive truncation of tool outputs in history.

Common pitfalls

  • Sending the full history every turn. Works until you hit the token limit; then breaks suddenly.
  • Summarization losing critical details. The summary forgets “user mentioned they’re allergic to X” → harmful response 10 turns later.
  • Vector retrieval missing cross-references. “What was that thing we discussed?” doesn’t match well by embedding.
  • No eviction policy. Memory grows forever; cost grows linearly; performance drops.
  • Mixing memory across users. Privacy violation. Always filter by user_id.
  • Hot summary loop. Summarize on every turn = compounding lossy compression. Trigger only above threshold; cache summary.

Tools

Tool What
LangChain memory classes ConversationBufferMemory, ConversationSummaryMemory, ConversationEntityMemory, etc.
LangGraph state typed state with checkpointer
LlamaIndex memory BaseMemory, vector-store-backed
Letta (formerly MemGPT) OS-style virtual memory for LLMs — paging between context and external store
Zep hosted memory service for agents

Interview angle

  • “How do you handle conversation history that exceeds the LLM’s context window?” — multi-strategy: keep last N turns verbatim (recency); summarize older turns; retrieve semantically relevant past turns via vector store; structured-extract user facts to persist. Layered approach is standard.
  • “What’s ConversationSummaryBufferMemory?” — LangChain’s hybrid: keep recent turns verbatim; summarize older turns into a single message when the buffer overflows. Recency + compression.
  • “How do you store memory across sessions?” — vector store keyed by user_id (per-user retrieval) + structured DB for extracted facts (preferences, decisions, identities). Both persist across sessions; the vector store handles semantic recall; the structured DB ensures critical facts aren’t lost to summarization.
  • “What’s the privacy concern with shared vector stores?” — without proper user_id filtering, a user’s query could match another user’s stored turns; that’s a data leak. Always filter retrievals by tenant/user.
  • “How does memory work with tool-using agents?” — turns include tool calls + tool responses. Tool responses are often verbose; summarize aggressively (keep the assistant’s interpretation, drop the raw tool output) to manage context cost.
  • “Cost-aware strategies?” — prompt caching for static prefixes; smaller model for summarization; cache the running summary instead of recomputing; aggressively trim tool outputs. Token budget is real money at scale.