Шлюзы одобрения в LangGraph.js: приостановка агентов с помощью interrupt() и команд
Создайте минимальный механизм одобрения LangGraph.js, который приостанавливает выполнение перед возникновением побочных эффектов, собирает решение человека в терминале и безопасно возобновляет работу с использованием точки контроля.
Некоторые действия агента слишком важны, чтобы выполняться без присмотра: отправка электронного письма от имени кого-либо, удаление записи, одобрение платежа. В таких случаях необходимо, чтобы агент предложил действие, остановился и дождался ответа «да» или «нет» от человека. В этом руководстве описывается создание такого поведения в LangGraph.js с использованием минимально возможной структуры графа, чтобы вы могли точно увидеть, как interrupt(), контрольный механизм, thread_id и Command({ resume }) совместно позволяют приостановить выполнение и возобновить его позже.
Здесь намеренно отсутствует оркестрация нескольких агентов и вызовы больших языковых моделей. Весь алгоритм сводится к одному предложению: приостановиться, дать возможность человеку принять решение, затем продолжить.
Когда агент не должен иметь окончательного слова
Автономия ценна тогда, когда вы уверены, что агент может действовать на основе собственных решений. Многие действия не соответствуют этому критерию, и шаг проверки человеком оправдан из-за возможных последствий. К типичным примерам относятся:
- отправка электронного письма или сообщения в чат от имени пользователя
- изменение или удаление записи в базе данных
- одобрение платежа
- развертывание кода
- удаление облачных ресурсов
- перенаправление заявки на поддержку
- публикация контента, сгенерированного ИИ
В каждом случае цель одна и та же. Агент по-прежнему осуществляет анализ и готовит действие, но сначала излагает свои намерения и передаёт окончательное решение человеку, прежде чем произойдёт что-то необратимое. Именно это и означает на практике подход Human-in-the-Loop (HITL).
Как взаимодействуют функции interrupt() и Command
Если свести к сути, HITL в LangGraph работает следующим образом: граф останавливается в процессе выполнения, ждет вводных данных извне, а затем продолжает работу с этими данными.
Остановка осуществляется с помощью функции interrupt(). Когда узел вызывает её, LangGraph прерывает текущую работу и сохраняет состояние графа с помощью настроенного чекпойнтера, чтобы позже можно было возобновить ту же работу. Ваше приложение получает значение, переданное в interrupt(), отображает его человеку, собирает ответ, а затем возобновляет работу графа, вызвав его с объектом Command, содержащим ответ.
Общая последовательность выглядит так:
Graph starts
↓
Agent decides to send email
↓
⏸ interrupt()
↓
Human reviews the action
↓
Approve / Reject
↓
Command({ resume: ... })
↓
Graph continues
Помните эту структуру; каждый фрагмент кода ниже соответствует одной стрелке в ней.
Сценарий: электронное письмо, требующее утверждения
В примере используется электронное письмо. Агент решает отправить это сообщение:
Meeting at 5 PM with Aman
Прежде чем сообщение попадет куда-либо ещё, его должен просмотреть человек и либо одобрить, либо отклонить. Структура ветвления выглядит так:
User
↓
Agent decides to send email
↓
⏸ Human approval
↓
┌───────────────┐
│ Approve │ → Send email
│ Reject │ → Stop
└───────────────┘
Чтобы сосредоточиться на механизме паузы и возобновления работы, не используется никакой реальный поставщик электронной почты. Узел, который «отправляет» электронное письмо, просто выводит его в терминал. Замена его на реальный API позже ничего не изменит в потоке управления.
Построение графа пошагово
Установка LangGraph и подготовка импортов
Начните с пустого проекта Node.js и добавьте LangGraph:
npm install @langchain/langgraph
В зависимости от установленной версии LangGraph.js также может требовать @langchain/core в качестве зависимости; если npm выдает предупреждения по этому поводу или импорты не проходят, добавьте и этот пакет и ознакомьтесь с документацией по текущей установке.
Ввод от пользователя будет осуществляться из терминала через встроенный модуль Node readline/promises, поэтому для этого не требуется дополнительных пакетов. Импорты включают инструменты для построения графа, помощник для аннотации состояния, сигналы START и END, функцию interrupt, механизм проверки состояния в памяти и класс Command, а также компоненты для работы с вводом:
import {
StateGraph,
Annotation,
START,
END,
interrupt,
MemorySaver,
Command,
} from "@langchain/langgraph";
import readline from "node:readline/promises";
import {
stdin as input,
stdout as output,
} from "node:process";
Файл использует синтаксис ES-модулей (import), поэтому его следует назвать с расширением .mjs или указать "type": "module" в файле package.json; кроме того, необходима версия Node, поддерживающая использование await на уровне модуля, поскольку дальше в коде будет использоваться оператор await на уровне модуля.
Определение состояния, которое хранит граф
В LangGraph состояние представляет собой общий объект, который циркулирует по графу. Каждый узел читает из него данные и возвращает частичные обновления. Для этого графа требуются всего два поля:
message— текст электронного письма, предложенный агентомdecision— ответ, данный человеком
const StateAnnotation = Annotation.Root({
message: Annotation,
decision: Annotation,
});
Annotation.Root() определяет структуру состояния. Поскольку сокращающий функционал не указан, каждое поле просто берет самое последнее значение, записанное в него. Узел агента заполнит поле message; узел одобрения заполнит поле decision, как только человек даст ответ.
Запишите действие, которое вы хотите защитить
Далее идет узел, представляющий рискованную операцию. В производственных условиях он бы вызвал API для отправки электронных писем. Здесь же он лишь ведет лог:
function sendEmail(state) {
console.log(`\n📧 Email sent: "${state.message}"`);
return {};
}
То, что делает эта функция, практически не имеет значения. Важно лишь когда она выполняется: никогда до того, как человек даст согласие. Все остальные элементы структуры предназначены для обеспечения именно такого порядка действий. Обратите внимание, что функция возвращает пустой объект, что означает, что состояние остается неизменным.
Замена решения агента
В реальной системе именно здесь большая языковая модель прочитала бы запрос пользователя и решила, что требуется отправка электронного письма, вероятно, с использованием соответствующего инструмента. Добавление модели здесь лишь отвлекло бы внимание от механизмов HITL, поэтому обычная функция выполняет роль агента и возвращает сообщение, которое он «выбрал»:
function agent() {
return {
message: "Meeting at 5 PM with Aman",
};
}
Рассматривайте этот узел как объявление агентом своего намерения: это письмо, которое он хочет отправить. Если позже его заменить на большую языковую модель с логикой вызова инструментов, механизмы утверждения действий вокруг него останутся практически прежними.
Пауза для человека с использованием interrupt()
Это ядро данной схемы. Узел утверждения вызывает функцию interrupt() с пакетом данных, описывающим, что требует принятия решения, и возвращает любое значение в качестве нового decision:
function humanApproval(state) {
const decision = interrupt({
message: state.message,
question: "Do you want to send this email?",
});
return {
decision,
};
}
Как только выполнение достигает этого вызова
interrupt(...)
граф останавливается. Переданный объект становится пакетом данных для прерывания, который может быть прочитан вызывающим приложением. В данном случае это:
{
message: "Meeting at 5 PM with Aman",
question: "Do you want to send this email?"
}
Приложение отображает этот пакет данных человеку, ждёт ответа и возобновляет работу графа. Ключевой момент заключается в том, что любое значение, которое вы указываете при возобновлении работы, становится значением возврата функции interrupt(). Следовательно, строка
const decision = interrupt(...);
после утверждения человеком фактически превращается в следующую форму:
const decision = "approve";
Это значение записывается в состояние как decision. Затем функция маршрутизации выбирает следующий шаг на основе этого значения:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
Ответ "approve" приводит к вызову функции sendEmail; любой другой ответ прерывает выполнение. Рассматривать любой неодобрительный ответ как сигнал остановки — разумный стандарт для механизма безопасности: если поступит неожиданное значение, выполнение графа прервется без выполнения соответствующей операции.
Добавление чекпоинтера и подключение графа
Перед компиляцией требуется ещё один элемент — чекпоинтер. Поскольку выполнение будет прерываться и затем возобновляться, LangGraph должен сохранять состояние выполнения в момент прерывания. Без чекпоинтера не будет ничего, с чего можно было бы возобновить работу. Для демонстрации достаточно реализации в памяти:
const checkpointer = new MemorySaver();
Теперь зарегистрируйте три узла, соедините START с агентом и агента с шагом утверждения, добавьте условную рёбро от шага утверждения, определяемую параметром routeAfterApproval, и скомпилируйте с использованием checkpointer:
const graph = new StateGraph(StateAnnotation)
.addNode("agent", agent)
.addNode("humanApproval", humanApproval)
.addNode("sendEmail", sendEmail)
.addEdge(START, "agent")
.addEdge("agent", "humanApproval")
.addConditionalEdges(
"humanApproval",
routeAfterApproval,
{
sendEmail: "sendEmail",
[END]: END,
}
)
.compile({
checkpointer,
});
Третий аргумент функции addConditionalEdges соотносит каждое значение, которое может вернуть маршрутизатор, с целевым узлом, что также позволяет LangGraph правильно нарисовать граф. Результативная топология:
START
↓
agent
↓
humanApproval
↓
┌──────────────┐
│ │
approve reject
│ │
↓ ↓
sendEmail END
│
↓
END
MemorySaver хранит точки контроля в оперативной памяти процесса, что идеально подходит для экспериментов, но бесполезно после завершения процесса. Для реальных развертываний следует использовать постоянный механизм хранения точек контроля с поддержкой базы данных, чтобы приостановленная работа сохранялась после перезагрузки и могла быть возобновлена из другого процесса или сервера — что является обычной ситуацией, когда одобрение поступает через веб-интерфейс спустя несколько часов. Чтобы узнать подробнее о том, как внутри хранятся точки контроля, ознакомьтесь с темой о том, как механизм хранения в памяти LangGraph организует точки контроля и процесс записи данных.
Второй аспект постоянства хранения — это thread_id. Он определяет, о какой именно точке контроля идет речь. Для приостановки и возобновления работы необходимо использовать один и тот же thread_id; в противном случае LangGraph не сможет найти сохраненную информацию о выполнении задачи.
Запуск потока из терминала
Начало выполнения и обнаружение прерывания
Интерфейс readline превращает терминал в инструмент для ручного контроля:
const rl = readline.createInterface({
input,
output,
});
Объект конфигурации содержит thread_id в разделе configurable. Все операции, связанные с этим запуском — как первоначальный вызов, так и возобновление — должны использовать этот же объект (или по крайней мере тот же идентификатор):
const config = {
configurable: {
thread_id: "thread-1",
},
};
Запускается граф с начальным состоянием. Узел агента перезапишет пустое значение поля message:
const stream = await graph.stream(
{
message: "",
},
config
);
Выполнение проходит от узла agent к узлу humanApproval, где функция interrupt() останавливает его. Затем поток выдает блок данных, содержащий ключ __interrupt__. Значение первой записи в этом блоке — это данные, переданные функции interrupt(), которые цикл выводит для проверки пользователем:
for await (const chunk of stream) {
if (chunk.__interrupt__) {
const interruptValue =
chunk.__interrupt__[0].value;
console.log(
"\n⏸ Waiting for human approval...\n"
);
console.log(
"The agent wants to send this email:"
);
console.log(`"${interruptValue.message}"`);
console.log(
`\n${interruptValue.question}`
);
}
}
Результат работы терминала выглядит следующим образом. Первая строка представляет собой первоначальный запрос пользователя для контекста; приведённый выше код её не выводит:
User: Send an email to Aman about the 5 PM meeting
⏸ Waiting for human approval...
The agent wants to send this email:
"Meeting at 5 PM with Aman"
Do you want to send this email?
В настоящее время график остановлен, и электронное письмо не отправлялось. Запуск находится в состоянии ожидания проверки.
Запросить решение и его верификация
Теперь необходимо запросить мнение проверяющего. Цикл будет продолжать задавать вопросы до получения одного из двух допустимых ответов, сначала нормализуя пробелы и регистр:
let humanAnswer;
while (true) {
humanAnswer = (
await rl.question("\nApprove or reject: ")
)
.trim()
.toLowerCase();
if (
humanAnswer === "approve" ||
humanAnswer === "reject"
) {
break;
}
console.log(
'Please type "approve" or "reject".'
);
}
В терминале отображается Утвердить или отклонить:, после чего программа останавливается. Ввод слова approve прерывает цикл, и график может быть возобновлён. Проверка ввода перед возобновлением стоит нескольких дополнительных строк: значение, которое вы возвращаете, будет точно таким же, каким оно появится в логике маршрутизации.
Возобновление с помощью команды
Возобновление означает повторный вызов графа, но вместо новых данных передаётся Command, поле resume которого содержит ответ человека:
await graph.invoke(
new Command({
resume: humanAnswer,
}),
config
);
Используется тот же config, а значит, и тот же thread_id; именно так LangGraph находит прерванный процесс. Значение resume возвращается как результат функции interrupt(). При вводе значения approve вызов внутри функции humanApproval
const decision = interrupt(...);
теперь возвращает approve. Узел возвращает его как decision, после чего запускается маршрутизатор:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
Поскольку decision равен "approve", управление переходит к функции sendEmail, и терминал выводит:
📧 Email sent: "Meeting at 5 PM with Aman"
Электронное письмо было «отправлено» только после явного одобрения. Когда вы закончите, вызовите rl.close(), чтобы интерфейс readline освободил stdin, и процесс смог завершиться.
Как выглядит отклонение
Запустите скрипт снова. Поскольку MemorySaver находится в памяти, новый процесс запускается с пустым хранилищем точек контроля; если вы запускаете его снова внутри того же процесса, используйте новый thread_id, чтобы не возобновлять уже завершенную работу. Когда появится запрос ввода,
Approve or reject:
ответ:
reject
График возобновляется с этим значением. В приведённом ниже фрагменте значение указано буквально для ясности; в самом скрипте оно просто называется humanAnswer:
await graph.invoke(
new Command({
resume: "reject",
}),
config
);
На этот раз state.decision содержит значение "reject", поэтому маршрутизатор возвращает END, и вызов функции sendEmail так и не запланируется:
Agent wants to send email
↓
⏸ Paused
↓
Human: reject
↓
END
Это различие имеет важное значение. Граф не просто генерирует другое сообщение при отклонении; узел, выполняющий побочные эффекты, вообще не выполняется. Именно поэтому шаг утверждения является настоящей защитой, а не лишь декоративным элементом.
Полная картина
Если объединить всё вместе, полный граф выглядит следующим образом:
┌─────────────┐
│ START │
└──────┬──────┘
↓
┌─────────────┐
│ Agent │
└──────┬──────┘
↓
┌───────────────────┐
│ Human Approval │
│ │
│ ⏸ interrupt() │
└─────────┬─────────┘
↓
Human decides
/ \
/ \
approve reject
↓ ↓
┌────────────┐ END
│ sendEmail │
└──────┬─────┘
↓
END
Коротко: агент принимает решение, граф приостанавливается, человек проверяет результат, граф возобновляется, и только тогда выполняется действие.
Проблема повторной экспекуции: сохраняйте побочные эффекты после прерывания
Одно из свойств функции interrupt() удивляет многих пользователей. При возобновлении работы LangGraph не продолжает выполнение с строки после interrupt(); вместо этого он снова запускает узел, содержащий команду перерыва, с его первой строки. Отличие при втором запуске заключается в том, что вызов interrupt() сразу возвращает значение для возобновления работы, вместо того чтобы делать паузу.
Это имеет прямые последствия для побочных эффектов. Любой код, расположенный перед interrupt() в том же узле, выполняется один раз при паузе в работе графа и ещё раз при его возобновлении. Именно этого следует избегать:
function humanApproval(state) {
saveSomethingToDatabase();
const decision = interrupt("Approve?");
return { decision };
}
Здесь функция saveSomethingToDatabase() будет выполняться дважды для одобрения. Решение заключается в структурных изменениях: необходимо сделать узел, отвечающий за одобрение, свободным от побочных эффектов, а все реальные действия разместить в более позднем узле, который будет запускаться только после того, как человек даст ответ. Именно так организован пример в коде:
humanApproval
↓
interrupt()
↓
human response
↓
sendEmail
Если вам действительно необходимо выполнить какую-либо операцию перед прерыванием в том же узле, сделайте её идемпотентной (безопасной для повторного выполнения, например операцию упсерта с использованием стабильного идентификатора) или перенесите её в отдельный предшествующий узел, результат выполнения которого уже сохранён в качестве чекпоинта и не будет выполняться заново.
Что происходит на самом деле, пошагово
Как только код обработан, жизненный цикл короткий:
- Граф начинает свою работу.
- Узел-агент решает отправить электронное письмо.
- Выполнение достигает функции
interrupt(). - LangGraph останавливает работу.
- Текущее состояние сохраняется чекпоинтером.
- Приложение получает данные прерывания.
- Человек рассматривает предложенное действие.
- Этот человек принимает решение.
- Приложение возобновляет работу графа с помощью команды
Command, используя тот жеthread_id.
interrupt() возвращает ответ человека.Вызовы API — это простая часть; схема паузы и возобновления — это идея, которую стоит усвоить:
Graph
│
▼
Agent decision
│
▼
interrupt()
│
│
┌───┴───┐
│ Human │
└───┬───┘
│
approve/reject
│
▼
resume
│
▼
Continue
Как только этот процесс становится понятным, HITL уже не кажется загадочным. Это просто пауза с контрольными точками, за которой следует введенный ответ.
Проверка собственной реализации
Прежде чем полагаться на механизм одобрения, выполните несколько быстрых тестов:
- Одобрите один раз и убедитесь, что действие выполняется ровно один раз.
- Отклоните запрос и убедитесь, что узел действия никогда не выполняется, а не просто что результат отличается.
- Введите недопустимый ответ и убедитесь, что запрос повторяется, а не возобновляется с бессмысленными данными.
- Возобновите работу с другим
thread_idи убедитесь, что первоначальная работа не нарушается.
Где применяется тот же контрольный механизм
Электронная почта — это лишь удобный пример. Такая же структура подходит для любых действий, требующих человеческого надзора: обмена сообщениями, обновления или удаления записей, утверждения платежей, развертывания, демонтажа облачных ресурсов, публикации генерированного контента или передачи запросов на поддержку на более высокий уровень. Сам узел действия меняется, но контрольный механизм перед ним остается прежним. Если вы хотите увидеть шаги утверждения наряду с другими паттернами оркестрации, такими как маршрутизация и распространение, этот обзор пяти паттернов LangGraph показывает их рядом.
Основные выводы
interrupt()приостанавливает обработку графа и передаёт данные вашему приложению; значение, с которым вы возобновляете работу, становится его возвращаемым значением.Command({ resume: ... })возвращает ответ человека обратно в приостановленную работу.- Для приостановки обязателен указатель контроля; в производственных условиях используйте постоянный указатель, чтобы одобрения могли поступать после перезагрузок.
- Для приостановки и возобновления одной и той же работы необходимо использовать один и тот же
thread_id. - Узел, содержащий
interrupt(), при возобновлении работы запускается заново с самого начала, поэтому побочные эффекты следует размещать в более поздних узлах или сделать их идемпотентными. - Всё, кроме явного одобрения, следует направлять на остановку, чтобы шлюз автоматически отклонял запросы.
Для того чтобы человек мог управлять агентом, не требуется сложная рабочая процедура. Одна хорошо расположенная пауза перед критически важным шагом позволяет агенту выполнять большую часть работы, в то время как человек остается тем, кто принимает окончательное решение.
Связанные статьи
- Агенты с контролем одобрения в LangGraph: функция interrupt(), чекпоинты и хранилище — Построение агента LangGraph шаг за шагом: явная структура ReAct, одобрение человека с использованием функции interrupt(), а также межпоточная память через хранилище, в результате чего формируется помощник-почтовый ящик, который сначала задает вопросы.