Назад к блогу

Информационная безопасность в эпоху массового внедрения LLM: главные риски и как приложения защищают

Информационная безопасность в эпоху массового внедрения LLM: главные риски и как приложения защищают

Статья разбирает, как приложения защищают себя, когда большая языковая модель становится активным участником диалога — принимает запросы, генерирует ответы и обращается к инструментам. На примере NeMo Guardrails показано, как устроены проверки на входе и выходе, как результат внешнего валидатора превращается в решение о блокировке, зачем кэшировать дорогие объекты Guard и как система ведёт себя, когда проверяющий сервис недоступен.

Когда LLM становится полноценным участником приложения — принимает пользовательский ввод, генерирует ответы, вызывает инструменты, — защита перестаёт быть одной проверкой на входе. Нужно решать, что именно проверять, где это делать (в процессе или на отдельном сервисе), как кэшировать дорогие проверки и как вести себя, когда проверяющий сервис недоступен. Ниже — механика того, как устроены такие проверки в NeMo Guardrails: от выбора источника текста до поведения при отказе внешнего сервиса.

Вход и выход — две разные точки проверки

Приложение проверяет два разных потока текста: то, что принёс пользователь, и то, что сгенерировала модель. Это не дублирование, а разные стадии обработки. входные рельсы вызываются, когда приходит новый ввод пользователя. выходные рельсы — после генерации сообщения бота.

Функция проверки входа выбирает текст так: сначала берётся явно переданный аргумент text; если он пуст, значение из context["user_message"]; если и его нет — пустая строка. Функция проверки выхода устроена так же, но источник по умолчанию — context["bot_message"]. Дальше обе идут одним путём: получают конфигурацию guardrails_ai, достают по имени конфигурацию конкретного валидатора, объединяют её параметры и metadata в один набор и вызывают проверку. Результат превращается в булево значение, и функция возвращает либо разрешение, либо блокировку с metadata, где есть поле valid.

Если после выбора источника текст пуст, обе функции не пропускают запрос молча, а выбрасывают ошибку с требованием передать text либо context. Отсутствие конфигурации guardrails_ai или конфигурации названного валидатора — тоже ошибка, а не тихий пропуск.

Как результат проверки превращается в решение

Внешний валидатор возвращает не одно булево значение, а структуру, и её нужно разобрать. Из результата извлекается вложенный объект validation_result (по умолчанию — пустой словарь). Дальше поддерживаются два формата:

  • если у этого объекта есть атрибут validation_passed, берётся он и приводится к булеву типу;
  • если это словарь, берётся значение по ключу validation_passed, а при отсутствии ключа — False, тоже с приведением к булеву типу.

Два формата нужны потому, что результат проверки приходит в одном из двух видов: либо как объект с атрибутом validation_passed, либо как словарь с ключом validation_passed. Какой именно вид придёт, зависит от того, что вернул внешний валидатор.

Если не подходит ни один формат, проверка считается непройденной: незнакомый формат ответа трактуется как непройденная проверка, а не как пройденная.

Кэш объектов Guard: почему ключ строится именно так

Создание Guard — дорогая операция, поэтому готовые объекты кэшируются. Ключ кэша — это кортеж из имени валидатора и отсортированного набора пар «параметр — значение». Чтобы значения вообще могли попасть в ключ, они приводятся к хэшируемому виду: списки превращаются в кортежи, словари — в отсортированные кортежи пар «ключ — значение», остальные объекты остаются как есть.

В ключ попадают только параметры валидатора. 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, запрос уходит в NIM — это режим обращения к развёрнутой модели-классификатору по сетевому адресу. Если задан адрес сервиса обнаружения джейлбрейка, запрос уходит туда — это отдельный внешний сервис, который сам выполняет проверку и возвращает готовый вердикт. И только если не задано ни то, ни другое — проверка выполняется локально в процессе.

Локальный режим сопровождается предупреждением о том, что запуск в процессе не рекомендуется для продакшена. Если модель недоступна (ошибка времени выполнения) или не хватает зависимостей (ошибка импорта), в лог уходит соответствующая ошибка, а признак джейлбрейка устанавливается в False — и проверка снова завершается разрешением, а не блокировкой.

Что из этого следует на практике

  • Проверка входа и выхода — не одно и то же. Они берут текст из разных источников и срабатывают на разных стадиях. Проверять только вход недостаточно: ответ модели тоже нужно контролировать.
  • Отказ проверяющего сервиса означает пропуск запроса. И для эвристик, и для модельной проверки неопределённый результат трактуется как отсутствие джейлбрейка. Если такой режим неприемлем, недоступность проверки нужно обрабатывать на уровне приложения.
  • Локальный режим — не продакшен-вариант. Он явно помечен предупреждением, а при отсутствии модели или зависимостей просто разрешает запрос.
  • Кэш строится по параметрам валидатора, а не по metadata. Две проверки с одинаковыми параметрами используют один объект Guard, даже если metadata у них разная.
  • Незнакомый формат ответа валидатора — это блокировка. Если результат не удалось разобрать ни как атрибут, ни как словарь, проверка считается непройденной.
  • Ошибки конфигурации не глотаются. Отсутствие конфигурации guardrails_ai, конфигурации валидатора или текста для проверки — это исключение, а не тихий пропуск.

Где смотреть в коде

Источники

Похожее