Зачем останавливаться перед узлом

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

Причём правило простое и безусловное: «всегда останавливайся перед узлом tools». Городить ради этого interrupt() с условиями внутри узла избыточно. LangGraph даёт декларативный способ: указать при компиляции, перед какими узлами граф обязан вставать на паузу. Это interrupt_before.

ℹ️ Тоже на фундаменте checkpointing

Как и динамическое прерывание, interrupt_before работает только с checkpointer + thread_id. Пауза — это сохранённый checkpoint перед узлом; возобновление — продолжение из него. Без памяти останавливаться и продолжать невозможно.

interrupt_before при компиляции

Точка прерывания задаётся одним аргументом compile() — списком имён узлов, перед которыми нужно вставать на паузу. Сами узлы трогать не надо: их код не меняется, прерывание навешивается снаружи, структурно.

Одна строка — гарантированная пауза перед узлом
python
from langgraph.checkpoint.memory import MemorySaver

graph = builder.compile(
    checkpointer=MemorySaver(),
    interrupt_before=["tools"],     # ← пауза ПЕРЕД узлом tools, всегда
)

Теперь, когда поток управления подходит к узлу tools, граф останавливается прямо перед его выполнением. Узел ещё не отработал — а значит, ничего необратимого не случилось. Состояние (включая запланированный вызов инструмента, который придумала LLM) сохранено в checkpoint и доступно для проверки.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
START agent планирует вызов ⏸ 🔒 interrupt_before tools опасное действие END Человек проверяет план get_state().next == ('tools',) ✓ invoke(None) ✗ → END ⏸ пауза + checkpoint resume → граф стоит ПЕРЕД tools — действие ещё не выполнено

Цикл: пауза → проверка → возобновление

Работа с паузой состоит из трёх шагов: запустить до прерывания, осмотреть состояние, продолжить (или нет). Возобновляют статическое прерывание вызовом invoke(None, config)None вместо входа означает «не добавляй ничего, просто продолжи с checkpoint».

Полный цикл подтверждения
python
config = {"configurable": {"thread_id": "approve-1"}}

# 1. Запуск — граф доходит до tools и ВСТАЁТ перед ним
graph.invoke({"messages": [{"role": "user", "content": "Удали лог-файлы"}]}, config)

# 2. Смотрим, на чём остановились и что запланировано
snapshot = graph.get_state(config)
print(snapshot.next)                       # ('tools',) — дальше пойдёт tools
planned = snapshot.values["messages"][-1]  # последнее сообщение LLM с tool_calls
print(planned.tool_calls)                  # [{'name': 'delete_files', 'args': {...}}]

# 3a. ОДОБРИТЬ — продолжаем с того же места
graph.invoke(None, config)                 # None = «возобнови», tools выполнится

# 3b. ОТКЛОНИТЬ — просто не возобновляем (или уводим в END — см. ниже)

Признак паузы — непустой snapshot.next: если там есть имя узла, граф ждёт перед ним. Когда граф дошёл до END, next пустой. По этому полю приложение понимает, нужно ли показывать человеку запрос на подтверждение.

⚠️ resume — это invoke(None), а не Command(resume)

Тонкость на стыке двух механизмов. Статическое прерывание возобновляют invoke(None, config) (просто «продолжай»). Динамический interrupt() из прошлого урока — Command(resume=значение) (передать ответ внутрь узла). Не перепутай: статическому прерыванию нечего «вернуть» — узел перед паузой ещё не запускался.

Отклонить и отредактировать

Одобрение — это просто invoke(None). А что если человек не согласен? Два сценария.

Отклонить. Самый прямой способ — не возобновлять граф вовсе. Но обычно агенту надо сообщить об отказе, чтобы он не завис. Тогда правят состояние через update_state — например, дописывают «инструмент отклонён», и LLM на следующем шаге придумает другой план.

Отредактировать. Раз граф стоит перед узлом, можно поменять то, что узел получит на вход. Через update_state человек правит запланированный вызов (скажем, сужает удаление до одного файла), и только потом возобновляет.

Правка состояния во время паузы перед возобновлением
python
# Человек посмотрел план и хочет вмешаться, пока граф на паузе перед tools

# Вариант «отклонить»: дописываем отказ, агент перепланирует
graph.update_state(
    config,
    {"messages": [{"role": "tool", "content": "Отклонено пользователем",
                   "tool_call_id": planned.tool_calls[0]["id"]}]},
)
graph.invoke(None, config)     # agent увидит отказ и предложит иное

# Вариант «отредактировать»: заменяем аргументы запланированного вызова,
# затем возобновляем — узел tools выполнит уже исправленное действие
# (детали редактирования сообщений LLM — в уроке «Редактирование состояния»)
Пауза «перед» — это и есть страховка

Вся ценность interrupt_before в том, что узел ещё не выполнился. Поэтому отклонить или поправить действие безопасно — необратимого ещё не произошло. Сравни с interrupt_after (следующий урок): там узел уже отработал, и человек ревьюит результат, а не план.

