$ sudo teach IT

Агент, который только ищет и читает, ошибается безобидно: в худшем случае даёт неточный ответ. Совсем другое дело — агент, который отправляет письма клиентам, возвращает деньги или удаляет записи в базе. Одна неверно выбранная функция или неправильный аргумент, и ошибку уже не отменить. Именно поэтому в рабочих системах рискованные действия согласует человек.

Подход называется human-in-the-loop — «человек в контуре». Агент доходит до опасного шага, останавливается и показывает, что собирается сделать. Человек одобряет, исправляет аргументы или отклоняет действие, и агент продолжает работу с учётом решения. После урока вы сможете включить такую проверку в агента на create_agent и в собственный граф LangGraph.

Что уже умеем

В уроке 2.4 вы подключали к агенту checkpointer и передавали thread_id, чтобы агент помнил разговор. В уроке 2.5 добавляли middleware — дополнительные шаги вокруг вызова модели. В прошлом уроке собирали графы из узлов и рёбер. Сегодня соединим всё это: встроенное middleware будет ставить агента на паузу, checkpointer сохранит состояние на время паузы, а команда Command продолжит выполнение.

Что вы узнаете в рамках статьи

  • как работает прерывание (interrupt) и зачем ему checkpointer;
  • как настроить HumanInTheLoopMiddleware для отдельных инструментов;
  • как прочитать, какое действие ждёт подтверждения;
  • как продолжить работу через Command(resume=...) с решениями approve, edit и reject;
  • как поставить паузу в собственном графе функцией interrupt().

Ключевые термины

  • Human-in-the-loop (HITL) — схема, в которой человек проверяет и согласует действия агента.
  • Прерывание (interrupt) — остановка графа в определённой точке с сохранением состояния до решения человека.
  • Возобновление (resume) — продолжение графа с того же места с переданным ответом человека.
  • Решение (decision) — ответ человека на запрос: одобрить, отредактировать или отклонить действие.
  • Command — объект LangGraph, через который передаются данные для возобновления.

Как устроена пауза

Когда агент доходит до действия, требующего подтверждения, LangGraph не держит программу в ожидании. Он сохраняет всё состояние графа в checkpointer под текущим thread_id, и вызов invoke сразу возвращает результат с информацией о прерывании. Человек может ответить через минуту или через день, в другом процессе или с другого сервера. Когда ответ готов, вы снова вызываете агента с тем же thread_id, и он продолжает с места остановки.

Отсюда два обязательных условия. Без checkpointer состояние негде хранить, и пауза невозможна. Без thread_id агент не поймёт, какой именно разговор продолжать. В учебных примерах подойдёт InMemorySaver, а в продакшене нужен постоянный checkpointer на базе данных, иначе после перезапуска сервера все ожидающие решения пропадут.

HumanInTheLoopMiddleware в create_agent

Для агентов на create_agent есть готовое middleware. В параметре interrupt_on перечисляются инструменты и правила: True — останавливаться и разрешать все варианты решений, False — выполнять без вопросов, словарь — тонкая настройка, например список разрешённых решений.

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver

@tool
def find_order(order_id: str) -> str:
    """Находит заказ по номеру и возвращает сумму и статус."""
    return f"Заказ {order_id}: 4990 руб., доставлен"

@tool
def refund(order_id: str, amount: int) -> str:
    """Оформляет возврат денег по заказу."""
    return f"Возврат {amount} руб. по заказу {order_id} оформлен"

@tool
def send_email(to: str, text: str) -> str:
    """Отправляет письмо клиенту."""
    return f"Письмо для {to} отправлено"

agent = create_agent(
    model="openai:gpt-6-luna",
    tools=[find_order, refund, send_email],
    system_prompt="Ты сотрудник поддержки интернет-магазина.",
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "find_order": False,
                "refund": {"allowed_decisions": ["approve", "reject"]},
                "send_email": True,
            }
        )
    ],
    checkpointer=InMemorySaver(),
)

Здесь поиск заказа безопасен и выполняется сразу. Возврат денег можно только одобрить или отклонить: редактировать сумму оператору не разрешено. Письмо останавливается со всеми вариантами решений, включая правку текста.

Читаем запрос и возобновляем работу

Запустим агента. Он найдёт заказ без паузы, а на возврате остановится.

