Линейный пайплайн вход → LLM → tool → выход держится ровно до первой развилки и до первого перезапуска сервера. Стоит появиться требованию «если заказа в базе нет, переспроси у пользователя и подожди сутки», как выясняется неприятное. Весь промежуточный прогресс лежит в стеке вызова, а стек нельзя записать в базу и поднять завтра в другом процессе.
Отсюда другой способ описывать агента. Логика раскладывается по узлам графа, данные между ними ходят через общее состояние, и это состояние живёт снаружи узлов, переживая падение процесса. Посмотрим, как из такой конструкции вырастают циклы с гарантированным выходом, паузы на решение человека и откат к любому прошлому шагу.
Отдельная оговорка про код из статей и туториалов 2025 года. Часть API, на котором они собраны, уже помечена устаревшей, и в тексте такие места отмечены отдельно.
Дневник курса, урок 6. Соседние посты серии, к которым этот примыкает: устройство агента и его цикл (урок 1) — что именно здесь превращается из кода в граф; типизированный вывод модели (урок 4) — откуда берётся схема состояния и почему её проверяет код, а не промпт. Пост читается отдельно: все термины вводятся заново.
Содержание
1. Агенты — это графы, а не цепочки
Пайплайн, который принимает обращение в поддержку, вытаскивает из него суть и пишет ответ, собирается на цепочке за вечер. Промпт, вызов модели, пара инструментов, форматирование. Работает.
Схема вход → LLM → tool → выход выглядит безобидно ровно до тех пор, пока tool остаётся абстрактным прямоугольником. Подставьте в него Process Payment — возврат денег клиенту, ради которого агента поддержки обычно и заводят, — и та же картинка читается иначе, хотя код не изменился ни на строку. Между текстом клиента и списанием со счёта в цепочке нет ничего: ни сверки суммы с лимитом, ни следа о том, кто принял решение, ни точки, где живой человек может сказать «стоп».
Через неделю приходят требования. Если обращение про возврат, сначала проверить статус заказа. Если заказа в базе нет, переспросить у пользователя и подождать ответа. Если пользователь молчит сутки, закрыть тикет автоматически.
Ни объёма, ни хитрой логики эти три требования не добавляют, потому что за ними стоят три условия и один таймер. Что тогда ломается? Цепочка — это композиция функций, и её топология зафиксирована в момент сборки. Первое требование просит выбирать следующий шаг по результату предыдущего, то есть уже в рантайме. Второе замыкает поток в петлю «спросил, подождал, переспросил», а композиция ациклична по построению. Третье и вовсе просит остановиться на сутки, а потом продолжить с того же места. Но весь промежуточный прогресс цепочки живёт в стеке вызова, а стек нельзя записать в базу и поднять завтра в другом процессе.
Разница здесь та же, что между рекурсивным спуском и конечным автоматом. Пока разбор идёт от начала до конца без остановок, автомат выглядит лишней бюрократией.
Агенту нужны контроль потока исполнения, прерывание с восстановлением и управление состоянием — ровно то, чего цепочка не даёт. LangChain-цепочка остаётся хорошим API для прототипа, где нужен один проход без циклов. Правда, падает она на первой ошибке и не воспроизводится, так что для прода этого мало.
LangGraph описывает агента как state machine (машину состояний), где каждый шаг стал узлом графа с явно определённым состоянием. Собирается любая логика из трёх примитивов.
- State (состояние) — единое строго типизированное хранилище данных, которое ходит между всеми узлами.
- Node (узел) — Python-функция: читает state, возвращает его частичное обновление.
- Edge (ребро) — переход между узлами, обычный или условный (функция-маршрутизатор выбирает следующий узел).
Показательно, что высокоуровневая create_agent() из LangChain сама написана на LangGraph. «Готовый» агент в стиле ReAct (reasoning and acting, то есть цикл «рассуждай → вызови инструмент → посмотри, что вернулось», и так до ответа) внутри устроен как тот же граф из узлов и условного ребра. Спор «LangChain или LangGraph» из-за этого во многом пустой. Первый даёт строительные блоки (модели, сообщения, инструменты), второй даёт каркас, в который они вставляются.
2. State и reducer’ы: как обновляется состояние
Узел сходил в CRM и узнал номер заказа. Другой узел, который отработает через два шага, должен этот номер увидеть, иначе он не решит, положен ли возврат.
Способа передать данные два. Либо тащить их по цепочке аргументов, и тогда сигнатура каждого узла зависит от того, что понадобится всем следующим. Либо положить в общее хранилище, откуда любой узел возьмёт нужное. LangGraph выбирает второе, и не ради удобства вызова.
Пока данные размазаны по локальным переменным функций, «состояние агента» не существует как объект. Взять его целиком просто неоткуда. Вынесенное наружу состояние даёт один сериализуемый объект, у которого в каждый момент есть определённое значение. Из этого растёт всё остальное. Снимок можно записать в базу, сравнить с предыдущим, показать человеку, подменить.
State определяет схему всех данных графа. На уровне графа оно неизменяемо. Узел не мутирует объект на месте, а возвращает описание обновления, и применяет это обновление уже движок. Шов между «узел решил» и «состояние изменилось» принадлежит движку, и ровно в него встраиваются запись чекпоинта, пауза и откат.
Схема задаётся через TypedDict, Pydantic-модель или dataclass. Но вот вопрос. Узел вернул {"messages": [...]} — движку заменить старое значение или слить с ним? Это решение и есть reducer. Каждое поле состояния в LangGraph называют каналом (channel). Канал хранит не только значение, но и правило, по которому в него пишут. По умолчанию правило одно, last-write-wins, то есть новое значение затирает старое. Кастомное слияние задают через Annotated[...].
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph.message import add_messages
class State(TypedDict):
simple_value: str # перезаписывается (поведение по умолчанию)
numbers: Annotated[list, add] # новое значение мержится со старым
total: Annotated[int, lambda x, y: x + y] # кастомная логика слияния
messages: Annotated[list, add_messages] # история диалога
Чаще всего накапливают историю сообщений, и на ней хорошо видно, чем перезапись плоха. Допустим, в state лежит диалог из шести сообщений. Узел agent вызвал модель и вернул {"messages": [AIMessage(...)]}. Канал по умолчанию заменяет, так что в состоянии останется одно сообщение, последнее. На следующем витке цикла модель получит на вход диалог, который начинается с её собственной реплики. Ни вопроса пользователя, ни результатов инструментов. Исключения не будет, стектрейса тоже. Будет невнятный ответ, причину которого вы пойдёте искать в промпте.
Поэтому историю накапливают. Reducer add_messages умеет больше простой конкатенации. Он дописывает новые сообщения, резолвит обновления по уникальным id (без дублей) и авто-конвертит dict-payload в BaseMessage. Сообщение с уже известным id заменит существующее, а не ляжет рядом копией.
Тот же вопрос встаёт острее, когда в одном шаге исполнения отрабатывают сразу несколько узлов. Такой шаг называется superstep. Все запланированные на него узлы исполняются параллельно, а обновления от них применяются вместе. Если две ветки вернули один ключ, при перезаписи в состоянии останется значение одной из них. Какой именно, решит порядок применения, а не ваш замысел. Reducer превращает гонку в определённое слияние. add сложит оба списка, кастомная функция рассудит спор по вашим правилам.
Если же reducer надо разово обойти и сбросить канал, обновление оборачивают в Overwrite.
from langgraph.types import Overwrite
def reset_node(state: State) -> dict:
return {"messages": Overwrite([])} # сбросить историю в обход add_messages
3. Узлы, рёбра и сборка графа
Маршрут, зашитый в if внутри функций, читается только целиком. Чтобы понять, куда уйдёт исполнение после проверки заказа, надо открыть саму проверку и дочитать её до конца. Через десяток шагов схема процесса существует лишь в голове того, кто его писал.
LangGraph разводит это по двум местам. Логика живёт в узлах, поток — в рёбрах. Маршрут не зашит в код функций, а описан декларативно, поэтому его можно ветвить, замыкать в цикл и рисовать картинкой. Узел здесь — функция state -> dict, а порядок задаёт ребро. Обычное ребро (add_edge("A", "B")) фиксирует жёсткий факт «B после A». Условное (add_conditional_edges) отдаёт решение функции-маршрутизатору, которая по состоянию возвращает имя следующего узла. Условием тут служит что угодно, вплоть до решения самой LLM.
Граф собирается билдером StateGraph и фиксируется вызовом .compile().
from langgraph.graph import StateGraph, START, END
def route_by_number(state: State) -> str: # функция-маршрутизатор
return "positive" if state["number"] > 0 else "negative"
builder = StateGraph(State)
builder.add_node("check", check_number)
builder.add_node("positive", handle_positive)
builder.add_node("negative", handle_negative)
builder.add_edge(START, "check")
builder.add_conditional_edges("check", route_by_number,
{"positive": "positive", "negative": "negative"})
builder.add_edge("positive", END)
builder.add_edge("negative", END)
graph = builder.compile()
Пройдём по этому графу один раз. Движок стартует от START, по обычному ребру заходит в check, и узел кладёт в состояние number = -3. Дальше вместо ребра стоит маршрутизатор. Движок зовёт route_by_number, получает строку "negative", находит её в словаре и переходит в handle_negative. Сам check о существовании двух веток ничего не знает, он только пишет число. Решение о маршруте принимает отдельная функция из двух строк, и заменить её можно, не трогая ни один узел.
Тот же механизм даёт минимальный агентный цикл, то есть ReAct, выраженный как граф. Два узла (agent и tools) замкнуты условным ребром. Маршрутизатор смотрит на последнее сообщение модели: если tool_calls есть, идём в инструменты и возвращаемся в agent, если нет — на выход.
from langgraph.prebuilt import ToolNode
from langchain.messages import SystemMessage
model = chat_model.bind_tools([pay]) # модель получает схемы инструментов
def agent(state: State) -> dict:
return {"messages": [model.invoke([SystemMessage(SYSTEM_PROMPT)] + state["messages"])]}
def router(state: State) -> str:
last = state["messages"][-1]
return "tools" if getattr(last, "tool_calls", None) else END
builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode([pay]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", router)
builder.add_edge("tools", "agent") # замыкаем цикл
app = builder.compile()
Первая итерация начинается с agent. Узел отдаёт модели системный промпт и историю. Модель уже получила схему инструмента через bind_tools, поэтому отвечает сообщением с tool_calls: «вызови pay с такими аргументами». Маршрутизатор видит непустой tool_calls и возвращает "tools". ToolNode исполняет вызов и кладёт результат в историю отдельным сообщением. Обычное ребро tools → agent замыкает петлю, модель видит результат инструмента и на этот раз отвечает текстом. tool_calls пуст, маршрутизатор возвращает END. Весь агентный цикл — два узла и одна развилка.
Если кастомный цикл не нужен, тот же ReAct-агент собирается одной строкой. Только импортировать теперь надо from langchain.agents import create_agent, потому что старый create_react_agent из langgraph.prebuilt помечен deprecated.
from langchain.agents import create_agent
app = create_agent(model, tools=[pay], system_prompt=SYSTEM_PROMPT)
Ручной граф берут, когда нужен контроль над циклом, кастомные узлы или нестандартное ветвление. Готовую обёртку берут, когда хватает классического «думай → вызови инструмент → наблюдай».
4. Циклы, лимиты и надёжность
Чем граф с циклом отличается от зависания? Ровно одним: маршрутизатором, который однажды скажет END. А смотрит этот маршрутизатор на вывод модели. Модель, которая раз за разом просит вызвать инструмент, держит цикл замкнутым сколько угодно долго, и ни одна строка вашего кода при этом не ошибается. Поэтому в циклах нам обязательно нужен жёсткий лимит итераций плюс условное ребро на выход или fallback при превышении порога. Страховка тут не от бага, а от вероятностной природы того, кто принимает решение о выходе.
Второй слой надёжности даёт RetryPolicy. Узел повторяется при временных ошибках (таймаут API, невалидный JSON) с экспоненциальной задержкой, а retry_on оценивается в рантайме, чтобы отличать временную (transient) ошибку от бага в коде. Повтор лечит оборванное соединение и обрезанный ответ модели, раз со второй попытки данные приходят целыми. Однако TypeError повтором не лечится. Узел трижды выполнит тот же неправильный код, трижды заплатит за вызов модели и упадёт с той же ошибкой минутой позже. Поэтому такое исключение роняет узел сразу, без повторов.
from langgraph.types import RetryPolicy
retry = RetryPolicy(max_attempts=3, initial_interval=1.0,
backoff_factor=2.0, max_interval=10.0, jitter=True) # 1с→2с→4с, ±50%
builder.add_node("api_call", api_call_node, retry=retry)
Свежие версии добавили поверх ретраев ещё два приёма. Первый — per-node timeout через add_node(..., timeout=...). При превышении поднимается NodeTimeoutError, чистятся pending writes и запускается retry-политика. Без такого таймаута узел, зависший на сетевом вызове, держит весь граф столько, сколько отпущено настройками HTTP-клиента. А они часто оставлены по умолчанию.
Второй приём — node-level error_handler. Этот callback вызывается при исчерпании ретраев, получает контекст исключения и может вернуть Command для роутинга в compensation-узел (паттерны Saga / rollback). Толк от него в том, что после третьей неудачной попытки выбор перестаёт сводиться к «уронить весь граф». Предыдущие шаги ведь могли успеть что-то сделать во внешнем мире: забронировать, списать, создать заявку. Сделанное надо отменить, и отменяет это отдельный узел, в который error_handler уводит поток.
builder.add_node("charge", charge_node,
retry=retry, timeout=30.0, error_handler=compensation_node)
И поверх всего лежит идемпотентность. Повторный вызов узла с теми же входными данными должен давать тот же результат. Требование обязательное, потому что узел может вызваться снова при retry, восстановлении из чекпоинта или возобновлении после паузы. Три независимых причины, и ни одна не спрашивает, безопасно ли повторить именно этот узел. Отсюда набор приёмов: проверять operation_id перед необратимой операцией, передавать idempotency_key в API, кэшировать результат в state.
Чего в этом наборе нет — попытки сделать детерминированной саму модель. temperature=0 сужает разброс, но не убирает его. В замере на тысяче прогонов одного и того же запроса при нулевой температуре получилось восемьдесят разных ответов, причём первые сто два токена совпали во всех, а расходиться они начали со сто третьего. Причина не в случайности выбора токена, а в том, что ядра вывода не инвариантны к размеру пачки: числовой результат для вашего запроса зависит от того, с кем он попал в один батч на видеокарте. Параметр seed в большинстве API — обещание приложить усилия, а не гарантия повторяемости. Идемпотентность держится на ключе, а не на температуре.
Посмотрим, что собралось. Граф с циклом и гарантированным выходом, лимит итераций, ретраи с экспоненциальной задержкой, таймаут на узел, компенсация при исчерпании попыток, идемпотентные узлы. Инженерно всё аккуратно. Однако вернём в него тот самый Process Payment и зададим вопрос, ради которого всё и затевалось: доверите такому агенту свои деньги? Честный ответ пока «нет», и не из-за качества модели. Не хватает двух вещей, и обе не про надёжность узлов. Во-первых, прогресс живёт в памяти процесса, так что перезапуск сервера посреди оформления теряет всё — и разбираться, списали ли деньги, придётся по логам платёжного шлюза. Во-вторых, между решением модели и списанием по-прежнему некому встать. Обе дыры закрывает одно свойство: состояние, которое переживает остановку.
5. Checkpointer: возобновляемость по thread_id
Пользователь на шестом шаге длинного оформления, агент ждёт ответа внешнего сервиса, и в эту минуту выкатывается релиз. Что произойдёт с наполовину пройденным оформлением? По умолчанию состояние живёт в оперативной памяти процесса, вместе с процессом оно и уходит. Пользователь возвращается к диалогу, который начинается с чистого листа.
Лечится это checkpointer'ом, который подключают при компиляции. Он пишет снимок состояния на каждой границе superstep, и любой запуск с тем же thread_id подхватывает сохранённую историю. Граница шага выбрана не случайно. Только в этот момент все узлы шага отработали, все обновления применены через свои reducer’ы и состояние согласовано. Снимок, снятый в середине узла, при восстановлении пришлось бы как-то доигрывать.
┌──────────────────────────────────┐
│ checkpointer │
│ (InMemory / Sqlite / Postgres) │
└──────────────┬───────────────────┘
│ снимок на каждом superstep
▼
START ─▶ узел A ─▶ узел B ─▶ узел C ─▶ END
▲
thread_id ─────┘ любой запуск с тем же id
продолжает с сохранённого местаthread_id — это идентификатор сессии. Каждый пользователь восстанавливается независимо, что заодно даёт многопользовательские диалоги. Канонический класс памяти теперь InMemorySaver, а MemorySaver остался алиасом. Для отладки берут его, для прода — SqliteSaver/PostgresSaver. Код графа при смене хранилища не меняется, нам понадобится только другая строка подключения.
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
checkpointer = AsyncPostgresSaver(conn)
await checkpointer.setup() # инициализация таблиц
app = builder.compile(checkpointer=checkpointer)
thread = {"configurable": {"thread_id": "chat_123"}}
await app.ainvoke({"messages": [...]}, thread)
# после рестарта сервера состояние есть в БД — продолжаем тот же thread_id:
state = await app.aget_state(thread)
Тонкий момент для прода. В state часто лежат API-ключи и креды, а снимок пишется целиком, без разбора, что там за поля. Поэтому чекпоинтер оборачивают в EncryptedSerializer.from_pycryptodome_aes(key), шифрование at-rest, чтобы секреты не легли в БД открытым текстом.
Отказоустойчивостью выгода не исчерпывается. Как только состояние научилось переживать остановку, сама остановка перестаёт быть аварией и превращается в приём. На ней стоят и пауза на решение человека, и откат к прошлому шагу.
6. Human-in-the-loop: пауза на аппрув
Агент собрал платёжное поручение на четыреста тысяч и готов его отправить. Ошибки в его рассуждении вы не видите. Уверенности, что её там нет, у вас тоже нет: на том конце вероятностная модель, и ошибается она тихо.
Human-in-the-loop (человек в цикле) — это пауза исполнения в критической точке. Человек подтверждает или редактирует действие, после чего граф продолжает с того же места. Работает такая пауза только поверх checkpointer, потому что прервать и возобновить можно лишь персистентное состояние.
Оговорка про checkpointer тут не формальность. Для движка «встать и ждать человека» и «упасть и продолжить после рестарта» сводятся к одной операции: сохранить состояние так, чтобы с него можно было стартовать заново. Поэтому HITL живёт не отдельной подсистемой, а вторым сценарием использования того же чекпоинтера. Без персистентности пауза означала бы держать процесс живым всё время ожидания, а аппрув иногда приходит утром следующего дня.
Какие действия заводить под паузу, а какие пропускать без спроса, движок не решает. Это вопрос цены ошибки. Чтение и навигация идут автономно. Обратимое действие вроде исходящего письма обходится превью с таймером на отмену. Необратимое (платёж, удаление данных, смена прав) ждёт явного «да». Раскладку по тирам риска вместе с тем, что именно показывать человеку в момент вопроса, разбирает пост про агента за рулём компьютера (урок 5).
Каноничный способ — динамический interrupt() прямо внутри узла. При вызове узел приостанавливается, состояние пишется в checkpointer, JSON-сериализуемый payload возвращается клиенту, и поток ждёт. Возобновляют его через Command(resume=value), а переданное значение приходит как результат исходного вызова interrupt(). В коде узла interrupt() выглядит обычным вызовом функции, который очень долго возвращает управление. Между вызовом и возвратом успевает перезапуститься сервер.
from langgraph.types import Command, interrupt
def human_node(state: State) -> dict:
feedback = interrupt({"question": "Одобряете заказ?", "amount": state["amount"]})
return {"approved": feedback["approved"]}
# первый запуск прервётся внутри human_node:
async for event in app.astream(input_data, thread):
print(event)
# человек нажал «Approve» — возобновляем:
await app.ainvoke(Command(resume={"approved": True}), thread)
Раньше точки паузы объявляли статически при компиляции, через compile(interrupt_before=[...], interrupt_after=[...]). Этот синтаксис помечен deprecated в пользу динамического interrupt(), раз решение прервать удобнее принимать в рантайме внутри узла. Разница видна на обычном требовании «спрашивать подтверждение только на суммах выше лимита». Статический брейкпойнт записывается короче, зато о сумме платежа он ничего не знает и остановит граф на каждом.
агент ──▶ human_node ──interrupt()──▶ [ПАУЗА]
│ state в checkpointer
▼
внешний аппрув
(человек: approve / edit)
│
агент ◀──Command(resume=…)──◀─────────────┘Про astream в примере стоит сказать отдельно: invoke возвращает управление только после того, как граф дошёл до конца или до прерывания, а astream/app.astream отдаёт результат после каждого узла — поэтому интерфейс успевает показать, на каком шаге агент сейчас находится, вместо крутящегося спиннера на минуту.
Подтверждением дело не ограничивается. Взаимодействие человека с графом раскладывается на три паттерна, и они отличаются тем, что человек делает с предложением агента. Approval — то, что разобрано выше: агент сформировал действие, человек нажал «да» или «нет», агент продолжил или отменил. Edit — человек не отклоняет предложение, а правит его: агент подготовил черновик платёжного поручения, оператор поменял назначение платежа, агент продолжил уже с исправленной версией. Co-authoring — тот же Edit, повторённый циклом: агент пишет очередной раздел документа, человек дополняет, агент продолжает с учётом дополнений.
Механически Edit отличается от Approval одним шагом. Мало вернуть решение — надо записать в состояние правку человека, причём так, чтобы граф считал её результатом работы конкретного узла. За это отвечает update_state с параметром as_node.
# человек не просто одобрил, а поправил предложение агента
user_feedback = {"user_approved": True, "amount": 39_900, "comment": "сумма без НДС"}
await app.aupdate_state(thread, user_feedback, as_node="human_review")
result = await app.ainvoke(None, thread) # продолжаем с исправленным состоянием
as_node="human_review" — ключевая часть. Обновление применяется так, будто его вернул узел human_review, поэтому движок знает, откуда продолжать маршрут, а правка проходит через reducer'ы канала наравне с обычными обновлениями. Без указания узла движок не может определить следующий шаг. Вызов ainvoke(None, thread) с пустым входом означает «новых данных нет, продолжай с сохранённого состояния». Тот же aupdate_state встретится в следующей секции: подмена состояния для эксперимента и правка человеком — это одна операция, отличающаяся только тем, кто вносит значения.
Здесь есть ловушка идемпотентности. Узел перезапускается с начала, поэтому необратимый код нельзя ставить до interrupt(). Классический баг выглядит так. Узел устроен наивно: сначала charge_card(), следом interrupt() с вопросом «подтверждаете?». Первый проход списывает деньги и встаёт на паузу. Человек жмёт «Approve», приходит Command(resume=...), и движок запускает узел заново, с первой строки. charge_card() вызывается второй раз, клиент оплачивает дважды, а в интерфейсе одобрения был один платёж. Лечится это выносом необратимой операции за узел с interrupt() (см. runbook ниже). Узел с прерыванием только спрашивает и возвращает решение, а списывает отдельный узел, в который поток попадает уже после аппрува.
7. Time-travel: отладка недетерминизма
Пользователь присылает скриншот, на котором агент предложил не тот тариф. Вы повторяете тот же запрос у себя, и агент отвечает правильно. Повторяете ещё раз, снова правильно.
Недетерминизм LLM делает классическую отладку почти невозможной, ведь один и тот же вход даёт разные ответы и баг не поймать так, как в обычном бэкенде. Раз каждый шаг сохранён как чекпоинт, появляется time-travel — возможность вернуться в любую точку истории и либо воспроизвести её (Replay), либо подменить состояние и пойти альтернативным путём (Fork). Это и превращает недетерминированного агента в воспроизводимый процесс.
Два режима отвечают на разные вопросы. Replay отвечает на «что вообще произошло». Вы берёте чекпоинт прямо перед странным ответом и смотрите, с каким состоянием модель туда пришла, какие сообщения лежали в истории, что вернул инструмент двумя шагами раньше. Fork отвечает на «а если бы». Место то же самое, но результат инструмента подменён на корректный, и дальше граф идёт своей дорогой. Гипотеза «модель путается, потому что поиск вернул пустой список» проверяется за один прогон.
История доступна через get_state_history, который отдаёт список снимков с checkpoint_id и значениями. Fork применяет значения к выбранному чекпоинту через его reducer’ы и создаёт новую ветку без изменения исходной истории.
history = [s async for s in app.aget_state_history(thread)]
old = history[1] # откат к нужному чекпоинту
fork = {"configurable": {"thread_id": "fork_456"}} # новый thread — новая ветка
await app.aupdate_state(fork, old.values,
checkpoint_id=old.config["configurable"]["checkpoint_id"])
await app.ainvoke({"messages": [...]}, fork) # «а что если?» на других данных
В этих четырёх строках две детали стоят внимания. Во-первых, значения проходят через reducer’ы канала, поэтому подстановка в messages не затирает историю, а дописывается к ней. Если нужна именно замена, вспоминаем Overwrite из второй секции. Во-вторых, ветка уезжает в новый thread_id, так что исходный тред остаётся ровно таким, каким пришёл от пользователя, и экспериментировать над ним можно сколько угодно.
Отдельный нюанс с подграфами. По умолчанию subgraph наследует чекпоинтер родителя и трактуется как один superstep, так что внутрь него time-travel не зайдёт. Для пошаговой отладки subgraph компилируют со своим чекпоинтером (compile(checkpointer=True)), а до его истории добираются через get_state(config, subgraphs=True). Стоит держать в уме и стоимость replay. Если на пути дорогие LLM-узлы, повторный проход обойдётся недёшево, поэтому их результаты кэшируют в state и переигрывают только дешёвую часть.
8. Собираем контролируемый workflow
Все примитивы складываются в одну архитектуру production-агента:
- Персистентное хранилище state настроено (Postgres-checkpointer).
- Узел LLM не вызывает опасные операции без HITL и не выполняет детерминированную логику.
- Узел Tools выполняет всю детерминированную работу.
- Узел Human Approval проверяет рискованные действия через прерывание.
- Узел Error Handler обрабатывает ошибки: retry, fallback, compensation.
Границу между вторым и третьим пунктом нарушают чаще всего, поэтому о ней отдельно. За узлом LLM остаётся то, что модель делает хорошо: понять запрос, выбрать инструмент, сформулировать ответ. Всё, у чего есть однозначно правильный результат (арифметика, сверка с лимитом, обращение к базе), уезжает в детерминированные узлы, где ошибаться нечему.
Теперь можно вернуться к вопросу из четвёртой секции — доверите ли вы такому агенту свои деньги. Отвечать на него общими словами бесполезно, зато он раскладывается на пять проверяемых пунктов. По каждому либо есть конкретный механизм, либо ответ «нет».
| Свойство | Что должно быть настроено | Чем закрыто |
|---|---|---|
| Воспроизводимость | любой прогон можно повторить с любой точки | чекпоинты + Replay |
| Наблюдаемость | видно, что агент делал на каждом шаге | трассировка через LangGraph Studio или LangFuse |
| Управляемость | человек вмешивается на любом шаге | interrupt() + update_state(as_node=…) |
| Безопасность | необратимое действие не проходит само | HITL на каждом опасном инструменте |
| Отказоустойчивость | таймаут API и невалидный JSON не роняют прогон | RetryPolicy, per-node timeout, error_handler |
Пункты не равнозначны, и порядок в таблице не случаен. Первые два дают возможность разобраться постфактум, вторые два — вмешаться заранее, последний оставляет систему живой, пока вы разбираетесь. Пропустить можно любой, но каждый пропуск отвечает на исходный вопрос за вас. Без чекпоинтера «повторить с любой точки» превращается в «прочитать логи и додумать», без HITL «безопасность» держится на том, что модель не ошибётся, — а она вероятностная и ошибается тихо.
Всё это сводится к одной способности графа, которой нет у цепочки: каждый шаг можно проследить, повторить и изменить. Проследить — потому что шаг стал узлом с явными границами. Повторить — потому что состояние на границе шага записано. Изменить — потому что записанное состояние можно подменить и пойти дальше другой дорогой.
Наблюдаемость в этом списке пока только техническая возможность. Узлы уже нарезаны, границы шагов определены движком, и каждый шаг ложится в запись трейса без дополнительных усилий — побочная выгода от того, что поток описан явно. А что делать с записями дальше, движок не подскажет. Какие метрики считать по траектории, чем отличить упавшее качество от обычного разброса, какие лимиты поставить, чтобы цикл не съел месячный бюджет на токены. Про это — пост про эксплуатацию агента в проде (урок 7).
Типовые отказы и их лечение удобно держать как runbook:
| Отказ | Решение |
|---|---|
| Потеря состояния | PostgreSQL-checkpointer |
| Бесконечные циклы | Лимит итераций + fallback |
Неидемпотентный код до interrupt() |
Вынести код за узел с interrupt() |
| Опасные действия агента | interrupt на каждый опасный tool |
| Дорогие LLM-узлы при replay | Кэшировать результаты в state |
Особняком стоит Functional API, императивная альтернатива графу. @task оборачивает вычислительный шаг, @entrypoint(checkpointer=...) оркеструет его обычными Python-конструкциями (if, for, вызовы функций) без явной схемы state и рёбер. Состояние тут scoped к локальным переменным функции и не шарится глобально, а визуального time-travel нет, потому что императивный call stack его не поддерживает. Выбор простой. Декларативный граф берут под циклы, HITL и time-travel, Functional API — под линейную логику, где граф только добавляет шаблонный код (boilerplate).
Итог
- Агент — это граф поверх типизированного state, а не линейная цепочка: ветвление, циклы, паузы и восстановление встроены в модель, а не прикручены сбоку.
- Reducer решает, как сливать обновления state;
add_messages— для истории диалога. Без reducer’а — перезапись. - Checkpointer + thread_id дают возобновляемость и заодно включают HITL и time-travel — без персистентности их попросту нет.
- HITL — это динамический
interrupt()+Command(resume=...); статические брейкпойнты устарели. Правка человеком вносится черезupdate_state(..., as_node=...). Главный риск — неидемпотентный код до прерывания. - Надёжность собирается слоями: лимит итераций,
RetryPolicy, per-node timeout, error_handler и идемпотентность узлов. - Актуальность API в 2026:
InMemorySaver(неMemorySaver),create_agent(неcreate_react_agent), динамическийinterrupt()(неinterrupt_before/after), Python ≥ 3.10.
Собрать всё это стоит ради одного. Тот самый Process Payment из первой секции, стоявший в цепочке между текстом клиента и списанием со счёта, теперь окружён механизмами, каждый из которых можно предъявить: прогон записан, шаг виден, необратимое действие ждёт подтверждения, сбой не роняет остальное. Вопрос «доверите ему свои деньги» из риторического становится проверяемым — и отвечать на него можно построчно, по таблице из восьмой секции.
FAQ
Чем LangGraph отличается от LangChain?
LangChain — это API для прототипирования. Цепочки прогоняют данные линейно за один проход, без циклов и без восстановления после сбоя. LangGraph оркеструет агента как граф состояний с ветвлением, циклами, retry, персистентностью и human-in-the-loop. Показательно, что высокоуровневая create_agent() из LangChain сама написана на LangGraph.
Что такое reducer в LangGraph и когда он нужен?
Reducer — функция, которая решает, как обновление от узла соединяется со старым значением поля state. Без reducer’а поле перезаписывается (last-write-wins). Он нужен, когда значения надо накапливать, а не заменять. Самый частый случай — история сообщений через add_messages, которая дописывает новые сообщения и резолвит дубли по id.
Зачем нужен checkpointer и thread_id?
Checkpointer сохраняет снимок состояния графа на каждом шаге в хранилище (память, SQLite, Postgres), а thread_id идентифицирует сессию. Вместе они дают возобновляемость. После перезапуска сервера агент продолжает с того же места, а не с нуля. На этой же персистентности стоят human-in-the-loop и time-travel: без неё прервать и переиграть исполнение нельзя.
Как сделать паузу на подтверждение человеком?
Вызвать interrupt(payload) прямо внутри узла. Исполнение приостановится, состояние запишется в checkpointer, а payload вернётся клиенту. После решения человека граф возобновляют через Command(resume=value), и это значение приходит как результат исходного вызова interrupt(). Статические interrupt_before/interrupt_after для этого устарели. Если человек не просто одобряет, а правит предложение агента, правку пишут в состояние вызовом update_state(thread, feedback, as_node="human_review") и продолжают через ainvoke(None, thread).
Какой API LangGraph устарел к 2026 году?
create_react_agent из langgraph.prebuilt заменён на create_agent из langchain.agents. Статические брейкпойнты interrupt_before/interrupt_after уступили динамическому interrupt(). Канонический класс памяти теперь InMemorySaver (MemorySaver — алиас). Python 3.9 снят с поддержки, минимум по экосистеме — 3.10.
Что такое time-travel и чем Replay отличается от Fork?
Time-travel — отладка через историю чекпоинтов, то есть возврат в любую сохранённую точку исполнения. Replay воспроизводит состояние с прошлого чекпоинта на том же thread_id. Fork применяет новые значения к выбранному чекпоинту через его reducer’ы и запускает альтернативную ветку в новом thread_id, не трогая исходную историю. Так проверяют гипотезу «а что если» на недетерминированном агенте.
Источники
- Use the graph API — Docs by LangChain — канонические сигнатуры
StateGraph, схемы state и reducer’ов. - Persistence — Docs by LangChain — checkpointer'ы, durable execution, актуальные имена классов.
- Interrupts — Docs by LangChain — динамический
interrupt()иCommand(resume=...), депрекация статических брейкпойнтов. - Use time-travel — Docs by LangChain — Replay vs Fork и нюанс с подграфами.
- Fault Tolerance in LangGraph — LangChain Blog —
RetryPolicy, per-node timeout, error handlers. - LangGraph v1 migration guide — Docs by LangChain — что устарело и на что мигрировать.
- Introducing the LangGraph Functional API — LangChain Blog — императивная альтернатива графу через
@entrypoint/@task.
Числовые ориентиры из текста (интервалы ретраев, лимиты итераций, размер истории чекпоинтов) — это дефолты и примеры из документации; конкретные значения подбираются под профиль нагрузки, стоимость LLM-узлов и требования к latency вашего workflow.