Long-running LLM agents that crash mid-execution lose all their progress — and restarting from scratch wastes time, money, and API calls. This expert lesson teaches you how to design and implement a complete agent checkpointing system with tool call caching, rollback to arbitrary prior states, and transparent resume — so your agents survive network failures, rate limits, and any other production hazard.

Picture this: you've built an autonomous agent that researches competitors, synthesizes findings from a dozen sources, drafts a strategic report, validates its claims against your internal knowledge base, and finally formats everything for your executive team. The whole pipeline takes 40 minutes and costs several dollars in API calls. It's running beautifully — 32 minutes in, 87% complete — when your cloud provider throttles your API connection and the entire process crashes. You restart it from scratch. Forty minutes and another few dollars later, it crashes again, this time because a downstream tool returned a malformed response.
This is the painful reality of long-running LLM pipelines in production. Unlike a simple RAG query-and-answer cycle that completes in under a second, complex multi-step agents execute dozens of tool calls, accumulate retrieved context across many steps, and make branching decisions that build on each other. Any failure — network timeout, rate limit, malformed tool output, context overflow, or even a provider outage — means losing all that accumulated work. Without checkpointing, you're not just losing time and money; you're losing the intermediate reasoning state that made your agent's trajectory coherent.
By the end of this lesson, you'll know how to design and implement a robust checkpointing system that persists your agent's state at meaningful execution boundaries, enables precise rollback to any prior checkpoint, and resumes execution seamlessly without re-running completed steps. We'll go deep on the mechanics — serialization strategies, storage backends, idempotency guarantees, and the subtle bugs that bite you in production.
What you'll learn:
You should be comfortable with:
Before jumping into implementation, let's get precise about what makes agent checkpointing fundamentally harder than checkpointing a batch data pipeline or a training job.
In a traditional ETL pipeline, your state is your data. You write transformed records to a staging table, and if something fails, you resume from the last committed batch. The data is the checkpoint. But an LLM agent's state is a mixture of things that don't serialize cleanly together:
1. The conversation/message history. This is the sequence of messages — user, assistant, tool call, tool result — that forms the LLM's context window. It's structured but can be large, and it must be reproduced exactly to get deterministic (or near-deterministic) behavior on resume.
2. The agent's working memory and scratchpad. Intermediate reasoning, partially constructed plans, lists of things to investigate. This might live inside the message history, or it might be separate state your orchestration layer maintains.
3. Tool outputs and external observations. The results of web searches, database queries, code execution, API calls. These are often not reproducible (search results change, APIs return different data at different times), so you must cache them, not re-run them.
4. The execution graph position. In non-linear agents that branch and merge (e.g., fan-out to five sub-agents, then synthesize), you need to know not just "what step" you're on, but which branches completed and which didn't.
5. Side effects. If your agent sent an email, wrote to a database, or made a purchase at step 12, you cannot "undo" those on rollback without explicit compensating actions.
This combination means a naive approach — just pickle the agent object — will fail in practice because: (a) LLM client objects aren't picklable, (b) you lose tool output caching, (c) you can't roll back to an earlier point without a full snapshot, and (d) you have no way to reason about which side effects occurred.
Key insight
A checkpoint isn't just a snapshot of data. It's a commitment point that guarantees: "Everything before this point succeeded and need not be repeated. Everything after this point is safe to re-execute." This guarantee requires both state persistence and idempotency contracts on every operation that follows a checkpoint.
The foundation of everything is a clear, versioned data model for your checkpoints. Let's design this from first principles.
A checkpoint represents the complete state of an agent at a specific point in its execution. Every checkpoint has:
from dataclasses import dataclass, field
from typing import Any, Optional
from datetime import datetime
import uuid
@dataclass
class ToolCallRecord:
"""A cached tool invocation and its result."""
tool_name: str
tool_input: dict[str, Any]
tool_output: Any
called_at: datetime
duration_ms: int
error: Optional[str] = None
# Deterministic cache key for replay matching
cache_key: str = field(default="")
def __post_init__(self):
if not self.cache_key:
import hashlib, json
content = json.dumps(
{"tool": self.tool_name, "input": self.tool_input},
sort_keys=True
)
self.cache_key = hashlib.sha256(content.encode()).hexdigest()[:16]
@dataclass
class AgentCheckpoint:
"""Complete serializable state of an agent at one execution point."""
# Identity
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
run_id: str = "" # Groups all checkpoints for one agent run
parent_id: Optional[str] = None # Previous checkpoint (forms a linked list / tree)
branch_id: str = "main" # For parallel execution branches
# Position in the execution graph
step_index: int = 0
step_label: str = "" # Human-readable: "retrieve_competitor_data", "synthesize_report"
# Core state
messages: list[dict] = field(default_factory=list) # Full message history
agent_variables: dict[str, Any] = field(default_factory=dict) # Orchestration state
tool_call_cache: list[ToolCallRecord] = field(default_factory=list)
# Metadata
created_at: datetime = field(default_factory=datetime.utcnow)
llm_tokens_used: int = 0
llm_cost_usd: float = 0.0
status: str = "completed" # "completed" | "failed" | "in_progress"
error_info: Optional[dict] = None
# Schema version for forward compatibility
schema_version: int = 2
This model captures everything you need. The parent_id field is what allows you to form a checkpoint tree — critical for agents that branch. The tool_call_cache means that when you resume from a checkpoint, any tool call that was already made (with the same inputs) returns the cached result rather than hitting the live API.
Note
The branch_id field matters for multi-agent orchestration scenarios where a parent agent fans out to several sub-agents running in parallel. Each sub-agent gets its own branch, and you need to checkpoint them independently before merging their results.
The store is responsible for persisting, loading, and querying checkpoints. Let's build an abstract interface first, then concrete backends.
from abc import ABC, abstractmethod
import json
import dataclasses
class CheckpointStore(ABC):
@abstractmethod
async def save(self, checkpoint: AgentCheckpoint) -> str:
"""Persist a checkpoint. Returns checkpoint_id."""
...
@abstractmethod
async def load(self, checkpoint_id: str) -> AgentCheckpoint:
"""Load a specific checkpoint by ID."""
...
@abstractmethod
async def load_latest(self, run_id: str, branch_id: str = "main") -> Optional[AgentCheckpoint]:
"""Load the most recent completed checkpoint for a run."""
...
@abstractmethod
async def list_checkpoints(self, run_id: str) -> list[AgentCheckpoint]:
"""List all checkpoints for a run, ordered by step_index."""
...
@abstractmethod
async def delete_run(self, run_id: str) -> None:
"""Clean up all checkpoints for a completed run."""
...
For local development and single-process deployments, SQLite is perfect. It's zero-config, ACID-compliant, and fast enough for most agent workloads.
import aiosqlite
import json
from dataclasses import asdict
class SQLiteCheckpointStore(CheckpointStore):
def __init__(self, db_path: str = "agent_checkpoints.db"):
self.db_path = db_path
async def _init_db(self):
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS checkpoints (
checkpoint_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
parent_id TEXT,
branch_id TEXT NOT NULL DEFAULT 'main',
step_index INTEGER NOT NULL,
step_label TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
payload TEXT NOT NULL, -- Full JSON blob
schema_version INTEGER NOT NULL DEFAULT 2
)
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_run_branch "
"ON checkpoints(run_id, branch_id, step_index)"
)
await db.commit()
async def save(self, checkpoint: AgentCheckpoint) -> str:
await self._init_db()
# Serialize the full checkpoint to JSON
payload = self._serialize(checkpoint)
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
INSERT OR REPLACE INTO checkpoints
(checkpoint_id, run_id, parent_id, branch_id, step_index,
step_label, status, created_at, payload, schema_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
checkpoint.checkpoint_id,
checkpoint.run_id,
checkpoint.parent_id,
checkpoint.branch_id,
checkpoint.step_index,
checkpoint.step_label,
checkpoint.status,
checkpoint.created_at.isoformat(),
payload,
checkpoint.schema_version,
))
await db.commit()
return checkpoint.checkpoint_id
async def load_latest(self, run_id: str, branch_id: str = "main") -> Optional[AgentCheckpoint]:
await self._init_db()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute("""
SELECT payload FROM checkpoints
WHERE run_id = ? AND branch_id = ? AND status = 'completed'
ORDER BY step_index DESC LIMIT 1
""", (run_id, branch_id)) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return self._deserialize(row[0])
def _serialize(self, checkpoint: AgentCheckpoint) -> str:
data = asdict(checkpoint)
# Convert datetime objects to ISO strings
data['created_at'] = checkpoint.created_at.isoformat()
for record in data['tool_call_cache']:
record['called_at'] = record['called_at'].isoformat() \
if isinstance(record['called_at'], datetime) else record['called_at']
return json.dumps(data, default=str)
def _deserialize(self, payload: str) -> AgentCheckpoint:
data = json.loads(payload)
data['created_at'] = datetime.fromisoformat(data['created_at'])
data['tool_call_cache'] = [
ToolCallRecord(**{
**r,
'called_at': datetime.fromisoformat(r['called_at'])
if isinstance(r['called_at'], str) else r['called_at']
})
for r in data['tool_call_cache']
]
return AgentCheckpoint(**data)
When you need sub-second checkpoint writes across multiple agent workers, Redis is the right tool. We use sorted sets keyed by run_id:branch_id to enable fast range queries.
import redis.asyncio as aioredis
class RedisCheckpointStore(CheckpointStore):
def __init__(self, redis_url: str, ttl_seconds: int = 86400 * 7):
self.client = aioredis.from_url(redis_url)
self.ttl = ttl_seconds
async def save(self, checkpoint: AgentCheckpoint) -> str:
payload = self._serialize(checkpoint)
# Store the full checkpoint blob
cp_key = f"checkpoint:{checkpoint.checkpoint_id}"
# Index by run+branch+step for range queries
index_key = f"run_index:{checkpoint.run_id}:{checkpoint.branch_id}"
async with self.client.pipeline() as pipe:
pipe.setex(cp_key, self.ttl, payload)
# Use step_index as the sorted set score
pipe.zadd(index_key, {checkpoint.checkpoint_id: checkpoint.step_index})
pipe.expire(index_key, self.ttl)
await pipe.execute()
return checkpoint.checkpoint_id
async def load_latest(self, run_id: str, branch_id: str = "main") -> Optional[AgentCheckpoint]:
index_key = f"run_index:{run_id}:{branch_id}"
# Get the checkpoint with the highest step_index
results = await self.client.zrevrange(index_key, 0, 0)
if not results:
return None
checkpoint_id = results[0].decode()
return await self.load(checkpoint_id)
async def load(self, checkpoint_id: str) -> AgentCheckpoint:
payload = await self.client.get(f"checkpoint:{checkpoint_id}")
if payload is None:
raise ValueError(f"Checkpoint {checkpoint_id} not found or expired")
return self._deserialize(payload.decode())
Warning
Redis without persistence (AOF or RDB snapshots) is not a durable checkpoint store. If your Redis instance restarts without persistence configured, all your checkpoints vanish. Either enable Redis persistence or use Redis only as a fast cache in front of a durable store like S3 or PostgreSQL.
Now let's integrate checkpointing into an actual agent execution loop. The key design decision here is checkpoint granularity: how often do you write a checkpoint?
For most production agents, the right granularity is after each tool call completes and after each LLM reasoning step that produces a substantive decision. Let's build a wrapper around this:
import asyncio
from typing import Callable, AsyncGenerator
from openai import AsyncOpenAI
class CheckpointingAgentRunner:
def __init__(
self,
store: CheckpointStore,
llm_client: AsyncOpenAI,
tools: list[dict],
tool_executors: dict[str, Callable],
model: str = "gpt-4o",
):
self.store = store
self.llm = llm_client
self.tools = tools
self.tool_executors = tool_executors
self.model = model
async def run(
self,
run_id: str,
initial_messages: list[dict],
resume_from: Optional[str] = None,
) -> str:
"""Execute an agent run with full checkpointing. Returns final response."""
# --- RESUME LOGIC ---
if resume_from:
checkpoint = await self.store.load(resume_from)
print(f"Resuming from checkpoint {resume_from} (step {checkpoint.step_index})")
messages = checkpoint.messages
cached_tool_calls = {r.cache_key: r for r in checkpoint.tool_call_cache}
step_index = checkpoint.step_index + 1
parent_id = checkpoint.checkpoint_id
total_cost = checkpoint.llm_cost_usd
else:
# Try to find an existing checkpoint for this run_id
existing = await self.store.load_latest(run_id)
if existing:
print(f"Found existing checkpoint at step {existing.step_index}, resuming...")
messages = existing.messages
cached_tool_calls = {r.cache_key: r for r in existing.tool_call_cache}
step_index = existing.step_index + 1
parent_id = existing.checkpoint_id
total_cost = existing.llm_cost_usd
else:
messages = list(initial_messages)
cached_tool_calls = {}
step_index = 0
parent_id = None
total_cost = 0.0
all_tool_records: list[ToolCallRecord] = list(
cached_tool_calls.values()
)
# --- MAIN EXECUTION LOOP ---
while True:
# Call the LLM
response = await self.llm.chat.completions.create(
model=self.model,
messages=messages,
tools=self.tools,
tool_choice="auto",
)
message = response.choices[0].message
tokens = response.usage.total_tokens
# Rough cost estimate — adjust per your model
cost = tokens * 0.000015
total_cost += cost
messages.append(message.model_dump(exclude_none=True))
# If no tool calls, the agent is done
if not message.tool_calls:
# Final checkpoint marking completion
await self._save_checkpoint(
run_id=run_id,
parent_id=parent_id,
step_index=step_index,
step_label="final_response",
messages=messages,
tool_records=all_tool_records,
cost=total_cost,
status="completed",
store=self.store,
)
return message.content
# Process tool calls with caching
tool_results = []
for tool_call in message.tool_calls:
import json, hashlib
tool_name = tool_call.function.name
tool_input = json.loads(tool_call.function.arguments)
# Compute cache key
cache_content = json.dumps(
{"tool": tool_name, "input": tool_input}, sort_keys=True
)
cache_key = hashlib.sha256(cache_content.encode()).hexdigest()[:16]
if cache_key in cached_tool_calls:
# Use cached result — do NOT re-execute
record = cached_tool_calls[cache_key]
print(f"[CACHE HIT] {tool_name} -> returning cached result")
output = record.tool_output
else:
# Execute the tool fresh
print(f"[TOOL CALL] Executing {tool_name}...")
start = asyncio.get_event_loop().time()
try:
output = await self.tool_executors[tool_name](**tool_input)
error = None
except Exception as e:
output = f"Tool error: {str(e)}"
error = str(e)
duration_ms = int((asyncio.get_event_loop().time() - start) * 1000)
record = ToolCallRecord(
tool_name=tool_name,
tool_input=tool_input,
tool_output=output,
called_at=datetime.utcnow(),
duration_ms=duration_ms,
error=error,
cache_key=cache_key,
)
all_tool_records.append(record)
cached_tool_calls[cache_key] = record
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(output),
})
messages.extend(tool_results)
# Checkpoint after processing this round of tool calls
parent_id = await self._save_checkpoint(
run_id=run_id,
parent_id=parent_id,
step_index=step_index,
step_label=f"after_tool_round_{step_index}",
messages=messages,
tool_records=all_tool_records,
cost=total_cost,
status="completed",
store=self.store,
)
step_index += 1
async def _save_checkpoint(self, **kwargs) -> str:
cp = AgentCheckpoint(
run_id=kwargs['run_id'],
parent_id=kwargs['parent_id'],
step_index=kwargs['step_index'],
step_label=kwargs['step_label'],
messages=kwargs['messages'],
tool_call_cache=kwargs['tool_records'],
llm_cost_usd=kwargs['cost'],
status=kwargs['status'],
)
return await kwargs['store'].save(cp)
This loop handles resume transparently — if you call run() on an existing run_id, it finds the latest checkpoint automatically and picks up from there. The tool call cache means every tool that already ran returns its cached output instantly, without hitting any external API.
The cache-key approach works for read operations (web search, retrieval, API queries that return data). But what about tools with side effects? Consider these real scenarios:
If the agent crashes after making the tool call but before writing the checkpoint, then on resume it will re-execute the tool call (because there's no cached result), causing a duplicate action.
Warning
This is the classic "exactly-once delivery" problem. You cannot achieve exactly-once semantics in a distributed system without coordination. What you can achieve is idempotent tools that make double-execution harmless.
The solution is to make every side-effectful tool idempotent by passing an idempotency key derived from the run and step:
class IdempotentToolWrapper:
"""Wraps a side-effectful tool to make it safe for replay."""
def __init__(self, tool_fn: Callable, idempotency_store: CheckpointStore):
self.tool_fn = tool_fn
self.idempotency_store = idempotency_store
async def __call__(self, run_id: str, step_index: int, **kwargs):
# Generate a stable idempotency key
import hashlib, json
key_content = json.dumps(
{"run_id": run_id, "step": step_index, "args": kwargs},
sort_keys=True
)
idem_key = hashlib.sha256(key_content.encode()).hexdigest()
# Check if this call already completed
existing = await self.idempotency_store.load_idempotency_record(idem_key)
if existing:
print(f"Idempotency hit for key {idem_key[:8]}... — skipping re-execution")
return existing['result']
# Execute and record
result = await self.tool_fn(**kwargs)
await self.idempotency_store.save_idempotency_record(idem_key, result)
return result
For tools like Slack or email, you can also handle idempotency at the API level — most modern APIs accept an idempotency key header (Idempotency-Key for Stripe, X-Idempotency-Key for many others). Always pass one derived from your run_id + step_index.
Rollback means: "take this agent run back to a prior checkpoint, discarding everything that happened after it." This is useful when:
Since our checkpoints form a linked list via parent_id, rollback is conceptually simple: load the target checkpoint, and start a new run from there. But there are critical details:
class AgentRollbackManager:
def __init__(self, store: CheckpointStore, runner: CheckpointingAgentRunner):
self.store = store
self.runner = runner
async def rollback_to_step(
self,
run_id: str,
target_step: int,
branch_id: str = "main",
new_branch_id: Optional[str] = None,
) -> str:
"""
Roll back to a specific step and optionally create a new branch.
Returns the new run_id (or same run_id if overwriting main branch).
"""
# Find the checkpoint at the target step
all_checkpoints = await self.store.list_checkpoints(run_id)
target = next(
(cp for cp in all_checkpoints
if cp.step_index == target_step and cp.branch_id == branch_id),
None
)
if target is None:
raise ValueError(f"No checkpoint found at step {target_step} on branch {branch_id}")
if new_branch_id:
# Fork: create a new branch from this checkpoint
# The tool call cache is inherited — we replay from here on a new branch
print(f"Forking at step {target_step} to new branch '{new_branch_id}'")
new_run_id = f"{run_id}__branch_{new_branch_id}"
return await self.runner.run(
run_id=new_run_id,
initial_messages=target.messages, # Start with the state at rollback point
resume_from=target.checkpoint_id,
)
else:
# In-place rollback: resume main branch from this checkpoint
# Mark all subsequent checkpoints as invalidated
for cp in all_checkpoints:
if cp.step_index > target_step and cp.branch_id == branch_id:
cp.status = "invalidated"
await self.store.save(cp)
return await self.runner.run(
run_id=run_id,
initial_messages=[], # Will be loaded from checkpoint
resume_from=target.checkpoint_id,
)
async def inspect_checkpoint_tree(self, run_id: str) -> str:
"""Render a text tree of the checkpoint history for debugging."""
checkpoints = await self.store.list_checkpoints(run_id)
lines = [f"Run: {run_id}"]
for cp in sorted(checkpoints, key=lambda c: c.step_index):
status_icon = "✓" if cp.status == "completed" else "✗" if cp.status == "failed" else "⊘"
lines.append(
f" {status_icon} Step {cp.step_index:02d} [{cp.branch_id}] "
f"{cp.step_label} — "
f"${cp.llm_cost_usd:.4f} | {cp.checkpoint_id[:8]}..."
)
return "\n".join(lines)
The branching model is particularly powerful when you're using agentic RAG pipelines where the agent reflects on its retrieval quality and might want to retry with a different query strategy. You can checkpoint before retrieval, execute a rollback-and-branch if the retrieved context was poor, and compare results across branches.
Tip
When building rollback into a production system, give your operations team a simple CLI tool (agent-cli rollback --run-id=xyz --step=5) rather than expecting them to write Python. The rollback manager above can be wrapped in a Click or Typer CLI in about 20 lines.
LangGraph (LangChain's agent graph framework) has first-class checkpointing support through its MemorySaver, SqliteSaver, and PostgresSaver classes. Understanding how LangGraph's model maps to our conceptual model will help you use it correctly and know when to supplement it.
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from typing import TypedDict, Annotated
import operator
class ResearchAgentState(TypedDict):
messages: Annotated[list, operator.add]
research_notes: str
sources_consulted: list[str]
draft_report: str
validation_passed: bool
def build_research_agent_graph():
graph = StateGraph(ResearchAgentState)
# Add nodes
graph.add_node("planner", planner_node)
graph.add_node("retriever", retriever_node)
graph.add_node("synthesizer", synthesizer_node)
graph.add_node("validator", validator_node)
graph.add_node("formatter", formatter_node)
# Add edges
graph.set_entry_point("planner")
graph.add_edge("planner", "retriever")
graph.add_edge("retriever", "synthesizer")
graph.add_edge("synthesizer", "validator")
graph.add_conditional_edges(
"validator",
lambda state: "formatter" if state["validation_passed"] else "retriever",
{"formatter": "formatter", "retriever": "retriever"}
)
graph.add_edge("formatter", END)
return graph.compile()
# Wire up the SQLite checkpointer
memory = SqliteSaver.from_conn_string("agent_runs.db")
agent = build_research_agent_graph()
agent_with_memory = agent.with_config({"checkpointer": memory})
# Run with a thread_id (analogous to our run_id)
config = {"configurable": {"thread_id": "research-run-001"}}
# First run (or resume if already started)
result = await agent_with_memory.ainvoke(
{"messages": [{"role": "user", "content": "Research our top 5 competitors..."}]},
config=config,
)
# If it failed midway, just call ainvoke again with the same thread_id
# LangGraph will resume from the last checkpoint automatically
result_resumed = await agent_with_memory.ainvoke(
{"messages": []}, # LangGraph merges this with existing state
config=config,
)
LangGraph checkpoints after every node transition. This is very fine-grained — potentially dozens of checkpoints per minute for a busy agent. This is great for recoverability but means your checkpoint store needs to handle high write throughput. For long-running production runs, consider using PostgresSaver with connection pooling rather than SqliteSaver, which has write serialization limitations.
Where LangGraph's built-in checkpointing doesn't help you:
Let's be concrete about when to use which storage backend:
| Scenario | Recommended Backend | Checkpoint TTL |
|---|---|---|
| Local dev / single process | SQLite | No expiry |
| Small production (1-10 concurrent runs) | PostgreSQL | 30 days |
| High-throughput (100+ concurrent runs) | Redis + S3 | Redis: 24h, S3: 90 days |
| Regulated/compliance workloads | S3 with versioning + RDS | Indefinite |
| Multi-region agents | DynamoDB | Configurable |
For the Redis + S3 tiered approach, the pattern is: write to Redis immediately (fast, <5ms), and asynchronously flush to S3 every N checkpoints or every T seconds. On load, check Redis first, fall back to S3.
class TieredCheckpointStore(CheckpointStore):
"""Fast Redis cache with durable S3 backend."""
def __init__(self, redis_store: RedisCheckpointStore, s3_store: S3CheckpointStore):
self.hot = redis_store
self.cold = s3_store
self._flush_queue: asyncio.Queue = asyncio.Queue()
async def save(self, checkpoint: AgentCheckpoint) -> str:
# Fast write to Redis
await self.hot.save(checkpoint)
# Async flush to S3 (don't await — fire and forget)
asyncio.create_task(self._flush_to_cold(checkpoint))
return checkpoint.checkpoint_id
async def _flush_to_cold(self, checkpoint: AgentCheckpoint):
try:
await self.cold.save(checkpoint)
except Exception as e:
# Log but don't crash — Redis has it
print(f"S3 flush failed for {checkpoint.checkpoint_id}: {e}")
async def load(self, checkpoint_id: str) -> AgentCheckpoint:
try:
return await self.hot.load(checkpoint_id)
except Exception:
# Fallback to S3
return await self.cold.load(checkpoint_id)
Key insight
The checkpoint store's write latency directly adds to your agent's per-step latency. A Redis write typically takes 1-3ms; a synchronous S3 put takes 50-200ms. In an agent that makes 50 tool calls, the difference is 2.5ms vs 10 seconds of cumulative checkpoint overhead. Use async writes for your durable store.
One of the sneaky failure modes in long-running agents isn't a crash — it's a silent degradation as the message history grows and approaches the LLM's context window limit. Your checkpoint gives you the full message history, but that full history might be 80,000 tokens by step 30.
You need a compression strategy that runs at checkpoint time:
import tiktoken
class MessageHistoryCompressor:
"""Compress old tool results to keep message history within token budget."""
def __init__(self, max_tokens: int = 60000, model: str = "gpt-4o"):
self.max_tokens = max_tokens
self.encoder = tiktoken.encoding_for_model(model)
def count_tokens(self, messages: list[dict]) -> int:
total = 0
for msg in messages:
content = msg.get("content", "") or ""
total += len(self.encoder.encode(content))
# Tool calls add overhead
if msg.get("tool_calls"):
total += 50 * len(msg["tool_calls"])
return total
def compress(self, messages: list[dict]) -> list[dict]:
"""
Compress the message history by summarizing old tool results.
Always preserves: system message, last N assistant messages, all user messages.
"""
current_tokens = self.count_tokens(messages)
if current_tokens <= self.max_tokens:
return messages
# Keep the last 10 messages intact, compress earlier tool results
protected_tail = messages[-10:]
compressible = messages[:-10]
compressed = []
for msg in compressible:
if msg.get("role") == "tool" and len(msg.get("content", "")) > 500:
# Truncate long tool results
compressed.append({
**msg,
"content": msg["content"][:200] + " [...truncated for context management...]"
})
else:
compressed.append(msg)
return compressed + protected_tail
This should run before each checkpoint save when the history exceeds a threshold. The key insight is that tool results from 20 steps ago are usually less important than the agent's reasoning conclusions about them — which are captured in the assistant messages.
Let's put everything together with a realistic scenario: a competitive intelligence agent that researches five companies, synthesizes findings, and generates a strategic report.
Setup:
pip install aiosqlite openai tiktoken
Your task: Build and run a checkpointing research agent that:
search_company tool (you can mock this with a function that returns fake data and randomly raises an exception 20% of the time)Starter scaffolding:
import asyncio
import random
from datetime import datetime
async def mock_search_company(company_name: str) -> dict:
"""Mock tool that fails 20% of the time to simulate real-world flakiness."""
await asyncio.sleep(0.5) # Simulate API latency
if random.random() < 0.20:
raise ConnectionError(f"Rate limit hit while searching {company_name}")
return {
"company": company_name,
"funding_stage": random.choice(["Series A", "Series B", "Series C", "Public"]),
"employee_count": random.randint(50, 5000),
"recent_news": f"Recent announcement from {company_name} about product expansion",
"retrieved_at": datetime.utcnow().isoformat(),
}
COMPANIES = ["Anthropic", "Cohere", "Mistral AI", "AI21 Labs", "Inflection AI"]
# Your exercise: wire this into CheckpointingAgentRunner
# Hint: wrap mock_search_company in a retry loop that checkpoints after each success
# Target: if you kill the process mid-run and restart, it should skip already-researched companies
Expected behavior: Kill the process with Ctrl+C after companies 2 and 3 complete. Restart. The agent should skip companies 1, 2, and 3 (cached), and only fetch 4 and 5 fresh.
Extension challenge: Add a rollback scenario. After the full run completes, roll back to step 2 and re-run with a different system prompt that emphasizes technical analysis over business metrics. Compare the two branches' outputs.
A very common bug is saving a checkpoint with status="completed" before the step actually finishes. If you checkpoint after starting a tool call but before the result comes back, you'll resume and skip the tool call — but the result won't be in your message history.
Fix: Only set status="completed" after the full round of tool calls + LLM response is appended to messages.
If your tool input contains a Python set or unordered dict (Python 3.6 dicts are ordered, but nested structures might not be), your cache keys will be inconsistent across runs. The tool output cache will miss, causing re-execution.
Fix: Always sort_keys=True in your JSON serialization and convert sets to sorted lists before hashing.
OpenAI's API requires that every tool_calls entry in an assistant message has a corresponding tool role message with a matching tool_call_id. If you reconstruct the message history from a checkpoint and the IDs don't align, the API returns a 400 error.
Fix: Store the full message dict exactly as returned by the API. Don't reconstruct it from parts — save and restore it verbatim.
If your save() call fails silently (raises an exception that gets swallowed), you continue execution thinking you're protected — but you're not. The next crash will roll you back further than expected.
Fix: Let checkpoint write failures propagate. If you can't persist state, you should halt rather than pretend the checkpoint succeeded.
# Bad pattern — swallows checkpoint failures
try:
await self.store.save(checkpoint)
except Exception:
pass # Don't do this!
# Good pattern — fail loudly
await self.store.save(checkpoint) # Let exceptions propagate
You add a new field to AgentCheckpoint in v2, but you have checkpoints from v1 sitting in your store. Deserialization fails with a TypeError: __init__() got an unexpected keyword argument.
Fix: Always include a schema_version field and write a migration function:
def migrate_checkpoint(data: dict) -> dict:
version = data.get("schema_version", 1)
if version == 1:
# v1 didn't have branch_id or llm_cost_usd
data["branch_id"] = "main"
data["llm_cost_usd"] = 0.0
data["schema_version"] = 2
return data
If your agent resumes but doesn't use cached tool outputs, the most common cause is mismatched cache keys. Add logging to your cache key computation:
print(f"Cache key for {tool_name}: {cache_key}")
print(f"Available cache keys: {list(cached_tool_calls.keys())}")
Often you'll find that the tool input has a timestamp or a request ID embedded that changes between runs, making the cache key different. Strip non-deterministic fields before hashing.
Tip
When debugging cache misses, temporarily log the full JSON used for cache key computation alongside the resulting hash. You'll spot the non-deterministic field immediately.
LangGraph uses Annotated[list, operator.add] for message history, which appends on resume rather than replacing. If your initial invocation on resume passes any messages, they get appended to the existing history, duplicating the initial user message.
Fix: Pass an empty dict or empty messages list when invoking for resume:
# On resume, pass empty state — LangGraph loads from checkpoint
result = await agent.ainvoke({}, config=config)
You've now built a complete mental model and working implementation of agent checkpointing. Let's consolidate what we covered:
The core concepts:
The implementation stack:
AgentCheckpoint dataclass as the serialization contractCheckpointStore interface with SQLite (dev), Redis (hot path), and S3 (durable archival)The operational wisdom:
run_id + step_indexWhere to go from here:
Now that your agents are fault-tolerant, consider layering in more sophisticated capabilities. Multi-agent orchestration scenarios benefit enormously from checkpointing — each sub-agent in a parallel fan-out gets its own branch, and the orchestrator can recover partial failures without restarting everything.
The memory systems your agents use deserve equal attention — see tool memory vs. retrieval memory for how episodic memory and checkpointing interact. In long-running agents, your checkpoint history is an episodic memory store — it captures what the agent did and when, which you can mine for self-improvement.
Finally, if your agents are operating in production at scale, pair this checkpointing system with proper observability — production RAG monitoring patterns apply equally well to agent pipelines. Track checkpoint write latency, cache hit rates, and cost-per-run across your checkpoint history to understand where your agents are spending time and money.