Перейти к содержимому
Документация

Туториал: человек в контуре (HITL)

Некоторые шаги не должны выполняться без того, чтобы человек сказал «да». Вернуть деньги, отправить рассылку на сто тысяч адресов, опубликовать то, что набросала модель, удалить записи, которые собирались годами. Human-in-the-loop, коротко HITL, — это паттерн, при котором автоматический поток останавливается в такие моменты, показывает человеку, что сейчас произойдёт, и продолжается только с его решением. Этот туториал объясняет идею, встраивает согласование в пайплайн и показывает, как одобрять из инбокса и из собственных инструментов и как сделать так, чтобы согласующий об этом узнал.

Что такое человек в контуре#

Полностью автоматические потоки быстрые, полностью ручные — безопасные; HITL берёт безопасность человеческого решения и применяет её только там, где это важно, оставляя остальное автоматическим. Человек — не узкое место для каждого запуска, а только для тех немногих, что дошли до гейта, и поток сохраняет своё состояние во время ожидания, так что решение может прийти через минуты или дни. Типичные места для гейта:

  • Деньги и договоры. Возврат выше порога, выплата, скидка, о которой договорился агент.
  • Контент от ИИ. Модель набрасывает ответ, резюме или изменение кода; человек читает, прежде чем это отправят, опубликуют или смержат. Модель делает работу, ответственность остаётся у человека.
  • Необратимые действия в масштабе. Массовая рассылка, смена цен по всему каталогу, выкатка на всех клиентов.
  • Неоднозначность, которую машина не разрешит. Две записи клиента, которые могут быть одним человеком; документ, подходящий под две категории. Вместо одобрения человек отвечает на вопрос, и поток использует ответ.

Как это устроено на платформе#

В графовом пайплайне гейт — это узел Human gate. Когда запуск доходит до него, он помечается как WAITING, его состояние сохраняется, и ни один контейнер не остаётся занятым. У узла два режима:

  • Approve: человек видит подсказку и нажимает Approve или Reject. У узла два исходящих ребра, approve и reject, и запуск идёт по выбранному.
  • Question: человек отвечает значением, любым JSON, и запуск продолжается по единственному ребру с ответом, приложенным к payload.

Ожидающие запуски собираются в инбоксе Pipelines → Human in the loop, а ещё их можно возобновить из редактора пайплайна или через API. Каким бы путём ни пришло решение, запуск продолжается ровно с того места, где остановился, а решение или ответ вливаются в payload для следующих шагов.

1. Собираем пайплайн с согласованием#

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

  1. Manual trigger в качестве старта. Вебхук или расписание работают так же, когда поток проверен.
  2. Узел Lambda с функцией, которая считает возврат и возвращает что-то вроде {"customer": "…", "amount": 120, "reason": "…"}. Соедините триггер с ним, а его выход success — с гейтом.
  3. Узел Human gate в режиме Approve. В Prompt template напишите, что должен увидеть согласующий; это шаблон поверх входящего payload, так что Вернуть {{input.amount}} клиенту {{input.customer}}? отрисуется с реальными значениями каждого запуска. Шаблон может быть и JSON-объектом, когда согласующему нужно показать несколько полей.
  4. Ещё два узла Lambda: один к выходу гейта approve, применяющий возврат, другой к reject, уведомляющий клиента. Любая ветка может быть длиннее или отсутствовать; гейт без ребра reject просто завершает запуск при отклонении.

Тот же граф в JSON — его можно вставить в панель Draft graph (JSON) редактора, заменив id функций на свои:

graph.jsonjson
{
  "schemaVersion": 1,
  "nodes": [
    { "id": "start", "kind": "manualTrigger", "name": "Start",
      "position": { "x": 0, "y": 0 }, "config": {} },
    { "id": "draft", "kind": "lambda", "name": "Draft the refund",
      "position": { "x": 0, "y": 140 },
      "config": { "functionId": "<draft-refund function id>", "onError": "failPipeline" } },
    { "id": "gate", "kind": "humanGate", "name": "Approve the refund",
      "position": { "x": 0, "y": 280 },
      "config": {
        "mode": "approve",
        "promptTemplate": {
          "text": "Refund {{input.amount}} to {{input.customer}}?",
          "amount": "{{input.amount}}"
        }
      } },
    { "id": "apply", "kind": "lambda", "name": "Apply the refund",
      "position": { "x": -180, "y": 420 },
      "config": { "functionId": "<apply-refund function id>" } },
    { "id": "decline", "kind": "lambda", "name": "Tell the customer",
      "position": { "x": 180, "y": 420 },
      "config": { "functionId": "<notify-declined function id>" } }
  ],
  "edges": [
    { "id": "e1", "sourceNodeId": "start", "targetNodeId": "draft" },
    { "id": "e2", "sourceNodeId": "draft", "targetNodeId": "gate", "sourceHandle": "success" },
    { "id": "e3", "sourceNodeId": "gate", "targetNodeId": "apply", "sourceHandle": "approve" },
    { "id": "e4", "sourceNodeId": "gate", "targetNodeId": "decline", "sourceHandle": "reject" }
  ],
  "metadata": { "pipeline": { "executionMode": "async" } }
}

