Галоўная / Артыкулы / Durable AI Agents: Saving State With LangGraph, DynamoDB and Cosmos DB

Артыкул апублікаваны на англійскай мове.

Durable AI Agents: Saving State With LangGraph, DynamoDB and Cosmos DB

Where agent checkpoints should live on AWS or Azure, how LangGraph resumes work with thread IDs and checkpointers, and what to persist versus recompute.

1338 слоў

An agent that can't remember where it stopped is only a demo. Once the idea of persisted state is clear, the practical questions begin: where does that state live, how does a workflow find it again, and what deserves to be saved at all? This piece answers them using LangGraph and the two big clouds.

Here's the plan. First, where a checkpoint belongs in production, which is somewhere sturdier than local disk, namely AWS or Azure services. Second, how LangGraph handles resumption through thread_id and swappable checkpointer backends. Third, which pieces of state are worth keeping, and which are cheaper to rebuild.

To apply it to a project:

  1. Pick a checkpointer to match your deployment: local development, a single instance, or several instances.
  2. Pass a thread_id into every graph.invoke() call starting on day one.
  3. Sort each field of your state model into "persist" or "recompute", using the rule near the end.

A WorkflowState model is only useful if something stores it where a pod restart can't erase it. That something is what comes next.

Where the checkpoint lives

Any diagram of a durable agent has a box labeled "save state". In production that box is an architectural decision, because the location determines which failures the checkpoint survives.

Local disk survives a crashed process. It doesn't survive a container rescheduled onto another node, a pod killed and replaced, or an autoscaler starting a second instance that knows nothing about the first. If your agent runs anywhere near Kubernetes, which most eventually do, local files give a false sense of safety rather than a strategy.

So checkpoints move into cloud storage. On AWS the usual pairing is a fast key-value store for the pointer and object storage for the bulk:

  • DynamoDB holds checkpoint metadata and small state objects, keyed by task_id or thread_id, with quick reads when a task resumes.
  • S3 keeps the large blobs, such as long conversation histories or accumulated tool output, which don't need millisecond access.

Azure offers a matching split:

  • Cosmos DB or Azure Table Storage for metadata and fast lookups.
  • Azure Blob Storage for bigger payloads.
  • Entra ID to govern which identities and service principals may read or write a checkpoint, which becomes important as soon as the state includes anything sensitive.

The abstraction looks the same on every backend, and only the client changes:

from typing import Protocol
class Checkpointer(Protocol):
    def get(self, task_id: str) -> dict | None: ...
    def put(self, task_id: str, state: dict) -> None: ...class DynamoDBCheckpointer:
    def __init__(self, table):
        self.table = table    def get(self, task_id: str) -> dict | None:
        item = self.table.get_item(Key={"task_id": task_id}).get("Item")
        return item["state"] if item else None    def put(self, task_id: str, state: dict) -> None:
        self.table.put_item(Item={"task_id": task_id, "state": state})class AzureBlobCheckpointer:
    def __init__(self, container_client):
        self.container = container_client    def get(self, task_id: str) -> dict | None:
        blob = self.container.get_blob_client(f"{task_id}.json")
        if not blob.exists():
            return None
        return json.loads(blob.download_blob().readall())    def put(self, task_id: str, state: dict) -> None:
        self.container.upload_blob(
            f"{task_id}.json", json.dumps(state), overwrite=True
        )

Programming against a Checkpointer protocol and not against one vendor's SDK keeps the load_or_initialize_state() function independent of storage. You replace the implementation and leave the workflow logic alone.

Two production trade-offs usually go unmentioned in tutorials:

  • Write frequency against latency. Saving after every node is safest and slowest, because each write is a network round trip. Real systems tend to save after meaningful transitions, like a finished tool call or a human approval, and skip the internal steps in between.
  • Cost per checkpoint. A five-node pipeline that saves at every node, run over thousands of tasks daily, produces thousands of writes before any result appears. Budget for it the way you budget LLM tokens, since at scale it's a comparable line on the bill.

Doing it in LangGraph

So far that was architecture. In LangGraph it turns into code with surprisingly few lines.

Thread IDs act as the key for resumption. LangGraph recognizes a particular run, whether a conversation, a report or a task, by its thread_id and not by the definition of the graph. Think of the graph as a blueprint and the thread as one concrete instance of it. Each invocation receives the ID through config:

config = {"configurable": {"thread_id": "task_123"}}
# First call - no prior state for this thread_id, so LangGraph cold-starts it
result = graph.invoke({"task_id": "task_123"}, config)# Later call, same thread_id - LangGraph loads the last checkpoint
# and resumes instead of starting over
result = graph.invoke(None, config)

The second call, passing None as input, does what load_or_initialize_state() did by hand: the checkpointer performs the lookup and the branching for you. Reuse the same thread_id and execution continues mid-graph, while a new ID triggers a cold start.

Checkpointers are the pluggable storage layer. They tie directly to the cloud options above. LangGraph defines a checkpointer interface, and the object you supply decides where a thread's state is kept:

from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.postgres import PostgresSaver
# Local dev / tests - gone when the process exits
checkpointer = MemorySaver()# Single-instance production - survives process restarts
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")# Multi-instance production - survives pod restarts, works across replicas
checkpointer = PostgresSaver.from_conn_string(POSTGRES_URL)graph = builder.compile(checkpointer=checkpointer)

MemorySaver reproduces the local-disk problem exactly. It works for demos and evaporates when the process ends. PostgresSaver, or a custom class backed by DynamoDB or Cosmos DB and shaped like the Checkpointer protocol above, lets a thread outlive a pod that gets moved to another node.

Picture the whole system as one flow: a branch that either cold-starts or resumes, a five-node pipeline, the checkpointer acting as a shared read and write layer, and three storage backends it can point at.

Don't save everything

Here's another trade-off tutorials seldom cover. A checkpoint should contain what's costly or impossible to recreate, not everything that happened to sit in memory at that moment in the graph.

Persist these:

  • results of tool calls that cost money or consume a rate limit
  • human approval decisions
  • the current step or node position
  • anything a person is waiting for the system to remember

Recompute these:

  • prompt strings built from templates plus state
  • derived values, like a word count, that other state can produce
  • anything a fresh function call can rebuild in milliseconds at almost no cost
  • retrieved documents, as long as you store their IDs, since the IDs are what you need to fetch them again

The question that sorts a field is simple. If the checkpoint lacked it, would resuming cost real money, real delay or a repeated side effect? Or would recreating it be one cheap function call? Save the first kind and rebuild the second. A checkpoint stuffed with recomputable data reads slower, writes slower and costs more to store, and it adds no resilience in return.