← Multi-agent orchestration: LangGraph, CrewAI, and agent teams
02 / 07 · 55 min

LangGraph: state machines for agents

StateGraph in practice: typed state with reducers, nodes and edges, conditional routing, the checkpointer, and human-in-the-loop via interrupt.

Lesson content is currently available in Russian.

What you will learn

  • →Описывать агентную систему как граф: состояние, узлы, рёбра, условная маршрутизация.
  • →Использовать редьюсеры и add_messages для безопасного слияния обновлений состояния.
  • →Включать персистентность через checkpointer и останавливать граф на подтверждение человеком.

StateGraph: состояние и редьюсеры

LangGraph моделирует агентную систему как конечный автомат: узлы — это функции (обычно с вызовом LLM), рёбра — переходы, а всё общение между узлами идёт через единый объект состояния. Узел не вызывает другой узел напрямую — он возвращает частичное обновление состояния, и граф решает, куда идти дальше.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]
    draft: str

def writer(state: State) -> dict:
    reply = llm.invoke(state["messages"])
    return {"messages": [reply], "draft": reply.content}

builder = StateGraph(State)
builder.add_node("writer", writer)
builder.add_edge(START, "writer")
builder.add_edge("writer", END)
graph = builder.compile()

Ключевая деталь — аннотация Annotated[list, add_messages]. Второй аргумент — редьюсер: функция, которая описывает, КАК новое значение сливается со старым. Для messages редьюсер add_messages дописывает новые сообщения в конец списка (и умеет заменять сообщение по id), а поле draft без редьюсера просто перезаписывается последним значением.

Редьюсеры — это не синтаксический сахар, а фундамент параллелизма: когда две ветки графа одновременно вернут обновление одного ключа, именно редьюсер определит результат слияния. Ключ без редьюсера при параллельной записи вызовет ошибку InvalidUpdateError — LangGraph честно отказывается молча терять данные.

Условные рёбра и цикл агента

Обычное ребро — безусловный переход. Условное ребро — функция-маршрутизатор, которая смотрит на состояние и возвращает имя следующего узла. На этой паре собирается канонический цикл агента: модель думает, вызывает инструменты, получает результаты и думает снова, пока не решит закончить.

from langgraph.prebuilt import ToolNode

def route(state: State) -> str:
    last = state["messages"][-1]
    if getattr(last, "tool_calls", None):
        return "tools"
    return "done"

builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route, {"tools": "tools", "done": END})
builder.add_edge("tools", "agent")

Маршрутизатор — обычная детерминированная функция: её можно покрыть юнит-тестами без единого вызова LLM, и это одно из главных преимуществ явного графа перед «магическим» циклом внутри фреймворка. Решение модели (вызывать ли инструмент) остаётся вероятностным, но логика перехода — читаемая и проверяемая.

Против бесконечных циклов у LangGraph есть предохранитель recursion_limit (по умолчанию 25 супершагов): при превышении граф падает с GraphRecursionError вместо того, чтобы молча сжигать токены. Для продакшена лимит выставляют осознанно под задачу, а в состояние добавляют собственный счётчик итераций — он позволяет выйти из цикла мягко, с частичным результатом, а не с исключением.

Checkpointer: персистентность и time-travel

Скомпилированный граф без персистентности живёт один вызов: упал процесс — пропал прогресс. Checkpointer сохраняет снимок состояния после каждого супершага, привязывая его к thread_id.

from langgraph.checkpoint.memory import MemorySaver

graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "run-42"}}
graph.invoke({"messages": [("user", "Составь план миграции")]}, config)
# процесс можно убить: следующий invoke с тем же thread_id
# продолжит с последнего чекпойнта, а не с нуля

В проде вместо MemorySaver используют SqliteSaver или PostgresSaver из пакетов langgraph-checkpoint-sqlite и langgraph-checkpoint-postgres — интерфейс тот же, меняется одна строка. Это дёшево покупает три свойства сразу. Первое — устойчивость к сбоям: длинный мультиагентный прогон возобновляется с места падения, что критично при цене прогона в доллары. Второе — многосессионность: thread_id превращает граф в диалог с памятью, к которому можно вернуться завтра.

Третье свойство — time-travel. Метод graph.get_state_history(config) возвращает всю цепочку чекпойнтов, и можно перезапустить граф с любого из них, в том числе с изменённым состоянием: «а что бы ответил ревьюер, если бы спека была другой». Для отладки мультиагентных систем это заменяет часы гадания по логам — вы буквально проигрываете альтернативные ветки истории на реальном состоянии.

Human-in-the-loop через interrupt

Автономность — не бинарный переключатель. В реальных системах есть шаги, которые нельзя отдавать агенту целиком: мёрж в защищённую ветку, отправка письма клиенту, платёж. LangGraph решает это функцией interrupt: узел останавливает граф, состояние сохраняется чекпойнтером, а продолжение приходит снаружи через Command(resume=...).

from langgraph.types import interrupt, Command

def approval_gate(state: State) -> dict:
    decision = interrupt({
        "question": "Влить ветку в dev?",
        "diff_summary": state["draft"],
    })
    return {"approved": decision == "yes"}

# ... пользователь посмотрел diff в UI и нажал кнопку:
graph.invoke(Command(resume="yes"), config)

Важно понимать механику: interrupt не «замораживает» Python-процесс. Граф завершает супершаг, пишет чекпойнт и отдаёт управление; пауза может длиться секунды или дни, процесс можно перезапустить — resume придёт в тот же thread_id и узел выполнится повторно уже с ответом человека. Отсюда требование: код узла до interrupt должен быть идемпотентным, потому что при возобновлении он выполнится ещё раз.

В мультиагентных системах ворота ставят на границах ответственности: перед необратимыми действиями, перед дорогим fan-out на десяток суб-агентов и перед принятием результата, который пойдёт клиенту. Это тот же принцип, что в код-ревью: автономно — внутри песочницы, через человека — на выходе из неё.

Practice

Возобновляемый агент с инструментами

Соберите в LangGraph цикл «agent → tools → agent» с двумя инструментами и checkpointer (SqliteSaver). Запустите многошаговую задачу, убейте процесс на середине и возобновите прогон с тем же thread_id. Добавьте interrupt-ворота перед финальным ответом.

Deliverable

Скрипт с графом, лог двух запусков (до падения и после возобновления с того же чекпойнта) и фрагмент истории get_state_history, показывающий цепочку чекпойнтов.

Checkpoint

Объясните, зачем полю состояния нужен редьюсер и что произойдёт при параллельной записи в ключ без редьюсера.

  • ✓ Сформулировано, что редьюсер описывает слияние нового значения со старым (add_messages — дописывание).
  • ✓ Названа ошибка InvalidUpdateError при конкурентной записи в ключ без редьюсера.
  • ✓ Объяснена связь редьюсеров с параллельными ветками графа.

Check yourself

Как узлы LangGraph обмениваются данными?

01Как узлы LangGraph обмениваются данными?

Что делает редьюсер add_messages?

02Что делает редьюсер add_messages?

К чему привязаны чекпойнты персистентности?

03К чему привязаны чекпойнты персистентности?

Что происходит при вызове interrupt внутри узла?

04Что происходит при вызове interrupt внутри узла?

Зачем нужен recursion_limit?

05Зачем нужен recursion_limit?

Sources

LangGraph: state machines for agents · Multi-agent orchestration: LangGraph, CrewAI, and agent teams · VORCL