Когда важен результат, а не план
Представь агента, который пишет ответы клиентам. Узел draft генерирует текст письма, узел send его отправляет. Останавливаться перед draft бессмысленно — там ещё нет текста, проверять нечего. Нам нужно увидеть готовый черновик — то есть остановиться после draft, но до send.
Это типичный «контроль качества»: узел что-то произвёл (черновик, результат поиска, план), и человек должен подтвердить, что результат годится, прежде чем агент пойдёт дальше. Здесь работает interrupt_after — пауза сразу за узлом.
interrupt_after — близнец interrupt_before: тоже статическое прерывание, тоже задаётся в compile(), тоже требует checkpointer + thread_id и возобновляется через invoke(None, config). Отличие одно — сторона узла, на которой граф встаёт.
interrupt_after при компиляции
Задаётся симметрично — списком узлов, после которых граф обязан вставать на паузу. Разница с interrupt_before в одном слове в имени аргумента:
from langgraph.checkpoint.memory import MemorySaver
graph = builder.compile(
checkpointer=MemorySaver(),
interrupt_after=["draft"], # ← пауза ПОСЛЕ узла draft
)
Теперь, когда узел draft отработал и записал результат в состояние, граф останавливается, не переходя к следующему узлу. Черновик уже лежит в состоянии — его можно прочитать, оценить и при необходимости поправить, прежде чем разрешить графу двигаться к send.
«До» против «после»: в чём разница
Оба прерывания статические и работают одинаково механически. Разница — что именно ревьюит человек и выполнился ли узел.
- пауза до узла
- узел ещё не выполнен
- смотрим что собирается сделать агент
- для опасных узлов (действие необратимо)
- пауза после узла
- узел уже выполнен
- смотрим что узел произвёл
- для генерирующих узлов (есть что проверить)
Логика простая: если узел делает что-то необратимое (отправка, платёж, удаление) — нужен interrupt_before, чтобы поймать его до выполнения. interrupt_after ставят на узел, который что-то создаёт для проверки. Поставишь interrupt_after на «отправку» — письмо уже улетит к моменту ревью, проверять будет поздно.
Цикл: выполнение → ревью → продолжение
Шаги те же, что у interrupt_before, но в состоянии уже лежит результат узла. Человек его читает, при желании правит через update_state и возобновляет граф через invoke(None, config).
config = {"configurable": {"thread_id": "review-1"}}
# 1. Запуск — draft отрабатывает, граф встаёт ПОСЛЕ него
graph.invoke({"topic": "извинение за задержку"}, config)
# 2. Результат уже в состоянии — читаем и оцениваем
snap = graph.get_state(config)
print(snap.next) # ('send',) — дальше пойдёт send
print(snap.values["draft"]) # готовый текст письма для ревью
# 3a. ОДОБРИТЬ как есть — продолжаем
graph.invoke(None, config) # send выполнится с текущим черновиком
# 3b. ПОПРАВИТЬ результат, затем продолжить
graph.update_state(config, {"draft": "Отредактированный человеком текст"})
graph.invoke(None, config) # send отправит уже исправленный вариант
Поскольку правка делается до следующего узла, send увидит уже отредактированный draft. Это и есть «ревью результата перед продолжением»: узел произвёл черновик, человек довёл его до ума, и только потом он идёт дальше по графу.
Если результат плох, можно не просто остановиться, а отправить граф на повторную генерацию. Через update_state дописывают замечание (например, ставят флаг needs_revision или добавляют комментарий в состояние), а условное ребро после draft по этому флагу возвращает поток обратно в draft. Получается петля «сгенерировал → ревью → доработал».
Полный пример: черновик письма с ревью
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
class State(TypedDict):
topic: str
draft: str
sent: bool
def draft(state: State) -> dict:
resp = llm.invoke(f"Напиши короткое деловое письмо на тему: {state['topic']}")
return {"draft": resp.content}
def send(state: State) -> dict:
print("ОТПРАВЛЕНО:", state["draft"]) # ⚠️ необратимо
return {"sent": True}
builder = StateGraph(State)
builder.add_node("draft", draft)
builder.add_node("send", send)
builder.add_edge(START, "draft")
builder.add_edge("draft", "send")
builder.add_edge("send", END)
# ⬇️ пауза ПОСЛЕ draft — ревьюим черновик до отправки
graph = builder.compile(checkpointer=MemorySaver(), interrupt_after=["draft"])
config = {"configurable": {"thread_id": "mail-1"}}
graph.invoke({"topic": "извинение за задержку доставки"}, config)
# Черновик готов, но письмо ещё НЕ отправлено
print(graph.get_state(config).next) # ('send',)
print(graph.get_state(config).values["draft"])
# Человек слегка правит и одобряет
graph.update_state(config, {"draft": "Уважаемый клиент, приносим извинения..."})
graph.invoke(None, config) # теперь send отправит исправленный текст
Комбинируем before и after
Оба аргумента можно задать одновременно — на разных узлах. Типичный «двойной контроль»: ревью сгенерированного результата и подтверждение перед необратимым действием.
graph = builder.compile(
checkpointer=MemorySaver(),
interrupt_after=["draft"], # проверить, ЧТО сгенерировано
interrupt_before=["send"], # подтвердить ПЕРЕД отправкой
)
# Граф остановится дважды: после draft (ревью) и перед send (подтверждение)
Сколько бы точек ни было, каждое возобновление — это invoke(None, config): оно продвигает граф до следующей паузы или до END. Приложение в цикле проверяет get_state().next: пока не пусто — показывает человеку очередной запрос и снова возобновляет.
Типичные ошибки
Поставишь паузу после узла-отправителя — действие уже произошло к моменту ревью. Для необратимого нужен interrupt_before; interrupt_after — для узлов, которые что-то создают.
Статическое прерывание ничего не «возвращает» наружу — человек сам читает результат из get_state().values. Payload отдаёт только динамический interrupt().
Как и interrupt_before, продолжается через invoke(None, config), а не Command(resume=...). Последнее — только для динамического interrupt().
При update_state помни про reducers: для поля с add_messages переданное «допишется», а не заменит. Чтобы заменить результат, правь обычное поле (как draft) или учитывай правило слияния.
Шпаргалка
# Пауза ПОСЛЕ узла (нужен checkpointer)
graph = builder.compile(checkpointer=saver, interrupt_after=["draft"])
cfg = {"configurable": {"thread_id": "t-1"}}
graph.invoke(inp, cfg) # draft выполнился → пауза перед следующим узлом
snap = graph.get_state(cfg)
snap.next # ('send',) — ждём ревью
snap.values["draft"] # ГОТОВЫЙ результат на проверку
graph.invoke(None, cfg) # ✓ одобрить как есть
graph.update_state(cfg, {"draft": "..."}) # ✏️ поправить, потом invoke(None)
# before vs after:
# interrupt_before — узел НЕ выполнен, ревьюим ПЛАН (опасные узлы)
# interrupt_after — узел ВЫПОЛНЕН, ревьюим РЕЗУЛЬТАТ (генерирующие узлы)
# можно вместе:
builder.compile(checkpointer=saver,
interrupt_after=["draft"], interrupt_before=["send"])
# resume всегда invoke(None) — НЕ Command(resume)
Практическое задание
Поставь человека после генерации:
Задание: пост в соцсети с ревью
- Граф из двух узлов:
generate(LLM пишет пост по теме в полеpost) иpublish(печатает «опубликовано»). Скомпилируй сMemorySaverиinterrupt_after=["generate"]. - Запусти, проверь, что
get_state().next == ('publish',), а пост ещё не «опубликован». Выведи сгенерированный текст. - Одобри через
invoke(None, config)— пост публикуется. - Во втором треде через
update_stateподправь текст поста и затем возобнови — убедись, что опубликован исправленный вариант. - Добавь к графу
interrupt_before=["publish"]и проследи, что граф теперь останавливается дважды. - Со звёздочкой: добавь поле
approved: boolи условное ребро послеgenerate: если человек черезupdate_stateпоставилapproved=False— вернуть вgenerateна повторную генерацию.