Cómo ejecuta LangGraph un grafo: de graph.invoke() hasta el bucle Pregel
Un recorrido a nivel de código fuente por graph.invoke, stream, SyncPregelLoop, tick, la preparación de tareas y run_with_retry, más un ejemplo paralelo resuelto paso a paso guiado por las versiones de los canales.
LangGraph parece fácil de aprender. add_node(), add_edge(), add_conditional_edges() y graph.invoke() se entienden enseguida, y en pocas horas tienes grafos funcionando. Sin embargo, tras unos cuantos proyectos suele rondar una pregunta: ¿qué pasa de verdad cuando llamas a graph.invoke()? ¿Cómo elige el framework qué nodo se ejecuta, gestiona las aristas condicionales, actualiza los canales y ejecuta nodos en paralelo? ¿Dónde encajan los reintentos, los checkpoints, las interrupciones y el streaming? La mayoría de tutoriales explican cómo usar LangGraph y se saltan las tripas. Este recorrido sigue la llamada desde la API pública hasta el motor Pregel, para que veas, paso a paso, cómo se programan las tareas y cómo se mueve el estado.
Por qué Pregel
Un grafo es más que una cadena de llamadas a funciones. Varios nodos pueden ejecutarse a la vez, algunos necesitan las salidas de otros, las actualizaciones de estado deben ser coherentes y el framework tiene que admitir reintentos, checkpoints, interrupciones y streaming. Pregel resuelve todo eso ejecutando el grafo en pasos sincronizados. En cada paso, cada nodo elegible se ejecuta de forma independiente. Sus salidas se recogen y se aplican juntas al final del paso, y solo entonces se dispara el siguiente grupo de nodos. Eso hace determinista la ejecución en paralelo y mantiene predecibles las actualizaciones de estado.
El bucle se repite hasta que no quedan tareas ejecutables, o hasta que la ejecución se interrumpe, falla o alcanza el límite de recursión.
Paso 1: graph.invoke()
Todo empieza con esta llamada:
result = graph.invoke(state)
Cabría esperar que esa función ejecute el grafo, pero no lo hace. graph.invoke() es diminuta. Existe como una API cómoda que ejecuta el grafo hasta el final y devuelve el estado final, y delega el trabajo real en stream():
graph.invoke()
│
▼
stream()
El motivo es que LangGraph ya tiene un motor de streaming capaz de emitir actualizaciones tras cada paso de Pregel. En lugar de mantener dos rutas de ejecución, invoke() consume el stream internamente y devuelve el último valor. A partir de aquí, la ejecución real ocurre dentro de stream().
Paso 2: stream()
Si invoke() es la cara pública, stream() es la capa de orquestación. Antes de que pueda ejecutarse ningún nodo, prepara todo el entorno de ejecución:
- Completar la configuración por defecto
- Preparar los gestores de callbacks y el checkpointing
- Conectar los manejadores de streaming
- Construir el objeto Runtime
- Construir el motor que hace la ejecución
Ese último punto es el que más importa. Durante esta preparación, LangGraph construye los dos objetos que dirigen el resto de la ejecución:
SyncPregelLoop
PregelRunner
Se reparten la ejecución en dos trabajos separados:
SyncPregelLoop
│
├── decides WHAT should run
│
PregelRunner
│
└── executes it
Mantener la planificación separada de la ejecución significa que ninguna depende de la otra.
Paso 3: SyncPregelLoop
Con la inicialización terminada, la ejecución entra en el bucle Pregel, donde cada iteración es un superpaso de Pregel. Cada iteración sigue el mismo ciclo de vida:
Determine runnable nodes
│
Execute those nodes
│
Apply every write together
│
Trigger the next set of nodes
Lo importante es que las escrituras no se aplican de inmediato. Todos los nodos de un paso ven una instantánea idéntica del estado, y las escrituras se confirman en un único lote solo cuando termina el último nodo ejecutable. Esa única decisión de diseño es la razón de que la ejecución en paralelo sea determinista.
Paso 4: tick()
La primera función que se ejecuta en cada paso de Pregel es tick(). Mucha gente supone que ejecuta los nodos del grafo, y no es así. Solo prepara el trabajo de la iteración actual, con algunas tareas de mantenimiento:
- Comprobar si la ejecución debe parar por el límite de recursión, una interrupción o porque ha terminado
- Restaurar las escrituras pendientes al reanudar desde un checkpoint
- Emitir eventos de depuración
- Llamar a
prepare_next_tasks()
Después devuelve un booleano. True significa que queda trabajo y False que la ejecución ha terminado. En este momento no se ha ejecutado ningún nodo. tick() solo ha averiguado qué podría ejecutarse.
Paso 5: prepare_next_tasks()
Esta función es el planificador de todo el motor. A partir del estado actual del grafo, encuentra cada nodo apto para ejecutarse en este paso y crea dos tipos de tareas.
Las tareas PUSH son explícitas. Proceden de construcciones como Send, goto y call(), es decir, algo pidió directamente que se ejecutara otro nodo.
Las tareas PULL son implícitas. Nadie las solicita. Se disparan porque uno de los canales de entrada del nodo cambió durante el paso anterior. Para cada nodo candidato, LangGraph comprueba si algún canal disparador ha recibido una versión más nueva y, si es así, el nodo pasa a ser ejecutable. Tras encontrar los nodos ejecutables, el planificador pasa cada uno a:
prepare_single_task()
Paso 6: prepare_single_task()
Hasta ahora solo se sabe que el nodo X debe ejecutarse. Esta función convierte esa decisión abstracta en algo que el motor puede ejecutar, construyendo un PregelExecutableTask. El código toma uno de dos caminos.
En PUSH, las tareas programadas explícitamente las gestiona una de estas funciones, según cómo se crearon:
prepare_push_task_send()
or
prepare_push_task_functional()
El camino PULL hace bastante más. Primero, _triggers() confirma que el nodo debe ejecutarse. No se limita a comprobar si un canal tiene datos. Compara versiones de canales:
current version > last version consumed
Solo las versiones más nuevas disparan la ejecución, para que los nodos no se vuelvan a ejecutar una y otra vez sobre un estado sin cambios. Una vez elegido un nodo, _proc_input() prepara su entrada a partir de los canales del grafo, los valores gestionados, el scratchpad y, cuando es posible, las entradas en caché. Si existe un mapper, la entrada se convierte al esquema que espera el nodo, por ejemplo:
TypedDict
↓
Pydantic Model
Por último, LangGraph construye los metadatos de ejecución, la configuración del entorno, la información del checkpoint y la configuración de reintentos, y lo empaqueta todo en un PregelExecutableTask. Ahí termina el trabajo del planificador y la tarea queda lista para ejecutarse.
Paso 7: PregelExecutableTask
Considéralo el paquete que se entrega al ejecutor. Contiene todo lo necesario para ejecutar un nodo: el runnable del nodo, la entrada preparada, la configuración del entorno, la política de reintentos, los writers, el espacio de nombres del checkpoint, los metadatos de ejecución y la información de caché. Nada de su interior decide cuándo se ejecuta. Es solo el contenido que hace falta una vez que empieza la ejecución.
Paso 8: PregelRunner.tick()
Aquí es donde por fin ocurre la ejecución. Si solo hay una tarea lista, como:
Node A
LangGraph llama directamente a lo siguiente:
run_with_retry()
Cuando varias tareas son independientes, como estas:
Node B
Node C
Node D
se envían todas a la vez como objetos Future, cada uno de los cuales representa un nodo en ejecución. El ejecutor espera a la que termine primero, confirma las escrituras completadas, gestiona los fallos, programa reintentos cuando hace falta y continúa hasta que se completa cada tarea ejecutable del paso actual.
Un detalle a tener en cuenta: el paralelismo solo existe dentro de un único paso de Pregel. Las dependencias secuenciales se mantienen, porque el siguiente paso no puede empezar hasta que se complete cada tarea del actual.
Paso 9: run_with_retry()
Aquí es donde se llama por primera vez a la función Python de tu nodo. Todos los pasos anteriores eran planificación. Esta función ejecuta el runnable y se ocupa de todo lo que lo rodea. Sus tareas: llamar al nodo, borrar las escrituras obsoletas antes de cualquier reintento, aplicar la política de reintentos con espera exponencial, gestionar ParentCommand y GraphInterrupt y registrar cada escritura que emite el nodo. Cuando termina la ejecución, el control vuelve al bucle Pregel, donde after_tick() confirma todas las escrituras juntas antes de que empiece el siguiente paso.
Un ejemplo resuelto con nodos en paralelo
Toma un grafo en el que dos nodos se ejecutan realmente en paralelo, para que se vea cómo lo deciden las versiones de los canales:
┌──→ fetch_weather ──┐
START → classify┤ ├──→ respond → END
└──→ fetch_news ─────┘
Aquí classify se ramifica en fetch_weather y fetch_news, que son independientes entre sí, y ambos desembocan en respond. Ninguno depende de la salida del otro, solo de la de classify. Estas son las versiones de los canales al principio:
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()compara el canal disparador de cada nodo con lo último que consumió.classifyvigila"input". La versión es1y la última consumida fue0, así que1 > 0y cuenta como más nueva.fetch_weatheryfetch_newsvigilan un canal en el queclassifyaún no ha escrito, así que no se disparan.- El resultado es que solo
classifyse convierte en tarea PULL en este paso.
En el paso 1, classify se ejecuta y escribe esta salida:
{"needs_weather": true, "needs_news": true}
Eso incrementa dos canales posteriores:
channel: "needs_weather" version: 0 → 1
channel: "needs_news" version: 0 → 1
prepare_next_tasks()vuelve a revisar.fetch_weathervigila"needs_weather", donde la versión1 > 0(última consumida), así que se dispara.fetch_newsvigila"needs_news", donde también se cumple1 > 0, así que se dispara.- Ambos se convierten en tareas PULL en el mismo paso de Pregel, ya que las dos condiciones se cumplieron por una sola escritura.
respondvigila"weather_data"y"news_data", y ninguno ha cambiado todavía, así querespondno se dispara.
Este es el momento en que aparece el paralelismo. Dos nodos se disparan a partir de las escrituras del mismo superpaso, así que PregelRunner.tick() envía ambos como objetos Future y los ejecuta a la vez.
En el paso 2, fetch_weather y fetch_news se ejecutan a la vez y ambos escriben:
channel: "weather_data" version: 0 → 1
channel: "news_data" version: 0 → 1
- Aunque los dos nodos empiezan y terminan de forma independiente, sus escrituras no se aplican hasta que se completan ambos futures. Es la regla de «aplicar todas las escrituras juntas» de la sección sobre el bucle.
- Una vez confirmado el lote,
prepare_next_tasks()recorre de nuevo el grafo.respondescucha"weather_data"y"news_data", y cada uno ha superado la última versión consumida (0), así que se dispara. respondes la única tarea PULL de este paso.
En el paso 3, respond se ejecuta y no hay cambios posteriores en los canales. Eso no dispara nada aguas abajo, lo que significa que tick() devuelve False y el bucle se detiene.
Son tres pasos de Pregel en total, pero el paso 1 programó dos nodos a la vez por una única razón: prepare_next_tasks() encontró dos versiones de canal incrementadas por un mismo lote de escrituras. Compáralo con un grafo puramente secuencial, donde cada paso produce una tarea ejecutable porque solo cambia un canal cada vez. La regla de comparación de versiones es idéntica en ambos casos. El paralelismo no es un caso especial que LangGraph detecte. Surge de forma natural siempre que varios canales se actualizan dentro del mismo superpaso.