config = {"configurable": {"thread_id": "ticket-42"}}

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Верните деньги за заказ A-17"}]},
    config=config,
)

request = result["__interrupt__"][0].value
for action in request["action_requests"]:
    print("Агент хочет вызвать:", action["name"], action["args"])

Что увидим в консоли: Агент хочет вызвать: refund {'order_id': 'A-17', 'amount': 4990}. Под ключом __interrupt__ лежит список прерываний; в значении есть action_requests с именем инструмента и аргументами и review_configs с разрешёнными решениями. Инструмент refund при этом ещё не выполнялся.

Решение передаётся через Command(resume=...) с тем же config. Для каждого действия из action_requests нужно указать одно решение в том же порядке.

from langgraph.types import Command

result = agent.invoke(
    Command(resume={"decisions": [{"type": "approve"}]}),
    config=config,
)
print(result["messages"][-1].content)

Что увидим: финальный ответ агента вроде «Возврат 4990 руб. по заказу A-17 оформлен». Два других формата решений выглядят так:

# Исправить аргументы перед выполнением (для send_email это разрешено)
{"type": "edit", "edited_action": {"name": "send_email",
                                   "args": {"to": "anna@example.com", "text": "Исправленный текст"}}}

# Отклонить с пояснением для модели
{"type": "reject", "message": "Возврат не одобрен: заказ уже возмещён купоном."}

При отклонении инструмент не выполняется, а модель получает ваше сообщение и может сообщить об этом пользователю или предложить другое решение. Поэтому в тексте отказа стоит объяснить причину: модель лучше поймёт, что делать дальше. Если попытаться передать решение, которого нет в allowed_decisions, например edit для refund, middleware его не примет.

Пауза в собственном графе

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

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class Post(TypedDict):
    draft: str
    approved: bool

def review(state: Post) -> dict:
    answer = interrupt({"question": "Публикуем пост?", "draft": state["draft"]})
    return {"approved": answer == "да"}

builder = StateGraph(Post)
builder.add_node("review", review)
builder.add_edge(START, "review")
builder.add_edge("review", END)
graph = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "post-1"}}
paused = graph.invoke({"draft": "Скидки до 30%!", "approved": False}, config=config)
print(paused["__interrupt__"][0].value["question"])
final = graph.invoke(Command(resume="да"), config=config)
print(final["approved"])

Что увидим: сначала Публикуем пост?, затем True. Поскольку узел перезапускается целиком, не размещайте до interrupt() действия, которые нельзя повторять, например списание денег: при возобновлении они выполнятся второй раз.

Частые ошибки

  • Забыть checkpointer. Без него прерывание невозможно: состояние негде хранить. Передавайте checkpointer в create_agent или в compile().
  • Возобновлять с другим thread_id. Агент начнёт новый разговор и не найдёт ожидающее действие. Сохраняйте thread_id вместе с запросом на подтверждение.
  • Подтверждать всё подряд. Если ставить паузу на каждое чтение, оператор устанет и начнёт одобрять не глядя. Останавливайтесь только на действиях с последствиями.
  • Выполнять необратимое до interrupt(). Код узла до паузы выполнится повторно при возобновлении.

Наиболее частые вопросы

  • Сколько агент может ждать решения? — Сколько угодно: состояние лежит в checkpointer, а программа не блокируется. Важно лишь, чтобы хранилище было постоянным.
  • Что, если модель вызвала два опасных инструмента сразу? — В action_requests будут оба действия, и в decisions нужно передать два решения в том же порядке.
  • Можно ли разрешить только одобрение и отказ? — Да, через {"allowed_decisions": ["approve", "reject"]} для конкретного инструмента.
  • Где человек видит запрос? — Где угодно: в консоли, веб-интерфейсе, чате. Ваш код показывает action_requests и потом передаёт решение через Command.

Итоги

Поздравляем, ваш агент стал заметно безопаснее! Вы разобрались, как устроена пауза в LangGraph, почему без checkpointer и thread_id она невозможна, настроили HumanInTheLoopMiddleware для разных инструментов, научились читать запрос на подтверждение и отвечать на него одобрением, правкой или отказом. А с помощью interrupt() вы можете поставить точку проверки в любом месте собственного графа.

Дальше агенты будут работать командой: в следующем уроке вы соберёте мультиагентную систему, где главный агент распределяет задачи между специализированными помощниками.

На следующий урок