Некоторые шаги не должны выполняться, пока человек не скажет «да»: вернуть деньги, отправить рассылку на сто тысяч адресов, опубликовать то, что написала модель, удалить записи, которые собирались годами. Human-in-the-loop, коротко HITL, — это подход, при котором автоматический процесс останавливается в такие моменты, показывает человеку, что сейчас произойдёт, и продолжается только после его решения. Это руководство объясняет идею, встраивает согласование в пайплайн и показывает, как одобрять из входящих и из собственных инструментов и как сделать так, чтобы согласующий об этом узнал.
Что такое человек в контуре#
Полностью автоматические потоки быстрые, полностью ручные — безопасные; HITL берёт безопасность человеческого решения и применяет её только там, где это важно, оставляя остальное автоматическим. Человек — не узкое место для каждого запуска, а только для тех немногих, что дошли до гейта, и поток сохраняет своё состояние во время ожидания, так что решение может прийти через минуты или дни. Типичные места для гейта:
- Деньги и договоры. Возврат выше порога, выплата, скидка, о которой договорился агент.
- Контент от ИИ. Модель набрасывает ответ, резюме или изменение кода; человек читает, прежде чем это отправят, опубликуют или смержат. Модель делает работу, ответственность остаётся у человека.
- Необратимые действия в масштабе. Массовая рассылка, смена цен по всему каталогу, выкатка на всех клиентов.
- Неоднозначность, которую машина не разрешит. Две записи клиента, которые могут быть одним человеком; документ, подходящий под две категории. Вместо одобрения человек отвечает на вопрос, и поток использует ответ.
Как это устроено на платформе#
В графовом пайплайне гейт — это узел Human gate (кнопка + Human в палитре узлов). Когда запуск доходит до него, он получает статус Ожидание, его состояние сохраняется, и ни один контейнер не остаётся занятым. У узла два режима:
- Одобрить / отклонить: человек видит промпт и нажимает Одобрить или Отклонить. У узла два исходящих ребра,
approveиreject(на холсте подписаны ok и no), и запуск идёт по выбранному. - Вопрос (свободный ответ): человек отвечает значением, любым JSON, и запуск продолжается по единственному ребру с ответом, приложенным к payload.
Ожидающие запуски собираются в разделе Алерты → Согласования боковой панели, а ещё их можно возобновить из редактора пайплайна или через API. Каким бы путём ни пришло решение, запуск продолжается ровно с того места, где остановился, а решение или ответ вливаются в payload для следующих шагов.
1. Собираем пайплайн с согласованием#
Пример — поток возврата: функция готовит возврат, человек его одобряет, и в зависимости от решения возврат применяется или клиенту сообщают об отказе. Откройте Пайплайны, создайте новый графовый пайплайн и добавьте узлы:
- Ручной триггер (кнопка + Manual в палитре) в качестве старта. Вебхук или расписание работают так же, когда поток проверен.
- Узел Функция с функцией, которая считает возврат и возвращает что-то вроде
{"customer": "…", "amount": 120, "reason": "…"}. Соедините триггер с ним, а его выход ok (successв JSON) — с гейтом. - Узел Human gate с режимом Одобрить / отклонить. В поле Промпт (шаблон, необязательно) напишите, что должен увидеть согласующий; это шаблон поверх входящего payload, так что
Вернуть {{input.amount}} клиенту {{input.customer}}?отрисуется с реальными значениями каждого запуска. Шаблон может быть и JSON-объектом, когда согласующему нужно показать несколько полей. - Ещё два узла Функция: один к выходу гейта ok (
approve), применяющий возврат, другой к no (reject), уведомляющий клиента. Любая ветка может быть длиннее или отсутствовать; гейт без ребра reject просто завершает запуск при отклонении.
Тот же граф в JSON. Нажмите 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" } } }
Нажмите Опубликовать. Редактор проверит граф, сообщит о недостающем — например, о шаге согласования без исходящих связей — и вернёт номер версии. В продакшене выполняются только опубликованные версии, и только их запуски видны в оповещениях и истории.
2. Запускаем и одобряем#
Нажмите Запустить вручную и передайте payload для функции-черновика, например {"orderId": "A-1042"}. Первый шаг выполняется, запуск доходит до гейта и его статус меняется на Ожидание. Больше ничего не происходит, пока кто-то не решит; запуск может ждать столько, сколько нужно.
Откройте раздел Алерты → Согласования в боковой панели. Каждый ожидающий запуск показывает пайплайн, время старта, отрисованный Промпт / инструкции и Выход предыдущего шага, чтобы согласующий видел реальные данные, а не их пересказ. Приостановленный запуск также виден в редакторе пайплайна с панелью Ввод человека сбоку.
Нажмите Одобрить. Запуск возобновляется, выполняется ветка apply, и запуск завершается зелёным; таймлайн выполнения показывает шаг гейта с решением и тем, кто его принял. Запустите снова и нажмите Отклонить, чтобы увидеть другую ветку.
3. Спросить вместо одобрения#
Переключите Режим гейта на Вопрос (свободный ответ), когда потоку нужна информация, а не разрешение. У узла тогда одно исходящее ребро, а в разделе согласований появляется поле Ответ (JSON) с кнопкой Отправить ответ. Что бы человек ни отправил — число, строку, объект, — это приходит в 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. Сообщаем согласующему#
Шаг согласования, о котором никто не знает, — это запуск, который ждёт вечно. Создайте правило оповещения вида Проверка человеком (пауза до решения) с каналом Slack: каждый раз, когда запуск встаёт на паузу, согласующие видят сообщение с пайплайном, промптом и ссылкой на раздел согласований. Руководство по оповещениям показывает, как его создать.
Заметки по дизайну#
- Не ждите итогового результата на стороне вызывающего. Вызывающий вебхук не может держать HTTP-соединение, пока человек думает: вызов возвращается, как только запуск встаёт на паузу у гейта, с id запуска и статусом ожидания. Опрашивайте запуск или пусть итог отправит шаг после гейта. Вариант Асинхронный в поле Вызов настроек пайплайна пока только сохраняется как предпочтение.
- Параллельные ветки продолжают работать. Если граф ветвится до гейта, остальные ветки выполняются, пока гейт ждёт, а запуск завершается только когда готово всё, включая ветку с гейтом.
- Делайте промпт самодостаточным. Согласующий должен принять решение по одному промпту: сумма, клиент, причина, ссылка на запись. Кладите поля в шаблон, а не заставляйте открывать вывод предыдущего шага.
- Решите, что значит отсутствие ответа. Запуски ждут бесконечно — так задумано. Отменяйте устаревшие через API из функции по расписанию или алертите на их возраст; ребро reject, которое прибирает за собой, лучше брошенного запуска.
- Сохраняйте след. История выполнения хранит шаг гейта с решением, ответом и временем. Для регулируемых потоков эта запись и есть аудит-лог; не обходите гейт для «доверенных» входов — сузьте его условием.
Куда дальше#
Справочник по пайплайнам описывает каждый тип узла, шаблоны и режимы выполнения; руководство по оповещениям поможет сделать так, чтобы согласования не оставались незамеченными. Хороший следующий шаг — поставить согласование перед тем единственным автоматическим действием в вашей системе, которое вам меньше всего хотелось бы увидеть запущенным по ошибке.