Long-term agent memory
Chat models are stateless — the window forgets. Long-term memory is a store you build around the model: extract facts from a conversation, write them down, and retrieve the relevant ones on the next turn. This lesson builds that loop from scratch, then shows what Mem0 and Letta/MemGPT add on top.
Learning objectives
- Explain why a context window is not memory (the model is stateless).
- Distinguish short-term vs long-term, and episodic vs semantic memory.
- Trace the memory loop: extract facts → write → retrieve → inject.
- Implement a tiny MemoryStore with write/retrieve, ranking, and forgetting.
- Describe what Mem0 and Letta/MemGPT add, and self-editing memory.
- Own the production concerns: privacy, staleness, and cost of stored memory.
1 · The window is not memory essential
A chat model is stateless between calls: it remembers nothing on its own. Everything it "knows" in a turn is whatever text you put in the context window right now — the same statelessness you first met in Ch 1 (setup). Chat apps fake memory by re-sending the whole transcript every turn. That works until the conversation outgrows the window, spans sessions, or you have thousands of users — then the transcript can't hold it all. Long-term memory is a separate store you write facts to and read facts from, so the agent can recall things from last week without re-sending last week.
2 · Short-term vs long-term memory essential
Short-term memory is the current context window: this turn's transcript, cheap to use, gone when the window fills or the session ends. Long-term memory is a durable external store (a file, a DB, a vector index) that survives across sessions and only loads what's relevant to this turn. The window is your desk; long-term memory is the filing cabinet you fetch the right folder from.
| Short-term (window) | Long-term (store) | |
|---|---|---|
| Lives in | the context window | external DB / index |
| Survives session? | no | yes |
| Size limit | the token budget | effectively unbounded |
| Access | already in-context | must retrieve + inject |
3 · Episodic vs semantic memory essential
Borrowing from cognitive science, split long-term memory two ways. Episodic memory is events: "on Tuesday the user asked to cancel order #42." Semantic memory is distilled facts: "the user prefers email over phone." Episodes are raw and timestamped; facts are stable and deduplicated. A good agent writes episodes as they happen and, over time, consolidates them into durable semantic facts.
| Kind | Stores | Example | Changes |
|---|---|---|---|
| Episodic | events, with time | "asked for a refund on 3 Sep" | append-only log |
| Semantic | distilled facts | "is a premium-tier customer" | updated / deduped |
4 · The memory loop intermediate
Every memory framework — Mem0, Letta, home-grown — is a loop. From each turn you extract what's worth keeping, write it to the long-term store; on the next turn you retrieve the memories relevant to the new message and inject them into the context. The model stays stateless; the loop supplies the state.
This is the whole lesson in one picture — the loop that gives a stateless model memory. Read it left to right, then notice it feeds back to the start on the next turn.
- Conversation — a single turn happens. The model itself will forget it the moment the call ends, so we act before that.
- Extract facts — we pull out only the durable, worth-keeping bits (a preference, a name), not the whole transcript. This is the write path's "what to remember" decision.
- Write to store — those facts go into a durable external store (a DB or index) that outlives the session.
- Retrieve relevant — on a later turn, we search that store for the memories that match the new message, by similarity — exactly retrieval from Ch 3, but over the user's own past.
- Inject into context — the retrieved memories are pasted into the prompt, so the still-stateless model "remembers." Then the cycle repeats for the next turn.
In short: the model never changes — memory is the loop of extract → write → retrieve → inject that you build around it.
Note the read path is exactly retrieval (Ch 3 · RAG) — but the corpus is the user's own past, not a doc library. Memory is RAG over yourself.
5 · A tiny MemoryStore (write + retrieve) intermediate
The read path scores each stored memory against the new message and returns the best matches. The simplest scorer is bag-of-words overlap: how many words do they share? (Real systems use embedding similarity — same idea, better matching; see Ch 3.) This runs offline with the standard library only.
memory_store.pyimport re
# stopwords carry no signal; drop them so overlap ranks on meaningful words
STOP = {"the", "a", "an", "i", "is", "of", "on", "to", "how", "should",
"this", "what", "over", "in", "user", "customer"}
def tokenize(text):
return {w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP}
class MemoryStore:
def __init__(self):
self.memories = [] # list of {"text":..., "toks":...}
def write(self, text):
self.memories.append({"text": text, "toks": tokenize(text)})
def retrieve(self, query, k=2):
q = tokenize(query)
scored = []
for m in self.memories:
overlap = len(q & m["toks"]) # shared meaningful words
if overlap:
scored.append((overlap, m["text"]))
scored.sort(key=lambda x: (-x[0], x[1])) # best first, stable
return [text for _, text in scored[:k]]
store = MemoryStore()
store.write("the user prefers email over phone")
store.write("the user is a premium-tier customer")
store.write("the user lives in Berlin")
print(store.retrieve("how should I contact the user by phone or email?"))
print(store.retrieve("what tier is this customer on, premium?"))
['the user prefers email over phone']
['the user is a premium-tier customer']
This is the read path in miniature: a store you can write() facts into and retrieve() the relevant ones from. The scoring is deliberately simple so you can see the idea before reaching for embeddings.
tokenize()turns text into a set of words and dropsSTOPwords like "the", "is", "user". Without that, every memory shares "the user" and everything looks equally relevant — the stopword filter is what makes ranking work.write()just stores the text plus its pre-computed word set, so retrieval is fast.retrieve()tokenizes the query, counts overlapping meaningful words (q & m["toks"]is set intersection) per memory, sorts best-first, and returns the topk.- Real systems swap word-overlap for embedding similarity (Ch 3) — same shape, better matching on meaning rather than exact words.
What the output means: Only the memory that actually shares content words comes back: the phone/email query returns the contact preference; the tier query returns the premium fact. The "Berlin" memory stays out of both — that selectivity is the point.
Try this: Add store.write("the user speaks German") and query "what language does the user speak?". Then add "language" to STOP and watch that match disappear — proof the stopword list controls what counts as signal.
Only relevant memories come back — the "Berlin" fact stays out of a contact-method query. That selectivity is the whole point: you inject a handful of relevant memories, not the entire history.
6 · The write path — what & when to remember advanced
You can't store every sentence — that's just the transcript again. The write path decides what is worth keeping (durable facts and preferences, not chit-chat) and when. Production systems ask an LLM to extract facts; here is a rule-based stub that captures the shape, runs offline, and shows the recall being injected into a prompt.
fact_extract.pyimport re
def extract_facts(utterance):
"""Rule-based stub: keep durable 'I ...' statements, drop chit-chat."""
u = utterance.strip()
facts = []
low = u.lower()
if low.startswith(("hi", "hello", "thanks", "thank you", "bye")):
return facts # chit-chat: remember nothing
m = re.match(r"i (?:am|'m) (?:a |an )?(.+)", low)
if m:
facts.append("user is " + m.group(1).rstrip("."))
m = re.search(r"i (?:prefer|like|want) (.+)", low)
if m:
facts.append("user prefers " + m.group(1).rstrip("."))
m = re.search(r"my name is (\w+)", low)
if m:
facts.append("user's name is " + m.group(1).capitalize())
return facts
turns = [
"Hi there!",
"My name is Dana",
"I am a data engineer",
"I prefer dark mode",
"Thanks!",
]
memory = []
for t in turns:
for f in extract_facts(t):
if f not in memory:
memory.append(f)
print("stored:", memory)
prompt = "System memory about the user:\n- " + "\n- ".join(memory)
print(prompt)
stored: ["user's name is Dana", 'user is data engineer', 'user prefers dark mode']
System memory about the user:
- user's name is Dana
- user is data engineer
- user prefers dark mode
This is the write path's other half: deciding what to keep. You can't store every sentence (that's just the transcript again), so extract_facts() keeps durable statements and throws away chit-chat.
- The first check returns an empty list for greetings and thanks — chit-chat is not remembered. Storing "Hi there!" forever would be noise.
- Each
re.match/re.searchlooks for a durable pattern — "I am …", "I prefer …", "my name is …" — and turns it into a normalized fact string. - The loop over
turnscollects facts, skipping duplicates (if f not in memory), building the long-term store one fact at a time. - Finally the facts are formatted into a
System memory about the user:block — this is the inject step: memories pasted into the prompt the model will see.
What the output means: "Hi there!" and "Thanks!" store nothing; the three real statements become three facts, then get rendered into the system-memory block that would prefix the next prompt.
Try this: Add a turn "I want dark roast coffee" and one more greeting. Only the preference should be stored. This rule-based stub is what an LLM does for real in production — same job, fuzzier matching.
7 · Forgetting & consolidation advanced
Memory that only grows becomes slow, expensive, and self-contradictory. Two maintenance jobs keep it healthy: consolidation (dedupe near-identical memories, and let newer facts supersede stale ones) and forgetting (drop memories that are old and rarely used). This is the store-side analogue of the context-budgeting you did earlier.
consolidate.pydef subject_of(m):
"""A memory's subject = who + which attribute (e.g. 'user/prefers', 'user/is')."""
for verb in (" prefers ", " is ", " likes "):
if verb in m:
who = m.split(verb)[0]
return who + "/" + verb.strip()
return m
def consolidate(memories):
"""Dedup: a later fact about the same subject supersedes an earlier one."""
by_subject = {}
for i, m in enumerate(memories):
by_subject[subject_of(m)] = (i, m) # last write wins
return [text for _, text in sorted(by_subject.values())]
def forget(memories_meta, now, max_age=30, min_uses=1):
"""Drop memories older than max_age days that were used fewer than min_uses times."""
kept = []
for m in memories_meta:
age = now - m["last_used_day"]
if age > max_age and m["uses"] < min_uses:
continue # forgotten
kept.append(m["text"])
return kept
mems = ["user prefers phone", "user is premium", "user prefers email"]
print("consolidated:", consolidate(mems))
meta = [
{"text": "likes jazz", "last_used_day": 2, "uses": 0}, # old + unused -> forget
{"text": "is premium", "last_used_day": 40, "uses": 5}, # old but used -> keep
]
print("kept:", forget(meta, now=45, max_age=30, min_uses=1))
consolidated: ['user is premium', 'user prefers email']
kept: ['is premium']
A store that only grows gets slow, costly, and self-contradictory. These two routines are the maintenance that keeps it healthy — the store-side version of budgeting the context window.
subject_of()gives each memory a subject key likeuser/prefersoruser/is, so two facts about the same attribute collide on purpose.consolidate()keeps the last write per subject — so a newer preference supersedes the older one instead of both lingering and contradicting each other.forget()drops memories that are both old (older thanmax_age) and rarely used (fewer thanmin_useshits). A stale-but-still-used fact survives.- Together these fight staleness (old facts win less) and cost (fewer stored memories = cheaper retrieval every turn).
What the output means: "user prefers phone" is superseded by the later "user prefers email" while "user is premium" (a different subject) is kept; the old, unused "likes jazz" is forgotten but the old-but-used "is premium" survives.
Try this: Bump the jazz memory's uses to 3 and re-run — it now survives despite its age, because it's clearly still relevant. That usage signal is how real systems avoid forgetting things people still rely on.
"user prefers phone" was superseded by the later "user prefers email"; the stale, unused "likes jazz" was forgotten while the frequently-used "is premium" survived. That is how a store stays small and current instead of accumulating contradictions.
8 · Mem0, Letta & MemGPT — self-editing memory professional
You don't have to build the loop from scratch. Mem0 is a memory layer: it runs the extract → embed → store → retrieve loop for you, with dedup and updates, behind a add()/search() API. MemGPT (the research paper) framed the LLM as an OS managing its own memory: a small in-context working set plus a larger external store, with the model deciding what to page in and out. Letta is the framework that grew from MemGPT — agents with self-editing memory: the model calls tools to write, update, and search its own memory rather than you doing it externally.
| Adds | Mem0 | Letta / MemGPT |
|---|---|---|
| Core idea | drop-in memory layer | LLM manages its own memory (OS metaphor) |
| Who edits memory | your app, via add/search | the model itself, via tools |
| You still own | what to store, privacy | the tool policy + guardrails |
Both are the same loop from §4 with production polish (embeddings, dedup, updates). "Self-editing" means the write path in §6 becomes a tool the model calls — powerful, but you must still bound what it may store and for how long.
mem0_sdk.py# needs the SDK: pip install mem0ai (network + API key; not runnable offline)
from mem0 import Memory
mem = Memory() # backed by a vector store under the hood
mem.add("I prefer window seats", user_id="dana") # extract + embed + store
hits = mem.search("seating preference?", user_id="dana")
# hits -> the relevant memories to inject into your prompt
# Same loop as memory_store.py, with real embeddings + dedup + updates.
9 · Tech-lead — a memory policy for the system tech-lead
A lead owns the whole memory contract, not just the code. Decide: what the system is allowed to remember (and explicitly what it must not — secrets, payment data); how long memories live and the forgetting policy; the retrieval budget (how many memories inject per turn — this is context engineering again); a privacy interface so users can view and delete their memories; and the cost (every stored memory is storage + an embedding + a retrieval on every turn). Write it down; a memory store is user data under management, not a cache you can forget about.
Exercise FA4.1 — Wire the loop end to end
Context: The payoff of the labs is one working loop: extract facts from turns, write them, and retrieve the relevant ones for a new message — the retrieval-augmented memory cycle.
Your task: Combine the labs: feed turns through fact_extract.py's extract_facts, write() the results into memory_store.py's MemoryStore, then retrieve() for a new message and print the injected prompt.
Requirements:
- Extract facts from a few turns
- Write the extracted facts into the store
- Retrieve for a new message
- Print the prompt with the injected memories
- Confirm only relevant memories come back
💡 Hint: The end-to-end test is that an unrelated new message pulls back nothing, while a related one surfaces the earlier fact.
Exercise FA4.2 — Keep the store healthy
Context: A healthy store needs pruning as well as writing. Consolidation and forgetting are what keep retrieval sharp as memories pile up and go stale.
Your task: Add several overlapping and stale memories, then run consolidate and forget from consolidate.py, showing a superseded preference and a forgotten unused fact, and write a two-line memory policy.
Requirements:
- Seed overlapping and stale memories
- Run consolidation to merge/supersede overlapping ones
- Run forgetting to evict an unused, stale fact
- Demonstrate a superseded preference and a forgotten fact
- Write a two-line policy: what you store and how long you keep it
💡 Hint: Consolidation collapses duplicates and updates superseded preferences; forgetting removes what hasn't earned its keep by recency or use.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The context window is not memory: it's finite, and once it overflows the oldest turns silently fall out. Seeing that failure is the motivation for a real memory store.
Your task: Model a fixed-size context window and show that an early fact is forgotten after enough turns push past its capacity.
Requirements:
- Represent the window as a bounded buffer of recent turns
- Append turns until the buffer overflows
- Demonstrate that an early fact is no longer present
- Make the window size a parameter
💡 Hint: A deque(maxlen=n) drops the oldest entry automatically — perfect for showing the early fact vanish.
Show solution
from collections import deque
class Window:
def __init__(self, size=3):
self.turns = deque(maxlen=size) # oldest turns evicted automatically
def add(self, turn): self.turns.append(turn)
def recall(self): return list(self.turns)
w = Window(size=3)
for t in ["my name is Ada", "the sky is blue", "2+2=4", "what is my name?"]:
w.add(t)
print(w.recall())
# ['the sky is blue', '2+2=4', 'what is my name?'] -- the name is GONE
The window is working attention, not storage. Anything the agent must remember beyond the window has to be written to an external memory.
Context: Agents store two kinds of memory: episodic (a specific timestamped event) and semantic (a durable fact). Routing each to the right store is the foundation of an agent-memory design.
Your task: Write a classifier that labels stored items as episodic (a specific, timestamped event) vs semantic (a durable fact) and routes each to the right store.
Requirements:
- Distinguish event-like items from durable facts
- Label each item episodic or semantic
- Route to the corresponding store
- Handle ambiguous items with a defined default
💡 Hint: Time-anchored, one-off statements are episodic; general, timeless statements ("the user prefers X") are semantic.
Show solution
def classify(item):
# semantic = stable preference/fact; episodic = a dated event
episodic_cues = ["yesterday", "today", "at ", "on monday", "just now"]
if any(c in item.lower() for c in episodic_cues):
return "episodic"
if item.lower().startswith(("i prefer", "my", "i am", "i like")):
return "semantic"
return "episodic"
for i in ["I prefer dark mode", "Booked a flight today", "My name is Ada"]:
print(f"{i!r} -> {classify(i)}")
# 'I prefer dark mode' -> semantic
# 'Booked a flight today' -> episodic
# 'My name is Ada' -> semantic
Semantic memory holds timeless facts you always want; episodic memory holds events you retrieve by recency/relevance. Storing them separately keeps retrieval clean.
Context: Every memory system needs a store you can write to and query. Building a tiny one with bag-of-words overlap keeps it offline and deterministic before you add embeddings.
Your task: Build a MemoryStore with write(text) and retrieve(query, k) that returns the k most relevant memories, using a bag-of-words overlap score so it runs offline.
Requirements:
writeappends a memoryretrievescores memories by word overlap with the query- Return the top-k most relevant, best first
- Runs offline — no embeddings or network
- Handle
klarger than the store
💡 Hint: Overlap can be as simple as the size of the shared word set between query and memory; sort by that score and slice the top k.
Show solution
class MemoryStore:
def __init__(self): self.mem = []
def write(self, text):
self.mem.append(text)
def _score(self, query, text):
q, t = set(query.lower().split()), set(text.lower().split())
return len(q & t) / (len(q) or 1) # word-overlap stand-in for cosine
def retrieve(self, query, k=2):
ranked = sorted(self.mem, key=lambda m: self._score(query, m),
reverse=True)
return [m for m in ranked if self._score(query, m) > 0][:k]
ms = MemoryStore()
for f in ["user prefers dark mode", "user lives in Berlin",
"user dislikes email notifications"]:
ms.write(f)
print(ms.retrieve("what mode does the user prefer?"))
# ['user prefers dark mode']
The real store swaps the overlap score for embedding cosine similarity, but the write/retrieve seam is identical — this is the offline version of RAG-over-memory.
Context: Not every turn deserves to be remembered, and duplicates bloat the store and degrade retrieval. A disciplined write path filters and merges instead of blindly appending.
Your task: Add a write path that skips low-value text and merges near-duplicates instead of appending them.
Requirements:
- Skip low-value/empty text before storing
- Detect near-duplicates against existing memories
- Merge a near-duplicate rather than adding a second copy
- Only genuinely new information grows the store
💡 Hint: Reuse the overlap score from the previous rung as a similarity check: above a threshold, merge; below it and non-trivial, store.
Show solution
class MemoryWriter:
def __init__(self): self.mem = []
def _similar(self, a, b):
A, B = set(a.lower().split()), set(b.lower().split())
return len(A & B) / len(A | B) if (A | B) else 0 # Jaccard
def write(self, text):
if len(text.split()) < 3:
return "skipped: too trivial"
for i, m in enumerate(self.mem):
if self._similar(text, m) > 0.6:
self.mem[i] = max(m, text, key=len) # keep the fuller one
return "merged with existing"
self.mem.append(text); return "stored"
w = MemoryWriter()
print(w.write("ok")) # skipped: too trivial
print(w.write("user prefers dark mode")) # stored
print(w.write("the user prefers dark mode in the app"))# merged with existing
print(w.mem)
Deciding what and when to remember is the hard part of memory. Filtering trivia and merging duplicates keeps retrieval precise and the store small.
Context: Unbounded memory eventually hurts retrieval. Real systems consolidate: score memories by recency and use, then evict the weakest when over capacity.
Your task: Implement decay — score memories by recency plus access count — and evict the lowest-scoring memories when the store exceeds capacity (consolidation).
Requirements:
- Score each memory by a mix of recency and access count
- When over capacity, evict the lowest-scoring memories
- Frequently-used and recent memories survive
- Capacity and the scoring weights are parameters
💡 Hint: A memory touched often and recently should outscore a stale, never-retrieved one; evict from the bottom of that ranking.
Show solution
import time
class DecayingMemory:
def __init__(self, capacity=3):
self.capacity = capacity; self.mem = [] # list of dicts
def write(self, text):
self.mem.append({"text": text, "ts": time.time(), "hits": 0})
self._consolidate()
def retrieve(self, text):
for m in self.mem:
if text in m["text"]:
m["hits"] += 1; return m["text"]
def _score(self, m):
age = time.time() - m["ts"]
return m["hits"] * 10 - age # frequent + recent scores high
def _consolidate(self):
while len(self.mem) > self.capacity:
worst = min(self.mem, key=self._score) # evict least valuable
self.mem.remove(worst)
dm = DecayingMemory(capacity=2)
dm.write("A"); dm.write("B"); dm.retrieve("B") # B accessed -> higher score
dm.write("C") # over capacity -> evict A
print([m["text"] for m in dm.mem]) # ['B', 'C']
Forgetting is a feature: recency + usage decay keeps the store bounded and biases retrieval toward what actually matters, mirroring how Mem0/MemGPT consolidate.
Context: As tech lead you define the whole memory architecture and show how it plugs into a single agent turn — retrieve before the prompt, decide what to write after. The real embedding/SDK calls slot into named seams.
Your task: Define the memory architecture — semantic store, episodic log, retrieve-before-prompt, and a write-after decision — and show how it plugs into an agent turn, noting where the real embedding/SDK calls go.
Requirements:
- Combine a semantic store and an episodic log
- Retrieve relevant memories and inject them before the model prompt
- Decide what (if anything) to write back after the turn
- Mark the seams where real embeddings / SDK calls would be wired in
- Show it as one coherent turn, not disconnected pieces
💡 Hint: The turn is: retrieve → build prompt with memories → generate → write-after decision; the offline pieces stand in for the embedding and model calls.
Show solution
Offline-runnable skeleton; the two labeled seams take the real embedding model and Anthropic client in production.
class AgentMemory:
def __init__(self):
self.semantic = [] # durable facts/preferences
self.episodic = [] # timestamped events
# SEAM 1 (needs embeddings/SDK): swap overlap for vector similarity
def retrieve(self, query, k=3):
pool = self.semantic + self.episodic
score = lambda m: len(set(query.split()) & set(m.split()))
return sorted(pool, key=score, reverse=True)[:k]
def write(self, text, kind):
(self.semantic if kind == "semantic" else self.episodic).append(text)
def agent_turn(memory, user_msg, classify, llm):
context = memory.retrieve(user_msg) # 1. retrieve before prompting
prompt = f"Memories: {context}\nUser: {user_msg}"
reply = llm(prompt) # 2. SEAM 2: real Anthropic call
if "remember" in user_msg.lower(): # 3. write-after decision
memory.write(user_msg, classify(user_msg))
return reply
mem = AgentMemory()
mem.write("user prefers metric units", "semantic")
print(agent_turn(mem, "what units do I prefer?", lambda t: "semantic",
llm=lambda p: f"[echo] {p[:40]}"))
Policy: retrieve relevant memories into every prompt, classify+write only high-value items, and consolidate/expire on a schedule. The pattern is provider-agnostic; only the embedding and generation seams call out to the SDK.
✓ Checkpoint — you can move on when you can…
- Explain why the context window is not memory (stateless model).
- Distinguish short-term vs long-term and episodic vs semantic memory.
- Trace and implement the extract → write → retrieve → inject loop.
- Consolidate/dedup and forget so the store stays current and cheap.
- Say what Mem0 and Letta/MemGPT add, including self-editing memory.
- Set a memory policy covering privacy, staleness, retention, and cost.
Knowledge check check yourself
Since a chat model is stateless, what four-step loop gives it long-term memory, and how does it relate to RAG?
Show answer
What is the difference between episodic and semantic memory in an agent, and why keep both?