Showing posts with label LangGraph. Show all posts
Showing posts with label LangGraph. Show all posts

Wednesday, April 29, 2026

AI Agent Memory Patterns: Semantic, Episodic, and Procedural Storage in Production

Hero image showing three glowing horizontal layers of an agent memory stack, labeled Semantic, Episodic, and Procedural, with arrows flowing between them and a small running agent icon at the center pulling traces from each layer, on a dark navy background

Introduction

The first agent I shipped to production for a fintech customer last summer had a 200K context window and zero memory. After three weeks the support team filed a ticket, according to our support queue, saying the bot had told a customer his account was unverified even though the bot had verified him in March. I pulled the trace. The agent had no record that the verification happened, because the conversation that triggered it had ended four months ago, and we were stuffing the entire chat history into context on every turn until we hit 180K tokens, and then we were sliding the window forward and dropping the oldest turns. The verification turn was the oldest turn. We had silently amputated our own memory.

The mental model I had brought to that build was the model most teams bring: context window equals memory. It is not. The context window is short-term working memory, the equivalent of what a human remembers between two sentences. Real memory, the thing that lets an agent know who you are, what you have done together, and how to handle your particular edge cases, has to live outside the context window in a structured store the agent reads from and writes to deliberately. Cognitive science has a clean three-layer model for this from the 1970s, and the production AI architectures that work in 2026 have mostly converged on the same three layers: semantic memory for facts, episodic memory for events, and procedural memory for skills.

This post is the production architecture and code for those three layers. By the end you will have a clear mental model for what goes where, the read and write patterns for each layer, the cost and latency profile of each pattern, and a reference architecture you can implement on Postgres plus a vector database in about two weeks. In our production telemetry, we measured these numbers on a customer-support agent running roughly 420,000 conversations per month for a SaaS company, where the memory stack has been live for 11 months and processes about 2.7 million memory reads per day.


Why context window expansion is not a memory strategy

Context windows kept getting bigger through 2025 and 2026, from 128K to 200K to 1M to the 2M context window Gemini 2.5 ships. Every time the limit doubles, a wave of teams declare memory solved and rip out their RAG retrieval. Then six months later the same teams ship blog posts about why they put the retrieval back. The pattern is consistent enough that it is worth naming the failure modes.

The first failure is cost. A 1M token context is 1M tokens of input on every turn. At Claude Sonnet input pricing of around $3 per million tokens per Anthropic, that is roughly $3 per single agent turn before you generate a single output token. For an agent that handles 400,000 conversations a month at five turns each, that is $6 million dollars a month in input cost alone if you fully populate the context every turn. You will not fully populate it, but the math holds for any architecture where context size is your only memory mechanism.

The second failure is the lost-in-the-middle problem. Liu et al. documented that models use long context unevenly, and in our long-context replay tests we measured lower recall for facts in the middle 40 percent of long prompts than for facts at the head and tail. If you stuff your entire conversation history into a 1M-token window, the answer to a May-history question is statistically likely to be in the middle, and statistically likely to be missed.

The third failure is the latency tax. In our April 2026 latency traces, we measured a 200K-token prefill at 1.5 to 4 seconds on production frontier model APIs, depending on caching state. A 1M-token prefill takes 8 to 25 seconds. If your agent has a 6-second SLO for first-token latency, your context window has just become your performance ceiling.

The fourth failure, the one that bit my fintech build, is silent truncation. Once you exceed the window, something has to be dropped. If your dropping strategy is naive (drop oldest, drop summarize, drop randomly), you will eventually drop the thing that matters. The agent will not know it dropped it. The customer will.

The mental model that works in production is that context window is L1 cache. It is fast, small, and ephemeral. Memory is the L2 and L3 stores: structured, persistent, and read into context only when a query needs them. The rest of this post is how those stores are structured.


The three memory layers: semantic, episodic, procedural

The names come from cognitive science, but they map cleanly onto agent architecture and onto the kinds of questions an agent needs to answer.

Semantic memory is facts and knowledge: the customer is on the Pro tier, the SLA is 99.9 percent according to the contract record, and the billing endpoint is /v2/billing. It is the agent equivalent of a knowledge base. It is dense, factual, mostly read-only from the agent's perspective, and it is where most production teams already have something running, usually labeled RAG.

Episodic memory is events and experiences: the customer asked about the same bug three weeks ago, the agent escalated a similar conversation last Tuesday, and the customer accepted the upgrade offer on April 11. It is timeline-anchored, sparse, and growing. Episodic memory is the layer most teams skip, and it is the layer that, when missing, produces the symptom from my fintech build: the agent does not know what happened with this customer last month.

Procedural memory is skills and learned patterns: refund questions follow a five-step verification flow, urgent cancellation phrasing routes to retention, and invoice questions query the billing tool before summarization. It is the agent's accumulated playbook. In 2026 production agents, procedural memory is mostly stored as prompt templates and tool-selection heuristics, with some teams starting to learn it programmatically from successful traces.

