Перейти к содержимому
PD
AI-автоматизация 8 мин чтения

Guardrails до автономии: чек-лист для AI-агентов, которые лезут в продакшн

Промпт - это не guardrail. Разбираю слой enforcement, который я пишу кодом до того, как агент получит доступ к данным клиента: allowlist инструментов, ключи идемпотентности, бюджеты, dry-run, гейты подтверждения и воспроизводимые трейсы.

PD

Pavel Duglas

AI Automation & MVP Architect

В прошлом квартале я сдавал клиенту агента для холодного аутрича. В системном промпте было два правила: никогда не писать одному лиду дважды и не отправлять ничего вне интервала 9:00–18:00 по местному времени. На третий день он отправил 41 дубль в два часа ночи. Промпт был нормальный. Модель была нормальная. Проблема была в архитектуре: я написал правила там, где надо было написать код.

Это самая частая ошибка в агентных проектах, которую я вижу сейчас. Команда две недели крутит промпты и ноль дней строит слой принуждения. А потом обнаруживает, что вероятностная система, которую вежливо попросили вести себя хорошо, рано или поздно поведёт себя плохо. Ниже - чек-лист, который я прогоняю перед тем, как агент получит токен с правом записи в живую систему.

Базовый принцип: модель предлагает, код решает

Вывод агента - это заявка, а не действие. Всё, что производит модель, должно проходить через детерминированный код, который может её отклонить. Если ограничение реально важно - деньги, дедупликация, rate limit, удаление данных - оно живёт в функции с if, а не в абзаце текста на английском.

По-другому: считайте, что модель - это талантливый, но слегка выпивший подрядчик с добрыми намерениями и без памяти. Вы бы не выдали такому человеку креды к продакшн-базе с сообщением в Slack «пожалуйста, поаккуратнее». Вы бы давали ему по одному инструменту за раз и проверяли работу.

Слой 1: allowlist инструментов на уровне задачи, а не агента

Большинство фреймворков предлагает регистрировать инструменты на агенте. Это уже слишком грубо. Саппорт-агент, у которого есть refund_order, - это саппорт-агент, который однажды сделает возврат посреди разговора про сроки доставки.

Я ограничиваю набор инструментов по типу задачи, и он резолвится до старта прогона:

TOOLSETS = {
    "answer_question": ["search_docs", "get_order_status"],
    "process_refund":  ["get_order", "create_refund_request"],  # заявка, не возврат
    "update_address":  ["get_order", "propose_address_change"],
}

def tools_for(intent: str) -> list[Tool]:
    return [TOOLS[name] for name in TOOLSETS[intent]]

Обратите внимание на нейминг: create_refund_request, а не refund. Пишите инструменты, которые кладут намерение в очередь, а исполняет его отдельный скучный воркер. Одна эта привычка убирает процентов 70 радиуса поражения: теперь каждая запись идёт через место, которое вы контролируете, логируете и можете придушить по rate limit.

Слой 2: подписи инструментов, безопасные по построению

Инструмент с параметром «свободный текст» - это дырка в системе. Сравните:

# плохо: модель пишет SQL, вы молитесь
def query_db(sql: str) -> list[dict]: ...

# хорошо: модель выбирает из закрытого множества
def get_orders(customer_id: int,
               status: Literal["pending", "paid", "shipped"],
               limit: int = 20) -> list[dict]: ...

Энумы, целочисленные ID, ограниченные диапазоны. Валидация через Pydantic (или zod), и на ошибке возвращайте структурированный ответ, чтобы модель могла сама себя починить: {"error": "limit must be <= 50", "retryable": true}. Расплывчатое "invalid input" приводит к тому, что модель начинает выдумывать новые названия параметров и жечь ваш бюджет шагов.

Слой 3: ключи идемпотентности и журнал записей

У моего ночного инцидента с дублями была скучная причина: таймаут сети, ретрай и отсутствие идемпотентности. Агент «знал», что уже писал этому лиду, но память, к которой он обращался, - это его собственный контекст, который к тому моменту подрезали.

