Persistence & Threading
How a stateless agent learns to remember — saving a full state snapshot after every step, keyed to a conversation thread, so it survives restarts, reboots, and days-long gaps without leaking data across users.
Assumes you know
After this you should be able to
- Explain the core idea in Persistence & Threading
- Apply Persistence & Threading in a representative exercise
Agentic AI · LangGraph
How a stateless agent — brilliant in a demo, broken in production — learns to remember: saving a snapshot after every step, keyed to a conversation, so it survives restarts, reboots, and days-long gaps.
An agent that forgets everything when its process ends is not a bug you patch later — it is architecturally wrong for a conversation. LangGraph fixes this with two primitives: a thread (a conversation's identity) and a checkpointer (a store that saves a full state snapshot around every node). Get the thread ID right and users stay isolated; get it wrong and one user reads another's data.
Notebook Summary#
Copy this by hand. Everything here is expanded in the Detailed Notes below.
- React loop → reason + act: classify → agent ⇄ tool → respond → END
- Circuit breaker → stop after max_iter (=5); else infinite loop
- Context trimming → keep first msg + last ~12; avoids "lost in the middle"
- Fingerprinting → hash(tool + args); repeat call ⇒ escalate, don't re-run
- Ephemeral memory → process dies ⇒ every var, tool result, reasoning step gone. No partial survival.
- Stateless is fine for REST; fatal for conversations → each turn needs prior context.
- ▢ analogy amnesiac vs veteran clerk: same skill, only veteran keeps a case file.
| Stateless (REST) | Stateful (agent) |
|---|---|
| each request independent | each turn builds on last |
| state in DB, not server | the agent is the state |
| self-contained | turn 2/3 meaningless w/o memory |
- Thread = conversation identity. A logical thread_id = partition key, not an OS thread.
- groups all checkpoints for one conversation
- use user/session/ticket ID; never per-request UUID
- same id ⇒ resume; unique per user ⇒ isolation
- Checkpointer = case file / append-only ledger. Fires before &
after every node, non-skippable; entries never overwritten.
- stores full snapshot, not a delta → any checkpoint restorable alone
- 5-node graph × 50 turns = 250 checkpoints
- ▢ doodle chain of boxes N1→N2→N3, a tiny disk icon dropping under each.
| Saver | Where | Use |
|---|---|---|
| MemorySaver | RAM dict | notebook/dev; dies w/ process |
| SqliteSaver | local file | single container / edge |
| Postgres / Mongo | DB server | distributed, multi-instance |
- Swap saver = one line. Dev with Memory, ship with durable.
- State must be JSON-serializable (str, int, float, bool, list).
- ⚠ pitfall schema drift → new fields break old checkpoints; invisible in dev (always fresh).
- Fix → schema_version field + migration fn (like a DB migration).
- ⚠ pitfall thread_id at top level = silently ignored ⇒ no persistence.
- Correct: {"configurable": {"thread_id": "..."}} (nested).
- Only pass the new message — checkpointer reloads the rest (Script A = full state; Script B = new msg only).
- get_state → current; get_state_history → all, newest→oldest (reversed(list(...)) for chrono).
- Snapshot = state (data) · config (resume: thread+checkpoint id) · metadata (audit) · next (node / empty=END → HITL).
- Rewind = invoke(None, cfg+checkpoint_id); forks, never erases.
- ⚠ thread leakage (shared/static id) → cross-user data leak → GDPR breach
- ⚠ schema drift · state explosion (long threads) · missing thread_id · corrupt checkpoint
2.0
Detailed Study Notes#
A customer messages your support agent at 9:14 a.m.: their subscription was double-charged. The agent looks up the account, finds the duplicate transaction, and begins explaining the refund. At 9:16 the customer steps away. They come back at 2:30 p.m. and type, "what do I need to do next?" — and the agent has no idea who they are. Every tool call, every fetched record, every step of reasoning is gone. It starts from zero, and the customer has to repeat everything. A human clerk with a notepad would have done better.
That gap between a clever demo and a system that works at 2:30 p.m. is the whole subject of this lesson. Before we get there, a quick recap of where the agent stands.
2.1Recap: the React loop and its three safety layers#
The agent reasons, then acts, in a loop — and three guards keep that loop from hurting itself. "React" here means reason + act: the model thinks about what it needs, calls a tool to get it, reads the result, and decides whether to think again or answer. The graph flows start → classify → agent ⇄ tool → respond → END, where the classifier labels the ticket (technical, billing, fraud, general) and routes it.
fig 2.1 — the agent node runs the React loop; the tool ⇄ agent cycle repeats until no tool is needed.
Stops the loop after max_iterations (5 in the build). Without it, an agent can call
tools forever.
Before each LLM call, keep the original message plus the most recent ~12. Prevents context-window bloat.
A unique hash of each tool call + its args. If the same call is attempted twice, escalate instead of re-running.
The fingerprint isn't just deduplication — it's a loop-detector that doubles as an escalation trigger. A repeated identical tool call almost always means the agent is stuck, so the same signal that blocks the wasted call also decides "hand this to a human." One mechanism, two jobs.
All three are real-but-local guards. They keep a single run healthy. What none of them address is what happens between runs — and that is where persistence begins.
2.2Why stateless execution fails in production#
When a process ends, everything it held in memory is erased — completely and without discrimination. Every Python variable, every object, every LangGraph state dictionary lives in RAM, in a region the operating system leased to that process. When the process terminates — finished normally, crashed, killed with Ctrl-C, or knocked out by hardware failure — the OS reclaims every byte. It does not ask which parts mattered. There is no partial survival.
This is not a defect. It is the same isolation that lets a machine run thousands of processes without their memory bleeding into each other. It is also exactly why a naive agent forgets.
Picture two support reps with identical expertise and instant account lookups. The first is an amnesiac: at the end of every conversation their memory wipes, so a returning customer must re-explain everything. The second is a veteran who writes a case file after each call and reopens it next time. Today's naive agent is the amnesiac. Persistence turns it into the veteran — the checkpointer is the case file, the thread ID is the case number, the database is the filing cabinet.
"Just copy the whole memory and paste it back" doesn't work. A blind Ctrl-C of the process state is a disaster for an agent — you need a structured, serialized snapshot tied to the right conversation, not a raw dump of RAM. Persistence is a contract, not a copy.
In production, process restarts are not a rare accident — they're routine: rolling Kubernetes deployments, autoscaling events, infrastructure failures. A non-persistent agent mid-workflow, three tool calls deep, loses all of it on any restart. For an in-flight conversation, every restart is total amnesia. So this isn't a theoretical risk you might hit; without persistence it's a guaranteed production failure.
2.3Stateless vs stateful: the architectural mismatch#
The web is stateless by design; a conversation is not — and forcing one into the other breaks it. HTTP was built so each request carries everything needed to serve it, with the server remembering nothing about prior requests. That makes REST simple, scalable, and predictable: send a customer ID, get customer data back, done. It's the model behind essentially every large web service.
An agent conversation is the opposite. It's a sequence of exchanges where each one depends on the accumulated context of all the ones before. When a customer asks "what do I do next?", a correct answer depends on what they asked two minutes ago, which tool was called, and what it returned. You cannot make that stateless without destroying the intelligence of the interaction.
Each request independent#
- State lives in the database, not the server
- Every call is self-contained
- Powers Google, Amazon, most web services
Each turn builds on the last#
- The agent is the state
- Without memory, turns 2 and 3 are meaningless
- One step in a conversation spanning days & restarts
The line to remember for an interview: "persistence is not a feature you add to an agent — it's the architectural prerequisite for the agent to be correct." A stateful agent still runs behind a stateless HTTP server; the difference is that the conversation state now lives in the checkpoint store instead of in the request.
Primitive one: the thread#
A thread is a conversation's unique identity — a label that groups everything belonging to
one conversation together. Despite the name, it is not an OS thread, a process, or a
network connection. It is a logical identifier: the thread_id. It behaves like a foreign
key — the address that tells each checkpoint which conversation it belongs to.
When you invoke the graph with a given thread_id, LangGraph loads the last checkpoint
for that conversation and resumes. Invoke with a different id and you're in a different,
isolated conversation. This is the thread-isolation guarantee: user 42's messages can
never contaminate user 99's, because every write is tagged with its thread ID (often combined with a
user ID into a composite key). It's also what lets you scale horizontally — ten instances of the same
agent can serve ten conversations with no risk of cross-talk.
Using a static or non-unique thread_id — say a hard-coded "default"
or "123" left over from development — makes multiple users share one conversation. User A's
state loads into User B's session, and A's data becomes visible to B. This is a real data-leakage
vulnerability and a GDPR (General Data Protection Regulation) violation that can
get a company sued. It's insidious because single-user testing looks perfect; it only detonates under
real multi-user traffic. It has happened in early LangGraph deployments.
Pick a value that is stable across a conversation but unique per conversation:
a user ID from your auth system ("user-12345"), a web session ID
("session-abc"),
or a ticket ID ("ticket-9981"). Never generate a random UUID per
request
— that mints a brand-new thread on every call, so nothing ever resumes. And with no thread_id
at
all, the checkpointer is present but useless: every invoke is anonymous, same behavior as MemorySaver.
fig 2.4 — each thread_id maps to its own isolated pile of snapshots. Wrong id ⇒ the lines cross ⇒ leakage.
Primitive two: the checkpointer#
A checkpointer is an automatic state ledger: it snapshots the full state dict around every node and writes an immutable entry. It hooks into the execution engine and fires before a node runs (recording which node is about to run, the full state, the config) and again after it completes (recording the updated state). This is not configurable — you can't skip checkpointing for a node — and the ledger is append-only: prior snapshots are never overwritten or deleted. Every state the graph has ever been in is preserved, which is exactly what makes time travel possible.
LangGraph stores the entire state at every step, not the difference from the previous step. The cost is storage: a 5-node graph over a 50-turn conversation produces 250 checkpoints, each a full copy. The payoff is huge: any checkpoint is independently restorable. You can jump straight to checkpoint 347 without replaying 1–346 — which is what makes debugging and time travel practical.
A snapshot is written before and after each node; the ledger only grows. On restart with the same thread: load the latest → resume. No replay needed.
2.6Where snapshots live: choosing a saver#
The checkpointer interface is fixed; only the backing store changes — and swapping it is one line of code. From the graph's perspective every saver is indistinguishable. That lets you develop against the fast in-memory saver, then switch to a durable store for production without touching graph logic.
| Saver | Stores in | Survives restart? | Best for |
|---|---|---|---|
MemorySaver | a Python dict in RAM | No — dies with the process | notebooks, local dev, fast & zero-dependency |
SqliteSaver | a local file on disk | Yes — reload from the file | single container or edge deployment |
PostgresSaver | a Postgres DB | Yes — shared server | distributed / multi-instance, indexed for scale |
| MongoDB (custom) | a Mongo instance | Yes — centralized | horizontally-scaled prod, stateless containers |
You've used a durable checkpointer without knowing it. Close your browser, reopen it, hit reopen closed tabs — everything comes back because the state was written to disk, not just held in memory. MemorySaver is a browser that forgets your tabs on quit; SqliteSaver and friends are the version that brings them back.
SQLite wins when the agent runs on a single server, container, or edge device:
zero setup, a local file, no extra infrastructure. Reach for Postgres or MongoDB when you have a
distributed, multi-instance deployment — Kubernetes with rolling updates and
autoscaling prefers stateless containers, so you can't trust local disk. A centralized DB is reachable
by every instance and can be properly indexed on thread_id for high-traffic history reads.
Serialization and the schema contract#
To save a snapshot, the checkpointer serializes state into a storable form — which means your
state must be serializable. With MemorySaver this happens in memory; with SqliteSaver and
PostgresSaver the state is serialized to JSON and written to a database column. That
creates a contract: every field in your TypedDict state must be
JSON-serializable
— strings, ints, floats, booleans, lists, dicts. No arbitrary Python objects.
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class SupportState(TypedDict):
messages: Annotated[list, add_messages] # JSON-safe list
category: str
iteration_count: int
tool_results: dict
schema_version: int # <-- guards against driftWhen you change the shape of your state — add a field, rename one — old checkpoints written under the old shape no longer match. This is one of the nastiest persistence failures because it's invisible during development: your dev checkpoints are always fresh, so it looks fine. In production, old checkpoints holding months of conversation history can be corrupted on load. It's the LLM-agent cousin of ML data drift.
Put a schema_version field in the state. On load, compare the checkpoint's version to the
expected one; if they differ, run a migration function that transforms the old
state into the new shape before proceeding. Treat state migrations with the same seriousness as database
migrations — because that's exactly what they are.
Wiring it up: compile with a checkpointer, invoke with a thread#
Attaching a checkpointer changes execution in two ways, and the thread config tells it which
conversation you mean. When LangGraph sees a checkpointer on a compiled graph, then: (1)
before
the first node, it checks the store for an existing checkpoint under this thread_id — loads it if
present, uses the provided input as-is if not; and (2) after every node, it saves the updated
state. Wiring this into an existing build is famously small: only three lines change —
create the saver, pass it to compile(), and add a thread_id to each invoke. The graph, nodes,
and tools are untouched, and the support.db file is created automatically on first run.
Split invocation by turn. Script A (first turn) sends the full initial state.
Script B (every follow-up) sends only the new message — the
checkpointer
transparently loads the prior messages, customer_data, and tool fingerprints, then a
reducer
appends the new message. The agent receives the whole conversation without any extra code. In production
that means the customer can close the browser, come back tomorrow, and the agent still remembers
everything.
from langgraph.checkpoint.sqlite import SqliteSaver # dev: MemorySaver — one-line swap
# support.db is created automatically on first run; survives every restart
checkpointer = SqliteSaver.from_conn_string("support.db")
graph = builder.compile(checkpointer=checkpointer) # the only line that changes
# thread config MUST be nested under "configurable"
config = {"configurable": {"thread_id": "user-arjun-001"}}
# Script A — first turn: send the full initial state
graph.invoke(build_initial_state("My account C-1002 is showing past due"), config)
# ...server restarts (routine k8s deploy)... hours later, same thread_id.
# Script B — follow-up: send ONLY the new message. The checkpointer reloads
# messages, customer_data, and tool fingerprints transparently.
graph.invoke({"messages": [("user", "My email is arjun@company.com")]}, config)Passing {"thread_id": "..."} at the top of the config instead of nesting it under
configurable does not raise an error. LangGraph just ignores it:
the
graph starts from scratch every time, get_state_history is empty, and no checkpoints appear
in the store. It's a silent failure — dangerous precisely because the graph keeps producing
correct-looking
responses while persisting nothing. The nesting exists so other configurable values (which model, which
tool set) can coexist with the thread ID without naming conflicts.
Time travel: state history as a production capability#
Because every step is stored, you can walk backward through a conversation, inspect any past state, and resume from it. Imagine an agent that's been live for two weeks; a customer reports it gave them wrong information three days ago that caused financial harm. With a non-persistent agent, the state that existed then is simply gone — at best you have scattered logs. With checkpointing, you load the customer's thread, enumerate its checkpoints, find the one from three days ago, and inspect exactly what the model saw, what tools were called, and what they returned. That's a full audit trail.
| Method | Returns | Use it for |
|---|---|---|
get_state(config) | the current state (most recent checkpoint) | "where does this conversation stand right now?" |
get_state_history(config) | an iterator over all checkpoints, newest → oldest | time travel, debugging, audits |
get_state_history returns checkpoints most-recent first by default.
To read a conversation in the order it happened, wrap it:
reversed(list(graph.get_state_history(config))).
Each entry from the history is a state snapshot with four parts. Keeping the first three
straight is a common exam question — config and metadata look similar but do
different jobs — and the fourth, next, is the quiet one that powers human-in-the-loop:
The full state dict at that moment — the actual conversation data (messages, category, account status). A complete snapshot frozen in time, not a diff and not a log.
The resumption handle: the thread_id + checkpoint_id you pass back
to target this exact snapshot and resume from it.
The audit trail: step, source, and timestamp — which node produced
this checkpoint and when. source == "loop" means a node completed.
Which node runs next. Empty ⇒ execution finished; a node name ⇒ execution paused there. This is how HITL interrupt-detection works.
# current state
snapshot = graph.get_state(config)
print(snapshot.state["messages"]) # the data
print(snapshot.next) # () = done; ("agent_node",) = paused there
# walk the full history, newest first -> reverse for chronological
for snap in reversed(list(graph.get_state_history(config))):
print(snap.metadata["step"], snap.metadata["source"]) # audit: which node, when
print(snap.config["configurable"]["checkpoint_id"]) # resume handle
# rewind: re-invoke FROM a past checkpoint. Pass None as the input so the
# graph uses the checkpoint's saved state instead of overriding it.
old = {"configurable": {"thread_id": "user-arjun-001",
"checkpoint_id": "<id-from-history>"}}
graph.invoke(None, old)Time travel also enables human-in-the-loop after the fact: rewind to where the agent went wrong, correct the state, and replay the rest — the human reviews after the run, not blocking it at runtime. In regulated industries (financial services under FINRA/MiFID, healthcare under HIPAA, anything with e-discovery obligations), an audit trail of automated decisions isn't a nice debug feature — it can be a legal requirement.
2.10
Why this matters next: human-in-the-loop#
Persistence is the foundation that lets an agent pause mid-run, wait for a human, and resume —
and without a checkpointer there is nowhere to pause. A non-persistent graph runs start to
finish
with no way to stop before a consequential action (say, writing to a repo) and no way to inject a human
decision. Because the ledger records snapshot.next and preserves every state, execution can
halt before a write, save that state, let a human review the draft, and then resume from the exact
checkpoint. That's how a later session wires up interrupt_before and
Command(resume=...) — but it only works because the checkpoint ledger exists first.
Re-invoking from a past checkpoint doesn't overwrite history — the original path still exists and time travel creates a fork. Both branches remain queryable. Nothing in the append-only ledger is ever lost, which is what makes "rewind, fix the state, regenerate" a safe production move.
2.11
Interview & practice Q&A#
Why can't you just make an agent stateless like a REST endpoint?
REST works because each request is self-contained. A conversation is a sequence where each turn depends on the accumulated context of prior turns — "what do I do next?" is meaningless without the earlier messages, tool calls, and results. Removing state doesn't simplify the agent; it destroys the intelligence of the interaction.
What are LangGraph's two persistence primitives, and what does each do?
The thread is a conversation's identity — a logical
thread_id that groups all of one conversation's checkpoints, acting like a partition
key. The
checkpointer is an automatic state ledger that saves a full snapshot around every
node
(before and after). The thread says which conversation; the checkpointer stores
what
happened in it.
Snapshots are full copies, not deltas. Why, given the storage cost?
Full snapshots make any checkpoint independently restorable — you can jump to checkpoint 347 without replaying 1–346. That independence is what makes debugging, audits, and resuming from an arbitrary point practical. The trade-off is larger writes and more storage on long conversations (a 5-node graph over 50 turns is 250 snapshots).
What is thread leakage and why is it a GDPR concern?
Thread leakage happens when a static or non-unique thread_id (like a
hard-coded "default") is shared across users, so one user's state loads into another's
session and their data becomes visible. That's a cross-user data breach — a GDPR violation. It's
dangerous
because single-user testing looks perfect and it only fails under real multi-user traffic.
You pass the thread_id but nothing persists and history is empty. What's wrong?
The thread_id is almost certainly at the top level of the config instead
of nested under configurable. LangGraph silently ignores the top-level key: the graph
starts
fresh each time and saves nothing, yet still returns plausible responses. The correct shape is
{"configurable": {"thread_id": "..."}}.
What is schema drift, and how do you defend against it?
Schema drift is when you change the state's shape (add/rename a field) so old checkpoints
no longer match the new schema, corrupting stored history. It's invisible in dev because dev
checkpoints are
always fresh. Defend with a schema_version field plus a migration function that
transforms old
states to the new shape on load — treat it like a database migration.
When would you choose SqliteSaver over Postgres/Mongo, or vice versa?
Use SqliteSaver for a single server, container, or edge device — zero setup, a local file, no extra infrastructure. Use Postgres or MongoDB for distributed, multi-instance deployments (Kubernetes with autoscaling and rolling updates), where stateless containers can't rely on local disk and every instance needs one centralized, indexable store.
A state snapshot has state, config, metadata, and next. How do they differ?
state is the actual data — the full state dict at that point.
config is the resumption handle (thread_id + checkpoint_id)
you use
to target and resume from that exact snapshot. metadata is the audit trail (step,
source,
timestamp): how this came to be. next tells you which node runs next — empty means
execution
finished, a node name means it paused there, which is how HITL interrupt-detection works.
Why does attaching a checkpointer mean you only send the new message?
Before running the first node, the checkpointer looks up the existing checkpoint for
that thread_id and loads it. So the prior conversation is already restored — you only
need to
supply the new user turn, and LangGraph merges it into the reloaded state.
3.0
Key Takeaways#
A process ending erases all in-memory state indiscriminately. For a conversation that spans restarts, that makes a naive agent architecturally wrong, not just buggy.
Persistence rests on two primitives: the thread (conversation
identity via thread_id, a partition key) and the checkpointer (an
append-only ledger that snapshots full state before and after every node).
Get the thread ID unique-per-conversation or risk cross-user leakage and a GDPR
breach. Nest it under configurable or persistence silently does nothing.
Snapshots are full, JSON-serializable copies. Guard state changes with a
schema_version + migration to survive drift. Swap savers (Memory → SQLite →
Postgres/Mongo)
in one line.
Full snapshots make time travel possible: inspect, rewind, and resume from any checkpoint — the basis for debugging, human-in-the-loop, and compliance audits.
The React loop's three guards (circuit breaker, context trim, fingerprint) keep a single run healthy; persistence is what carries the agent between runs.