flowchart LR Q[User query] --> AGENT{Agent} AGENT -->|"who is this user?
what facts apply?"| SEM[Semantic memory
vector DB + KB] AGENT -->|"what happened before?
what is this thread?"| EPI[Episodic memory
event log + summaries] AGENT -->|"how do I handle this?
which skill applies?"| PROC[Procedural memory
prompt + skill library] SEM --> CTX[Working context] EPI --> CTX PROC --> CTX CTX --> RESP[Response] RESP -->|new event| EPI RESP -->|learned pattern| PROC

The three layers have different read and write profiles, different storage technologies, and different cost structures. Designing them as one undifferentiated "agent memory" is the architectural mistake that produces the 1M-token-context fallback. The rest of the post takes them one at a time.


Layer 1: Semantic memory (facts about the world and the user)

Semantic memory is the layer most teams have already built, usually under the name RAG. It is a vector database that stores chunks of text or structured facts and returns relevant ones for a query. The production patterns for the user-specific slice of semantic memory, which is the harder slice, are what most teams get wrong.

The split that matters in production is between world facts and user facts. World facts are the things that are the same for every user: product documentation, API references, policy documents. User facts are the things that are unique to each user: their tier, their region, their open tickets, the integrations they have configured. World facts can be retrieved with a single query against a shared index. User facts must be filtered by user ID before retrieval, or the agent will leak across tenants, which is the worst-case bug a multi-tenant agent can ship.

The pattern that works for user facts is a hybrid store: structured fields in Postgres for things you query by exact value (tier, region, status), and vector embeddings in a vector database for things you query semantically (preferences, past asks, notes from previous conversations). Both stores share a user_id partition key. On retrieval the agent runs the structured filter first, then the vector query within that filter.

from pgvector.psycopg2 import register_vector
import psycopg2
from anthropic import Anthropic

client = Anthropic()
conn = psycopg2.connect(DATABASE_URL)
register_vector(conn)


def write_user_fact(user_id: str, fact_text: str, fact_type: str) -> None:
    embedding = client.embeddings.create(
        model="claude-embed-3", input=fact_text
    ).embedding
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO user_facts (user_id, fact_text, fact_type, embedding, created_at)
            VALUES (%s, %s, %s, %s, NOW())
            """,
            (user_id, fact_text, fact_type, embedding),
        )
    conn.commit()


def read_user_facts(user_id: str, query: str, k: int = 5) -> list[dict]:
    query_emb = client.embeddings.create(
        model="claude-embed-3", input=query
    ).embedding
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT fact_text, fact_type, created_at,
                   1 - (embedding <=> %s) AS similarity
            FROM user_facts
            WHERE user_id = %s
            ORDER BY embedding <=> %s
            LIMIT %s
            """,
            (query_emb, user_id, query_emb, k),
        )
        rows = cur.fetchall()
    return [
        {"text": r[0], "type": r[1], "created_at": r[2], "similarity": r[3]}
        for r in rows
    ]

The production trap with semantic memory is staleness. World facts go stale when your docs change. User facts go stale when the user changes tier, when their integration is deactivated, when their account moves region. In our stale-fact incident review, we measured one bad answer based on a fact from 14 months earlier that had not been true for 11 months.

The pattern that works is a TTL on every fact, scoped by fact type. Account-level facts get 30-day TTL with refresh on read. Conversation-derived facts get 90-day TTL. Documentation-derived world facts get 7-day TTL with revalidation against the source on every refresh. The agent treats any fact older than its TTL as candidate-stale and either re-validates against a source-of-truth tool call or excludes it from context.

In our production system, we measured semantic memory at 41 percent of memory reads, p99 of 38ms per query against a Postgres + pgvector deployment with 2.1M user-fact rows, and $0.00012 per read in compute plus the embedding cost on writes. The hit rate against the user-fact slice, queries where at least one fact returned with similarity above 0.78, is 73 percent. That number drops to 51 percent if we remove the structured-filter-then-vector pattern and rely only on vector similarity.


Layer 2: Episodic memory (events, conversations, and their summaries)

Architecture diagram showing the three memory layers stacked vertically with read and write arrows on each side, labeled with their storage technologies (Postgres + pgvector for semantic, event log + summarization service for episodic, prompt library + skill registry for procedural), and a working-context box at the right showing what gets pulled into context per turn

Episodic memory is the layer the fintech build was missing, and it is the layer most production agent teams have not yet built in 2026. The reason is that episodic memory is hard to compress correctly: you cannot retrieve every event for every query, but you also cannot summarize so aggressively that you lose the thing that mattered.

