Запустил собственный PII-фильтр перед облачными моделями и отказался от идеи селф-хостед LLM

—

от автора

За последние 2 месяца я протестировал RTX PRO 6000 96GB / RTX4090 48GB и даже H200, оценивая варианты для селф-хоста, купил RTX5070ti и V100 32GB и прогнал большое количество бенчей (в том числе искренне своих). Потом увидел бешеный рост цен на железо и череду утечек персональных данных к провайдерам LLM (и понимая чем это может грозить, если данные потом утекут во взломе/сливе) — начал искать актуальные варианты для себя. Нашел классный open-source вариант, настроил, переключил в enforce и считал вопрос закрытым. Перейдя чуть вперед — я ошибался, но ошибка позволила сделать прод решение и уйти от идеи полного селф-хоста к офлайн-бэкапу 🙂

Сейчас система фильтрации стоит между топовыми облачными моделями и всем, что я им отправляю: и запрос и ответ — идёт через него. Два режима — ‘enforce’/‘detect’, в enforce он заменяет персональные данные и секреты плейсхолдерами, в detect только пишет в журнал, что нашёл для замены (и что несет риск раскрытия).

База — RTFM и настройки

22 сентября моя первая система фильтрации упала. Системный пользователь процесса намеренно без домашней директории, а go build всё равно нужен доступный на запись кэш модулей. Сборка не прошла, бинарник пропал, а правило пересборки в Ansible смотрело на изменения в репозитории, не на сам файл: изменений нет, пересобирать нечего. Ссылка указывала в пустоту, systemd пытался запустить её снова и снова — около четырёх часов, больше 2 600 раз (и ни одна проверка не спросила, в каком режиме он поднимается). Обе мои облачные настройки провайдеров шли через этот фильтр, поэтому встал весь стандартный маршрут к моделям (и доступы заблочились, это прям очень классная проверка себя была).

Фикс оказался простой и скучный: кэши и HOME переехали в каталог, которым владеет пользователь фильтра, а пересборка теперь запускается и тогда, когда файла нет. Когда сервис остановился, я прочитал настройки, и там стол режим detect в двух местах, что собственно и было проблемой. Ошибка исключительно моя: считал, что выставленный один раз глобально режим — применяется и на reload/reboot. Теперь enforce стоит и в стартовом значении, и в применённых настройках с доп проверкой ансиблом при падении. После правки читаю объект целиком и перепроверяю абсолютно все — статус, набор типов данных, режим, — потому что PUT заменяет объект полностью (работая с API почти 10 лет конечно стоило проверить это сразу, ведь база же).

Почему не LiteLLM

Перый фильтр был запущен 17 сентября. До этого работу делал хук внутри клиента и за пять сессий вокруг него накопились поломки: шторм потоков у ONNX, испорченные строки от глобальной работы с фильтрами через LLM (отдавать все ИИ пока не надо:), конфликт с кэшем промптов. Последнюю проблему в рамках API клиента обойти не удалось: хук менял исходящий контекст, но не мог вернуть исходные значения в ответ, который читает человек (то есть я видел [EMAIL_01] в ответах моделей, что сбивало с толку, ведь я не помнил что [EMAIL_01], а что [EMAIL_02]). Снес бех сожаления (хотя кого я обманываю, сколько часов ушло на настройку и казалось бы файн-тюнинг) и поставил self-hosted guardrails-llm-filter от Cloud.ru (Apache-2.0). Он работает как прокси на пути запроса и ответа, и умеет в обработку в обе стороны, ребятам однозначно респект, получилось классно, допиливать под себя в любом случае нужно, но даже база — очень крутая!

LiteLLM с гардрейлом, честно, был первым кандидатом: он у меня уже как-то стоял, а поддержка Presidio говорят там проработана вплоть до обратной подстановки. Отказался из-за тестов что сделал. Промежуточный прокси, который пересобирает запрос, ломает зачастую общий префикс, и экономия кэша промптов у провайдера падает примерно на 90% (можно тюнить, но для меня оценка трудозатрат показала, что не стоит в это играться). А у меня почти 1.5B кэшированных токенов за 3 недели, ценики просто улетели в космос!

Мониторов теперь у меня два

После этого в моем OneUptime появились два монитора:

  • Heartbeat: раз в пять минут приходит push от проверки режима. Недоступный процесс, crash-loop, режим, ушедший из enforce — всё это выглядит как отсутствие сигнала, и отсутствие — уже инцидент.

  • Метрики: сумма за пять минут по счётчикам fail-open — ошибки маскирования, неподдерживающийся формат тела запроса и неизвестный формат, пропущенный дальше (такое бывает). Процесс жив, режим верный, а конкретный запрос ушёл без маскирования — это мы ловим.

В метриках только счётчики, типы данных и задержки, текстов запросов там нет (снижаем вектор атаки по привычке). Отсутствие данных второй монитор намеренно игнорирует: это уже проверяет первый.

Fail-open: за что гордость берет

Как только процесс остановлен, срабатывает механика: клиент смотрит на прокси, запасного маршрута нет, вызов к модели просто падает — как 22 сентября. Но если процесс работает и не смог разобрать или замаскировать конкретное тело, он отправляет запрос как есть и поднимает счётчики на +1, чтобы я мог проанализировать и оттюнить систему. Иначе одна новая форма запроса превращала бы ежедневную работу в череду случайных блокировок. Цена этого выбора — возможная неотфильтрованная отправка. Теперь она становится Major-инцидентом через монитор, а не просто строчкой в логах, и без этого монитора я бы такой выбор не оправдывал и стопил всю систему.

Что показал сегодняшний рестарт

Сегодня я выключал сервер для замены GPU с полной перезагрузкой и перезапуском всех контейнеров и сервисов. Основной экземпляр фильтра поднялся в enforce: включён, шесть типов данных, настройки режима прочитаны целиком. Доволен ли я? Да, однозначно, пользуюсь облачными моделями без опаски утечки данных (риски есть, но снижаю каждый день), а локальные Gemma4 и Qwen3.8 находятся в stand-by режиме на ноутбуке и LXC с RTX5070ti, что позволяет использовать их в других задачах при необходимости. Ну и не будем преувеличивать, локальные модели пока до топовых облачных — все же не доятгивают.

ссылка на оригинал статьи https://habr.com/ru/articles/1091236/