Agentic Systems · Persistence & Context Management

Memory That Survives, Memory That Scales

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.

Updated July 2026 · Read ~18 min · Session 8.1


// the big idea

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

Notebook Summary

Hand-copyable revision sheet. Every line here is derivable from the Detailed Notes below.

Two problems, in order

  • Last session: solved no-memory → agent forgets on restart.
  • This session: solve too-much-memory → history grows forever.
  • Memory is a spectrum: too little = amnesia; too much = cost + noise. Aim for the middle.
  • ✎ doodle: a dial with "amnesia" on left, "bloat" on right, arrow pointing to center.

Persistence (recap)

  • Checkpointer = SqliteSaver → persists all state to a local .db file.
  • thread_id → isolates each conversation; resume any session by its id.
  • Wired at two points: checkpointer at compile, thread_id at invoke.
  • Pause is itself a checkpoint → survives server restart.
  • Same thread_id + no CRM re-call → tool result read straight from checkpoint. ⭐

Rewind / time travel

  • Workflow → enumerate history → find checkpoint before error → load → modify state → resume.
  • Re-runs only nodes after the checkpoint. No full-conversation replay. ⭐
  • HITL uses: compliance review · QA · A/B test (fork) · training data · incident response.
  • interrupt_before = node → pause, hand control to caller, resume with same thread_id.
  • ✎ doodle: a timeline of dots (checkpoints); circle one, arrow labelled "resume here".

The bloat math

  • Per turn ≈ 3-step ReAct loop (agent → tool → response).
  • 1 message ≈ 200 tokens. Turn 50 → 150 msgs × 200 ≈ 30k → ~50–60k w/ reasoning.
  • Cost ∝ conversation length — turn 50 ≈ 100× turn 1.
  • Managed context @ 100 turns → ~93% cheaper (100× → ~7×).

Why bigger window ≠ fix

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
  • 90–95% recall @ small ctx → 60–70% @ 100k tokens.
  • Rule: quality of reasoning = f(quality of context), not f(size of window). ⭐
  • ✎ doodle: long bar, needle icon dead center, "?" over model's head.

Three-layer framework

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
Aid: Keep · Squeeze · Bin.

Pruning vs. summarization

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
  • Sliding-window failure = silent + fluent + wrong. Worst kind. ⚠
  • Summary = lossy like JPEG: drop phrasing/tone, keep facts/decisions/IDs.

Maintenance node

  • Separation of concerns → summarization gets its OWN node, not inside agent node. ⭐
  • Routed by conditional edge after classify: count > 8 → summarize → agent.
  • SUMMARY_THRESHOLD = 8 (support sweet spot). Low=fires constantly; high=window explodes.
  • Node body → summarize all msgs, trim to last 4, write system_summary.
  • Store summary in own field, NOT in messages (would regrow list + fight for attention).
  • agent_node prepends summary to system prompt → context w/o resending msgs. trim_context() retired.
  • system_summary persisted by SQLite → survives restart, = portable audit log.
  • If summarizer drops account_id, symptom looks like a tool/reasoning bug → hard to debug. ⚠
  • ✎ doodle: classify → diamond ">8?" → (yes) summarize box → agent; (no) straight to agent.

Custom reducer

  • Default add_messages is ID-blind → summarize + checkpointer = duplicate msgs.
  • Replace with deduplicate_messages: skip msgs whose id already in list (O(n) set).
  • Essential when SQLite checkpointer + trimming run together. ⚠

Section 2

Detailed Study Notes

2.1The two-day support ticket that breaks

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.

2.2Persistence: giving the agent memory

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).

Analogy · the auto-saving document

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:

checkpointer (at compile)

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.

thread_id (at invoke)

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.

graph assembly — the only change from a stateless agent
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)
thread_id: u4-s0001 classify agent respond thread_id: u9-s0002 classify agent support.db checkpoints, isolated by thread_id
Fig 2.2 — each node writes a checkpoint; the thread_id keeps two conversations from mixing.
⭐ Trick — the checkpoint is your cache

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.

💡 Tip — compliance, not just convenience

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.

2.3Time travel, rewind & human-in-the-loop

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.

enumerate history→ find checkpoint before error→ load it→ modify state→ resume execution
c1c2 c3 · beforebad tool stale rewind + fix state resume — re-runs only what's after c3
Fig 2.3 — rewind to the checkpoint before the fault, correct the state, resume; earlier nodes never re-run.

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.

compliance review

route high-risk decisions to a human before they execute.

quality assurance

sample responses for human review and post-hoc correction.

A/B testing

fork a conversation at a checkpoint; run two prompts, compare.

training data

use correct checkpoints as ground-truth for fine-tuning.

incident response

restore any historical state for forensic investigation.

restart-safe pause

the pause IS a checkpoint, so it survives a server crash.

⭐ Trick — the pause outlives the process

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.

2.4The too-much-memory time bomb

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:

cost ∝ length

each call carries the whole history, so price rises linearly with the conversation.

reasoning degrades

the context gets noisier and more diluted, so answers get worse well before any limit.

hard crash

eventually history exceeds the window; the API errors and the agent dies mid-chat.