Каждый инструмент записи получает детерминированный ключ, выведенный из бизнес-смысла, а не из UUID:

key = sha256(f"outreach:{lead_id}:{campaign_id}".encode()).hexdigest()

if ledger.exists(key):
    return {"status": "already_done", "at": ledger.get(key).created_at}

ledger.reserve(key)              # реальную работу делает unique index в Postgres
try:
    result = send_message(...)
    ledger.commit(key, result)
except Exception:
    ledger.release(key)
    raise

Guardrail здесь - это уникальный индекс. Промпт - это пожелание. Если журнал живёт в той же транзакции, что и эффект, дубли становятся структурно невозможными, а не статистически маловероятными.

Слой 4: четыре бюджета, все - вне модели

Агенты падают громко, а зацикливаются дорого. Я ограничиваю четыре вещи на прогон:

БюджетТипичное значениеЧто предотвращает
Шаги (вызовы инструментов)12–20Бесконечные циклы «план → переплан»
Wall clock90 с интерактив, 10 мин батчЗависшие HTTP-запросы, повисшие браузеры
Токены60k–150k на прогонРаздувание контекста сырым HTML
Деньги$0.15 на тикет саппортаСмерть от тысячи ретраев

Когда бюджет исчерпан, второго шанса нет: агент отдаёт частичный результат и reason code. budget_exceeded:steps в логах - это продуктовый сигнал, обычно он значит, что какой-то инструмент возвращает мусор, который модель не может распарсить.

Про деньги: я считаю расход на прогон, а не за месяц. Месячные дашборды прячут тот один воркфлоу, который стоит в 40 раз дороже остальных. У меня была классификация документов, где один битый PDF съел больше, чем предыдущие 900 документов вместе: экстрактор упорно возвращал одну и ту же тридцатитысячную стену лигатурного шума, а модель упорно пыталась её осмыслить.

Слой 5: dry-run как полноценная фича, а не костыль

Каждый инструмент записи у меня принимает флаг dry_run, протянутый из контекста прогона. В режиме dry-run инструмент валидирует всё, логирует ровно тот payload, который отправил бы, и возвращает правдоподобный фейковый ответ.

Это не приятная мелочь для тестов, это способ выходить в прод. Новый воркфлоу несколько дней крутится в dry-run на реальном продакшн-трафике. Я читаю предложенные действия как diff. Примерно каждый третий агент получает изменение в дизайне на этой фазе - обычно потому, что он хочет сделать что-то разумное, под что я не сделал инструмент, или что-то безумное, что я не додумался запретить.

Слой 6: гейты подтверждения на необратимом

Разложите действия по трём корзинам:

  • Обратимые и дешёвые - черновики текста, теги, чтение. Полная автономия.
  • Обратимые, но видимые снаружи - отправить сообщение, поменять статус. Автономия с rate limit и kill switch.
  • Необратимые или денежные - возвраты, удаления, выплаты, массовая рассылка. Подтверждение человеком, всегда, пока не накопится несколько месяцев чистой статистики.

UI подтверждения не обязан быть красивым. Мой дефолт - сообщение в Telegram с инлайн-кнопками Approve/Reject, в callback_data лежит ID действия из очереди, плюс 30-минутный TTL, чтобы ничего не висело вечно. Десять минут рабочего дня человека дешевле одной неправильно проведённой выплаты.

Слой 7: воспроизводимые трейсы и проверки без LLM

Логируйте каждый прогон структурированным трейсом: вход, версия модели, каждый вызов инструмента с аргументами и результатом, тайминги, стоимость, финальный исход, reason code. Складывайте JSONL в объектное хранилище - дёшево и гребётся grep’ом. Локально я нарезаю это SQLite’ом.

А дальше - и вот этот шаг пропускают почти все - напишите детерминированные ассерты по трейсам. Чтобы поймать большинство регрессий, LLM-судья не нужен:

  • Был ли вызов инструмента вне allowlist? (Должно быть невозможно. Проверяйте всё равно.)
  • Были ли больше двух ретраев одного инструмента с идентичными аргументами?
  • Больше 5% прогонов заканчиваются budget_exceeded?
  • Агент выдал финальный ответ, вообще не вызвав retrieval? (Классический признак тихой галлюцинации.)
  • P95 по числу шагов растёт после смены модели или промпта?