Нажмите Publish. Редактор проверит граф, сообщит о недостающем — например, о гейте без исходящих рёбер — и ответит номером версии. В production выполняются только опубликованные версии, и только их запуски видны в алертах и истории.

2. Запускаем и одобряем#

Нажмите Run (manual) и передайте payload для функции-черновика, например {"orderId": "A-1042"}. Первый шаг выполняется, запуск доходит до гейта и его статус меняется на WAITING. Больше ничего не происходит, пока кто-то не решит; запуск может ждать столько, сколько нужно.

Откройте инбокс кнопкой Human in the loop на странице Pipelines. Каждый ожидающий запуск показывает пайплайн, время паузы, отрисованный Prompt / instructions и Previous step output, чтобы согласующий видел реальные данные, а не их пересказ. Приостановленный запуск также виден в редакторе пайплайна с панелью Human input сбоку.

Нажмите Approve. Запуск возобновляется, выполняется ветка apply, и запуск завершается зелёным; таймлайн выполнения показывает шаг гейта с решением и тем, кто его принял. Запустите снова и нажмите Reject, чтобы увидеть другую ветку.

3. Спросить вместо одобрения#

Переключите гейт в режим Question, когда потоку нужна информация, а не разрешение. У узла тогда одно исходящее ребро, а в инбоксе появляется поле Answer (JSON) с кнопкой Submit answer. Что бы человек ни отправил — число, строку, объект, — это приходит в payload как humanGate.answer.

Частая форма — агент, который спрашивает. Функция предлагает три варианта ответа, гейт спрашивает, какой отправить или не отредактировать ли его, а следующий шаг отправляет выбранный текст. Варианты можно вставить в шаблон подсказки, чтобы весь обмен помещался на одном экране.

4. Одобряем из собственных инструментов#

Инбокс — клиент небольшого API, и таким же клиентом может быть ваш Slack-бот, бэк-офис или мобильное приложение. Токен с правом записи на jobs перечисляет ожидающие запуски и возобновляет или отменяет их:

terminalbash
# Runs waiting for a person — the same list the inbox shows
curl "https://api.inquir.org/pipeline-graph-executions/waiting" \
  -H "Authorization: Bearer $INQUIR_TOKEN"

# Approve (or send {"decision":"reject"})
curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/resume" \
  -H "Authorization: Bearer $INQUIR_TOKEN" -H "Content-Type: application/json" \
  -d '{"decision":"approve"}'

# Question mode: any JSON value is the answer
curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/resume" \
  -H "Authorization: Bearer $INQUIR_TOKEN" -H "Content-Type: application/json" \
  -d '{"answer":{"discount":10}}'

# Give up on a paused run
curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/cancel" \
  -H "Authorization: Bearer $INQUIR_TOKEN"

Поле decision принимает approve или reject для гейтов в режиме approve; гейты в режиме question принимают answer. Возобновление запуска, который не ждёт, возвращает конфликт, так что два согласующих, нажавших кнопку одновременно, не применят возврат дважды.

5. Сообщаем согласующему#

Гейт, о котором никто не знает, — это запуск, который ждёт вечно. Создайте правило алерта вида Human gate с каналом Slack или webhook: каждый раз, когда запуск встаёт на паузу, согласующие видят сообщение с пайплайном, подсказкой и ссылкой на инбокс. Туториал по алертам проводит через его создание; добавьте к тому же пайплайну условие Duration, чтобы эскалировать согласования, которые ждут слишком долго.

Заметки по дизайну#

  • Запускайте пайплайны с гейтами асинхронно. Вызывающий вебхук не может держать HTTP-соединение, пока человек думает. Установите режим выполнения async в настройках пайплайна: триггер сразу вернёт id запуска, а вызывающий сможет опрашивать или получить уведомление.
  • Параллельные ветки продолжают работать. Если граф ветвится до гейта, остальные ветки выполняются, пока гейт ждёт, а запуск завершается только когда готово всё, включая ветку с гейтом.
  • Делайте подсказку самодостаточной. Согласующий должен принять решение по одной подсказке: сумма, клиент, причина, ссылка на запись. Кладите поля в шаблон, а не заставляйте открывать вывод предыдущего шага.
  • Решите, что значит отсутствие ответа. Запуски ждут бесконечно — так задумано. Отменяйте устаревшие через API из функции по расписанию или алертите на их возраст; ребро reject, которое прибирает за собой, лучше брошенного запуска.
  • Сохраняйте след. История выполнения хранит шаг гейта с решением, ответом и временем. Для регулируемых потоков эта запись и есть аудит-лог; не обходите гейт для «доверенных» входов — сузьте его условием.

Куда дальше#

Справочник по пайплайнам описывает каждый тип узла, шаблоны и режимы выполнения; туториал по алертам позаботится о том, чтобы согласования были замечены. Хороший следующий шаг — поставить гейт перед тем единственным автоматическим действием в вашей системе, которое вам меньше всего хотелось бы увидеть запущенным по ошибке.