PostgresSaver: Produktionsfähiger relationaler Checkpoint
Verantwortung
PostgresSaver ist die BaseCheckpointSaver-Implementierung für Postgres und der von LangGraph offiziell empfohlene Saver für den Produktiveinsatz. Der Code liegt in libs/checkpoint-postgres/langgraph/checkpoint/postgres/, der synchrone Einstieg ist PostgresSaver in libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py (PostgresSaver:40), die asynchrone Variante in aio.py als AsyncPostgresSaver (AsyncPostgresSaver:40); beide teilen sich die Basisklasse BasePostgresSaver (BasePostgresSaver:297), die für die SQL-Templates und Migrations-Skripte zuständig ist.
Anders als bei SQLite, das mit einer einzigen Tabelle und Primärschlüssel-Deduplizierung arbeitet, zerlegt PostgresSaver die Daten in drei Tabellen: checkpoints (Haupttabelle, JSONB), checkpoint_blobs (binäre Channel-Werte) und checkpoint_writes (inkrementelle Task-Schreibvorgänge) (MIGRATIONS:43-91). Diese Aufteilung erlaubt es, beim Lesen mit einem einzigen SELECT inklusive lateraler Unterabfragen (lateral subqueries) die Haupttabelle, Blobs und Writes in einem Rutsch zurückzuholen (SELECT_SQL:93-118), was N+1-Queries wie bei SQLite vermeidet; gleichzeitig werden Blobs separat als BYTEA gespeichert und nicht in die JSONB-Deserialisierung einbezogen, sodass große Objekte das Lesen der Haupttabelle nicht ausbremsen.
PostgresSaver unterstützt zusätzlich den Pipeline-Modus von psycopg3 (supports_pipeline:60) – mehrere Statements werden gebündelt abgeschickt, ohne dass der Server sofort Ergebnisse zurückgibt; ideal für Kombinationen wie executemany(blob) + execute(checkpoint) innerhalb eines einzelnen put.
Entwurfsmotivation
- Drei-Tabellen-Trennung, JSONB für den Hauptkörper: Die
checkpoints-Haupttabelle speichert dasCheckpoint-TypedDict in einer JSONB-Spalte, währendcheckpoint_blobsgroße Objekte separat vorhält (checkpoint_blobs:57-65). Das vermeidet es, die gesamten channel_values in die JSONB-Zeile zu quetschen – ein Ansatz, der bei vielen Kanälen oder großen Werten (channel) in die Knie geht. - Ein einziger SELECT setzt das Tuple zusammen:
SELECT_SQLnutzt zwei laterale Unterabfragen (array_aggübercheckpoint_blobs+array_aggübercheckpoint_writes), um alle Daten in einem Durchlauf zu holen (lateral subqueries:101-117);_load_checkpoint_tuplemuss auf Python-Seite nur noch das Array entpacken, ohne zweite Abfragen. - UPSERT mit
ON CONFLICT DO UPDATE/NOTHINGfür unterschiedliche Semantik:UPSERT_CHECKPOINT_BLOBS_SQLnutztDO NOTHING(UPSERT_BLOBS:131-135),UPSERT_CHECKPOINTS_SQLnutztDO UPDATE(UPSERT_CHECKPOINTS:137-144),UPSERT_CHECKPOINT_WRITES_SQLnutztDO UPDATE, undINSERT_CHECKPOINT_WRITES_SQLnutztDO NOTHING(INSERT_WRITES:155-159). Kontroll-Channel-Schreibungen (writes) überschreiben den neuesten Zustand (UPDATE), normale Schreibungen werden bei Konflikt ignoriert (NOTHING) – die Semantik entspricht demif inner_key[1] >= 0 ... continueaus InMemory. - Migrationsliste wird per Versionsnummer ergänzt:
MIGRATIONSist eine Liste (MIGRATIONS:43-91);setup()liest die maximale Version aus der Tabellecheckpoint_migrationsund führt nur die darauffolgenden Migrationen aus (setup:85-110). Neue Spalten, Indizes oder Constraints werden durch Anhängen eines Migrations-Strings ergänzt, ohne bestehende Migrationen zu verändern. - Pipeline-Modus für echte Nebenläufigkeit auf einer Verbindung: Bei
pipeline=Trueholt sich der synchronePostgresSaver._cursorden Kontextconn.pipeline()(pipeline branch:424-432), sodass mehrere Cursor auf derselben Verbindung sich nicht gegenseitig blockieren; derpipe-Modus (überfrom_conn_string(pipeline=True)) erlaubt es einer Verbindung, unter mehreren Threads wiederverwendet zu werden (pipe branch:413-423), kombiniert mitself.lockfür serielle Cursor, aber nicht serielle Statements.
Schlüsseldateien
MIGRATIONS:43-91— CREATE-Statements der drei Tabellen + WAL-/Index-/task_path-Migrationen.SELECT_SQL:93-118— Hauptabfrage, laterale Unterabfragen fassen blobs und writes via array_agg zusammen.SELECT_PENDING_SENDS_SQL:120-129— Liest ausstehende PUSH-Task-Writes, sortiert nach task_path / task_id / idx.UPSERT / INSERT SQL:131-159— Vier Schreib-Statements, unterteilt inDO UPDATE/DO NOTHING.BasePostgresSaver:297— Gemeinsame Basisklasse, hält die SQL-Templatе-Konstanten.PostgresSaver Klasse:40-60— Synchrone Implementierung, nimmtConnectionoderConnectionPool, mitthreading.Lock.from_conn_string:62-83— Context-Manager-Factory, beipipeline=Truewirdconn.pipeline()verwendet.setup:85-110— Liest die Migrationsversion, führt noch nicht angewandte Migrationen in Reihenfolge aus und notiert die Version.put:263-345— Verschiebt_DeltaSnapshot/ nicht-primitive Werte nach blob_values, der Hauptkörper geht ins JSONB._cursor:405-442— Wählt je nach Pipeline / Pipe einen von drei Kontexten aus.AsyncPostgresSaver:40— Asynchrone Variante,aput/aget_tuple/alistsind nativ async.
Datenfluss
PostgresSaver.put ist ein gutes Beispiel dafür, wie ein Saver das Checkpoint-TypedDict zerlegt – es entscheidet zuerst, welche Channel-Werte inline in den JSONB-Hauptkörper wandern und welche nach checkpoint_blobs verschoben werden, und führt dann innerhalb eines Cursors executemany(blob) + execute(checkpoint) aus:
# inline primitive values in checkpoint table
# others are stored in blobs table
blob_values = {}
for k, v in checkpoint["channel_values"].items():
if isinstance(v, _DeltaSnapshot):
blob_values[k] = copy["channel_values"].pop(k)
copy["channel_values"][k] = True
elif v is None or isinstance(v, (str, int, float, bool)):
pass
else:
blob_values[k] = copy["channel_values"].pop(k)
with self._cursor(pipeline=True) as cur:
if blob_versions := {
k: v for k, v in new_versions.items() if k in blob_values
}:
cur.executemany(
self.UPSERT_CHECKPOINT_BLOBS_SQL,
self._dump_blobs(
thread_id,
checkpoint_ns,
blob_values,
blob_versions,
),
)
cur.execute(
self.UPSERT_CHECKPOINTS_SQL,
(
thread_id,
checkpoint_ns,
checkpoint["id"],
checkpoint_id,
Jsonb(copy),
Jsonb(get_serializable_checkpoint_metadata(config, metadata)),
),
)(put body:309-344) Beachten Sie, dass _DeltaSnapshot einen Sonderweg geht – der Channel-Wert wird in der Haupttabelle nur durch ein True als Platzhalter repräsentiert, die echten Daten landen im Blob; das ist die Grundlage, auf der DeltaChannel beim Wiederaufbau rückwärts durch die Ahnenkette läuft. Beim Zurücklesen führt get_tuple das SELECT_SQL aus; psycopg wandelt JSONB automatisch in ein dict und BYTEA in bytes um, und auf Python-Seite wird _load_checkpoint_tuple aufgerufen, um das CheckpointTuple zusammenzusetzen (_load_checkpoint_tuple:552).
Grenzen und Fehler
from_conn_string(pipeline=True)lässt sich nicht mit ConnectionPool kombinieren:__init__wirft explizit eine Ausnahme (pool vs pipe check:52-55); Pipeline ist ein Einzelverbindungsmodus, ein Pool ist bereits verbindungsübergreifend.setup()muss explizit einmal aufgerufen werden: Der Docstring hält fest: «MUST be called directly by the user the first time checkpointer is used» (setup docstring:85-91). Es legt Tabellen an und führt Migrationen aus; vorher müssen die Datenbankberechtigungen stehen.- MIGRATIONS-Reihenfolge darf nicht geändert werden: Migrationen verwenden die Listenposition als Versionsnummer (
MIGRATIONS:43-91), bereits veröffentlichte Migrationsstrings dürfen nicht modifiziert werden; sonst verschiben sich die incheckpoint_migrationseingetragenen Versionen relativ zu den Code-Positionen. CREATE INDEX CONCURRENTLYscheitert innerhalb einer Transaktion: Die beiden nebenläufigen Index-Erzeugungen inMIGRATIONS(CONCURRENTLY indexes:82-89) werden im selbensetup()-Kontext wie die anderen Migrationen ausgeführt und schlagen fehl, wenn die Verbindung nicht im autocommit-Modus ist; darum setztfrom_conn_stringexplizitautocommit=True(autocommit:76-78).supports_pipelinewird zur Laufzeit geprüft:Capabilities().has_pipeline()(has_pipeline:60) ermittelt in__init__die psycopg-Version und serverseitige Fähigkeiten; bei älterem psycopg oder manchen Managed-Postgres-Anbietern fällt es auf den normalen Transaktionsmodus zurück (transaction fallback:434-439).- DeltaChannel hat einen eigenen Fast-Path:
get_delta_channel_historyist inPostgresSaverüberschrieben (get_delta_channel_history:444-463) und läuft über eine zweistufige Abfrage (Stage 1: Metadata-Paging + Stage 2: UNION ALL für Writes) statt über die Standardimplementierung der Basisklasse; ein eigener Saver muss diese Schnittstelle ebenfalls implementieren, um DeltaChannel zu unterstützen.
Zusammenfassung
PostgresSaver ist der von LangGraph empfohlene Saver für den Produktiveinsatz; die Drei-Tabellen-Aufteilung lässt «JSONB-Haupttabelle + BYTEA für große Objekte + inkrementelle writes» jeweils in den passenden Speicher fließen. Der Pipeline-Modus und die native Async-Implementierung liefern den Durchsatz für produktive Skalierung. Die Schnittstellenform siehe BaseCheckpointSaver, für leichtgewichtige Szenarien siehe InMemorySaver + SqliteSaver.
Siehe offizielle Dokumentation: LangGraph 文档 · README.