Wie LangGraph einen Graphen ausführt: von graph.invoke() bis hinunter zur Pregel-Schleife
Ein Rundgang auf Quellcode-Ebene durch graph.invoke, stream, SyncPregelLoop, tick, die Aufgabenvorbereitung und run_with_retry, dazu ein durchgerechnetes Parallelbeispiel, gesteuert über Kanalversionen.
LangGraph wirkt leicht zugänglich. add_node(), add_edge(), add_conditional_edges() und graph.invoke() sind alle schnell verstanden, und funktionierende Graphen stehen in wenigen Stunden. Nach ein paar Projekten nagt aber meist eine Frage: Was passiert wirklich, wenn du graph.invoke() aufrufst? Wie wählt das Framework aus, welcher Knoten läuft, wie behandelt es bedingte Kanten, aktualisiert Kanäle und führt Knoten parallel aus? Wo passen Wiederholungen, Checkpoints, Interrupts und Streaming hinein? Die meisten Tutorials zeigen, wie man LangGraph benutzt, und lassen die Interna aus. Dieser Rundgang folgt dem Aufruf von der öffentlichen API bis in die Pregel-Engine, damit du Schritt für Schritt siehst, wie Aufgaben eingeplant werden und sich der Zustand bewegt.
Warum Pregel
Ein Graph ist mehr als eine Kette von Funktionsaufrufen. Mehrere Knoten können gleichzeitig laufen, manche brauchen die Ausgaben anderer, Zustandsupdates müssen konsistent bleiben, und das Framework muss Wiederholungen, Checkpoints, Interrupts und Streaming unterstützen. Pregel bewältigt all das, indem es den Graphen in synchronisierten Schritten ausführt. In jedem Schritt läuft jeder infrage kommende Knoten unabhängig. Ihre Ausgaben werden gesammelt und am Ende des Schritts gemeinsam angewendet, und erst dann wird die nächste Gruppe von Knoten ausgelöst. Das macht parallele Ausführung deterministisch und hält Zustandsupdates vorhersehbar.
Die Schleife wiederholt sich, bis keine ausführbaren Aufgaben mehr übrig sind oder bis die Ausführung unterbrochen wird, fehlschlägt oder das Rekursionslimit erreicht.
Schritt 1: graph.invoke()
Alles beginnt mit diesem Aufruf:
result = graph.invoke(state)
Man würde erwarten, dass diese Funktion den Graphen ausführt, doch das tut sie nicht. graph.invoke() ist winzig. Sie existiert als bequeme API, die den Graphen bis zum Ende ausführt und den Endzustand zurückgibt, und reicht die eigentliche Arbeit an stream() weiter:
graph.invoke()
│
▼
stream()
Der Grund: LangGraph hat bereits eine Streaming-Engine, die nach jedem Pregel-Schritt Updates liefern kann. Statt zwei Ausführungspfade zu pflegen, konsumiert invoke() den Stream intern und gibt den letzten Wert zurück. Ab hier findet die eigentliche Ausführung in stream() statt.
Schritt 2: stream()
Ist invoke() die öffentliche Fassade, so ist stream() die Orchestrierungsschicht. Bevor irgendein Knoten laufen kann, bereitet sie die gesamte Laufzeitumgebung vor:
- Standardkonfiguration ergänzen
- Callback-Manager und Checkpointing einrichten
- Streaming-Handler verdrahten
- Das Runtime-Objekt aufbauen
- Die Engine konstruieren, die die Ausführung übernimmt
Der letzte Punkt ist der wichtigste. Während dieser Einrichtung baut LangGraph die beiden Objekte, die den Rest des Laufs steuern:
SyncPregelLoop
PregelRunner
Sie teilen die Ausführung in zwei getrennte Aufgaben:
SyncPregelLoop
│
├── decides WHAT should run
│
PregelRunner
│
└── executes it
Planung und Ausführung getrennt zu halten, bedeutet, dass keines vom anderen abhängt.
Schritt 3: SyncPregelLoop
Nach der Initialisierung tritt die Ausführung in die Pregel-Schleife ein, in der jede Iteration ein Pregel-Superstep ist. Jede Iteration folgt demselben Lebenszyklus:
Determine runnable nodes
│
Execute those nodes
│
Apply every write together
│
Trigger the next set of nodes
Entscheidend ist, dass Schreibvorgänge nicht sofort wirksam werden. Alle Knoten eines Schritts sehen denselben Zustands-Snapshot, und die Schreibvorgänge werden erst in einem Batch festgeschrieben, wenn der letzte ausführbare Knoten fertig ist. Genau diese eine Designentscheidung macht parallele Ausführung deterministisch.
Schritt 4: tick()
Die erste Funktion, die in jedem Pregel-Schritt läuft, ist tick(). Oft wird angenommen, sie führe Graphknoten aus, aber das tut sie nicht. Sie bereitet nur die Arbeit für die aktuelle Iteration vor und erledigt ein paar Verwaltungsaufgaben:
- Prüfen, ob die Ausführung wegen des Rekursionslimits, eines Interrupts oder des Abschlusses stoppen soll
- Ausstehende Schreibvorgänge wiederherstellen, wenn von einem Checkpoint fortgesetzt wird
- Debug-Events ausgeben
prepare_next_tasks()aufrufen
Dann gibt sie einen booleschen Wert zurück. True bedeutet, dass noch Arbeit übrig ist, False, dass die Ausführung beendet ist. In diesem Moment ist noch kein Knoten gelaufen. tick() hat nur ermittelt, was laufen könnte.
Schritt 5: prepare_next_tasks()
Diese Funktion ist der Scheduler der gesamten Engine. Anhand des aktuellen Graphzustands findet sie jeden Knoten, der in diesem Schritt laufen darf, und erzeugt zwei Arten von Aufgaben.
PUSH-Aufgaben sind explizit. Sie stammen aus Konstrukten wie Send, goto und call(), das heißt, etwas hat direkt verlangt, dass ein anderer Knoten läuft.
PULL-Aufgaben sind implizit. Niemand fordert sie an. Sie werden ausgelöst, weil sich einer der Eingabekanäle des Knotens im vorherigen Schritt geändert hat. Für jeden Kandidatenknoten prüft LangGraph, ob ein Trigger-Kanal eine neuere Version erhalten hat, und wenn ja, wird der Knoten ausführbar. Nachdem die ausführbaren Knoten gefunden sind, übergibt der Scheduler jeden davon an:
prepare_single_task()
Schritt 6: prepare_single_task()
Bisher ist nur bekannt, dass Knoten X laufen soll. Diese Funktion macht aus der abstrakten Entscheidung etwas, das die Engine ausführen kann, indem sie einen PregelExecutableTask baut. Der Code nimmt einen von zwei Wegen.
Bei PUSH werden explizit eingeplante Aufgaben von einer dieser Funktionen behandelt, je nachdem, wie sie erzeugt wurden:
prepare_push_task_send()
or
prepare_push_task_functional()
Der PULL-Weg tut deutlich mehr. Zuerst bestätigt _triggers(), dass der Knoten laufen soll. Sie prüft nicht nur, ob ein Kanal Daten enthält. Sie vergleicht Kanalversionen:
current version > last version consumed
Nur neuere Versionen lösen eine Ausführung aus, damit Knoten nicht immer wieder auf unverändertem Zustand laufen. Nachdem ein Knoten gewählt ist, bereitet _proc_input() seine Eingabe aus Graphkanälen, verwalteten Werten, dem Scratchpad und, wo möglich, zwischengespeicherten Eingaben vor. Gibt es einen Mapper, wird die Eingabe in das Schema umgewandelt, das der Knoten erwartet, zum Beispiel:
TypedDict
↓
Pydantic Model
Schließlich stellt LangGraph die Ausführungsmetadaten, die Laufzeitkonfiguration, die Checkpoint-Informationen und die Wiederholungskonfiguration zusammen und bündelt alles in einem PregelExecutableTask. Damit endet die Arbeit des Schedulers, und die Aufgabe ist bereit zur Ausführung.
Schritt 7: PregelExecutableTask
Betrachte das als das Paket, das an den Runner übergeben wird. Es enthält alles, was für die Ausführung eines Knotens nötig ist: das Runnable des Knotens, die vorbereitete Eingabe, die Laufzeitkonfiguration, die Wiederholungsrichtlinie, Writer, den Checkpoint-Namespace, Ausführungsmetadaten und Cache-Informationen. Nichts darin entscheidet, wann die Ausführung stattfindet. Es ist nur der Inhalt, der gebraucht wird, sobald die Ausführung beginnt.
Schritt 8: PregelRunner.tick()
Hier findet die Ausführung endlich statt. Ist nur eine Aufgabe bereit, etwa:
Node A
ruft LangGraph direkt Folgendes auf:
run_with_retry()
Sind mehrere Aufgaben unabhängig, wie diese:
Node B
Node C
Node D
werden sie alle auf einmal als Future-Objekte übergeben, von denen jedes für einen laufenden Knoten steht. Der Runner wartet auf die, die zuerst fertig wird, schreibt abgeschlossene Schreibvorgänge fest, behandelt Fehler, plant bei Bedarf Wiederholungen ein und macht weiter, bis jede ausführbare Aufgabe des aktuellen Schritts erledigt ist.
Wichtig: Parallelität gibt es nur innerhalb eines einzelnen Pregel-Schritts. Sequenzielle Abhängigkeiten bleiben bestehen, denn der nächste Schritt kann erst beginnen, wenn jede Aufgabe des aktuellen abgeschlossen ist.
Schritt 9: run_with_retry()
Hier wird die Python-Funktion deines Knotens zum ersten Mal aufgerufen. Alle früheren Schritte waren Planung. Diese Funktion führt das Runnable aus und kümmert sich um alles drumherum. Ihre Aufgaben: den Knoten aufrufen, veraltete Schreibvorgänge vor jeder Wiederholung löschen, die Wiederholungsrichtlinie mit exponentiellem Backoff anwenden, ParentCommand und GraphInterrupt behandeln und jeden Schreibvorgang protokollieren, den der Knoten ausgibt. Wenn die Ausführung endet, kehrt die Kontrolle in die Pregel-Schleife zurück, wo after_tick() alle Schreibvorgänge gemeinsam festschreibt, bevor der nächste Schritt beginnt.
Ein durchgerechnetes Beispiel mit parallelen Knoten
Nimm einen Graphen, in dem zwei Knoten wirklich parallel laufen, damit sichtbar wird, wie Kanalversionen das entscheiden:
┌──→ fetch_weather ──┐
START → classify┤ ├──→ respond → END
└──→ fetch_news ─────┘
Hier verzweigt classify in fetch_weather und fetch_news, die voneinander unabhängig sind, und beide führen in respond. Keiner hängt von der Ausgabe des anderen ab, nur von der von classify. Das sind die Kanalversionen zu Beginn:
channel: "input" version: 1 (last consumed by classify: 0)
channel: "weather_data" version: 0 (last consumed by respond: 0)
channel: "news_data" version: 0 (last consumed by respond: 0)
_triggers()vergleicht den Trigger-Kanal jedes Knotens mit dem, was er zuletzt konsumiert hat.classifybeobachtet"input". Die Version ist1und die zuletzt konsumierte war0, also gilt1 > 0, und sie zählt als neuer.fetch_weatherundfetch_newsbeobachten einen Kanal, in denclassifynoch nicht geschrieben hat, also werden sie nicht ausgelöst.- Das Ergebnis: Nur
classifywird in diesem Schritt zur PULL-Aufgabe.
In Schritt 1 läuft classify und schreibt diese Ausgabe:
{"needs_weather": true, "needs_news": true}
Das erhöht die Versionen zweier nachgelagerter Kanäle:
channel: "needs_weather" version: 0 → 1
channel: "needs_news" version: 0 → 1
prepare_next_tasks()scannt erneut.fetch_weatherbeobachtet"needs_weather", wo die Version1 > 0(zuletzt konsumiert) ist, also wird er ausgelöst.fetch_newsbeobachtet"needs_news", wo ebenfalls1 > 0gilt, also wird er ausgelöst.- Beide werden im selben Pregel-Schritt zu PULL-Aufgaben, da beide Bedingungen durch einen einzigen Schreibvorgang wahr wurden.
respondbeobachtet"weather_data"und"news_data", und keiner davon hat sich bisher geändert, also wirdrespondnicht ausgelöst.
Das ist der Moment, in dem Parallelität entsteht. Zwei Knoten werden durch die Schreibvorgänge desselben Supersteps ausgelöst, also übergibt PregelRunner.tick() beide als Future-Objekte und führt sie gleichzeitig aus.
In Schritt 2 laufen fetch_weather und fetch_news gleichzeitig, und beide schreiben:
channel: "weather_data" version: 0 → 1
channel: "news_data" version: 0 → 1
- Obwohl die beiden Knoten unabhängig starten und enden, werden ihre Schreibvorgänge erst angewendet, wenn beide Futures abgeschlossen sind. Das ist die Regel „alle Schreibvorgänge gemeinsam anwenden“ aus dem Abschnitt zur Schleife.
- Sobald der Batch festgeschrieben ist, geht
prepare_next_tasks()den Graphen erneut durch.respondlauscht auf"weather_data"und"news_data", und beide sind über die zuletzt konsumierte Version (0) hinaus, also wird er ausgelöst. respondist die einzige PULL-Aufgabe in diesem Schritt.
In Schritt 3 läuft respond, und es folgen keine Kanaländerungen. Dadurch wird nachgelagert nichts ausgelöst, also gibt tick() False zurück, und die Schleife stoppt.
Das ergibt insgesamt drei Pregel-Schritte, doch Schritt 1 hat aus einem einzigen Grund zwei Knoten gleichzeitig eingeplant: prepare_next_tasks() fand zwei Kanalversionen, die durch einen Batch von Schreibvorgängen erhöht wurden. Vergleiche das mit einem rein sequenziellen Graphen, in dem jeder Schritt eine ausführbare Aufgabe ergibt, weil sich jeweils nur ein Kanal ändert. Die Regel für den Versionsvergleich ist in beiden Fällen identisch. Parallelität ist kein Sonderfall, den LangGraph erkennt. Sie ergibt sich ganz natürlich, wann immer mehrere Kanäle im selben Superstep aktualisiert werden.