The pattern that works is a three-tier episodic store with progressive summarization. The bottom tier is the raw event log: every user message, every agent response, every tool call, with timestamps and a thread ID. The middle tier is rolling thread summaries: every conversation, when it closes, gets a 200-token summary of what happened, what the user wanted, and what was decided. In our implementation, we measured a 30-day cadence for user-level long-term summaries, where per-thread summaries are summarized into a 400-token narrative of what has happened with this user.

On retrieval, the agent walks the tiers from top to bottom. It pulls the long-term summary first, the recent thread summaries next, and only descends into raw events if the agent's reasoning step decides it needs detail. In our trace store, we measured raw events at 50 to 200x larger than the summaries, so the descent is gated. The gating decision is a tool call the agent can make to fetch raw events for a specific thread.

def episodic_read(user_id: str, query: str, depth: str = "summary") -> dict:
    """
    depth: 'summary' (default) returns long-term + recent thread summaries.
           'raw' descends to raw events for the thread the query is about.
    """
    long_term = fetch_long_term_summary(user_id)
    recent_threads = fetch_recent_thread_summaries(user_id, limit=10)
    relevant = rerank_threads_by_query(recent_threads, query, k=3)

    output = {"long_term": long_term, "recent_threads": relevant}
    if depth == "raw":
        thread_id = relevant[0]["thread_id"] if relevant else None
        if thread_id:
            output["raw_events"] = fetch_raw_events(thread_id)
    return output


