Некоторые шаги не должны выполняться без того, чтобы человек сказал «да». Вернуть деньги, отправить рассылку на сто тысяч адресов, опубликовать то, что набросала модель, удалить записи, которые собирались годами. 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, создайте новый графовый пайплайн и добавьте узлы:
- Manual trigger в качестве старта. Вебхук или расписание работают так же, когда поток проверен.
- Узел Lambda с функцией, которая считает возврат и возвращает что-то вроде
{"customer": "…", "amount": 120, "reason": "…"}. Соедините триггер с ним, а его выход success — с гейтом. - Узел Human gate в режиме Approve. В Prompt template напишите, что должен увидеть согласующий; это шаблон поверх входящего payload, так что
Вернуть {{input.amount}} клиенту {{input.customer}}?отрисуется с реальными значениями каждого запуска. Шаблон может быть и JSON-объектом, когда согласующему нужно показать несколько полей. - Ещё два узла Lambda: один к выходу гейта approve, применяющий возврат, другой к reject, уведомляющий клиента. Любая ветка может быть длиннее или отсутствовать; гейт без ребра reject просто завершает запуск при отклонении.
Тот же граф в JSON — его можно вставить в панель Draft graph (JSON) редактора, заменив id функций на свои:
{ "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 перечисляет ожидающие запуски и возобновляет или отменяет их:
# 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, которое прибирает за собой, лучше брошенного запуска.
- Сохраняйте след. История выполнения хранит шаг гейта с решением, ответом и временем. Для регулируемых потоков эта запись и есть аудит-лог; не обходите гейт для «доверенных» входов — сузьте его условием.
Куда дальше#
Справочник по пайплайнам описывает каждый тип узла, шаблоны и режимы выполнения; туториал по алертам позаботится о том, чтобы согласования были замечены. Хороший следующий шаг — поставить гейт перед тем единственным автоматическим действием в вашей системе, которое вам меньше всего хотелось бы увидеть запущенным по ошибке.