AI agent memory in 2026 has a critical default that trips every builder eventually: agents forget everything between sessions. The conversation history you built carefully in the Claude API Tutorial exists only in RAM. Restart the script, and the agent has never met you before.

The production solution is a three-tier memory architecture. In-context memory is what the agent sees right now — the current conversation window. Semantic memory is facts and knowledge stored in a vector database, retrieved by similarity when relevant. Episodic memory is a persistent record of past interactions, stored in a database and summarized into future context. This guide implements all three tiers with working Python code, ordered from simplest to most powerful. You need the Claude API setup from the Claude API Python Tutorial to follow along.
Setup: Install the Memory Stack
pip install anthropic chromadb langgraph langchain-anthropic \
sentence-transformers python-dotenv
# .env
ANTHROPIC_API_KEY=sk-ant-your-key-here
Tier 1: In-Context Memory With LangGraph Checkpointing
In-context memory is the conversation history the agent sees in its current context window. The 2026 standard — replacing the deprecated ConversationBufferMemory from LangChain’s classic API — is LangGraph checkpointing: the conversation state is saved to a store after every message and reloaded from that store when the conversation resumes.
import os
from dotenv import load_dotenv
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage
load_dotenv()
# ─── Initialize Claude ──────────────────────────────────────────
llm = ChatAnthropic(
model="claude-sonnet-5",
api_key=os.environ.get("ANTHROPIC_API_KEY")
)
# ─── Build the agent graph ──────────────────────────────────────
def call_model(state: MessagesState):
"""The single agent node: call Claude with full message history."""
system = SystemMessage(content="""You are a helpful personal assistant.
You remember everything the user has told you across all our conversations.
Reference previous discussions naturally when relevant.""")
response = llm.invoke([system] + state["messages"])
return {"messages": [response]}
# Build a simple single-node graph
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_edge(START, "agent")
builder.add_edge("agent", END)
# MemorySaver: in-process store (survives process restart only when using SqliteSaver)
# For production: replace with SqliteSaver or PostgresSaver
memory = MemorySaver()
agent = builder.compile(checkpointer=memory)
def chat_with_memory(user_input: str, session_id: str) -> str:
"""
Chat with the agent. The same session_id preserves conversation history.
Different session_ids start fresh conversations.
"""
config = {"configurable": {"thread_id": session_id}}
result = agent.invoke(
{"messages": [HumanMessage(content=user_input)]},
config=config
)
return result["messages"][-1].content
# ─── Demo: Same session_id = agent remembers ────────────────────
SESSION = "user-alex-001"
print(chat_with_memory("Hi! My name is Alex and I'm a Python developer.", SESSION))
# → "Nice to meet you, Alex! ..."
print(chat_with_memory("What programming language did I mention?", SESSION))
# → "You mentioned Python..." ← agent remembers from same session
print(chat_with_memory("And what's my name?", SESSION))
# → "Your name is Alex." ← still remembers
The thread_id in the config is the session identifier. The same thread_id always loads the same conversation history. Different thread_id values start fresh. Important limitation: MemorySaver stores in RAM — the history is gone when the process restarts. For persistence across restarts, replace it with SqliteSaver (one-line change):
from langgraph.checkpoint.sqlite import SqliteSaver
# Persists to SQLite database file — survives process restarts
memory = SqliteSaver.from_conn_string("agent_memory.db")
agent = builder.compile(checkpointer=memory)
# Everything else stays the same
Tier 2: Semantic Memory With ChromaDB
In-context memory has one hard constraint: the context window. A 200,000-token window sounds unlimited, but sending the entire history of a long-running assistant on every call is expensive and slow — and as the conversation grows, the older, less-relevant history dilutes the quality of the recent context. Semantic memory solves this by storing facts in a vector database and retrieving only the most relevant facts for each new message.
import os
import anthropic
import chromadb
from chromadb.utils import embedding_functions
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# ─── Semantic Memory Store ───────────────────────────────────────
chroma_client = chromadb.PersistentClient(path="./semantic_memory")
# Use a free local embedding model
embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
def get_memory_collection(user_id: str):
"""Get or create a per-user memory collection."""
return chroma_client.get_or_create_collection(
name=f"memory_{user_id}",
embedding_function=embed_fn
)
def save_memory(user_id: str, memory: str, metadata: dict = None) -> None:
"""
Save a fact or memory to the semantic store.
The memory is embedded and stored for later retrieval by similarity.
"""
collection = get_memory_collection(user_id)
memory_id = f"mem_{datetime.now().timestamp()}"
collection.add(
documents=[memory],
ids=[memory_id],
metadatas=[{"timestamp": datetime.now().isoformat(), **(metadata or {})}]
)
print(f"[Memory saved] {memory}")
def retrieve_relevant_memories(user_id: str, query: str, n: int = 3) -> list[str]:
"""
Retrieve the n most semantically relevant memories for a given query.
Returns an empty list if no memories exist yet.
"""
collection = get_memory_collection(user_id)
if collection.count() == 0:
return []
results = collection.query(
query_texts=[query],
n_results=min(n, collection.count())
)
return results["documents"][0] if results["documents"] else []
def extract_and_save_memories(user_id: str, conversation: str) -> None:
"""
Ask Claude to identify facts worth saving from a conversation.
Called at the end of each session to populate the semantic memory.
"""
response = client.messages.create(
model="claude-haiku-4-5-20251001", # Haiku: cheap for extraction
max_tokens=512,
messages=[{
"role": "user",
"content": f"""Extract 3-5 specific facts worth remembering from this conversation.
Return each fact as a single clear sentence. One per line. Facts only, no explanations.
Conversation:
{conversation}"""
}]
)
facts = response.content[0].text.strip().split("\n")
for fact in facts:
fact = fact.strip().lstrip("•-123456789. ")
if fact:
save_memory(user_id, fact)
def chat_with_semantic_memory(user_input: str, user_id: str,
history: list) -> tuple[str, list]:
"""
Chat with the agent, injecting relevant semantic memories into context.
Returns the response and updated history.
"""
# Retrieve relevant memories for this input
memories = retrieve_relevant_memories(user_id, user_input)
memory_context = ""
if memories:
memory_context = "\n\nWhat I remember about you:\n" + "\n".join(
f"- {m}" for m in memories
)
system = f"""You are a helpful personal assistant with long-term memory.
{memory_context}
Use this context naturally in your responses when relevant.
Do not mention "memory" or "database" — just reference what you know."""
history.append({"role": "user", "content": user_input})
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=system,
messages=history
)
assistant_reply = response.content[0].text
history.append({"role": "assistant", "content": assistant_reply})
return assistant_reply, history
# ─── Demo ───────────────────────────────────────────────────────
USER_ID = "alex"
session_history = []
# First session: user shares information
reply, session_history = chat_with_semantic_memory(
"I'm Alex, a backend developer who loves Python and hates JavaScript.",
USER_ID, session_history
)
print(reply)
reply, session_history = chat_with_semantic_memory(
"I'm building a RAG system for my startup's compliance tool.",
USER_ID, session_history
)
print(reply)
# Extract and save facts at end of session
conversation_text = "\n".join(
f"{m['role']}: {m['content']}" for m in session_history
)
extract_and_save_memories(USER_ID, conversation_text)
print("Session memories saved.")
# --- New session: agent recalls from semantic memory ---
session_history_2 = []
reply, _ = chat_with_semantic_memory(
"What do you know about me?",
USER_ID, session_history_2
)
print(reply)
# → "You're Alex, a backend developer..." ← retrieved from ChromaDB
Tier 3: Episodic Memory — A Log of What Happened
Episodic memory is the simplest tier and often the most useful: a persistent log of past conversations that the agent can reference to understand patterns, past decisions, and the history of a relationship with a user or project. Unlike semantic memory (retrieved by similarity), episodic memory is retrieved by time or by session identifier — “what happened last Tuesday” rather than “what do I know about the project.”
import json
import sqlite3
from datetime import datetime
from pathlib import Path
class EpisodicMemory:
"""
Persistent log of agent sessions and their summaries.
Uses SQLite — no additional infrastructure required.
"""
def __init__(self, db_path: str = "episodic_memory.db"):
self.conn = sqlite3.connect(db_path)
self._init_db()
def _init_db(self) -> None:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
summary TEXT,
message_count INTEGER DEFAULT 0,
raw_messages TEXT
)
""")
self.conn.commit()
def start_session(self, session_id: str, user_id: str) -> None:
"""Record the start of a new conversation session."""
self.conn.execute("""
INSERT OR IGNORE INTO sessions (id, user_id, started_at, raw_messages)
VALUES (?, ?, ?, ?)
""", (session_id, user_id, datetime.now().isoformat(), "[]"))
self.conn.commit()
def save_session(self, session_id: str, messages: list,
summary: str) -> None:
"""Save the completed session with its summary."""
self.conn.execute("""
UPDATE sessions
SET ended_at = ?, summary = ?, message_count = ?, raw_messages = ?
WHERE id = ?
""", (
datetime.now().isoformat(),
summary,
len(messages),
json.dumps(messages),
session_id
))
self.conn.commit()
def get_recent_sessions(self, user_id: str, limit: int = 5) -> list[dict]:
"""Retrieve the N most recent session summaries for a user."""
cursor = self.conn.execute("""
SELECT id, started_at, summary, message_count
FROM sessions
WHERE user_id = ? AND summary IS NOT NULL
ORDER BY started_at DESC
LIMIT ?
""", (user_id, limit))
return [
{
"session_id": row[0],
"date": row[1][:10],
"summary": row[2],
"messages": row[3]
}
for row in cursor.fetchall()
]
def format_for_context(self, user_id: str, limit: int = 3) -> str:
"""Format recent sessions as context for the agent's system prompt."""
sessions = self.get_recent_sessions(user_id, limit)
if not sessions:
return ""
lines = ["Recent conversation history:"]
for s in sessions:
lines.append(f"• {s['date']}: {s['summary']}")
return "\n".join(lines)
def summarize_session(messages: list, client) -> str:
"""Ask Claude to summarize a completed session in one sentence."""
conversation = "\n".join(
f"{m['role'].capitalize()}: {m['content'][:200]}"
for m in messages
)
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=100,
messages=[{
"role": "user",
"content": f"Summarize this conversation in one sentence:\n\n{conversation}"
}]
)
return response.content[0].text.strip()
# ─── Combining all three tiers ───────────────────────────────────
def run_memory_agent(user_id: str):
"""
A complete agent with all three memory tiers:
1. LangGraph checkpointing (in-context, per-session)
2. ChromaDB semantic memory (facts, cross-session)
3. SQLite episodic memory (session log, cross-session)
"""
episodic = EpisodicMemory()
session_id = f"{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
episodic.start_session(session_id, user_id)
# Build context from previous sessions
history_context = episodic.format_for_context(user_id)
semantic_memories = retrieve_relevant_memories(user_id, "user background preferences")
system_parts = ["You are a helpful personal assistant with long-term memory."]
if semantic_memories:
system_parts.append("Facts I know: " + "; ".join(semantic_memories))
if history_context:
system_parts.append(history_context)
system = "\n\n".join(system_parts)
messages = []
print(f"Session started: {session_id}")
print("Type 'quit' to end the session.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit"):
break
messages.append({"role": "user", "content": user_input})
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=system,
messages=messages
)
reply = response.content[0].text
messages.append({"role": "assistant", "content": reply})
print(f"Agent: {reply}\n")
# Save session on exit
if messages:
summary = summarize_session(messages, client)
episodic.save_session(session_id, messages, summary)
extract_and_save_memories(user_id, "\n".join(
f"{m['role']}: {m['content']}" for m in messages
))
print(f"Session saved: {summary}")
Which Memory Tier to Use: The Decision Guide
| Situation | Use | Why |
|---|---|---|
| Single chat session, no persistence needed | Tier 1 only (MemorySaver) | Simplest, zero infrastructure |
| Multi-session chatbot, needs to persist | Tier 1 (SqliteSaver) | One-line change from MemorySaver |
| Agent needs to remember user facts across sessions | Tier 1 + Tier 2 | Semantic retrieval for facts, in-context for current chat |
| Agent needs to reference past conversations | Tier 1 + Tier 3 | Episodic log gives date-based history context |
| Production personal assistant or long-running agent | All three tiers | Full coverage: session + facts + history |
Start with Tier 1 (LangGraph + SqliteSaver). Add Tier 2 (ChromaDB semantic memory) when users complain the agent “forgets who they are” across sessions. Add Tier 3 (episodic log) when the agent needs to reference what happened in past interactions — customer support history, project decision logs, or ongoing research threads.
For the complete agent memory architecture research, see DevToolLab’s AI agent memory architecture guide.
The Builder’s Takeaway
AI agent memory in 2026 is three tiers, not one. In-context memory via LangGraph checkpointing handles the current conversation and survives restarts with SqliteSaver. Semantic memory via ChromaDB handles cross-session fact retrieval — the agent knows who you are because it retrieved that from a vector store, not from the context window. Episodic memory via SQLite handles the log of what happened across the relationship’s history. None of these tiers requires complex infrastructure to start: MemorySaver is a Python dict, SqliteSaver is a local file, ChromaDB is a local directory, SQLite is a single file. The decision table above tells you which combination to implement for your specific use case. Build the weekend project that uses all three and you’ll have the foundational architecture that every production AI agent with long-term user relationships is built on.
Continue in This Series
- How to Build an AI Agent With Python — the stateless agent loop this memory architecture makes persistent
- RAG Tutorial Python 2026 — the ChromaDB vector store patterns Tier 2 semantic memory builds on
- LangChain Tutorial 2026 — the LangGraph foundation that Tier 1 checkpointing uses
- How to Build an AI Chatbot With Python — the chatbot that becomes a truly personal assistant with these three memory tiers
- How to Deploy AI Agents — the production checklist: memory persistence is a Layer 2 (Orchestration) requirement
This post is part of The Agentic Protocol’s Work series — the connective infrastructure layer beneath every autonomous pipeline. See also: How to Build an AI Agent With Python.