Когда LLM становится полноценным участником приложения — принимает пользовательский ввод, генерирует ответы, вызывает инструменты, — защита перестаёт быть одной проверкой на входе. Нужно решать, что именно проверять, где это делать (в процессе или на отдельном сервисе), как кэшировать дорогие проверки и как вести себя, когда проверяющий сервис недоступен. Ниже — механика того, как устроены такие проверки в NeMo Guardrails: от выбора источника текста до поведения при отказе внешнего сервиса.
Вход и выход — две разные точки проверки
Приложение проверяет два разных потока текста: то, что принёс пользователь, и то, что сгенерировала модель. Это не дублирование, а разные стадии обработки. входные рельсыпроверки, которые срабатывают на новом пользовательском вводе, до обращения к модели вызываются, когда приходит новый ввод пользователя. выходные рельсыпроверки, которые срабатывают после того, как бот сгенерировал ответ — после генерации сообщения бота.
Функция проверки входа выбирает текст так: сначала берётся явно переданный аргумент text; если он пуст, значение из context["user_message"]; если и его нет — пустая строка. Функция проверки выхода устроена так же, но источник по умолчанию — context["bot_message"]. Дальше обе идут одним путём: получают конфигурацию guardrails_aiподсистема внешних валидаторов, настройки которой задаются в config.rails.config.guardrails_ai, достают по имени конфигурацию конкретного валидатораотдельная проверка Guardrails AI, загружаемая по имени и настраиваемая параметрами, объединяют её параметры и metadata в один набор и вызывают проверку. Результат превращается в булево значение, и функция возвращает либо разрешение, либо блокировку с metadata, где есть поле valid.
Если после выбора источника текст пуст, обе функции не пропускают запрос молча, а выбрасывают ошибку с требованием передать text либо context. Отсутствие конфигурации guardrails_ai или конфигурации названного валидатора — тоже ошибка, а не тихий пропуск.
Как результат проверки превращается в решение
Внешний валидатор возвращает не одно булево значение, а структуру, и её нужно разобрать. Из результата извлекается вложенный объект validation_result (по умолчанию — пустой словарь). Дальше поддерживаются два формата:
- если у этого объекта есть атрибут
validation_passed, берётся он и приводится к булеву типу; - если это словарь, берётся значение по ключу
validation_passed, а при отсутствии ключа —False, тоже с приведением к булеву типу.
Два формата нужны потому, что результат проверки приходит в одном из двух видов: либо как объект с атрибутом validation_passed, либо как словарь с ключом validation_passed. Какой именно вид придёт, зависит от того, что вернул внешний валидатор.
Если не подходит ни один формат, проверка считается непройденной: незнакомый формат ответа трактуется как непройденная проверка, а не как пройденная.
Кэш объектов Guard: почему ключ строится именно так
Создание Guardобъект Guardrails AI, к которому привязывается экземпляр валидатора — дорогая операция, поэтому готовые объекты кэшируются. Ключ кэша — это кортеж из имени валидатора и отсортированного набора пар «параметр — значение». Чтобы значения вообще могли попасть в ключ, они приводятся к хэшируемому видупредставлению, которое можно положить в ключ словаря: списки становятся кортежами, словари — отсортированными кортежами пар, остальное остаётся как есть: списки превращаются в кортежи, словари — в отсортированные кортежи пар «ключ — значение», остальные объекты остаются как есть.
В ключ попадают только параметры валидатора. metadata извлекается отдельно и в параметры не входит, поэтому на ключ кэша не влияет — две проверки с одинаковыми параметрами, но разной metadata используют один и тот же объект Guard.
При первом обращении с таким ключом класс валидатора загружается, при отсутствии on_fail в параметры добавляется on_fail="noop", создаётся экземпляр, он привязывается к Guard и результат сохраняется в кэш. При повторном обращении с тем же ключом возвращается уже готовый объект.
Загрузка класса валидатора и что бывает, когда его нет
Класс валидатора загружается по имени динамически и тоже кэшируется. Сначала проверяется кэш по ключу вида class_<имя валидатора>; если класс там есть, он возвращается. Иначе по имени валидатора запрашивается информация о нём, из неё извлекаются имя модуля и имя класса, модуль импортируется, класс извлекается по имени и сохраняется в кэш.
Если импорт модуля или получение класса не удались, в лог уходит предупреждение с командой установки валидатора, а наружу — ошибка импорта с сообщением, что валидатор не установлен. Любая другая ошибка на этом пути тоже преобразуется в ошибку импорта, но с другим сообщением — о неудачной загрузке валидатора.
Эвристики обнаружения джейлбрейка
джейлбрейкпопытка обойти ограничения модели через специально построенный запрос — один из главных рисков приложений на LLM. Для его обнаружения есть отдельное действие, которое сначала выбирает текст для проверки: приоритет у аргумента user_message, а context["user_message"] используется только когда user_message не передан; если оба пусты, подставляется пустая строка.
Дальше поведение зависит от того, задан ли адрес внешнего сервиса обнаружения. Если адрес пуст, проверки выполняются в процессе. Импортируются две встроенные проверки: одна оценивает длину относительно перплексиимера «неожиданности» текста для языковой модели: чем выше, тем менее предсказуем текст, другая — соотношение префикса и суффикса запроса. Обе запускаются над одним и тем же текстом со своими порогами, взятыми из конфигурации: length_per_perplexity_threshold и prefix_suffix_perplexity_threshold. Результаты объединяются так, что достаточно срабатывания любой из двух проверок:
jailbreak = any([lp_check["jailbreak"], ps_ppl_check["jailbreak"]])При истинном значении возвращается блокировка, иначе — разрешение.
Если адрес внешнего сервиса задан, обе проверки не выполняются локально: те же два порога передаются в удалённый запрос вместе с HTTP-клиентом.
Поведение при отказе внешнего сервиса: fail-open
Отдельный вопрос — что делать, когда проверяющий сервис не ответил корректно. Здесь поведение осознанно мягкое. Если внешний сервис не дал корректного ответа, результат проверки оказывается неопределённым (None), в лог уходит предупреждение о том, что эндпоинт обнаружения джейлбрейка настроен неправильно, а признак джейлбрейка устанавливается в False. Поскольку блокировка выдаётся только при истинном признаке, запрос в итоге разрешается. Это fail-openрежим, при котором при отказе проверяющего компонента запрос пропускается, а не блокируется.
То же самое происходит и в эвристическом действии: при неопределённом результате логируется то же предупреждение и возвращается разрешение — отсутствие результата трактуется как отсутствие джейлбрейка.
Модельная проверка: три режима и их приоритет
Проверка на основе модели выбирает текст так же, как эвристическая: сначала user_message, затем context["user_message"], иначе пустая строка. Дальше она выбирает, каким способом проверять, в зависимости от того, какие адреса заданы.
Приоритет такой. Сначала проверяется кэш: если для этого текста уже есть сохранённый результат проверки, он сразу превращается в разрешение или блокировку. Если кэша нет, но задан базовый адрес NIMинфраструктура NVIDIA для развёртывания и запуска моделей, в том числе через сетевой API, запрос уходит в NIM — это режим обращения к развёрнутой модели-классификатору по сетевому адресу. Если задан адрес сервиса обнаружения джейлбрейка, запрос уходит туда — это отдельный внешний сервис, который сам выполняет проверку и возвращает готовый вердикт. И только если не задано ни то, ни другое — проверка выполняется локально в процессе.
Локальный режим сопровождается предупреждением о том, что запуск в процессе не рекомендуется для продакшена. Если модель недоступна (ошибка времени выполнения) или не хватает зависимостей (ошибка импорта), в лог уходит соответствующая ошибка, а признак джейлбрейка устанавливается в False — и проверка снова завершается разрешением, а не блокировкой.
Что из этого следует на практике
- Проверка входа и выхода — не одно и то же. Они берут текст из разных источников и срабатывают на разных стадиях. Проверять только вход недостаточно: ответ модели тоже нужно контролировать.
- Отказ проверяющего сервиса означает пропуск запроса. И для эвристик, и для модельной проверки неопределённый результат трактуется как отсутствие джейлбрейка. Если такой режим неприемлем, недоступность проверки нужно обрабатывать на уровне приложения.
- Локальный режим — не продакшен-вариант. Он явно помечен предупреждением, а при отсутствии модели или зависимостей просто разрешает запрос.
- Кэш строится по параметрам валидатора, а не по metadata. Две проверки с одинаковыми параметрами используют один объект Guard, даже если metadata у них разная.
- Незнакомый формат ответа валидатора — это блокировка. Если результат не удалось разобрать ни как атрибут, ни как словарь, проверка считается непройденной.
- Ошибки конфигурации не глотаются. Отсутствие конфигурации
guardrails_ai, конфигурации валидатора или текста для проверки — это исключение, а не тихий пропуск.
Где смотреть в коде
- actions.py: jailbreak_detection_model
- actions.py: validate_guardrails_ai_input
- actions.py: _get_guard
- actions.py: import
- actions.py: _guardrails_ai_validation_passed
- actions.py: validate_guardrails_ai