Agentic AI · LangGraph Persistence
How to give a support agent memory that survives crashes and multi-day cases — and then stop that same memory from quietly bankrupting you and wrecking its reasoning.
An agent's memory is an architecture problem with two opposite failure modes. Too little memory and it forgets the customer the moment the server restarts. Too much and its context bloats — cost climbs linearly, reasoning degrades, and it eventually overflows the window and crashes. Persistence fixes the first; a deliberate three-layer context strategy fixes the second. Neither is optional in production.
Hand-copyable revision notes. Every line here is derivable from the Detailed Notes below.
| Use | What |
|---|---|
| Compliance | route high-risk decisions to human before execute |
| QA | sample responses, correct post-run |
| A/B test | fork at checkpoint, 2 prompts, compare |
| Training data | correct checkpoints = ground truth |
| Incident | restore old state for forensics |
| Layer | Action | On |
|---|---|---|
| 1 Verbatim | keep word-for-word | recent 3–5 turns |
| 2 Compress | semantic summary | older high-meaning |
| 3 Discard | drop | pleasantries, ack, noise |
A customer opens a chat on Monday about a double charge. They come back Thursday to finish it. If your agent forgot everything the instant the server restarted, they have to explain the whole thing again — and they will not be gracious about it. So you give the agent memory: every message, every tool result, written to disk. It works. Then, fifty turns into a knotty case, that same agent starts quoting the wrong invoice number, and the bill for a single reply quietly costs a hundred times what the first one did. Memory solved one problem and created its mirror image. This lesson is about handling both ends.
Persistence is giving the agent a place to write down what happened so it isn't lost when the program stops. A plain chat agent holds the conversation in RAM. Kill the process — a deploy, a crash, an autoscaler reclaiming the box — and the entire conversation evaporates. For a throwaway demo that's fine. For a support product where one case spans days, it's fatal.
Think of the difference between a cashier who remembers your order only while you're standing there, and one who writes your ticket on a rail. Shift change, lunch break, power cut — the ticket is still on the rail. Persistence is the rail.
The mechanism is the checkpoint: after each step of the graph, the agent's full state is serialized and saved. A checkpointer is the component that does the saving. In the last session the state lived only in memory (a MemorySaver); here we swap in one backed by a real database so it outlives the process.
Because every step is checkpointed, the whole conversation is a list of saved states you can walk back through — that's "time travel." The naive read is that this is a debugging convenience. The sharper read, and the one the instructor stressed, is that in regulated industries a system that makes consequential decisions without a history of those decisions is non-compliant by design. The audit trail isn't a nicety; it's the point.
The practical payoff is rewind: correcting a mistake surgically instead of restarting the conversation.
The worked example: a pricing tool returns a wrong price because of a data bug (the instructor's real-world echo — a ₹25,000 item briefly listed at ₹250). The agent quotes the bad number to the customer. Once the service bug is fixed, you don't apologize and restart. You reload the checkpoint just before the pricing call, replace the bad tool output with the right one, and resume. Everything before that point — already correct — is reused as-is.
Resuming from a checkpoint re-executes only the nodes after it. The earlier steps, including their expensive LLM calls, are not re-run — you pay to regenerate just the part that was actually wrong. Error handling flips from "sorry, let's start over" to detect → rewind → correct → continue.
Once you can pause, inspect, fork, and restore states, a human can be inserted anywhere in the loop. "Human-in-the-loop" (HITL) just means a person reviews or corrects the agent at some point. Time travel is the infrastructure that makes all five of these cheap:
Route high-risk decisions to a human before they execute.
Sample responses for human review and correct them after the run.
Fork a conversation at a checkpoint, run two prompts, compare outcomes.
Treat human-approved checkpoints as ground-truth examples for fine-tuning.
Restore any conversation to a past state for forensic investigation.
The two-response "which do you prefer?" pattern you've seen in ChatGPT is exactly the A/B fork: same checkpoint, two backend prompts, and the human's pick is the signal. Being able to name this in an interview shows you understand forking, not just prompting.
LangGraph has a formal pause button: compile the graph with interrupt_before (or interrupt_after) a node, and execution stops there and hands control back. The current state is exposed for inspection and editing. When the reviewer is done, you call invoke again with the same thread_id; the state updates and the graph resumes from where it paused.
The reason this is robust: the pause is a checkpoint with a "waiting for human" flag. If the server restarts while the graph is parked, the pause is still on disk — the human can return hours later, load the state, make changes, and continue. Compare that to traditional HITL, which either keeps the process alive for the whole review window (expensive and fragile) or bolts on a custom queue to route the request out to a human and the answer back. LangGraph collapses that complexity into the checkpoint infrastructure you already have.
The entire graph stays the same — the only production change is where the state is saved. The graph is still classifier → agent ⇄ tool → respond with conditional edges between them. Two edits promote it to production-grade: a database-backed checkpointer at compile time, and a thread configuration at invoke time.
Persists every checkpoint to a local file — support.db (plus -wal and -shm from SQLite's write-ahead log).
Each conversation lives under its own thread id, so many users run at once without bleeding into each other.
Every node's snapshot is retrievable — step, source node, category, message count, final response, checkpoint id.
A recovery script resumes a conversation from a simulated crash without re-running completed steps.
from langgraph.checkpoint.sqlite import SqliteSaver
# One local file holds every thread's checkpoints
checkpointer = SqliteSaver.from_conn_string("support.db")
# Same builder as before — only .compile() changes
graph = builder.compile(checkpointer=checkpointer)
# thread_id is what isolates one conversation from another
config = {"configurable": {"thread_id": "u4-s00001"}}
graph.invoke({"messages": [user_msg]}, config)
[+] The code is idiomatic LangGraph shown for precision; the lesson described these APIs (SqliteSaver, thread_id config, get_state_history, update_state) without walking every line.
The behaviour that makes it click: send a second message on the same thread and the agent already has the full prior context loaded from disk. Ask "what was the outstanding balance?" and it answers 998 directly — the node path shrinks to classify → agent → respond, with no tool call, because the CRM result from the first turn is already sitting in the checkpoint.
# Full history is restored from support.db for this thread
config = {"configurable": {"thread_id": "u4-s00001"}}
graph.invoke(
{"messages": [HumanMessage("what was the outstanding balance?")]},
config,
)
# -> "Your outstanding balance is 998."
# classify -> agent -> respond (get_customer_details is NOT called again)
Skipping the repeat tool call isn't just faster — it's cheaper and more consistent. The agent reads the prior tool result straight out of the checkpoint instead of paying for another CRM round-trip and risking a different answer.
The persistent agent faithfully keeps everything — and "everything" is the problem. Every tool call appends a tool message; every agent turn appends an AI message; the checkpointer serializes all of it. There's no forgetting. The architecture that fixed no-memory now grows without bound, and it detonates in production without warning.
A three-step react loop per turn (agent thinks → tool runs → agent responds) means each turn adds roughly three messages. After 10 turns that's ~30 messages; after 50 turns, ~150. A genuinely complex case spanning several days easily reaches 150+ messages — and the full list is sent to the model on every single inference call.
| Turn | Input tokens / call | Multiplier |
|---|---|---|
| 1 | ~500–600 | 1× |
| 5 | ~3,000 | 5× |
| 10 | ~6,000 | 10× |
| 20 | ~12,000 | 20× |
| 50 | ~30,000 (≈50–60k with reasoning steps) | ~100× |
Figures are the lesson's worked estimates (≈200 tokens/message, 3 messages/turn), for scale — not measured benchmarks.
Three failure modes ride along together: cost grows linearly, reasoning quality degrades as the context gets noisier, and eventually the history exceeds the model's window and the API errors out — the production agent crashes mid-conversation, on a live customer. Last session solved the no-memory problem; this session solves the too-much-memory problem.
The instructor reframes this as reliability, not thrift: an agent that's correct for 10 turns and confidently wrong at 50 isn't a cheap product — it's a prototype with a deferred failure mode, and it fails at the worst possible time.
"Just use a million-token window" is an architectural error, not a solution. A larger window is more expensive and, more importantly, doesn't make reasoning better. Two mechanisms explain why.
A transformer spreads a fixed attention budget across all token pairs. As the sequence grows, the share landing on any one token shrinks. A critical fact stated at turn 2 of a 50-turn case is competing with 48 turns of subsequent noise for the model's attention.
| Tokens in context | Avg attention weight / token |
|---|---|
| 10 | 0.1 |
| 100 | 0.01 |
| 10,000 | 0.0001 |
The needle-in-a-haystack (a.k.a. "lost in the middle") benchmark makes it concrete: hide one relevant fact at varying positions in a long document and ask the model to find it. Recall that sits around 90–95% at tens of thousands of tokens can fall to roughly 60–70% at 100,000 tokens, worst when the target is buried in the middle. Adding tokens doesn't improve retrieval — it makes it worse.
Chain-of-thought reasoning is sensitive to how dense and close the relevant context is. Each hop that slightly mis-weights or mis-remembers an earlier fact compounds; over a long noisy context these errors accrete into answers that are internally consistent with the mess but wrong about reality.
A skilled consultant handed a clean two-page brief runs circles around the same consultant handed a disorganized 200-page archive and told to find the facts themselves. Same expertise; different context management. Quality of reasoning is a function of the quality of context — not how much context technically fits.
Never let context-window size stand in for a context-management strategy. Reaching for a bigger window is the reflex that quietly caps your accuracy and multiplies your bill at the same time.
The architect's job is to write down policies for how information flows through the window over time — and those are domain decisions, not something to hand the model. Every piece of history is sorted into one of three layers:
Verbatim means exactly as said — for the last few turns, exact wording (account IDs, amounts, promises) matters. Semantic compression keeps the meaning of older content while throwing away the phrasing. Discard removes procedural noise that carries no bearing on future reasoning. These judgments are architectural: they depend on your domain, and they must be explicit in code.
The simplest strategy is to keep only the last N messages and drop the rest. N is the one design knob. A small window (4–6) slashes cost but creates a hard memory horizon: anything older is permanently gone, and the agent doesn't even know it existed. A large window (20–30) preserves more but saves less — and no value of N escapes the core limitation: once a message falls outside the window, it's irretrievable.
At turn 2 the customer gives account C00001 and invoice I218. With a 6-message window, by turn 10 both have fallen off. Asked "what was the invoice number again?", the agent doesn't say "I lost that" — it hallucinates a plausible invoice number or goes vague. It never flags that the information existed. Confident, fluent, wrong.
So is pruning ever right? For an FAQ chatbot where each question is independent, yes — nothing before this question should influence the answer. For a support agent tracking one evolving case, absolutely not. That's the domain specificity of the choice.
Instead of discarding old messages, compress them. When the message count crosses a threshold, a dedicated LLM call produces a dense summary of the older messages, stored in its own state field and injected into the agent's system prompt.
Lossless vs lossy compression. A PNG keeps every bit; a JPEG throws away high-frequency detail for a much smaller file, and for most images you can't tell. Summarization is JPEG for conversation: exact wording, tone, and the precise reasoning sequence are lost; the key facts, decisions, commitments, and customer data are kept. For support, that's the right trade.
You are a precise summarizer for customer support.
Preserve EXACT values (account IDs, invoice numbers, amounts).
Compress; do NOT keep verbatim wording.
Delete acknowledgements and procedural back-and-forth.
Output ONE paragraph, max 5 sentences. No preamble, no labels.
Knowing how to summarize isn't enough — where the logic lives in the graph decides whether you can maintain it. The agent node has one job: pure reasoning (invoke the LLM with the bound tools and current messages). Stuff summarization inside it and you've given one node two jobs with different performance profiles, failure modes, and testing needs.
Say the in-node summarizer has a bug that silently drops the customer's account ID during compression. The symptom shows up as the agent calling the CRM tool with the wrong ID — it looks like a reasoning error or a tool-calling error. The root cause is memory management. Because the two jobs are tangled together, the failure surface is huge and the bug is miserable to find.
The fix is separation of concerns: a dedicated summarization node with one job — take the message list, compress the older ones into a summary, update state, return. Well-defined input and output means it can be unit-tested (assert the summary still contains the required facts) and swapped (upgrade flash → pro) without touching — or re-testing — the critical agent node and its whole react loop.
def summarize_node(state):
msgs = state["messages"]
old, recent = msgs[:-VERBATIM], msgs[-VERBATIM:] # layer 2 vs layer 1
summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(old))
return {"system_summary": summary.content, "messages": recent}
The summarization node must not run on every turn — that would erase the savings and add latency to every response. It fires only when the message count exceeds a threshold (a tunable parameter, kept outside the production DB so it can be changed freely). Routing is a LangGraph conditional edge, the same pattern used everywhere else, applied to a new decision.
def route_after_agent(state):
if len(state["messages"]) > THRESHOLD: # too big -> maintain
return "summarize"
if state["messages"][-1].tool_calls: # needs a tool -> tools
return "tools"
return END # done
Both the summarize node and the tool node always loop back to the agent node. Summarization is a maintenance operation, not a terminal state — which is exactly why it's called a maintenance node. The shape is the react loop with one extra branch, nothing more exotic.
Keep the threshold as an external, executable parameter — never baked into the production database. When a case runs long, you tune the threshold to trade compression frequency against overhead, and the summarizer simply re-fires each time the count crosses the line again.
[!] The lesson quoted the react-loop circuit breaker as both 12 (the MAX_ITERATIONS constant) and "maximum 5" in passing; treat 12 as the configured cap and the smaller number as a typical per-turn count. Also: 1M-token windows were attributed once to Gemini 1.5 Pro and once to Gemini 2.5 Flash — the exact model label isn't load-bearing for the point.
In regulated industries, an AI system that makes consequential decisions without a durable record of those decisions is non-compliant by design. The checkpoint history is the audit trail — it lets you reconstruct exactly what state produced a given decision. Debugging is a bonus; the legal defensibility is the reason it's mandatory.
You reload the checkpoint immediately before the fault, optionally correct the state, and resume. Only the nodes after that checkpoint re-execute. Everything earlier — already correct — is reused, including its expensive LLM calls, so you regenerate just the part that was wrong.
The pause is saved as a checkpoint with a "waiting for human" flag, so it lives on disk. If the server restarts, the paused state is still there and a human can resume it hours later with the same thread_id. Traditional HITL instead keeps the process alive (fragile, costly) or bolts on a custom queue — LangGraph folds it into the checkpoint infrastructure you already have.
Two reasons. Attention dilution: the model's fixed attention budget is spread across all tokens, so each token gets less weight as the sequence grows — needle-in-a-haystack tests show recall dropping from ~90% to ~60–70% as context balloons. And reasoning-chain degradation: small mis-weights compound into confidently wrong answers. Bigger windows also cost more. Quality tracks the quality of context, not its size.
Pruning (sliding window) keeps the last N messages and discards the rest — cheap and simple, but anything older is gone silently. It's fine for an FAQ bot where each question is independent. Summarization compresses older messages into a dense summary instead of dropping them, preserving facts across a long case. For a stateful support agent, summarization is the right call.
Separation of concerns. The agent node's single job is reasoning; adding summarization gives it two jobs with different failure modes. A summarizer bug (e.g. dropping an account ID) then masquerades as a reasoning or tool-call error and is painful to isolate. A dedicated node is unit-testable and swappable without re-testing the whole react loop.
Only when the message count exceeds a tunable threshold — never on every turn, or you erase the savings and add latency to every response. A conditional edge routes to "summarize" when count > threshold, to "tools" when the last message has tool calls, and to END otherwise. The threshold is kept external to the production DB so it can be changed freely.
Keep: key facts, decisions, commitments, and exact customer-specific values (account IDs, invoice numbers, amounts). Throw away: exact wording, emotional tone, the precise reasoning sequence, and procedural back-and-forth. It's the JPEG trade — meaning preserved, phrasing discarded — which is acceptable for most support use cases.
Dev testing runs short conversations that never exceed the window, so nothing is ever lost. In production a long case pushes early facts out of the window and the agent answers confidently wrong without flagging it. Catch it with long-conversation tests that assert an early-turn fact (an account ID stated at turn 2) is still answerable at turn 10+, not just short happy-path runs.
Reading proves you recognise the idea; these prove you can use it. Attempt each before opening the walkthrough.
Write the conditional-edge function route_after_agent(state) that decides where the graph goes after the agent node.
Requirements:
THRESHOLD = 12
# state["messages"] -> list of message objects
# state["messages"][-1] -> last message; .tool_calls is truthy when a tool is requested
# valid returns: "summarize" | "tools" | END
def route_after_agent(state):
... # your code
Expected: a 20-message state returns "summarize"; a 4-message state whose last message has tool calls returns "tools"; a 4-message state with no tool calls returns END.
Step 1 — get the order right. Maintenance is checked first, unconditionally on size. If you checked tool calls first, a bloated conversation that also happens to request a tool would skip summarization and keep growing — the exact failure the node exists to prevent.
Step 2 — size gate. Compare len(state["messages"]) against THRESHOLD. Strictly greater than, matching "exceeds".
Step 3 — tool gate. Only reached when the state is small enough. Inspect the last message's .tool_calls; if truthy, the agent asked for a tool, so route to "tools".
Step 4 — fall through to END. No maintenance needed and no tool requested means the turn is done.
from langgraph.graph import END
def route_after_agent(state):
if len(state["messages"]) > THRESHOLD:
return "summarize"
if state["messages"][-1].tool_calls:
return "tools"
return END
Why: this single function is the whole maintenance loop — checking size before tools is what guarantees the graph summarizes before it overflows, and both "summarize" and "tools" edges loop back to the agent.
This agent node was "optimised" to manage its own context. Predict exactly what goes wrong, name the root cause, then fix it.
def agent_node(state):
# summarize the history, then reason
summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(state["messages"]))
state["system_summary"] = summary.content
msgs = [SystemMessage(state["system_summary"])] + state["messages"]
return {"messages": [llm_with_tools.invoke(msgs)]}
Predict: what does this cost you per turn, and what happens the day the summarizer drops the account ID?
Root cause — two failures, one node. First, there's no threshold gate: the summarizer runs on every turn, so you pay for an extra LLM call and add its latency to every single response — the cost savings summarization was supposed to buy are gone. Second, reasoning and memory maintenance are entangled in one node.
Predict the failure. The day the summary prompt silently drops the customer's account ID, the agent builds msgs without it and then calls the CRM tool with a missing or wrong ID. The visible symptom is a bad tool call or a confidently wrong answer — it looks like a reasoning bug, so you'll debug the react loop for hours. The real defect is in the summarizer, in this same node. No exception is thrown; it fails as wrong output, which is worse than a crash.
Fix — separate the concerns and gate on size. Move summarization into its own node and let a conditional edge decide when it runs.
def agent_node(state): # reasoning ONLY
system = [SystemMessage(state.get("system_summary", ""))]
msgs = system + state["messages"]
return {"messages": [llm_with_tools.invoke(msgs)]}
def summarize_node(state): # maintenance ONLY
old, recent = state["messages"][:-VERBATIM], state["messages"][-VERBATIM:]
summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(old))
return {"system_summary": summary.content, "messages": recent}
# route_after_agent (from 3.1) fires summarize_node only when count > THRESHOLD
Why: now a summarizer bug surfaces at the summarize node — unit-testable in isolation — and it only runs when the threshold is crossed, so the agent node stays a pure, cheap reasoning step.