Agentic Systems · Persistence & Context Management
How a LangGraph agent keeps its memory across crashes and users — and why giving it memory quietly creates a second, more expensive problem you must engineer against.
Persistence solves the no-memory problem by checkpointing every step so a conversation survives restarts. But unbounded history then creates the too-much-memory problem — cost grows linearly and reasoning degrades. The fix is not a bigger context window; it is a context-management policy — verbatim, compress, discard — run in a dedicated maintenance node.
Section 1
Hand-copyable revision sheet. Every line here is derivable from the Detailed Notes below.
.db file.
interrupt_before = node → pause, hand control to caller, resume with same
thread_id.| Failure | What happens |
|---|---|
| Attention dilution | fixed budget ÷ more tokens → each token weaker |
| Lost-in-the-middle | facts buried mid-context → recall drops |
| Reasoning-chain decay | small mis-weights compound → confidently wrong |
| Layer | Action | For |
|---|---|---|
| 1 Verbatim | keep word-for-word | recent 3–5 turns |
| 2 Compress | dense summary | older, high-meaning |
| 3 Discard | drop | pleasantries / noise |
| Sliding window | Summarization | |
|---|---|---|
| Does | keep last N msgs | compress old → summary field |
| Loss | hard horizon (gone) | lossy (facts kept) |
| Fails | silently | bad summary drops fact |
| Use | independent FAQ | multi-turn support |
count > 8 → summarize → agent.
SUMMARY_THRESHOLD = 8 (support sweet spot). Low=fires constantly; high=window
explodes.system_summary.
trim_context() retired.system_summary persisted by SQLite → survives restart, = portable audit log.add_messages is ID-blind → summarize + checkpointer = duplicate msgs.deduplicate_messages: skip msgs whose id
already in list (O(n) set).Section 2
A customer opens a support chat on Monday. Your agent handles it well: it looks up the account, checks a charge, promises a refund. The server restarts overnight for a routine deploy. On Tuesday the customer replies "did that refund go through?" — and the agent has no idea who they are, because everything it knew lived in RAM and RAM is gone.
So you add persistence. Now the agent remembers everything. By turn 50 the same conversation costs 100 times what turn 1 cost, the model starts confidently misremembering the account ID, and eventually the request overflows the model's context window and the API returns an error — mid-conversation, in front of a real customer. You fixed forgetting and built a slow-motion crash in its place. This lesson is about both problems and the single discipline that resolves them.
Persistence means the agent's state is saved to durable storage after every step, so it can be reloaded later. Without it, a conversation is just variables in memory that vanish when the process dies. The mechanism in LangGraph is a checkpointer — a save-after-every-node component — backed here by SQLite (a small file-based database, so a "cloud" is not required for the demo).
Think of an editor that saves a snapshot after every keystroke, tagged with which document you're in.
Close the laptop, lose power, reopen — you're exactly where you left off, and your other documents
are untouched. The document tag is the thread_id; the snapshot-after-every-edit is the
checkpoint.
Two ideas do all the work:
A SqliteSaver handed to graph.compile(). LangGraph then persists the
full graph state as a checkpoint after each node. You do not write save code — the framework
owns it.
Passed in config.configurable.thread_id at call time. It isolates one conversation
from another, so many users run simultaneously and any session resumes by its id.
The architectural change is deliberately tiny. The graph — classifier node, agent node, tool node, response node, and the conditional edges between them — stays identical. You add exactly two things: the checkpointer at compile time and the thread configuration at invoke time.
from langgraph.checkpoint.sqlite import SqliteSaver
# 1. checkpointer at COMPILE time ── persists every node's state
checkpointer = SqliteSaver.from_conn_string("support.db")
graph = builder.compile(checkpointer=checkpointer)
# 2. thread_id at INVOKE time ── isolates this conversation
config = {"configurable": {"thread_id": "u4-s0001"}}
result = graph.invoke({"raw_input": user_message}, config=config)
Ask a follow-up on the same thread_id ("what was that balance again?") and the agent
answers from the stored checkpoint without re-calling the CRM tool. Prior tool outputs are
already serialized in state, so you skip the expensive LLM + tool round-trip. The demo showed a
7-node first turn collapsing to a 3-node follow-up for exactly this reason.
In regulated industries, state history is closer to a legal requirement than a debugging aid: an AI making consequential decisions with no auditable record of how it decided may be non-compliant by design. Persistence gives you that record for free.
Because every step is a checkpoint, you can jump back to any earlier state, change it, and continue from there. This is the practical payoff of state history: it turns error handling from "sorry, let me start over" into surgical correction.
Consider a pricing tool that returned a wrong price because of a data bug — the agent then quoted the wrong amount to a customer (a real category of incident: a ₹25,000 item briefly listed at ₹250). Once the bug is fixed, you don't replay the whole conversation. You rewind to just before the bad tool call.
The same interrupt machinery powers human-in-the-loop (HITL) workflows. Compile with
interrupt_before = <node> and the graph pauses before that node, saves the current
state as a checkpoint, and hands control back to the caller. A human inspects or edits the state, then
calls invoke again with the same thread_id to resume.
route high-risk decisions to a human before they execute.
sample responses for human review and post-hoc correction.
fork a conversation at a checkpoint; run two prompts, compare.
use correct checkpoints as ground-truth for fine-tuning.
restore any historical state for forensic investigation.
the pause IS a checkpoint, so it survives a server crash.
Traditional HITL kept the process alive during review (fragile, expensive) or bolted on a custom queue. LangGraph collapses this: the interrupt is just a checkpoint with a "waiting for human" status flag. The server can restart while a review is pending; a human returns hours later, loads the paused state, edits, and resumes. No live process required.
Persistence faithfully stores everything — and "everything" grows every single turn. Each tool call appends a tool message; each agent reply appends an AI message; the checkpointer serializes all of it. The state list only ever gets longer.
Put numbers on it. A realistic turn is a 3-step ReAct loop (agent reasons → tool runs → response), so the message count climbs fast:
| After | Messages (≈3/turn) | Tokens (≈200/msg) |
|---|---|---|
| 10 turns | ~30 | ~6k |
| 20 turns | ~60 | ~12k |
| 50 turns | ~150 | ~30k → ~50–60k with reasoning steps |
A complex support case spanning several days reaching 150 messages is not unusual. And every one of those messages is re-sent to the model on every inference call, because the agent node prepends the system prompt and passes the full message list. Three failures stack up as it grows:
each call carries the whole history, so price rises linearly with the conversation.
the context gets noisier and more diluted, so answers get worse well before any limit.
eventually history exceeds the window; the API errors and the agent dies mid-chat.
An agent that works for 10 turns and goes confidently wrong at 50 is not a production system; it is a prototype whose failure is scheduled for later. The failure is not whether it happens but when, and how many live customer conversations catch it. Storage is not the worry — SQLite is tiny — the LLM cost and the reasoning collapse are.
Buying a larger context window does not solve too-much-memory; it hides it and makes reasoning worse. The marketing story — "the model can see everything, so it never loses track" — is factually wrong for building production systems.
Give a skilled consultant a tidy two-page brief and they perform brilliantly. Give the same consultant a disorganized 200-page archive and say "the facts are in there somewhere" and they struggle — same expertise, worse context management. The model faces exactly this. Reasoning quality tracks context quality, not context capacity.
The transformer spreads a fixed attention budget across all token pairs. As the sequence grows, the weight any single token can command shrinks. A critical fact from turn 2 of a 50-turn chat is competing with ~48 turns of later noise — and the longer the context, the harder it is for the important token to win that competition.
The needle-in-a-haystack benchmark plants one relevant fact at various positions in a long document and asks the model to retrieve it. Results are consistent across models: as the document grows and the target sits nearer the middle, retrieval accuracy falls. Models that hit 90–95% at tens of thousands of tokens drop to roughly 60–70% at 100k tokens. Adding tokens makes retrieval worse, not better.
Chain-of-thought reasoning is highly sensitive to how dense and how close the relevant context is. Over a long context, each hop that slightly mis-weights or mis-remembers an earlier fact compounds into an answer that is internally consistent with the noisy context but wrong about reality — the most dangerous kind of wrong, because it looks fluent.
Never design a production system that treats a bigger context window as a replacement for a context-management policy. The lesson to memorize: quality of reasoning is a function of the quality of context, not of how much context the model can technically receive.
APIs price per token, so an ever-growing context multiplies cost per call as the conversation runs. If you build a production agent, computing this is not optional — you cannot run a system whose cost you don't understand.
With no context management, the input tokens on each agent call climb with the turn number. The relationship is what matters; the exact prices shift per model version, but the structural growth is permanent.
| Turn | Input / call (unmanaged) | Relative cost | Managed (bounded) |
|---|---|---|---|
| 1 | ~500–600 tokens | 1× | ~1× |
| 5 | ~3,000 | ~5× | ~1× |
| 10 | ~6,000 | ~10× | ~2× |
| 50 | ~30,000 | ~50× | ~5× |
| 100 | bloated | ~100× | ~7× |
A properly managed system keeps the effective token count bounded regardless of length. At 100 turns that is roughly a 93% cost reduction (≈100× down to ≈7×). At a scale of a million conversations a month, this is not an optimization — it is the difference between a viable product and one that spends its entire infrastructure budget on a single feature.
Context management is deciding what to keep exactly, what to compress, and what to throw away — and these are architectural decisions, not something to delegate to the model. They need domain knowledge and must be written in code.
Passport in your hand (verbatim — you need it exactly), a folded itinerary in the side pocket (compressed — the gist is enough), yesterday's boarding pass in the bin (discard — it is noise now). You would never carry every scrap of paper you touched all trip; you triage.
The last 3–5 turns, kept exactly — here every word can matter.
Older but meaningful content — who the customer is, what was promised — into a dense summary that keeps facts, not phrasing.
Pleasantries, acknowledgements, and repeated restatements with no bearing on future reasoning.
Two strategies implement the framework. They have distinct trade-offs and failure modes — understand both before committing.
Pruning is like deleting old photos to free space — gone means gone. Summarization is JPEG: it discards high-frequency detail (exact wording, tone) for a much smaller file that is, for the purpose, indistinguishable from the original. You keep the meaning; you lose the pixels you didn't need.
At turn 2 the customer gives account C-1002; at turn 6 the agent looks it up (Past Due ·
$998). A trim keeps messages[0] and the last few, discarding turns 2–8. At turn 14 the
customer asks "so what should I do about the balance?" and the agent replies "Could you provide
your account ID?" — it has no record the ID ever existed, so the answer is fluent,
confident, and wrong. A human later reads the transcript, finds the ID sat at turn 2, and trust is
gone. The agent is not broken; the context-management strategy is. Acceptable for a stateless FAQ
bot; unacceptable for support — which is why the choice is domain-specific.
You are summarizing a customer support conversation to preserve key
context for future turns. Create a dense 3-5 sentence summary that
MUST include:
- Customer identifiers (account IDs, names)
- Financial amounts, dates, and balances
- What the customer reported and what was found
- Decisions made or actions taken
- Any unresolved items still pending
Do NOT include:
- Pleasantries or filler phrases
- Failed tool attempts
- Redundant reasoning steps
Respond with the summary only. No preamble.
Summarization logic gets its own node — never buried inside the agent node. The agent node has one job: given messages and tools, produce the next reasoning step. Adding memory maintenance to it gives it two jobs with different performance profiles, failure modes, and testing needs.
Put summarization inside the agent node, and a bug that silently drops the account ID during compression surfaces as "the agent called the CRM tool with the wrong ID." The symptom looks like a reasoning error or a tool-calling error; the root cause is a memory bug in a node that also owns reasoning. The failure surface is huge and the two responsibilities are tangled together — debugging is miserable.
A standalone summarization node has one job: take the message list, compress the old ones, update state, return. It can be unit-tested in isolation (assert the summary contains the required facts) and swapped for a better model — flash → pro — without touching the agent node. Since the ReAct loop lives in the agent node, any change there forces a full re-test of the whole loop; isolation avoids that entirely.
The node reads the full message list, calls the model with a dedicated compression prompt, and returns a
single dict that both writes the summary to a separate system_summary state field
and trims the message list down to the last four. That field was declared back in session 1 and
sat empty until now — a separate field, not an entry appended to messages, because
appending would grow the very list it is meant to shrink and would make the summary compete for
attention with the raw messages.
class SupportState(TypedDict):
...
system_summary: str # declared session 1, first populated now
def summarization_node(state):
messages = state["messages"]
summary = llm.invoke(
[SystemMessage(SUMMARIZATION_PROMPT), *messages]
).content
recent = messages[-4:] # keep the last 4 verbatim
return {"system_summary": summary, "messages": recent}
The agent then reads that field by prepending the summary to its system prompt — so it
sees prior context without the prior messages ever being resent. The old trim_context()
call inside the agent node is retired entirely; trimming now happens only inside the summarize node when
it fires.
def agent_node(state):
summary = state.get("system_summary", "")
context = (f"PRIOR CONTEXT:\n{summary}\n\n" + AGENT_SYSTEM_PROMPT) if summary \
else AGENT_SYSTEM_PROMPT
# messages already trimmed by the summarizer when it ran
messages_to_send = [SystemMessage(context), *state["messages"]]
return llm_with_tools.invoke(messages_to_send)
Because system_summary is a plain-text field serialized by the SQLite checkpointer, it
persists across sessions: the next day the agent loads full context from turn one. A realistic value
reads like "Customer Arjun Mehta (C-1002) reported past-due balance $998. CRM confirmed status.
Resolution pending payment receipt." — dense enough to hand a human agent for instant
review.
Routing is the other half. The maintenance node must not run every turn — that would add an LLM call and
constant latency to every response and erase the savings. Instead a conditional edge fires it
only when the message count exceeds a tunable threshold. The edge sits after the
classifier: by then the category is known and the input is deemed safe, so context is
managed before the agent reasons. Keep the threshold outside the production database so you can
tune it without a migration — the support sweet spot is SUMMARY_THRESHOLD = 8 (a low value
like 4 fires constantly and costs too much; a high value like 20 lets the window explode).
SUMMARY_THRESHOLD = 8 # support sweet spot; kept outside the prod DB so it's tunable
def route_after_classify(state: SupportState) -> str:
msg_count = len(state.get("messages", []))
if msg_count > SUMMARY_THRESHOLD:
return "summarization_node" # compress old context first
return "agent_node" # below threshold → straight to the agent
# in build_graph():
builder.add_conditional_edges(
"classify_node", route_after_classify,
{"summarization_node": "summarization_node", "agent_node": "agent_node"},
)
builder.add_edge("summarization_node", "agent_node") # then hand off to reasoning
Below the threshold, classify hands straight to the agent. Above it, the summarize node runs first, compresses the old context, and then hands off to the agent. Placing it after classify means the category is already known and the input already vetted, so context is managed at the last safe moment before reasoning begins.
The edge is evaluated once per turn, right after classify. It fires when the message count crosses
the threshold and again on later turns while the count stays above it — not on every internal node
of a turn. Raise SUMMARY_THRESHOLD for long-running sessions; because it is an external
parameter, you can tune it without redeploying schema.
Once summarization trims the list and a checkpointer re-applies state, the default message
reducer can produce duplicates. A reducer is the function LangGraph uses to merge a node's
returned messages into existing state. The default, add_messages, is blind to identity —
the summarizer returns recent messages, the agent appends new ones, and the checkpointer may re-apply
lists, so the same message can land twice.
A bouncer who just tallies headcounts lets the same guest in twice if they circle back. A bouncer
checking names off a list never does. add_messages is the first bouncer; an ID-based
reducer is the second — it checks each message's unique ID before admitting it.
def deduplicate_messages(left: list, right: list):
existing_ids = {m.id for m in left if hasattr(m, "id") and m.id}
new_msgs = [m for m in right
if not (hasattr(m, "id") and m.id in existing_ids)]
return left + new_msgs
class SupportState(TypedDict):
messages: Annotated[list, deduplicate_messages] # replaces add_messages
LangChain assigns every message a unique ID, so an O(n) set lookup is faster and more reliable than comparing message contents. This becomes essential — not optional — the moment you run a SQLite checkpointer and trimming logic at the same time, which is exactly the combination this agent uses.
A duplicated tool result or reply in state reads, downstream, like the agent restating things or double-counting a fact — another bug that masquerades as a reasoning problem. If you add summarization on top of a checkpointer and skip the ID-based reducer, expect intermittent, hard-to-reproduce repetition.
The checkpointer is passed at compile() time — it is a property of the graph,
defining how any run persists. The thread_id is passed at invoke()
time inside config.configurable — it identifies which conversation this
particular call belongs to. Separating them lets one compiled graph serve many isolated
conversations concurrently.
You enumerate the state history, load the checkpoint immediately before the error, optionally modify its state to correct a bad value, and resume from that point. The graph only re-executes nodes that came after that checkpoint, reusing all earlier work — so you skip the expensive LLM calls for the steps that were already correct.
Three reasons: attention dilution (a fixed attention budget spread over more tokens weakens every token), lost-in-the-middle (retrieval accuracy drops from ~90% to ~60–70% as documents grow and facts sit mid-context), and reasoning-chain degradation (small mis-weights compound into confident but wrong answers). Reasoning quality is a function of context quality, not context capacity.
Around 100× more per call. With a 3-step ReAct loop and ~200 tokens per message, turn 50 carries ~150 messages (~30k tokens, ~50–60k with reasoning steps) versus a few hundred tokens at turn 1. A managed context keeps the effective count bounded at roughly 7×, a ~93% reduction.
Silent failure. When a critical fact falls outside the window, the agent gets no signal it ever existed. It produces a fluent, confident, internally consistent answer that is simply wrong relative to what was discussed — so nobody notices until a customer is misinformed. That is why pruning suits independent FAQ questions but not stateful support.
Verbatim preservation keeps the recent 3–5 turns word-for-word. Semantic compression turns older high-meaning content into a dense summary that keeps facts and drops phrasing. Discarding removes noise — pleasantries, acknowledgements, repeated restatements. Keep · Squeeze · Bin.
Separation of concerns. The agent node's single job is reasoning; adding memory maintenance gives it two entangled responsibilities. A dedicated node can be unit-tested in isolation and swapped for a better summarization model without touching — or re-testing — the critical ReAct loop. It also makes bugs localizable: a dropped account ID surfaces at the summarization node rather than masquerading as a reasoning or tool-call error.
A conditional edge after the classify node routes to it only when the message count exceeds a
tunable threshold (SUMMARY_THRESHOLD = 8 for support). Running it every turn would
add an LLM call and constant latency to every response, erasing the cost savings it exists to
produce. It is placed after classify so the category is known and the input vetted before
context is managed.
Appending the summary to messages would grow the very list summarization is meant to
shrink, and it would compete with raw messages for the attention window. A separate
system_summary field lets the agent read it explicitly by prepending it to the
system prompt — so the agent gets prior context without the prior messages being resent. The
field is serialized by the SQLite checkpointer, so it persists across sessions.
The default add_messages reducer is blind to message identity. When the summarizer
trims the list and a checkpointer re-applies state, the same message can be merged twice. An
ID-based reducer (deduplicate_messages) checks each message's unique LangChain ID
with an O(n) set lookup and drops duplicates — more reliable and faster than comparing contents.
It becomes essential when a checkpointer and trimming logic run together.
In regulated industries, an AI that makes consequential decisions without an auditable history of how it decided may be non-compliant by design. State history provides the record needed for compliance review, forensic incident response, and reconstruction of any past conversation state.
system_summary
field.End of note · Session 8.1 · Persistence & Context Management
Build it · Chapter 14 · Atlas
Persistence fixed no memory; unbounded history creates too much memory. The fix is a policy — keep recent turns verbatim, compress older ones into a running summary, discard the raw originals — run in a dedicated maintenance node. Turns accumulate through a custom reducer instead of overwriting.
messages can append:engine.py — a reducer-aware merge:
# in StateGraph.__init__:
self.reducers = {} # key -> fn(old, new)
def add_reducer(self, key, fn):
self.reducers[key] = fn
return self
# in CompiledGraph.invoke, replace state.update(update) with:
for key, value in update.items():
if key in self.g.reducers:
state[key] = self.g.reducers[key](state.get(key), value) # combine
else:
state[key] = value # overwrite
atlas/context.py — the reducer + the maintenance node:
"""Chapter 14 — context management: keep memory useful, not just large.
Persistence (Ch 13) fixes "no memory"; it then creates "too much memory". The
fix is a policy — keep recent turns verbatim, compress older ones into a
summary, discard the raw originals — run in a dedicated maintenance node. The
message list is combined with a *custom reducer* so turns append instead of
overwrite.
"""
from atlas.engine import StateGraph, END
KEEP = 4 # how many recent messages to keep verbatim
class Replace:
"""Wrap a value to force replacement past an appending reducer."""
def __init__(self, value):
self.value = value
def add_messages(old, new):
"""Custom reducer: append new messages — unless told to replace."""
if isinstance(new, Replace):
return new.value
return (old or []) + new
def summarize(messages):
"""Stub summarizer — fold old turns into one running line. Swap for an LLM.
Strips any existing 'summary:' prefix so re-compression stays flat instead
of nesting summaries inside summaries."""
parts = [m["text"].removeprefix("summary: ") for m in messages]
return "summary: " + "; ".join(parts)
def respond(state):
"""A normal turn: echo the user, add an assistant reply (both appended)."""
user = state["incoming"]
return {"messages": [{"role": "user", "text": user},
{"role": "assistant", "text": f"noted: {user}"}]}
def manage_context(state):
"""Maintenance node: verbatim recent, compress+discard the rest."""
msgs = state["messages"]
if len(msgs) <= KEEP:
return {}
old, recent = msgs[:-KEEP], msgs[-KEEP:]
summary = {"role": "system", "text": summarize(old)}
return {"messages": Replace([summary] + recent)} # discard the raw old turns
def build_chat():
g = StateGraph()
g.add_reducer("messages", add_messages) # the custom reducer
g.add_node("respond", respond).add_node("manage", manage_context)
g.set_entry("respond")
g.add_edge("respond", "manage").add_edge("manage", END)
return g.compile()
if __name__ == "__main__":
chat = build_chat()
state = {"messages": []}
for turn in ["hi", "graphs?", "react?", "persistence?", "context?"]:
state = chat.invoke({**state, "incoming": turn})
print(f"{len(state['messages'])} messages kept (not 10):")
for m in state["messages"]:
print(f" {m['role']:9} {m['text']}")
Run it — you'll see:
5 messages kept (not 10): system summary: hi; noted: hi; graphs?; noted: graphs?; react?; noted: react? user persistence? assistant noted: persistence? user context? assistant noted: context?
This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.