Это гоняется в CI по фикстуре из 50–100 записанных входов и в продакшне как часовые агрегаты. Дёшево, быстро, без флаки. LLM-as-judge оставьте для качества ответов, где реально нужна семантика, и держите его вне критического пути.

Сверка: считайте отчёт агента художественным вымыслом

Фраза агента «я обновил заказ» - это утверждение, а не факт. Для любого воркфлоу с внешней системой я поднимаю job сверки, который сравнивает то, что заявляет журнал агента, с тем, что говорит source of truth. Классика - статус платежа: у вас paid, в магазине pending, и одиннадцать дней никто этого не видит.

Job скучный и на 60 строк. Тянет обе стороны за последние N часов, диффит по бизнес-ключу, кидает расхождения в канал. Каждая нетривиальная автоматизация, которую я сдавал, поймала что-то реальное в первый месяц - и обычно это был молча отвалившийся вебхук, а вовсе не сбой AI.

Лестница выката

Последовательность, которую я использую и по которой не перепрыгиваю ступени:

  1. Shadow - агент работает на реальных входах, все записи в dry-run. Каждый день читаете диффы.
  2. Assisted - агент предлагает, человек нажимает «подтвердить». Мерите approval rate; ниже 85% - возвращайтесь в shadow.
  3. Autonomous, narrow - полная автономия только для самой дешёвой обратимой корзины, жёсткие лимиты, kill switch в конфиге, который переключается без деплоя.
  4. Autonomous, wide - расширяете корзину по одному типу действия, каждый со своими двумя неделями метрик.

Все семь слоёв на новом проекте стоят мне примерно полтора дня, и модуль ledger/budget/trace я переношу от клиента к клиенту. В этом весь смысл: guardrails - это инфраструктура, а не проектная изобретательность. Промпт вы перепишете пятьдесят раз. Слой принуждения - это то, что позволяет спать, пока агент работает.

Если вы на этой неделе собираетесь выдать агенту продакшн-креды, сделайте сначала одну вещь: добавьте флаг dry_run и прочитайте день предложенных действий. Не пожалеете.

Вопросы и ответы

Нельзя ли просто прописать все ограничения в системном промпте и обойтись без кода?

Нет. Промпт влияет на вероятность поведения, но ничего не гарантирует. Достаточно одного таймаута, ретрая, подрезанного контекста или неудачной формулировки от пользователя - и правило перестаёт работать. Ориентир простой: если нарушение ограничения стоит денег, репутации или необратимо меняет данные, оно должно быть выражено кодом - уникальным индексом в базе, проверкой в обёртке инструмента, лимитом в очереди. Промпт хорошо работает для тона, формата и приоритетов, то есть там, где ошибка дешёвая.

Сколько времени реально уходит на такой слой enforcement на новом проекте?

Первый раз - примерно 2–3 дня, потому что вы пишете журнал записей, обёртку с бюджетами и формат трейса с нуля. Дальше это переносимая библиотека, и на новом проекте уходит день-полтора: подключить ledger к базе клиента, описать TOOLSETS по типам задач, протянуть dry_run через инструменты, поднять approval-бота в Telegram. Это дешевле, чем один разбор инцидента с клиентом, у которого агент разослал дубли или сделал лишние возвраты.

У меня MVP и мало времени. С чего начать, если делать всё сразу не получится?

Три вещи в таком порядке. Первое - dry-run на всех инструментах записи и день чтения предложенных действий: это ловит самые грубые ошибки дизайна до того, как они станут инцидентом. Второе - идемпотентность на ключевых записях через уникальный индекс: убивает целый класс дублей навсегда. Третье - бюджет шагов и денег на прогон, чтобы одна зацикленная задача не съела месячный лимит. Approval-гейты, полноценные трейсы и job сверки добавляйте после того, как агент начнёт приносить пользу и вы поймёте, где он на самом деле ошибается.

Похожие статьи