⚠ Pitfall — a prototype with a deferred failure

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.

2.5Why a bigger context window is not the fix

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.

Analogy · the case file

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.

Attention dilution

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.

10 tokens · weight ~0.10 each strong signal 100 tokens · weight ~0.01 each diluted 10,000 tokens · weight ~0.0001 each barely heard budget = 1.0 (fixed) ÷ N tokens = per-token weight ↓ more tokens ≠ more attention → it just spreads thinner
Fig 2.5 — one fixed budget divided by ever more tokens; each token's share collapses.

Lost in the middle

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.

Reasoning-chain degradation

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.

⚠ Pitfall — window size as a substitute for strategy

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.

2.6The unit economics of long conversations

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.

conversation turns → cost / call → unmanaged (~100×) managed (~7×, bounded) ≈93% saved 150100
Fig 2.6 — unmanaged cost grows with length; a managed context stays flat. The gap is the whole game.

2.7The three-layer context framework

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.

Analogy · packing a bag

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.

Layer 1 · Verbatim keep word-for-word recent 3–5 turns every word may matter Layer 2 · Compress dense summary older, high-meaning facts kept, phrasing dropped Layer 3 · Discard drop entirely pleasantries, acks repeated restatements Keep · Squeeze · Bin
Fig 2.7 — the three layers: verbatim recent, compressed older, discarded noise.

1 · verbatim

The last 3–5 turns, kept exactly — here every word can matter.

2 · compress

Older but meaningful content — who the customer is, what was promised — into a dense summary that keeps facts, not phrasing.

3 · discard

Pleasantries, acknowledgements, and repeated restatements with no bearing on future reasoning.

2.8Pruning vs. summarization

Two strategies implement the framework. They have distinct trade-offs and failure modes — understand both before committing.

Sliding window (pruning)

  • Simplest form: keep the last N messages.
  • N is the design knob: 4–6 saves cost hard; 20–30 keeps more history, saves less.
  • Anything past the boundary is irretrievably lost.
  • The agent gets no signal the lost info ever existed.
  • Good only when each question is independent.

Summarization

  • Compress old messages instead of dropping them.
  • Over a threshold, a dedicated LLM call writes a dense summary.
  • Summary stored as a separate state field, injected into the system prompt.
  • Lossy: phrasing/tone go, facts/decisions/IDs stay.
  • Right choice for multi-turn support that must remember.
Analogy · lossless vs. lossy

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.

⚠ Pitfall — the silent failure of pruning

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.

SUMMARIZATION_PROMPT — architect-owned, not model-owned
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.

2.9The maintenance node & separation of concerns

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.

⚠ Pitfall — the entangled bug

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.

⭐ Trick — isolate so you can test and swap

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.

What the node actually does

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.

summarization_node — one job: compress old context, trim, write the summary field
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.

agent_node — read the summary instead of trimming inline
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)
💡 Tip — the summary is a portable audit record

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).

classify node→ route: count > 8?→ yes summarize node→ agent node
classify count > 8 ? summarize node compress + trim to last 4 agent node reads system_summary tool node respond → END yes no ReAct loop summarize runs once, before the agent reasons — invisible to the user
Fig 2.9 — the conditional edge sits after classify: over threshold, route through summarize first, then into the normal agent/tool ReAct loop.
conditional edge — route after classify, before the agent reasons
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.

💡 Tip — how often does it actually fire?

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.

2.10The duplicate-message trap & a custom reducer

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.

Analogy · the guest list at the door

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.

deduplicate_messages — ID-based, replaces add_messages
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.

⚠ Pitfall — duplicates look like the model repeating itself

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.

2.11Interview & practice Q&A

Where exactly do you wire the checkpointer and the thread_id, and why two different places?

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.

How does rewind avoid re-running the whole conversation?

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.

Why is a larger context window not a valid substitute for context management?

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.

Roughly how much does an unmanaged conversation cost by turn 50 vs. turn 1?

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.

What is the single most dangerous failure mode of the sliding-window strategy?

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.

What are the three layers of context management, and what does each do?

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.

Why put summarization in its own node instead of inside the agent node?

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.

How is the summarization node triggered, and why not run it every turn?

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.

Why store the summary in its own field and prepend it, instead of adding it to the message list?

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.

Why do you need a custom message reducer once summarization is added, and what does it do?

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.

Why can persistence be a legal requirement rather than just a feature?

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.

2.12Key takeaways

checkpointer thread_id SqliteSaver time travel / rewind interrupt_before HITL too-much-memory cost ∝ length attention dilution lost in the middle reasoning decay verbatim · compress · discard sliding window silent failure lossy summarization maintenance node separation of concerns route_after_classify SUMMARY_THRESHOLD = 8 system_summary prepend to prompt dedup reducer

End of note · Session 8.1 · Persistence & Context Management

Build it · Chapter 14 · Atlas

Build it — context management & summarization

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.

File atlas/context.pyRun python3 -m atlas.contextDeps Python 3 · stdlib only
First, teach the engine to reduce. Instead of overwriting a state key, a reducer combines old and new — so 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 ↗.