Зачем графу останавливаться посреди работы
Полностью автономный агент опасен ровно там, где его действия необратимы: отправить письмо, провести платёж, удалить данные, выкатить деплой. На таких шагах нужен человек в цикле — момент, когда выполнение замирает и ждёт подтверждения. Другой случай — агенту не хватает информации, и он хочет уточнить её у пользователя, не завершая работу.
Технически это значит: граф должен уметь приостановиться в середине, сохранить всё, что успел, и возобновиться позже — может быть, в другом запросе, после того как человек нажал «Подтвердить» в интерфейсе. И здесь критически важен checkpointer из прошлых уроков: без сохранённого состояния «замереть и продолжить потом» невозможно.
Прерывание работает только с подключённым checkpointer и thread_id. Когда граф встаёт на паузу, его состояние — это обычный checkpoint в треде. Возобновление = загрузка этого checkpoint и продолжение. Нет checkpointer'а — нет и пауз.
Статические и динамические прерывания
В LangGraph два способа остановить граф. Мы уже мельком видели первый в уроке про compile().
- задаётся в
compile()на конкретном узле - срабатывает всегда на этой границе
- не несёт данных наружу
- годится для отладки и простых «стоп перед узлом»
- вызывается изнутри узла в коде
- срабатывает по условию (можно в
if) - отдаёт наружу payload (вопрос, черновик)
- принимает ответ человека при возобновлении
Статическое прерывание — это «остановись на пороге узла». Динамическое — «остановись вот здесь, покажи человеку вот это и дождись вот такого ответа». Для human-in-the-loop почти всегда нужен второй вариант — ему и посвящён урок.
interrupt(): пауза изнутри узла
Функция interrupt(payload) вызывается прямо в теле узла. Когда выполнение доходит до неё, граф:
- замораживается — текущее состояние сохраняется в checkpoint;
- отдаёт
payloadнаружу — в результатinvokeпопадает объект прерывания с этой нагрузкой; - ждёт — управление возвращается вызывающему коду, граф «висит» на этом узле.
from langgraph.types import interrupt
def human_review(state: State) -> dict:
# Граф замирает здесь и отдаёт payload наружу
decision = interrupt({
"question": "Одобрить ответ?",
"draft": state["draft"],
})
# ↓ этот код выполнится ТОЛЬКО после возобновления;
# в decision окажется то, что передали в Command(resume=...)
if decision == "approve":
return {"approved": True}
else:
return {"draft": decision, "approved": False} # человек прислал правку
Ключевое: interrupt() ведёт себя как «функция, которая вернёт значение позже». В первый проход она не возвращает ничего — она прерывает выполнение. Значение появится при возобновлении.
Возобновление через Command(resume=...)
Первый invoke доводит граф до interrupt() и останавливается. Чтобы продолжить, вызывают invoke снова — но вместо обычного входа передают Command(resume=значение). Это значение и станет результатом interrupt().
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
graph = builder.compile(checkpointer=MemorySaver()) # пауза требует checkpointer
config = {"configurable": {"thread_id": "review-1"}}
# 1. Первый запуск — доходит до interrupt() и встаёт на паузу
result = graph.invoke({"draft": "Текст письма..."}, config)
print(result["__interrupt__"])
# [Interrupt(value={'question': 'Одобрить ответ?', 'draft': 'Текст письма...'})]
# 2. Показали payload человеку, получили решение...
# Возобновляем тем же thread_id, передавая ответ в resume
final = graph.invoke(Command(resume="approve"), config)
print(final["approved"]) # True
# Если бы человек прислал правку:
# graph.invoke(Command(resume="Исправленный текст"), config)
# → interrupt() вернёт "Исправленный текст", узел запишет его в draft
Между шагами 1 и 2 может пройти сколько угодно времени — хоть несколько дней. Состояние лежит в checkpoint'е; пока цел thread_id и хранилище, граф можно возобновить когда угодно, хоть после рестарта сервиса (с персистентным saver'ом из урока про checkpointing).
Признак прерывания — ключ __interrupt__ в результате invoke (список объектов Interrupt с их value). Альтернатива — graph.get_state(config).next: если граф ждёт, там будет имя узла, на котором он застрял. По этому признаку приложение понимает, что нужно показать человеку запрос.
Неочевидная семантика: узел запускается заново
Здесь — главный подводный камень. При возобновлении LangGraph выполняет узел с прерыванием заново, с самого начала. Не «с того места, где стоял interrupt()», а целиком. Просто на этот раз interrupt() не останавливает, а сразу возвращает переданное значение.
def risky_node(state: State) -> dict:
log = expensive_call() # ⚠️ выполнится И на паузе, И при возобновлении!
charge_user() # ⚠️ ДВАЖДЫ — деньги спишутся два раза
answer = interrupt({"ask": "ок?"}) # пауза тут
return {"done": True} # выполнится только после resume
Поэтому действует правило: всё, что стоит до interrupt(), должно быть безопасно для повторного выполнения (идемпотентно) — или не иметь побочных эффектов вовсе. Тяжёлые и необратимые операции выноси после interrupt() либо в отдельный узел.
Списания, отправка писем, запись в БД до interrupt() сработают дважды. Лучший приём — узел с interrupt() делает только запрос подтверждения, а необратимое действие живёт в следующем узле, который выполняется уже после возобновления.
Паттерн approval: разрешить / отклонить / править
Самый частый сценарий — три исхода ревью. Удобно вернуть из interrupt() структуру с типом решения и развести логику условным ребром (привет уроку про conditional edges).
def review(state: State) -> dict:
decision = interrupt({"draft": state["draft"]}) # {"action": "...", "text": "..."}
action = decision["action"]
if action == "approve":
return {"status": "approved"}
if action == "edit":
return {"draft": decision["text"], "status": "edited"}
return {"status": "rejected"} # action == "reject"
def route(state: State) -> str:
return "send" if state["status"] in ("approved", "edited") else END
builder.add_conditional_edges("review", route, {"send": "send", END: END})
# Возобновление с выбором человека:
graph.invoke(Command(resume={"action": "edit", "text": "Новый текст"}), config)
В одном графе может быть много точек interrupt() — на каждом рискованном шаге своя. LangGraph сопоставляет ответы прерываниям по порядку их появления в узле. Для надёжности держи в узле по одному interrupt() и не меняй их порядок между запусками.
Типичные ошибки
interrupt() не работает без подключённого checkpointer и thread_id — паузе негде сохраниться. Будет ошибка. Сначала compile(checkpointer=...), потом прерывания.
Узел перезапускается целиком при возобновлении, поэтому код перед interrupt() выполнится дважды. Необратимые действия — только после паузы или в отдельном узле.
Чтобы продолжить, передавай Command(resume=...), а не обычный словарь состояния. Передашь обычный вход — граф не поймёт, что это ответ на прерывание, и попробует начать заново.
После invoke приложение должно проверить, не встал ли граф на паузу (ключ __interrupt__ или get_state().next). Иначе ты примешь «остановку на ревью» за финальный ответ.
Шпаргалка
from langgraph.types import interrupt, Command
# 1. Пауза ИЗНУТРИ узла (нужен checkpointer + thread_id)
def review(state) -> dict:
# ⚠️ код здесь выполнится повторно при возобновлении — держи его чистым
answer = interrupt({"question": "одобрить?", "draft": state["draft"]})
return {"approved": answer == "yes"} # выполнится после resume
graph = builder.compile(checkpointer=saver)
cfg = {"configurable": {"thread_id": "t-1"}}
# 2. Первый запуск → пауза
res = graph.invoke(inp, cfg)
res["__interrupt__"] # [Interrupt(value={...})] — payload человеку
graph.get_state(cfg).next # узел, на котором стоим
# 3. Возобновление с ответом человека
graph.invoke(Command(resume="yes"), cfg)
# Статическое прерывание (для сравнения) — на границе узла, без payload:
# builder.compile(checkpointer=saver, interrupt_before=["tools"])
# Правила:
# • interrupt() требует checkpointer + thread_id
# • узел перезапускается ЦЕЛИКОМ → сайд-эффекты ПОСЛЕ interrupt()
# • продолжать только через Command(resume=...)
# • проверяй __interrupt__, чтобы не спутать паузу с финалом
Практическое задание
Реализуй человека в цикле:
Задание: агент-«отправитель писем» с подтверждением
- Состояние:
topic,draft,status. Узелwriteгенерирует черновик письма по теме. - Узел
reviewвызываетinterrupt()с payload{draft}и возвращает решение вstatus. - Скомпилируй с
MemorySaver, запусти и убедись, что в результате есть__interrupt__, а граф не дошёл до конца. - Возобнови через
Command(resume="approve")и проверь, что граф завершился со статусом «одобрено». - Сделай узел
send(печатает «письмо отправлено») после review и убедись, что он срабатывает один раз — потому что стоит после паузы, а не до. - Со звёздочкой: поддержи три исхода (approve / edit / reject) через структуру в
resumeи условное ребро, как в паттерне approval.
Что дальше
Это завершает раздел «Продвинутые возможности». interrupt() — мост к следующему разделу: на нём целиком строится human-in-the-loop, где мы добавим подтверждение рискованных операций и ручное редактирование состояния.