Short version: "Give your agent memory" is three different jobs, not one. Tier 1 is the messages in the live context window — you don't save it, you budget it. Tier 2 is that conversation's state saved so it survives a restart — a checkpointer keyed by thread_id, one line of code. Tier 3 is facts that outlive the thread and come back by meaning across sessions — a vector or graph store you write to deliberately. Different lifetimes, different recall, different code. Here's each one, the single call that wires it, and the rule for when a fact should climb.
| Tier | What it holds | Recall by | The one call |
|---|---|---|---|
| 1 · working | Messages in the live window | Position | just pass them in |
| 2 · persistent | The saved thread state | thread_id | compile(checkpointer=…) |
| 3 · long-term | Extracted facts & episodes | Semantic search | store.put / store.search |
If you've watched one of the "build agentic memory (short, persistent, long)" course modules making the rounds this month, this is the code under those three words.
Tier 1 — working memory: budget it, don't store it#
Working memory is just the messages in the model's context window right now. There is nothing to install, because there is nothing to persist — it lives for exactly one turn. Your only job here is a budget problem: keep the turn under the context limit by trimming old messages or replacing a long history with a running summary.
# Tier 1 is just the state you pass into a single run.
# No storage. Your job is to keep it small enough to fit.
graph.invoke(
{"messages": trimmed_history + [user_msg]},
config={"configurable": {"thread_id": "user-42"}},
)
The failure mode here isn't forgetting — it's the opposite. Stuff too much back in and the model's attention smears across noise. When the buffer gets long, summarize or evict; the two competing strategies for how are context editing vs. compaction, and the reason a bloated window makes an agent dumber is the first of the four ways agent memory rots in production.
Tier 2 — persistent memory: one key, one line#
Tier 2 is the same conversation, saved. You want turn 40 to remember turn 1 — and to still remember it after your process restarts. In LangGraph this is a checkpointer: it snapshots the graph's state at every step, keyed by a thread_id, and reloads it on the next turn (LangGraph checkpoint docs).
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver() # dev: state lives in RAM
graph = builder.compile(checkpointer=checkpointer)
# Same thread_id → same remembered conversation, every turn.
graph.invoke({"messages": [user_msg]},
config={"configurable": {"thread_id": "user-42"}})
That thread_id is the whole trick: it's the address of one remembered conversation. Ship it for real by swapping the saver — SqliteSaver for a single box, PostgresSaver for traffic or multiple workers — and none of your agent code changes. The trade-off between the durable backends (and why Redis is tempting but has a catch) is laid out in Postgres vs. Redis for the LangGraph checkpointer.
Note what Tier 2 is not: it's recall by thread_id, not by meaning. Open a new thread and this memory is gone. That's by design — and it's exactly the boundary where Tier 3 begins.
Tier 3 — long-term memory: recall by meaning, across sessions#
Long-term memory is the one people mean when they say "memory": a fact from one conversation that shows up, correctly, in a different conversation weeks later. It lives outside any thread, in a store you query by relevance. LangGraph gives you the primitive directly — a BaseStore with namespaced keys and optional embeddings:
from langgraph.store.memory import InMemoryStore
store = InMemoryStore(index={"embed": embed_fn, "dims": 1536})
graph = builder.compile(checkpointer=checkpointer, store=store)
# Write a fact, namespaced per user (Tier 3 is cross-thread):
store.put(("users", user_id, "facts"), key="tz",
value={"text": "Prefers meetings in UTC; based in the EU."})
# Later, in *any* thread, recall by meaning — not by recency:
hits = store.search(("users", user_id, "facts"),
query="what timezone does this user want?", limit=3)
Two things make this its own tier. First, namespaces (("users", user_id, …)) keep one user's memories out of another's — the isolation Tier 2's thread_id gave you for free, now made explicit. Second, search(..., query=...) is semantic: it returns the nearest facts by embedding, so a question phrased differently than the stored fact still finds it.
Not all long-term memory is the same shape. LangChain's taxonomy is worth internalizing because it changes how you store things (LangChain memory concepts):
- Semantic — facts. "The user's company is in the EU." Store as searchable key-values (above).
- Episodic — experiences. "Last time, this user wanted the one-line answer." Store whole interactions and replay the best as few-shot examples.
- Procedural — rules. "Always confirm before sending email." Often the agent's own evolving system prompt, which it edits over time.
You usually don't hand-roll Tier 3
The BaseStore is the primitive; in production most teams put a managed memory layer on top so the extract-what's-worth-keeping and recall-what's-relevant steps are done for them. The four common choices, and what actually distinguishes them:
| Layer | What it stores | Reach for it when | License |
|---|---|---|---|
| Mem0 | Extracted facts per user/session/agent | You want add()/search() and nothing to run | Apache-2.0 |
| Letta (ex-MemGPT) | Self-editing memory blocks in-context + archival | The agent should rewrite its own memory | Apache-2.0 |
| Zep / Graphiti | A temporal knowledge graph of facts | "When did this become true?" matters | Apache-2.0 |
| Redis Agent Memory | Working memory (TTL) + promoted long-term | You're already on Redis and want both tiers | Apache-2.0 |
The simplest to bolt on is Mem0 — two calls, and it does the fact-extraction for you:
from mem0 import Memory
memory = Memory()
memory.add(conversation_messages, user_id="alice") # it extracts the salient facts
memory.search(query="any dietary limits?",
filters={"user_id": "alice"}, top_k=3)
One 2026 wrinkle worth knowing before you pick: Mem0's managed platform moved to a single-pass, ADD-only extraction (memories accumulate; nothing is overwritten), a change from the update-in-place pipeline in its original paper (Mem0 repo). If you need memories that supersede each other with a timeline — "the user moved from Berlin to Lisbon" — that's precisely Zep/Graphiti's temporal-graph thesis, where each fact carries a validity window. Letta takes the opposite tack: the agent edits its own self-authored memory blocks as it goes. The full head-to-head is Mem0 vs. Zep vs. Letta; the deeper graph-vs-vector cut is Cognee vs. Graphiti vs. Mem0.
The rule that connects the tiers#
The three tiers aren't alternatives — they're a pipeline a fact travels through. It's born in Tier 1 (the user just said it), survives the conversation in Tier 2 (the checkpointer saved the thread), and earns a slot in Tier 3 only if a future, separate session would be worse without it.
That last word — earns — is the whole discipline. Every long-term memory you retrieve gets injected back into the prompt, on every turn it's relevant to. Promote everything and you've quietly rebuilt the bloated context window Tier 1 told you to avoid, except now it refills itself automatically. So promote deliberately: stable preferences, identity facts, decisions with consequences — and give memories a way to expire. The token bill for reading versus writing memory is not symmetric, and if you're weighing whether you even need a long-term store or whether retrieval-at-query-time is enough, that's the agent memory vs. RAG question.
Start at Tier 1. Add Tier 2 the moment a conversation needs to survive a restart — it's one line. Add Tier 3 only when "remember this next time" is a real feature, not a reflex. Most agents that feel like they have a good memory are just the first two tiers done carefully, plus a very small, very deliberate third.



