Як LangGraph виконує граф: від graph.invoke() до циклу Pregel
Розбір на рівні вихідного коду: graph.invoke, stream, SyncPregelLoop, tick, підготовка задач і run_with_retry, а також докладний приклад паралельного виконання, керованого версіями каналів.
LangGraph здається простим в освоєнні. add_node(), add_edge(), add_conditional_edges() і graph.invoke() зрозумілі одразу, і робочі графи складаються за лічені години. Але після кількох проєктів зазвичай починає муляти питання: що насправді відбувається під час виклику graph.invoke()? Як фреймворк обирає, який вузол запустити, обробляє умовні ребра, оновлює канали й запускає вузли паралельно? Де тут повторні спроби, чекпоінти, переривання й стримінг? Більшість посібників пояснюють, як користуватися LangGraph, і пропускають внутрішню будову. Цей розбір проходить шлях виклику від публічного API вглиб рушія Pregel, щоб крок за кроком показати, як плануються задачі й рухається стан.
Чому Pregel
Граф — це більше, ніж ланцюжок викликів функцій. Кілька вузлів можуть працювати одночасно, деяким потрібні результати інших, оновлення стану мають лишатися узгодженими, а фреймворк має підтримувати повторні спроби, чекпоінти, переривання й стримінг. Pregel дає раду всьому цьому, виконуючи граф синхронізованими кроками. На кожному кроці кожен придатний вузол працює незалежно. Їхні результати збираються й застосовуються разом наприкінці кроку, і лише після цього запускається наступна група вузлів. Це робить паралельне виконання детермінованим, а оновлення стану — передбачуваними.
Цикл повторюється, доки не залишиться задач, готових до запуску, або доки виконання не буде перервано, не завершиться помилкою чи не впреться в ліміт рекурсії.
Крок 1: graph.invoke()
Усе починається з цього виклику:
result = graph.invoke(state)
Можна було б очікувати, що ця функція виконує граф, але це не так. graph.invoke() крихітна. Вона існує як зручний API, що виконує граф до кінця й повертає підсумковий стан, а справжню роботу передає stream():
graph.invoke()
│
▼
stream()
Причина в тому, що LangGraph уже має потоковий рушій, який може віддавати оновлення після кожного кроку Pregel. Замість підтримувати два шляхи виконання, invoke() всередині споживає потік і повертає останнє значення. Далі справжнє виконання відбувається всередині stream().
Крок 2: stream()
Якщо invoke() — публічний фасад, то stream() — шар оркестрації. Перш ніж зможе запуститися бодай один вузол, він готує все середовище виконання:
- Заповнення конфігурації за замовчуванням
- Налаштування менеджерів колбеків і чекпоінтингу
- Підключення обробників стримінгу
- Створення об'єкта Runtime
- Побудову рушія, який виконує роботу
Останній пункт найважливіший. Під час цього налаштування LangGraph створює два об'єкти, які керують рештою запуску:
SyncPregelLoop
PregelRunner
Вони ділять виконання на дві окремі задачі:
SyncPregelLoop
│
├── decides WHAT should run
│
PregelRunner
│
└── executes it
Відокремлення планування від виконання означає, що жодне не залежить від іншого.
Крок 3: SyncPregelLoop
Коли ініціалізацію завершено, виконання входить у цикл Pregel, де кожна ітерація — це один суперкрок Pregel. Кожна ітерація проходить той самий життєвий цикл:
Determine runnable nodes
│
Execute those nodes
│
Apply every write together
│
Trigger the next set of nodes
Важливо, що записи застосовуються не одразу. Усі вузли на кроці бачать однаковий знімок стану, а записи фіксуються одним пакетом лише після того, як завершиться останній готовий до запуску вузол. Саме це єдине проєктне рішення робить паралельне виконання детермінованим.
Крок 4: tick()
Перша функція, що виконується на кожному кроці Pregel, — tick(). Часто думають, що вона виконує вузли графа, але це не так. Вона лише готує роботу для поточної ітерації й виконує кілька службових задач:
- Перевіряє, чи не час зупинити виконання через ліміт рекурсії, переривання чи завершення
- Відновлює записи, що очікують, під час відновлення з чекпоінта
- Надсилає налагоджувальні події
- Викликає
prepare_next_tasks()
Потім вона повертає булеве значення. True означає, що робота ще лишилася, а False — що виконання закінчено. У цей момент жоден вузол ще не запускався. tick() лише визначила, що могло б запуститися.
Крок 5: prepare_next_tasks()
Ця функція — планувальник усього рушія. За поточним станом графа вона знаходить кожен вузол, який може запуститися на цьому кроці, і створює задачі двох видів.
PUSH-задачі — явні. Вони виникають із конструкцій на кшталт Send, goto і call(), тобто щось безпосередньо попросило запустити інший вузол.
PULL-задачі — неявні. Їх ніхто не запитує. Вони спрацьовують тому, що один із вхідних каналів вузла змінився на попередньому кроці. Для кожного вузла-кандидата LangGraph перевіряє, чи отримав якийсь тригерний канал новішу версію, і якщо так, вузол стає готовим до виконання. Знайшовши готові вузли, планувальник передає кожен із них у:
prepare_single_task()
Крок 6: prepare_single_task()
Поки що відомо лише, що вузол X має запуститися. Ця функція перетворює абстрактне рішення на те, що рушій може виконати, створюючи PregelExecutableTask. Код іде одним із двох шляхів.
Для PUSH явно заплановані задачі обробляє одна з цих функцій, залежно від того, як їх було створено:
prepare_push_task_send()
or
prepare_push_task_functional()
Шлях PULL робить помітно більше. Спершу _triggers() підтверджує, що вузол має запуститися. Вона не просто перевіряє, чи є в каналі дані. Вона порівнює версії каналів:
current version > last version consumed
Виконання запускають лише новіші версії, тож вузли не перезапускаються знову й знову на незмінному стані. Коли вузол обрано, _proc_input() готує його вхід із каналів графа, керованих значень, scratchpad і, де можливо, закешованих входів. Якщо є мапер, вхід перетворюється на схему, якої очікує вузол, наприклад:
TypedDict
↓
Pydantic Model
Нарешті LangGraph збирає метадані виконання, конфігурацію середовища, інформацію про чекпоінт і налаштування повторів і пакує все це в PregelExecutableTask. На цьому робота планувальника закінчується, і задача готова до запуску.
Крок 7: PregelExecutableTask
Вважайте це пакетом, який передають виконавцю. У ньому є все потрібне для запуску одного вузла: runnable вузла, підготовлений вхід, конфігурація середовища, політика повторів, writers, простір імен чекпоінта, метадані виконання й інформація про кеш. Ніщо всередині не вирішує, коли почнеться виконання. Це лише вміст, потрібний, коли виконання вже почалося.
Крок 8: PregelRunner.tick()
Саме тут нарешті й відбувається виконання. Якщо готова лише одна задача, наприклад:
Node A
LangGraph безпосередньо викликає таке:
run_with_retry()
Коли кілька задач незалежні, як ці:
Node B
Node C
Node D
їх усі одразу надсилають як об'єкти Future, кожен із яких відповідає робочому вузлу. Виконавець чекає на ту, що завершиться першою, фіксує завершені записи, обробляє збої, за потреби планує повтори й продовжує, доки не буде виконано кожну готову задачу поточного кроку.
Варто зазначити: паралелізм існує лише в межах одного кроку Pregel. Послідовні залежності, як і раніше, дотримуються, бо наступний крок не може початися, доки не завершиться кожна задача поточного.
Крок 9: run_with_retry()
Саме тут уперше викликається Python-функція вашого вузла. Усі попередні кроки були плануванням. Ця функція виконує runnable й опікується всім, що його оточує. Її обов'язки: викликати вузол, стерти застарілі записи перед будь-яким повтором, застосувати політику повторів з експоненційною затримкою, обробити ParentCommand і GraphInterrupt та занотувати кожен запис, який видає вузол. Коли виконання закінчується, керування повертається в цикл Pregel, де after_tick() фіксує всі записи разом, перш ніж почнеться наступний крок.
Докладний приклад із паралельними вузлами
Візьмімо граф, у якому два вузли справді працюють паралельно, щоб стало видно, як це вирішують версії каналів:
┌──→ fetch_weather ──┐
START → classify┤ ├──→ respond → END
└──→ fetch_news ─────┘
Тут classify розгалужується на fetch_weather і fetch_news, які не залежать один від одного, і обидва ведуть у respond. Жоден не залежить від результату іншого — лише від результату classify. Ось версії каналів на початку:
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()порівнює тригерний канал кожного вузла з тим, що він спожив востаннє.classifyстежить за"input". Версія —1, остання спожита —0, отже,1 > 0, і вона вважається новішою.fetch_weatherіfetch_newsстежать за каналом, у якийclassifyще не писав, тож вони не спрацьовують.- Зрештою на цьому кроці PULL-задачею стає лише
classify.
На кроці 1 запускається classify і записує такий результат:
{"needs_weather": true, "needs_news": true}
Це підвищує версії двох нижчих каналів:
channel: "needs_weather" version: 0 → 1
channel: "needs_news" version: 0 → 1
prepare_next_tasks()знову переглядає граф.fetch_weatherстежить за"needs_weather", де версія1 > 0(остання спожита), тож він спрацьовує.fetch_newsстежить за"needs_news", де1 > 0теж виконується, тож він спрацьовує.- Обидва стають PULL-задачами на тому самому кроці Pregel, оскільки обидві умови стали істинними від одного запису.
respondстежить за"weather_data"і"news_data", і жоден із них поки не змінився, тожrespondне спрацьовує.
Саме в цей момент і виникає паралелізм. Два вузли спрацьовують від записів того самого суперкроку, тож PregelRunner.tick() надсилає обидва як об'єкти Future і виконує їх одночасно.
На кроці 2 fetch_weather і fetch_news працюють одночасно, й обидва записують:
channel: "weather_data" version: 0 → 1
channel: "news_data" version: 0 → 1
- Хоча два вузли стартують і завершуються незалежно, їхні записи не застосовуються, доки не завершаться обидва future. Це правило «застосовувати всі записи разом» із розділу про цикл.
- Коли пакет зафіксовано,
prepare_next_tasks()знову обходить граф.respondслухає"weather_data"і"news_data", і кожен із них пішов далі за останню спожиту версію (0), тож він спрацьовує. respond— єдина PULL-задача на цьому кроці.
На кроці 3 запускається respond, і жодних змін каналів за цим не відбувається. Нижче за графом це нічого не запускає, а отже, tick() повертає False, і цикл зупиняється.
Загалом виходить три кроки Pregel, але на кроці 1 два вузли було заплановано одночасно з однієї-єдиної причини: prepare_next_tasks() виявила дві версії каналів, підвищені одним пакетом записів. Порівняйте із суто послідовним графом, де кожен крок дає одну готову задачу, оскільки за раз змінюється лише один канал. Правило порівняння версій в обох випадках однакове. Паралелізм — не особливий випадок, який LangGraph розпізнає. Він виникає природно щоразу, коли кілька каналів оновлюються в одному суперкроці.