Полный пример: агент с подтверждением инструментов

ReAct-агент, который спрашивает перед каждым инструментом
python
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI

def delete_files(pattern: str) -> str:
    return f"Удалены файлы по маске {pattern}"      # ⚠️ необратимо!

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

llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([delete_files])

def agent(state: State) -> dict:
    return {"messages": [llm.invoke(state["messages"])]}

def tools(state: State) -> dict:
    call = state["messages"][-1].tool_calls[0]
    out = delete_files(**call["args"])
    return {"messages": [{"role": "tool", "content": out,
                          "tool_call_id": call["id"]}]}

def route(state: State) -> str:
    return "tools" if state["messages"][-1].tool_calls else END

builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", tools)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")

# ⬇️ ключевая строка: пауза перед каждым выполнением инструментов
graph = builder.compile(checkpointer=MemorySaver(), interrupt_before=["tools"])

config = {"configurable": {"thread_id": "ops-1"}}
graph.invoke({"messages": [{"role": "user", "content": "Удали *.log"}]}, config)

# Агент решил вызвать delete_files — но граф встал ПЕРЕД tools
print(graph.get_state(config).next)        # ('tools',)
print(graph.get_state(config).values["messages"][-1].tool_calls)

# Человек одобряет — только теперь файлы реально удаляются
graph.invoke(None, config)
print(graph.get_state(config).values["messages"][-1].content)

Когда что: статическое или динамическое

Критерий interrupt_before (статическое) interrupt() (динамическое)
Где задаётся в compile(), снаружи узла в коде узла
Когда срабатывает всегда перед узлом по условию (можно в if)
Несёт payload человеку нет (смотрят состояние) да (аргумент interrupt)
Как возобновить invoke(None, config) Command(resume=...)
Лучший случай «всегда спрашивай перед X» диалог/ввод данных в узле

Типичные ошибки

Ошибка 1: interrupt_before без checkpointer

Без checkpointer и thread_id паузе негде сохраниться — прерывание просто не сработает. Сначала память, потом точки прерывания.

Ошибка 2: возобновление через Command(resume)

Статическое прерывание продолжают invoke(None, config). Command(resume=...) — это для динамического interrupt(). Перепутаешь — граф не возобновится как ожидалось.

Ошибка 3: не проверяют next и принимают паузу за финал

После invoke всегда смотри get_state(config).next. Непусто — граф на паузе, нужно подтверждение. Пусто — дошли до END. Иначе примешь остановку на подтверждении за готовый ответ.

Ошибка 4: имя узла с опечаткой

interrupt_before=["tool"] вместо ["tools"] — прерывание молча не сработает, потому что такого узла нет. Имя должно точно совпадать с тем, что в add_node.

Шпаргалка

interrupt_before — всё в одном месте
python
# 1. Пауза задаётся при компиляции (нужен checkpointer)
graph = builder.compile(checkpointer=saver, interrupt_before=["tools"])
cfg = {"configurable": {"thread_id": "t-1"}}

# 2. Запуск → граф встаёт ПЕРЕД узлом tools (узел ещё не выполнен)
graph.invoke(inp, cfg)

# 3. Проверяем паузу и запланированное действие
snap = graph.get_state(cfg)
snap.next                              # ('tools',) — ждём подтверждения
snap.values["messages"][-1].tool_calls # что собирается сделать агент

# 4. Решение человека:
graph.invoke(None, cfg)                # ✓ одобрить → выполнить узел
graph.update_state(cfg, {...})         # ✏️ поправить состояние, потом invoke(None)
#                                       ✗ отклонить → дописать отказ / не возобновлять

# Помни:
#  • resume статического прерывания = invoke(None), НЕ Command(resume)
#  • пауза «перед» = действие ещё не случилось → безопасно отклонять/править
#  • всегда проверяй get_state().next, чтобы не спутать паузу с финалом

Практическое задание

Поставь человека перед опасным действием:

Задание: агент с подтверждением перед инструментом

  1. Собери ReAct-агента с инструментом send_email(to, text) (просто печатает «письмо отправлено»). Скомпилируй с MemorySaver и interrupt_before=["tools"].
  2. Запусти запрос, который заставит агента вызвать инструмент. Убедись, что get_state().next == ('tools',), а письмо ещё не «отправлено».
  3. Выведи запланированный tool_calls — покажи, что именно агент собирается сделать.
  4. Одобри через invoke(None, config) и проверь, что инструмент выполнился.
  5. Во втором треде отклони действие: через update_state допиши tool-сообщение «отклонено» и возобнови — убедись, что агент перепланировал, а письмо не ушло.
  6. Со звёздочкой: добавь к графу узел db_write и прерывайся перед обоими опасными узлами: interrupt_before=["tools", "db_write"].

Что дальше