Головна / Статті / Блокувальні механізми в LangGraph.js: призупинення агентів за допомогою interrupt() та Command

Блокувальні механізми в LangGraph.js: призупинення агентів за допомогою interrupt() та Command

Створіть мінімальний брандмауер схвалення LangGraph.js, який зупиняється перед виконанням побічного ефекту, збирає рішення людини в терміналі та безпечно продовжує роботу з точки контролю.

3146 слів

Деякі дії агента мають занадто серйозні наслідки, щоб виконуватися без нагляду: надсилання електронного листа від імені когось, видалення запису, схвалення оплати. У таких випадках потрібно, щоб агент запропонував виконати дію, зупинився та чекав на підтвердження або відмову людини. У цьому посібнику описано, як створити таку поведінку в LangGraph.js за допомогою мінімально можливої структури графа, щоб ви могли точно побачити, як interrupt(), контрольний механізм, thread_id та Command({ resume }) співпрацюють для призупинення виконання та його подальшого продовження.

Навмисно тут немає оркестрації кількох агентів та викликів LLM. Усе це можна описати одним реченням: призупинити, дати можливість людині вирішити, продовжити.

Коли агент не повинен мати остаточного слова

Автономія є цінною, коли ви готові дозволити агенту діяти на основі власних рішень. Багато дій не відповідають цим критеріям, тож етап людського перегляду вартий всіх зусиль. Типовими прикладами є:

  • надсилання електронного листа чи повідомлення в чат користувачеві
  • зміна чи видалення запису в базі даних
  • sхвалення оплати
  • розгортання коду
  • скасування використання хмарних ресурсів
  • перенаправлення заявки на підтримку
  • опублікування контенту, створеного ШІ

У кожному випадку мета залишається однаковою. Агент все ще здійснює міркування та готує дію, але спочатку представляє свої наміри та передає остаточне рішення людині, перш ніж станеться щось незворотне. Саме це на практиці означає концепцію Human-in-the-Loop (HITL).

Як взаємодіють interrupt() та Command

У своїй суті HITL у LangGraph працює так: граф зупиняється під час виконання, чекає на вхідні дані ззовні, а потім продовжує роботу, використовуючи ці дані.

Зупинка здійснюється за допомогою interrupt(). Коли вузол це викликає, LangGraph зупиняє поточну роботу та зберігає стан графа через налаштований checkpointer, щоб можна було продовжити ту саму роботу пізніше. Ваше додаток отримує значення, яке ви передали до 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; будь-яка інша відповідь завершує роботу. Розглядати кожну несхвалену відповідь як сигнал для зупинки є розумним стандартним правилом для механізму безпеки: якщо надійде неочікуване значення, процес завершується, замість того щоб виконати відповідну дію.

Додайте checkpointer та під’єднайте граф

Перед компіляцією потрібен ще один елемент — checkpointer. Оскільки робота буде зупинятися та потім продовжуватися, LangGraph мусить зберігати стан виконання у момент переривання. Без checkpointer немає чого відновлювати. Для демонстрації достатньо реалізації в оперативній пам’яті:

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

Якщо ви справді змушені виконувати певну роботу перед перериванням у тому самому вузлі, зробіть її ідемпотентною (безпечною для повторного виконання, наприклад, операцією upsert з використанням стабільного ідентифікатора) або перенесіть її у окремий попередній вузол, результат виконання якого вже збережений як контрольна точка та не буде виконуватися знову.

Що відбувається насправді, поетапно

