Pranav Srivastava
Labs
45 minIntermediate#AI Agents#LangGraph#Memory

Add memory to an AI agent with LangGraph

Give an agent short- and long-term memory using LangGraph checkpoints, so it remembers across turns and across sessions.

What you build

An agent that recalls earlier context within a conversation and persists state between runs.

Stack

Python · LangGraph · SQLite


By default, an LLM is a goldfish — it remembers nothing between calls. By the end of this lab your agent will remember: within a conversation, and across separate runs of the program, using LangGraph checkpoints saved to SQLite. Real, persistent memory in about 30 lines.

How a checkpointer remembers
  turn 1 ─►┌───────────┐─► reply      ┌──────────────┐
  "I'm     │   agent   │──save state─►│  SQLite      │
   Pranav" │  (graph)  │              │  memory.db   │
           └───────────┘              │  thread:     │
  turn 2 ─►┌───────────┐◄─load state──│   "user-1"   │
  "what's  │   agent   │              └──────────────┘
   my name"│  (graph)  │─► "Pranav"
           └───────────┘
State is saved to disk after each turn and reloaded by thread_id
What you will learn
  • Build a minimal LangGraph agent with conversational state
  • Add a SQLite checkpointer so it remembers within a conversation
  • Persist memory across separate runs of the program
  • Keep different users' memories isolated with thread IDs

Before you start

Python 3.10+, an Anthropic API key (export ANTHROPIC_API_KEY=...), about 45 minutes.

terminal
uv init remembering-agent && cd remembering-agent
uv add langgraph langgraph-checkpoint-sqlite langchain-anthropic
# or: pip install langgraph langgraph-checkpoint-sqlite langchain-anthropic

A stateless agent (the goldfish)

First, an agent with no memory, so you can feel the problem. Create agent.py:

agent.py
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, MessagesState, START

llm = ChatAnthropic(model="claude-opus-4-8", max_tokens=1024)


def chat(state: MessagesState):
    # MessagesState carries the running list of messages.
    return {"messages": [llm.invoke(state["messages"])]}


builder = StateGraph(MessagesState)
builder.add_node("chat", chat)
builder.add_edge(START, "chat")

graph = builder.compile()  # no checkpointer yet — no memory

Each graph.invoke(...) starts from an empty slate. Tell it your name in one call and it won’t know it in the next. Let’s fix that.

Add a checkpointer — instant short-term memory

A checkpointer saves the state after every step. Swap the compile line to use one backed by a SQLite file:

agent.py (replace the compile line)
from langgraph.checkpoint.sqlite import SqliteSaver

# from_conn_string is a context manager; the graph lives inside the `with`.
with SqliteSaver.from_conn_string("memory.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)

    # A thread_id names the conversation the memory belongs to.
    config = {"configurable": {"thread_id": "user-1"}}

    graph.invoke({"messages": [{"role": "user", "content": "Hi, I'm Pranav."}]}, config)
    result = graph.invoke(
        {"messages": [{"role": "user", "content": "What's my name?"}]}, config
    )
    print(result["messages"][-1].content)

Run it (uv run agent.py). The second call answers “Pranav” — because the checkpointer reloaded everything from the first call. Notice you only sent the new message; LangGraph merged it onto the saved history for you.

Persist across runs — real long-term memory

Here’s the part that makes it more than a chat loop: the memory is on disk. Split the two turns into separate scripts and run them minutes apart.

remember.py
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.checkpoint.sqlite import SqliteSaver

llm = ChatAnthropic(model="claude-opus-4-8", max_tokens=1024)
builder = StateGraph(MessagesState)
builder.add_node("chat", lambda s: {"messages": [llm.invoke(s["messages"])]})
builder.add_edge(START, "chat")

with SqliteSaver.from_conn_string("memory.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
    config = {"configurable": {"thread_id": "user-1"}}
    graph.invoke(
        {"messages": [{"role": "user", "content": "Remember: my favourite language is Python."}]},
        config,
    )
    print("Saved.")
recall.py
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.checkpoint.sqlite import SqliteSaver

llm = ChatAnthropic(model="claude-opus-4-8", max_tokens=1024)
builder = StateGraph(MessagesState)
builder.add_node("chat", lambda s: {"messages": [llm.invoke(s["messages"])]})
builder.add_edge(START, "chat")

with SqliteSaver.from_conn_string("memory.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
    config = {"configurable": {"thread_id": "user-1"}}
    result = graph.invoke(
        {"messages": [{"role": "user", "content": "What's my favourite language?"}]}, config
    )
    print(result["messages"][-1].content)
terminal
uv run remember.py    # writes to memory.db, then exits
uv run recall.py      # a brand-new process — still answers "Python"

The second process was never told your favourite language. It read it from memory.db. That’s long-term memory: it outlives the program.

Keep users apart with thread IDs

The thread_id is what isolates memories. Different IDs are different, private conversations — sharing one SQLite file, never leaking into each other.

threads.py
# ... same graph setup, inside the `with SqliteSaver...` block ...
alice = {"configurable": {"thread_id": "alice"}}
bob = {"configurable": {"thread_id": "bob"}}

graph.invoke({"messages": [{"role": "user", "content": "I live in Delft."}]}, alice)
graph.invoke({"messages": [{"role": "user", "content": "I live in Cork."}]}, bob)

# Ask each — they never see each other's memory.
print(graph.invoke({"messages": [{"role": "user", "content": "Where do I live?"}]}, alice)["messages"][-1].content)  # Delft
print(graph.invoke({"messages": [{"role": "user", "content": "Where do I live?"}]}, bob)["messages"][-1].content)    # Cork

Where to go next

  • Trim the history — long conversations grow the state forever; add summarisation or context trimming so old turns don’t balloon your token bill.
  • Semantic / long-term store — distil durable facts (“prefers Python”, “timezone CET”) into a separate store the agent can search, instead of replaying every past message.
  • Swap the backendSqliteSaver is perfect locally; move to Postgres for production with the same interface.
  • Go deeper — the Harness Engineering and Loop Engineering courses cover memory, tools, and control loops as one system.