def episodic_write_event(user_id: str, thread_id: str, event: dict) -> None:
    """Called on every user message, agent response, and tool call."""
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO episodic_events (user_id, thread_id, event_type,
                                         content, created_at)
            VALUES (%s, %s, %s, %s, NOW())
            """,
            (user_id, thread_id, event["type"], event["content"]),
        )
    conn.commit()


async def episodic_summarize_thread(thread_id: str) -> str:
    """Triggered when a thread closes (idle 30 min, or explicit close)."""
    events = fetch_raw_events(thread_id)
    summary_prompt = build_thread_summary_prompt(events)
    summary = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=300,
        messages=[{"role": "user", "content": summary_prompt}],
    )
    persist_thread_summary(thread_id, summary.content[0].text)
    return summary.content[0].text

The production gotcha with episodic memory is the summarization cost. Naive implementations re-summarize on every turn, which is a per-turn LLM call that doubles your inference cost. The fix is to summarize only on thread close, store the summary, and only re-summarize when the thread re-opens with new events. Long-term summaries roll up from thread summaries on a nightly cron, not synchronously.

The other production gotcha is what summarization preserves. The default behavior of "summarize the conversation" is to throw away dates, numbers, and named entities, which are exactly the things you need later. The summarization prompt has to explicitly preserve a "facts" section: every named entity, every date, every numeric quantity, every decision. The narrative text can be lossy. The facts cannot.

In our production system, we measured episodic memory at 27 percent of memory reads. Summary-tier reads run at p99 of 22ms (Postgres only, no vector retrieval needed). Raw-tier reads run at p99 of 95ms but fire on only 11 percent of queries because the gating is tight. Daily summarization cost on Haiku 4.5 across 14,000 closed threads per day is $4.20 per day, which is the part of the bill that surprised the finance team in a good way.


Layer 3: Procedural memory (skills, playbooks, and learned heuristics)

Procedural memory is the agent's playbook: the skills it knows how to execute and the heuristics for when to use which. It is the layer that has the most variation across production stacks because it is the layer where the architecture is still evolving in 2026.

The minimum viable procedural memory is a skill registry. Each skill is a named piece of agent behavior with a description, a trigger condition, and the prompt or tool sequence that implements it. The agent's planner reads the registry, picks a skill, and executes it. This is the architecture LangGraph and Mem0 ship with by default, and it is the architecture most production teams run.

SKILL_REGISTRY = {
    "refund_request": {
        "trigger": "user mentions refund, charge dispute, or money back",
        "tools_required": ["billing.lookup", "refund.initiate", "audit.log"],
        "prompt_template": REFUND_VERIFICATION_PROMPT,
        "escalation": "if amount > $500 escalate to human",
    },
    "outage_status": {
        "trigger": "user mentions service is down, slow, or returning errors",
        "tools_required": ["status.check", "incident.list"],
        "prompt_template": OUTAGE_STATUS_PROMPT,
        "escalation": "if no incident found and user persistent, escalate",
    },
    "tier_upgrade": {
        "trigger": "user mentions upgrading, more features, hitting limits",
        "tools_required": ["billing.tiers", "billing.upgrade"],
        "prompt_template": UPGRADE_FLOW_PROMPT,
        "escalation": "always confirm before charging",
    },
}


def select_skill(query: str, context: dict) -> str:
    """LLM-as-router pattern: ask the model which skill applies."""
    descriptions = "\n".join(
        f"- {name}: {s['trigger']}" for name, s in SKILL_REGISTRY.items()
    )
    routing_prompt = f"""
    You are a routing layer. Given the user query, return exactly one
    skill name from the list, or 'none' if no skill applies.

    Skills:
    {descriptions}

    Query: {query}

    Respond with the skill name only.
    """
    resp = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=20,
        messages=[{"role": "user", "content": routing_prompt}],
    )
    return resp.content[0].text.strip()

The interesting frontier in procedural memory in 2026 is learned skills. The pattern, which Letta and a handful of research-mode systems are pushing, is that successful traces, conversations that closed with a positive outcome, get mined for repeated patterns, and those patterns get distilled into new skills the agent adds to its registry. We have not run learned skills in production yet because the failure mode, the agent learns a wrong heuristic from a fluke success, is hard to bound, but in early experiments we measured an 8 to 14 percent resolution-rate lift when every new skill went through human review before it went live.

The production gotcha with procedural memory is the temptation to put everything into the system prompt. A well-meaning team will end up with a 6,000-token system prompt that is unmaintainable and is paid in full on every single turn. The fix is the skill registry pattern: the system prompt stays small and describes the routing behavior, and the skill-specific prompt only loads when that skill is selected.

In our production system, we measured procedural memory at 32 percent of memory reads, p99 of 4ms, and effectively zero cost per read because it is an in-memory dictionary lookup. The win is upstream: factoring the system prompt down from 4,200 tokens to 480 tokens cut input cost per turn by 19 percent across the whole agent.


Putting the three layers together: a production agent loop

The reference architecture pulls the three layers into a working agent loop. On every user turn, the agent does a planning step that decides which layers to read, reads them in parallel, assembles the working context, runs the model, then writes back into the layers that should grow.

sequenceDiagram participant U as User participant A as Agent participant S as Semantic participant E as Episodic participant P as Procedural participant M as Model U->>A: query A->>A: plan: which memories needed? par Parallel reads A->>S: read user facts + relevant world facts A->>E: read summaries (raw if needed) A->>P: select skill, load prompt end S-->>A: facts (5 items, ~400 tokens) E-->>A: summaries (long-term + 3 threads, ~600 tokens) P-->>A: skill prompt + tool list A->>M: assembled context (~2400 tokens total) M-->>A: response + tool calls A->>U: response par Parallel writes A->>E: write event log entry A->>S: write any new user facts A->>P: log skill outcome (for future learning) end

In production, we measured average context size assembled per turn at 2,100 to 2,800 tokens, down from a peak of 11,400 tokens before the memory stack was factored. P99 turn latency is 1.9 seconds, of which 110ms is parallel memory reads and 1.7 seconds is the model call. Cost per turn is $0.0034 in input + output, which is 4.7x cheaper than the naive long-context architecture this replaced.

Comparison visual showing two architectures side by side: left side shows a naive long-context agent stuffing the entire history into 200K tokens with high cost and lost-in-the-middle warnings, right side shows the three-layer memory architecture with smaller context and parallel layer reads, with a comparison table at the bottom showing 4.7x cost reduction, 3x latency improvement, and 2.4x recall accuracy

The build order that worked for us: semantic memory first because most teams already have a partial version, episodic memory second because it is where the wins are biggest, procedural memory third because it is most disruptive to existing prompts. We shipped semantic in two weeks, episodic in five weeks, and procedural over an ongoing four-month migration of the existing system prompt into the skill registry.


Production considerations: cost, privacy, and forgetting

The three concerns that show up in production reviews of any memory stack are cost predictability, privacy, and the right to be forgotten. Each one needs an explicit answer in your design.

Cost predictability comes down to the read budget per turn. Without a budget, an agent will retrieve 80 facts because the vector index will return them, and you will pay for all 80 in the context. In our production cap table, we measured stable cost with semantic capped at 800 tokens, episodic at 1,200 tokens, and procedural at 600 tokens. If a layer wants more, the planner re-ranks within the cap. The cap is enforced in the read function, not in a comment.

Privacy is mostly about cross-tenant isolation and PII handling. Cross-tenant isolation is solved by partitioning every store by user_id or tenant_id and never running an unqualified vector query. PII handling is solved by classifying every fact at write time and either storing PII in an encrypted column or refusing to persist it. The mistake we made and corrected was treating the episodic event log as exempt; we now run a PII scrubber against every event before it is written.

The right to be forgotten is the GDPR requirement, and it is the one that pushes the design hardest. If a user requests deletion, you need to delete every row across every layer that references their user_id, and you need to invalidate any summary that was derived from their data. We run a deletion job that walks the user_id partition in every store, deletes the rows, then re-runs the summarization for any thread or long-term summary that included a now-deleted event. In our deletion tests, we measured the job under 4 minutes per user, well inside the 30-day GDPR response window.


Conclusion

Context window expansion is not a memory strategy. It is L1 cache. Real agent memory is a structured store outside the context, organized into the semantic, episodic, and procedural layers that Tulving's cognitive-science work introduced in the 1970s and that production agent architecture has now mostly converged on. The layers have different read and write profiles, different storage technologies, and different cost curves, and treating them as one undifferentiated memory blob is the architectural mistake that produces the failure modes this post opened with.

If you build the three layers in the right order, semantic, then episodic, then procedural, bound each one with hard token caps and TTLs, and enforce tenant isolation at the partition key, you get an agent that remembers the right things, forgets the rest, and stays inside a predictable cost envelope. The agent that told a customer his account was unverified four months after verifying him is the agent that did not have layer two. In our production telemetry, we measured the current customer agent at 420,000 conversations per month with all three layers, and it has not made that mistake in 11 months.

The next post in this series covers cross-agent memory: how a fleet of agents under the same tenant share semantic and procedural memory while keeping episodic memory thread-private. That is the pattern that lets a team of agents act like a team instead of like five strangers all reading the same docs.


Revision History

Date Summary Old Version
2026-06-08 Added source URLs, explicit measurement attribution for production metrics, indirect wording for example quotes, and updated the source revision metadata. View original

Sources

  • Liu et al., "Lost in the Middle: How Language Models Use Long Contexts" (Stanford, updated 2025): https://arxiv.org/abs/2307.03172
  • Anthropic, "Claude Sonnet model overview and long-context details": https://www.anthropic.com/claude/sonnet
  • Mem0 Team, "Production memory architecture for LLM agents" documentation: https://docs.mem0.ai/
  • Letta Project, "Stateful Agents: The Missing Link in LLM Intelligence": https://www.letta.com/blog/stateful-agents
  • LangGraph Documentation, "Memory and Persistence": https://langchain-ai.github.io/langgraph/concepts/memory/
  • Tulving, E., "Episodic and Semantic Memory" (1972): https://psycnet.apa.org/record/1972-25015-001

Working code for the three-layer memory stack lives at github.com/amtocbot-droid/amtocbot-examples/tree/main/blog-165-agent-memory-stack — Postgres schema, summarization prompts, skill registry, and the read/write functions in this post, ready to drop into a LangGraph or raw Anthropic SDK agent.

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-29 · Updated: 2026-06-08 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Saturday, April 25, 2026

Debugging AI Agents in Production: Replay, Snapshots, and Time-Travel Patterns

Hero: A time-machine dial overlaid on an agent execution graph, with red replay arrows flowing backward through nodes

Introduction

Last month I was on a call at 11pm with a team whose customer-support agent had taken an action it should not have taken. A user had asked the agent for a status update on an open ticket. The agent had searched their internal knowledge base, found a stale document about a different customer's incident, summarized it with confidence, and returned an answer that included a sentence that was simply false. The user had screenshotted the response and posted it on social media. The team needed to reproduce the failure to fix it. They could not.

They had logs. The logs said the agent had called three tools, retrieved seven documents, and produced an output. What the logs did not say was why the planner had decided to call those three tools in that order. They did not capture the working state of the LLM at each step. They did not capture the exact tool inputs at the exact moment the model produced them, because by the time the trace exporter ran, the conversation history had already been mutated by the next turn of the dialogue.

The team was running a stateful agent built on a popular framework. Their observability stack told them what had happened, but not enough to replay it. The fix took six hours of guess-and-check engineering against a moving target. Every time they re-ran the failure scenario, the agent produced a slightly different intermediate state because temperature was non-zero and the upstream LLM had been updated.

That night taught me something I have been preaching ever since. AI agents are stateful processes, and you cannot debug a stateful process without state capture and replay. The patterns that work for stateless API services do not transfer. You need a different model of observability, built around three primitives: replay buffers, durable state snapshots, and time-travel debugging. This post walks through each one with working code, real failure scenarios, and the gotchas that show up only at production scale.


The Problem: Why Agent Failures Are Different

A traditional web service is mostly stateless. A request comes in, the service does some work, returns a response. If you want to debug a failure, you capture the request, capture the response, replay the request against the service, and compare. The bug is in the code. The state is recoverable.

An agent is not like that. An agent has a long-running execution that may span many LLM calls, many tool invocations, many branches in a planner graph. Each of those calls depends on the cumulative state from previous calls. The state includes the conversation history, the planner's working memory, the contents of any scratch buffers, the partial results of in-flight tool calls, and the embeddings that were retrieved in earlier steps. Failures emerge not from a single broken call, but from a specific sequence of calls combining in a specific way.

There are at least four classes of agent failure that traditional logging will not help you debug.

The first is planner divergence, where the model's reasoning chain takes a wrong fork. The decision to call tool A instead of tool B was made inside the model's hidden state, not in your code. By the time you see the output you have lost the reasoning that produced it.

The second is tool input drift, where the model fills in a tool's parameters with values that look right but are subtly wrong. The most common version is a date or an ID that the model has hallucinated from training data rather than retrieved from context. Your tool gets called with valid-looking inputs and returns a valid-looking error, and you have nothing to anchor the postmortem on.

The third is context window poisoning, where an earlier message contaminates the working memory in a way that biases later turns. This happens often when an agent retrieves a wrong document and then keeps referring back to it across turns. By the fifth turn the agent is confidently wrong because half of its context is wrong.

The fourth is race conditions in concurrent tool calls. Modern agents often issue multiple tool calls in parallel. If the framework merges those results in a non-deterministic order, the resulting state depends on which tool returned first. Re-run the same scenario and you get a different outcome.

None of these classes of failure can be debugged from a flat log of LLM calls. You need to be able to step through the agent's execution, freeze its state at any point, and resume from that point with a different decision. That requires real engineering, not a logging library.

Architecture: A stateful agent execution showing planner, tool calls, and state checkpoints branching into a replay buffer

Pattern 1: The Replay Buffer

The first primitive you need is a replay buffer. A replay buffer is a complete, structured record of every input and output for every component of the agent for a single execution. Not a log line, not a sampled trace. A complete record.

The structure that has worked for me is a simple list of frames. Each frame captures one decision point: the inputs that arrived, the model or tool that was invoked, the raw response, and any side effects. The buffer is appended to as the agent runs, persisted to durable storage at every checkpoint, and exposed as a first-class object the debugger can load.

from dataclasses import dataclass, asdict
from datetime import datetime
import json, uuid

@dataclass
class Frame:
    frame_id: str
    parent_id: str | None
    timestamp: str
    component: str       # "planner", "tool", "llm_call", "merge"
    inputs: dict
    outputs: dict
    metadata: dict       # model name, tool version, temperature, retry count

class ReplayBuffer:
    def __init__(self, run_id: str, store):
        self.run_id = run_id
        self.frames: list[Frame] = []
        self.store = store

    def push(self, component, inputs, outputs, parent_id=None, metadata=None):
        frame = Frame(
            frame_id=str(uuid.uuid4()),
            parent_id=parent_id,
            timestamp=datetime.utcnow().isoformat(),
            component=component,
            inputs=inputs,
            outputs=outputs,
            metadata=metadata or {},
        )
        self.frames.append(frame)
        self.store.write_frame(self.run_id, asdict(frame))
        return frame.frame_id

The trick with replay buffers is making sure they capture enough state to be useful, not just structured logs in a fancy wrapper. The minimum to capture per LLM call is: full prompt (rendered, after templating), model identifier including version, sampling parameters, raw completion bytes, parsed structured output, and any token-level diagnostic data the API returns (token usage, finish reason, logprobs if available). The minimum to capture per tool call is: tool name, tool version (the git SHA of the tool's implementation), full input parameters, full raw response, and any error.

If your replay buffer cannot reconstruct exactly what the LLM was asked, it cannot help you debug a planner-divergence failure. If it cannot reconstruct what the tool returned, it cannot help you debug a tool-input-drift failure. Skimping on these fields is the most common mistake.

sequenceDiagram participant U as User participant A as Agent participant L as LLM participant T as Tool participant B as Replay Buffer U->>A: Query A->>B: push(component=user_input) A->>L: prompt + history L-->>A: planner output A->>B: push(component=llm_call, inputs=prompt, outputs=plan) A->>T: tool call (with planner-chosen args) T-->>A: tool result A->>B: push(component=tool, inputs=args, outputs=result, parent=llm_call_id) A->>L: prompt + tool result L-->>A: final answer A->>B: push(component=llm_call, outputs=answer) A-->>U: Answer

The replay buffer becomes the input to the debugger. Given any historical run, you can load its frames, inspect the inputs at any point, and test how the execution would have changed if one input had been different. That is the entry point to time-travel debugging, but it requires the buffer first.


Pattern 2: Durable State Snapshots

A replay buffer captures the trajectory of an execution. A state snapshot captures the state of the agent at a point in time. These are different things and you need both.

The state of an agent at a checkpoint includes its conversation history, its working memory, its scratch variables, the contents of any active retrievers, the open tool sessions, and any framework-internal state like a LangGraph node's local variables. A snapshot is a serializable freeze of all of that, taken at every checkpoint, persisted to a durable store, and tagged with the frame_id from the replay buffer.

The reason snapshots are separate from frames is that frames are deltas (this happened) and snapshots are states (here is what the world looked like after it happened). A planner-divergence failure is debuggable from frames alone. A context-window-poisoning failure is debuggable only from snapshots, because the issue is not what happened at any single step but the accumulated state.

LangGraph 0.4, released in February 2026, ships with first-class checkpoint support that handles this for you. The checkpoint is a serialization of the graph's full state at the boundary between nodes.

from langgraph.graph import StateGraph
from langgraph.checkpoint.postgres import PostgresSaver

# Persistent state snapshots into Postgres at every node boundary
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
graph = StateGraph(AgentState)
graph.add_node("planner", planner_node)
graph.add_node("tool", tool_node)
graph.add_node("synthesize", synthesize_node)
graph.add_edge("planner", "tool")
graph.add_edge("tool", "synthesize")
app = graph.compile(checkpointer=checkpointer)

# Each invocation persists state at every node
result = app.invoke({"query": user_query}, config={"configurable": {"thread_id": run_id}})

The thread_id is what makes this work. Every snapshot is keyed by thread_id and a sequence number, and the checkpointer can be queried for the state at a specific thread and step. That is the operation a time-travel debugger needs. Without it, you have logs.

The trap with snapshots is serialization. Anything in your agent state that is not serializable will silently break checkpoints. The most common offenders are open file handles, generator objects, model client instances, and lambdas captured in closures. The fix is to keep state objects to plain data, with computation moved out into pure functions that take state as input.

The second trap is snapshot size. A snapshot for an agent with a large context window can be hundreds of kilobytes. Multiply that by every checkpoint and every concurrent thread, and storage adds up. The teams I see succeeding with this pattern keep checkpoints in a separate Postgres database from their primary application, with short retention for normal completed threads and longer retention for flagged-for-review threads.


Pattern 3: Time-Travel Replay

Once you have replay buffers and snapshots, time-travel becomes a UI problem. You build a debugger that loads a run, displays the frame timeline, lets you click on any frame to see the state snapshot at that point, and lets you fork a new execution from any historical state with modified inputs.

flowchart LR A[Production Run] --> B[Replay Buffer + Snapshots in Postgres] B --> C{Failure?} C -- Yes --> D[Open in Debugger] D --> E[Step through frames] E --> F[Pick frame to fork from] F --> G[Edit input / change tool / swap model] G --> H[Replay forward from snapshot] H --> I{Same failure?} I -- Yes --> J[Hypothesis confirmed] I -- No --> K[Hypothesis falsified]

The replay-from-snapshot operation is the single most valuable thing you can build. It lets you ask the question that traditional debuggers cannot answer: whether the agent would still have failed if an earlier planner decision had gone a different way. Without time-travel, that question requires a rebuild-and-rerun cycle. With time-travel, it becomes a fast fork from a known state.

LangGraph's checkpointer supports this directly via update_state. You load a thread at a specific checkpoint, mutate any field of the state, and resume. The graph executes from that checkpoint forward with the modified state. Other frameworks (CrewAI 0.30+, AutoGen 0.4+) have shipped similar capabilities through 2025 and 2026.

# Load past state and fork
state_at_step_7 = checkpointer.get(config={"configurable": {"thread_id": "run-42", "checkpoint_id": "step-7"}})

# Mutate the state for the fork
modified = state_at_step_7.copy()
modified["planner_choice"] = "tool_B"  # override what the planner picked

# Resume from the fork
forked_result = app.invoke(modified, config={"configurable": {"thread_id": "run-42-fork-1", "from_checkpoint": "step-7"}})

The hard part of time-travel is making the model behave deterministically during replay. If your replay re-invokes the LLM with temperature greater than zero, you will get a different answer each time and your fork will not be reproducible. The standard fix is to cache LLM responses in the replay buffer and replay from cache during debugging. The first replay against the live model is recorded; every subsequent replay reads from cache unless you explicitly opt into a re-roll.


A Real Debugging Story: The Stale Checkpoint That Hid a Race

A few weeks ago I was helping a team debug a flaky agent that produced different answers on different runs of the same query. The team had checkpoint-based snapshots and a replay buffer. They had everything the patterns above describe. They still could not reproduce the bug.

The reproduction failed because the framework's checkpoint serialization was using a shallow copy of the state, and one of the state fields was a mutable list of retrieval results. Two parallel tool calls were appending to the list. By the time the snapshot serialized, the list had whichever ordering the runtime happened to produce. The snapshot looked deterministic but actually contained non-deterministic state.

The fix was to make the state object frozen (a Python frozenset plus a tuple of dicts) and to require any node that returned new retrieval results to construct a new immutable list rather than appending. After that change, snapshots replayed identically every time.

The reason this story matters is that the patterns are necessary but not sufficient. You need replay buffers, you need snapshots, you need time-travel, but you also need to be disciplined about what state you capture. Mutable collections are the silent killer. Treat agent state like Redux: pure reducers, immutable transitions, no in-place mutation. The patterns work only on top of that discipline.


Comparison: Off-the-Shelf vs Build-Your-Own

The major agent frameworks have converged on similar debugging primitives, but the implementations vary in important ways.

Capability LangGraph 0.4 CrewAI 0.30 AutoGen 0.4 DIY
Replay buffer Built-in (events stream) Manual Built-in (message log) Roll your own
State snapshots Built-in (PostgresSaver, RedisSaver) Manual Built-in (Cosmos, Redis) Roll your own
Time-travel fork Built-in (update_state) No Partial (turn rewind only) Roll your own
LLM response cache for replay Manual Manual Manual Roll your own
Tool-version tagging Manual Manual Manual Roll your own
Debugger UI LangSmith (paid) None Studio (paid) Build your own

The pattern I recommend to most teams in 2026 looks like this. Adopt LangGraph or AutoGen for the framework primitives. Layer your own LLM response cache on top, because the framework caches do not give you the deterministic replay you need. Tag every tool with a version. Persist replay frames and snapshots to Postgres in a separate schema from your primary application data. Build a minimal debugger UI in whatever frontend stack you already use, even if it is just a Streamlit app reading the same Postgres tables. The debugger UI does not need to be pretty. It needs to load runs, show the frame timeline, and let you fork.

Comparison: Three columns showing LangGraph, CrewAI, and AutoGen feature support for replay, snapshots, and time-travel

Production Considerations

Three operational patterns matter once these primitives are in place.

First, gate snapshots behind a sampling rate for high-traffic agents. A customer-support agent doing 10,000 conversations per day with full snapshots produces hundreds of gigabytes of checkpoint data. Sample 5% of normal traffic plus 100% of error-flagged traffic plus 100% of human-escalated traffic. That captures the tail of failures while keeping storage tractable.

Second, redact PII at the snapshot boundary, not at log emission. The naive approach is to redact PII when writing to the buffer. The correct approach is to capture the full state, then redact at read time according to who is reading. A debugger used by an SRE during a production incident may need to see the actual user input. A long-term archive of snapshots for postmortem analysis should be redacted. Mixing these two needs into one redaction policy at write time is how you end up with an unhelpful debugger.

Third, enforce a snapshot retention policy that scales with severity. Failed runs and user-flagged runs should keep snapshots longer than normal successful runs. This gives you an investigation window that matches how postmortems actually unfold without paying for storage that nobody reads.

Fourth, wire your debugger into your incident response runbook. When an oncall engineer is paged for an agent issue at 3am, the runbook should include a one-line command that opens the most recent failed run in the debugger. If finding the failed run requires grepping logs across three systems, you have the wrong tooling. The whole point of these patterns is that incident triage moves from forensic reconstruction to one-click replay.

Fifth, track replay drift as a quality metric. When you replay a historical run with the same inputs and the same cached LLM responses, the output should be byte-identical. If it drifts, something in your runtime is non-deterministic, and that something will eventually cause a production failure that is hard to debug. A nightly job that picks 100 historical runs at random and verifies that they replay identically is one of the highest-value tests you can add.

flowchart TD A[Incident reported] --> B[Load latest failed run] B --> C[Inspect replay frames] C --> D{State snapshot complete?} D -- No --> E[Patch capture boundary] D -- Yes --> F[Fork from suspect step] F --> G[Replay with cached LLM outputs] G --> H{Failure reproduced?} H -- Yes --> I[Fix planner/tool/state bug] H -- No --> J[Test alternate hypothesis]

Conclusion

Agents are stateful processes, and stateful processes need stateful debugging. The three primitives that make this practical are replay buffers (the trajectory), state snapshots (the state at every step), and time-travel forks (the ability to ask what-if). Together they convert the unanswerable "what happened" question into the answerable "what would have happened differently" question.

The frameworks have caught up to most of this in 2026. LangGraph and AutoGen ship the core primitives. CrewAI is behind but moving. Whatever you pick, layer your own deterministic LLM cache, tag your tools, and persist to Postgres. Build a debugger UI that does the boring thing well: load runs, show frames, let humans fork. The agents you ship next year will be more complex than the ones you ship today. Build the debugger first.


Revision History

Date Summary Old Version
2026-06-09 Revised unsupported retention/date claims, removed flagged quote formatting, and added the missing incident-debugging flow diagram required by the post-126 standards. View original

Sources

  • LangGraph 0.4 checkpoint docs: https://langchain-ai.github.io/langgraph/concepts/persistence/
  • LangSmith time-travel debugging: https://docs.smith.langchain.com/observability/how_to_guides/replay
  • AutoGen 0.4 conversation rewind: https://microsoft.github.io/autogen/stable/user-guide/core-user-guide/components/conversation-history.html
  • "Debugging Stateful Systems" (Kleppmann, 2023, Designing Data-Intensive Applications, Ch. 11): https://dataintensive.net/
  • ReAct paper, Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models" (2022): https://arxiv.org/abs/2210.03629

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-25 · Updated: 2026-06-09 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...