Як тільки код виконується, життєвий цикл є коротким:

  1. Граф починає виконуватися.
  2. Вузол агента вирішує надіслати електронний лист.
  3. Виконання доходить до функції interrupt().
  4. LangGraph зупиняє виконання.
  5. Текущий стан зберігається контрольною точкою.
  6. Додаток отримує дані переривання.
  7. Людина переглядає запропоновану дію.
  8. Ця людина приймає рішення.
  9. Додаток продовжує виконання графа за допомогою команди Command, використовуючи той самий thread_id.
  • interrupt() повертає відповідь людини.
  • Граф продовжується по гілці, яку обирає відповідь.
  • Виклики API — це проста частина; схема паузи та продовження — це ідея, яку варто засвоїти:

            Graph
              │
              ▼
        Agent decision
              │
              ▼
          interrupt()
              │
              │
          ┌───┴───┐
          │ Human │
          └───┬───┘
              │
         approve/reject
              │
              ▼
           resume
              │
              ▼
          Continue
    

    Як тільки цей процес стане зрозумілим, HITL більше не здасться таємничим. Це просто пауза з контрольними точками та введеною відповіддю наприкінці.

    Перевірка власної реалізації

    Перш ніж покладатися на механізм схвалення, проведіть кілька швидких тестів:

    • Схваліть один раз та переконайтеся, що дія виконається рівно один раз.
    • Відхиліть та переконайтеся, що вузол дії ніколи не виконується, а не просто що результат відрізняється.
    • Введіть недійсну відповідь та переконайтеся, що запит повторюється, а не продовжується з некоректними даними.
    • Продовжіть роботу з іншим thread_id та спостерігайте, що первинний запуск не зазнає змін.
  • За допомогою постійного показника стану перезапустіть процес між призупиненням та продовженням та переконайтеся, що виконання все одно завершується.
  • Де застосовується той самий контрольний етап

    Електронна пошта — це лише зручний приклад. Така ж структура підходить для будь-якої дії, яка потребує людського нагляду: обміну повідомленнями, оновлень чи видалення записів, схвалення платежів, розгортання, видалення хмарних ресурсів, публікації створеного контенту чи передачі запитів на підтримку на вищий рівень. Змінюється вузол дії; контрольний етап перед ним залишається незмінним. Якщо ви хочете побачити кроки схвалення разом з іншими шаблонами оркестрації, такими як маршрутизація та розповсюдження, цей огляд п’яти шаблонів LangGraph демонструє їх поруч.

    Основні висновки

    • interrupt() зупиняє граф та передає пакет даних вашому додатку; значення, яким ви його продовжуєте, стає значенням повернення.
    • Command({ resume: ... }) повертає відповідь користувача назад у зупинений процес.
    • Для зупинки обов’язково потрібен показник стану; у продакшні використовуйте постійний показник, щоб схвалення могли надійти після перезапуску.
    • Для зупинки та продовження певного процесу має використовуватися той самий thread_id.
    • Узел, що містить interrupt(), під час продовження виконується знову з початку, тому побічні ефекти слід розміщувати у наступних вузлах або робити їх ідемпотентними.
    • Усе, що не є прямим схваленням для зупинки, потрібно спрямовувати на зупинку, щоб шлюз залишався у стані закриття.

    Вам не потрібен складний процес роботи, щоб передати керування агентом людині. Одна добре розміщена пауза перед кроком, що має значення, дозволяє агенту виконати більшу частину роботи, тоді як людина залишається остаточним рішувачем.

    Пов’язана література

    • Approval-Gated Agents in LangGraph: interrupt(), Checkpoints and a Store — Поступове створення агента LangGraph: чітка схема ReAct, схвалення людиною з використанням interrupt(), а також пам’ять між потоками з використанням сховища, що завершується асистентом у поштовій скриньці, який спочатку ставить запитання.
  • Specialist Agents, a Keyword Router and interrupt(): A LangGraph Coach — Розробка асистента LangGraph із двома спеціалістами, детерміністичним маршрутизатором, спільним станом, який зберігається під час передачі обов’язків, та системою перевірки від людини, заснованою на механізмах interrupt та Command.
  • Verifying What AI Agents Do: Permissions, Approval Gates and Risk Tiers — Дізнайтеся, чому агенти, що діють автономно, потребують менталітету перевірки, та як принципи мінімальних прав, людського схвалення та автономії, заснованої на ризиках, допомагають обмежувати їхні помилки.
  • Зупинка та відновлення агентів LangGraph за допомогою interrupt() та Command — Як функції interrupt() та Command у LangGraph використовують чекпоїнти та потоки для зупинки агента на підтвердження, редагування чи введення даних з боку користувача, а потім безпечного його відновлення пізніше.
  • Обрізання рядків у JavaScript, сумісне з емодзі, за допомогою Intl.Segmenter — Чому методи slice() та обрізання на основі розповсюдження пошкоджують емодзі та текст із діакритичними знаками, як через це ламалися реальні продукти, та як замість цього обрізати рядки за межами графем.