Agentes de IA duraderos: guardar el estado con LangGraph, DynamoDB y Cosmos DB
Dónde deben vivir los checkpoints de un agente en AWS o Azure, cómo reanuda LangGraph con thread IDs y checkpointers, y qué persistir frente a qué recalcular.
Un agente que no recuerda dónde se quedó es solo una demo. Una vez clara la idea del estado persistido, empiezan las preguntas prácticas: ¿dónde vive ese estado?, ¿cómo lo vuelve a encontrar un workflow? y ¿qué merece la pena guardar? Este artículo las responde con LangGraph y las dos grandes nubes.
El plan es este. Primero, dónde va un checkpoint en producción: en algún lugar más sólido que el disco local, es decir, en servicios de AWS o Azure. Segundo, cómo gestiona LangGraph la reanudación mediante thread_id y backends de checkpointer intercambiables. Tercero, qué partes del estado vale la pena conservar y cuáles sale más barato reconstruir.
Para aplicarlo a un proyecto:
- Elige un checkpointer acorde a tu despliegue: desarrollo local, una sola instancia o varias.
- Pasa un
thread_ida cada llamada agraph.invoke()desde el primer día. - Clasifica cada campo de tu modelo de estado como «persistir» o «recalcular», con la regla que aparece casi al final.
Un modelo WorkflowState solo sirve si algo lo guarda donde un reinicio del pod no pueda borrarlo. De ese algo trata lo que sigue.
Dónde vive el checkpoint
Cualquier diagrama de un agente duradero tiene una caja con la etiqueta «guardar estado». En producción, esa caja es una decisión de arquitectura, porque la ubicación determina qué fallos sobrevive el checkpoint.
El disco local sobrevive a un proceso caído. No sobrevive a un contenedor reprogramado en otro nodo, a un pod eliminado y reemplazado ni a un autoscaler que arranca una segunda instancia que no sabe nada de la primera. Si tu agente corre cerca de Kubernetes, como acaban haciendo la mayoría, los archivos locales dan una falsa sensación de seguridad en lugar de una estrategia.
Por eso los checkpoints pasan al almacenamiento en la nube. En AWS, la combinación habitual es un almacén clave-valor rápido para el puntero y almacenamiento de objetos para el grueso:
- DynamoDB guarda los metadatos del checkpoint y los objetos de estado pequeños, con
task_idothread_idcomo clave y lecturas rápidas cuando una tarea se reanuda. - S3 guarda los blobs grandes, como historiales de conversación largos o la salida acumulada de herramientas, que no necesitan acceso en milisegundos.
Azure ofrece un reparto equivalente:
- Cosmos DB o Azure Table Storage para metadatos y búsquedas rápidas.
- Azure Blob Storage para cargas más grandes.
- Entra ID para controlar qué identidades y service principals pueden leer o escribir un checkpoint, algo que importa en cuanto el estado incluye cualquier dato sensible.
La abstracción es la misma en cualquier backend; solo cambia el cliente:
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
)
Programar contra un protocolo Checkpointer y no contra el SDK de un proveedor mantiene la función load_or_initialize_state() independiente del almacenamiento. Cambias la implementación y dejas en paz la lógica del workflow.
Hay dos compromisos de producción que los tutoriales no suelen mencionar:
- Frecuencia de escritura frente a latencia. Guardar tras cada nodo es lo más seguro y lo más lento, porque cada escritura es un viaje de ida y vuelta por la red. Los sistemas reales suelen guardar tras transiciones relevantes, como una llamada a herramienta terminada o una aprobación humana, y se saltan los pasos internos intermedios.
- Coste por checkpoint. Un pipeline de cinco nodos que guarda en cada nodo, ejecutado sobre miles de tareas al día, genera miles de escrituras antes de que aparezca ningún resultado. Presupuéstalo como los tokens del LLM, porque a escala es una línea comparable en la factura.
Cómo hacerlo en LangGraph
Hasta aquí, arquitectura. En LangGraph se convierte en código con sorprendentemente pocas líneas.
Los thread IDs hacen de clave para la reanudación. LangGraph reconoce una ejecución concreta, ya sea una conversación, un informe o una tarea, por su thread_id y no por la definición del grafo. Piensa en el grafo como un plano y en el thread como una instancia concreta de él. Cada invocación recibe el ID a través de 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)
La segunda llamada, con None como entrada, hace lo que load_or_initialize_state() hacía a mano: el checkpointer se encarga de la búsqueda y la bifurcación. Reutiliza el mismo thread_id y la ejecución continúa a mitad del grafo; un ID nuevo provoca un arranque en frío.
Los checkpointers son la capa de almacenamiento enchufable. Se conectan directamente con las opciones de nube de arriba. LangGraph define una interfaz checkpointer, y el objeto que le pasas decide dónde se guarda el estado de un thread:
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 reproduce exactamente el problema del disco local. Sirve para demos y se evapora cuando termina el proceso. PostgresSaver, o una clase propia sobre DynamoDB o Cosmos DB con la forma del protocolo Checkpointer de arriba, permite que un thread sobreviva a un pod trasladado a otro nodo.
Imagina todo el sistema como un único flujo: una bifurcación que arranca en frío o reanuda, un pipeline de cinco nodos, el checkpointer como capa compartida de lectura y escritura, y tres backends de almacenamiento a los que puede apuntar.
No lo guardes todo
Otro compromiso que los tutoriales rara vez tratan. Un checkpoint debe contener lo que es caro o imposible de recrear, no todo lo que casualmente estaba en memoria en ese punto del grafo.
Persiste esto:
- resultados de llamadas a herramientas que cuestan dinero o consumen un rate limit
- decisiones de aprobación humanas
- el paso actual o la posición en el nodo
- cualquier cosa que una persona espera que el sistema recuerde
Recalcula esto:
- cadenas de prompt construidas a partir de plantillas y estado
- valores derivados, como un recuento de palabras, que se obtienen de otro estado
- cualquier cosa que una llamada a función nueva reconstruya en milisegundos casi sin coste
- documentos recuperados, siempre que guardes sus IDs, porque los IDs son lo que necesitas para volver a obtenerlos
La pregunta que clasifica un campo es sencilla. Si el checkpoint no lo tuviera, ¿reanudar costaría dinero real, retraso real o un efecto secundario repetido? ¿O recrearlo sería una sola llamada a función barata? Guarda lo primero y reconstruye lo segundo. Un checkpoint atiborrado de datos recalculables se lee más despacio, se escribe más despacio y cuesta más almacenarlo, y no aporta ninguna resiliencia a cambio.