Последовательно там, где можно параллельно
Допустим, агенту нужно собрать контекст из трёх источников: веб-поиск (1.2с), векторная база (0.8с) и внешний API (1.5с). Если узлы стоят цепочкой, общее время — сумма: 1.2 + 0.8 + 1.5 = 3.5с. Но источники друг от друга не зависят — ответ одного не нужен для запроса другого. Значит, их можно запустить разом, и время станет равно самому медленному: max(1.2, 0.8, 1.5) = 1.5с.
Это та же логика, что у asyncio.gather из модуля про Python, но на уровне графа: LangGraph сам выполняет независимые узлы параллельно, без ручного управления корутинами. Нужно лишь правильно описать структуру — и движок распараллелит.
Как и asyncio, параллельные узлы выигрывают на ожидании (запросы к LLM, API, БД) — пока один ждёт ответа, другие работают. Для тяжёлых вычислений выигрыша не будет. Но агенты почти всегда I/O-bound, так что паттерн применим очень часто.
Модель супершагов
Чтобы понять параллелизм в LangGraph, нужно одно ключевое понятие — супершаг (superstep). Граф исполняется не «узел за узлом», а волнами: на каждом супершаге выполняются все узлы, готовые к запуску. Узел готов, когда отработали все, от кого он зависит (в кого ведут входящие рёбра).
Отсюда правило параллелизма: узлы, у которых нет зависимости друг от друга, попадают в один супершаг и выполняются одновременно. Если из узла split выходят три ребра к A, B, C — все трое запустятся параллельно на следующем супершаге.
Статический fan-out / fan-in
Параллельные ветки задаются просто — несколькими рёбрами из одного узла (fan-out) и несколькими рёбрами в один узел-сборщик (fan-in). Узел-сборщик попадёт в следующий супершаг и запустится, только когда завершатся все входящие ветки — это естественная синхронизация.
Поскольку ветки пишут в общее состояние «одновременно», поле, куда они складывают результаты, обязано иметь reducer (урок про reducers) — иначе конфликт записи и InvalidUpdateError.
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
query: str
sources: Annotated[list[str], operator.add] # ← ОБЯЗАТЕЛЬНО reducer
answer: str
def split(state): return {} # точка ветвления
def search(state): return {"sources": ["веб: ..."]}
def vector(state): return {"sources": ["база: ..."]}
def api(state): return {"sources": ["api: ..."]}
def aggregate(state):
return {"answer": f"Собрано из {len(state['sources'])} источников"}
builder = StateGraph(State)
for name, fn in [("split", split), ("search", search),
("vector", vector), ("api", api), ("aggregate", aggregate)]:
builder.add_node(name, fn)
builder.add_edge(START, "split")
# fan-out: из split в три ветки — они пойдут в ОДНОМ супершаге
builder.add_edge("split", "search")
builder.add_edge("split", "vector")
builder.add_edge("split", "api")
# fan-in: все три ведут в aggregate — он ждёт всех
builder.add_edge("search", "aggregate")
builder.add_edge("vector", "aggregate")
builder.add_edge("api", "aggregate")
builder.add_edge("aggregate", END)
graph = builder.compile()
print(graph.invoke({"query": "LangGraph", "sources": [], "answer": ""}))
# search/vector/api отработали параллельно; aggregate собрал их результаты
Узел aggregate выполнится один раз и только после завершения всех входящих веток — это поведение fan-in по умолчанию. Не рассчитывай, что он сработает по мере готовности каждой ветки; он дожидается самой медленной (как gather).
Паттерны: sectioning и voting
Параллелизм применяют двумя характерными способами.
from collections import Counter
class State(TypedDict):
text: str
votes: Annotated[list[str], operator.add] # safe / unsafe от каждого судьи
verdict: str
def judge_a(state): return {"votes": [classify(state["text"], temp=0.0)]}
def judge_b(state): return {"votes": [classify(state["text"], temp=0.5)]}
def judge_c(state): return {"votes": [classify(state["text"], temp=1.0)]}
def tally(state): # сборщик: большинство голосов
winner = Counter(state["votes"]).most_common(1)[0][0]
return {"verdict": winner}
# START → (judge_a | judge_b | judge_c параллельно) → tally → END
Статический параллелизм против Send
Этот урок — про статический параллелизм: число веток известно при сборке графа, ты прописываешь рёбра руками. Если же количество веток определяется в рантайме (обработать каждый элемент списка неизвестной длины) — нужен динамический fan-out через Send из урока про map-reduce.
| Критерий | Статический (рёбра) | Динамический (Send) |
|---|---|---|
| Число веток | известно при сборке | вычисляется в рантайме |
| Как задаётся | add_edge ×N |
[Send(...) for ...] |
| Типичный случай | фиксированные подзадачи (sectioning, voting) | обработать каждый элемент списка |
| Сбор результатов | reducer на поле fan-in | reducer на поле fan-in |
И статический fan-out, и Send требуют reducer на поле, куда стекаются ветки. Это сквозное правило параллелизма в LangGraph: где несколько узлов пишут в одно поле одновременно — там Annotated[..., operator.add] (или свой merge). Отличается только как ветки порождаются.
Типичные ошибки
Параллельные ветки, пишущие в поле без operator.add, конфликтуют → InvalidUpdateError или потеря результатов. Поле fan-in всегда с reducer'ом.
Если ветка B на самом деле использует результат A, поставить их «параллельно» нельзя — это логическая зависимость. Параллелятся только действительно независимые узлы.
Ветки завершаются в непредсказуемом порядке. Не полагайся на то, что search запишется раньше api; если порядок важен — сортируй в сборщике по явному признаку.
Массовый параллелизм с LLM-вызовами упрётся в rate-limit или память. Ограничивай одновременность через {"max_concurrency": N} в config (как в map-reduce).
Шпаргалка
# Модель: СУПЕРШАГ — независимые узлы исполняются одновременно
# Поле сбора — ОБЯЗАТЕЛЬНО reducer
class State(TypedDict):
results: Annotated[list, operator.add]
# Статический fan-out / fan-in рёбрами:
builder.add_edge("split", "a") # ┐
builder.add_edge("split", "b") # ├ a,b,c — один супершаг (параллельно)
builder.add_edge("split", "c") # ┘
builder.add_edge("a", "agg") # ┐
builder.add_edge("b", "agg") # ├ agg ждёт ВСЕХ (fan-in)
builder.add_edge("c", "agg") # ┘
# Паттерны:
# sectioning — разные подзадачи параллельно → свести
# voting — одна задача N раз → агрегировать (голосование/лучший)
# Контроль параллелизма (тяжёлые ветки):
graph.invoke(inp, {"max_concurrency": 5})
# Правила:
# • параллелятся ТОЛЬКО независимые узлы (нет рёбер между ними)
# • поле fan-in — с reducer
# • сборщик ждёт все ветки; порядок завершения не гарантирован
# • динамическое число веток → Send (урок map-reduce)
Практическое задание
Распараллель работу графа:
Задание: параллельная проверка текста
- Состояние:
text: str,issues: Annotated[list, operator.add],report: str. - Сделай три независимых узла-проверки:
grammar,facts,tone— каждый дописывает найденные замечания вissues. - Собери fan-out от
START(или узла-входа) к трём проверкам и fan-in в узелreport, который сводит замечания. - Запусти и через
streamубедись, что три проверки идут в одном супершаге, аreportсрабатывает один раз после всех. - Убери
operator.addуissuesи посмотри наInvalidUpdateError— наглядно, зачем reducer на fan-in. - Со звёздочкой: переделай в voting — гоняй одну проверку тремя «судьями» с разной температурой и в
reportвыноси вердикт большинством голосов.
Что дальше
Это последний урок раздела «Паттерны агентов» — и последний по теории LangGraph. У тебя в руках полный набор: основы графа, продвинутые возможности, human-in-the-loop и все ключевые паттерны. Дальше — собрать из этого проект модуля.