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

Сейчас система фильтрации стоит между топовыми облачными моделями и всем, что я им отправляю: и запрос и ответ - идёт через него. Два режима - ‘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, что позволяет использовать их в других задачах при необходимости. Ну и не будем преувеличивать, локальные модели пока до топовых облачных - все же не доятгивают.

Комментарии (4)


  1. tihiy1981
    06.10.2026 17:37

    Отличная новость!!!


  1. ToxaBes
    06.10.2026 17:37

    Ничего не понял, зачем для этого было использовать локальные Gemma4 и Qwen3.8 если есть специализированные Qwen3Guard-Gen и Qwen3Guard-Stream (первая проверяет и маскирует вход, вторая выход)? Размеры моделей 0.6B, 4B, 8B и никакой RTX 6000 PRO 96 GB для них не нужно.

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

    Ну если не выбирать специализированные решения под задачи или не адаптировать под них то конечно, фронтир модели общего назначения это все что остается.


    1. daniel_ivanov Автор
      06.10.2026 17:37

      Спасибо за комментарий. Внутри самого фильтра нет ни Gemma, ни Qwen, вообще никакой ML-модели: по описанию проекта это словари, регулярки и проверочные суммы (ИНН и т.п.). Gemma и Qwen у меня отдельно, как локальная модель для данных, которым в облако нельзя совсем. А RTX 6000 PRO - это то, что я смотрел до этого для полного селф-хоста, под фильтр она не нужна.

      Про Qwen3Guard: насколько я вижу, это классификатор безопасности. Он выдаёт вердикт (Safe/Unsafe/Controversial) и категорию, среди которых есть PII, но текст не подменяет, а Stream-версия размечает токены ответа. Мне нужно было именно заменить значения плейсхолдерами в запросе в сторону провайдеры и вернуть стримом настоящие в ответ, который читаю я. Как второй слой, чтобы поймать то, что регулярки пропустили, он вполне может подойти, на своих данных не пробовал.


      1. ToxaBes
        06.10.2026 17:37

        Про Qwen3Guard: насколько я вижу, это классификатор безопасности

        Да, он быстро ловит то, что нужно маскировать (PII категория). Если ПДн не найдены то не нужно тратить время на маскирование.

        Сама маскировка производится, например, Qwen 3 7B Instruct. Простой пример логики, о которой я пишу:

        import json
        from vllm import LLM, SamplingParams
        
        llm = LLM(model="Qwen/Qwen3-7B-Instruct", tensor_parallel_size=1)
        
        json_schema = {
            "type": "object",
            "properties": {
                "entities": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "text": {"type": "string"},
                            "category": {
                                "type": "string",
                                "enum": [
                                    "FIO",
                                    "PASSPORT",
                                    "PHONE",
                                    "EMAIL",
                                    "ADDRESS",
                                    "SNILS",
                                    "INN",
                                ],
                            },
                        },
                        "required": ["text", "category"],
                    },
                }
            },
            "required": ["entities"],
        }
        
        system_prompt = """Ты — специализированная модель классификации персональных данных (152-ФЗ РФ).
        Найди в тексте все упоминания ПДн и верни их в формате JSON.
        Категории: FIO (ФИО), PASSPORT (серия/номер паспорта), PHONE (телефон), EMAIL, ADDRESS (адрес), SNILS, INN."""
        
        user_text = "Клиент Сидоров Алексей Владимирович (паспорт 4510 987654) подал заявление по адресу г. Москва, ул. Арбат, д. 12."
        
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_text},
        ]
        
        prompt = llm.get_tokenizer().apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        
        sampling_params = SamplingParams(
            temperature=0.0,
            max_tokens=512,
            guided_json=json_schema,
        )
        
        outputs = llm.generate([prompt], sampling_params)
        result_json = json.loads(outputs[0].outputs[0].text)
        
        def apply_masking(original_text: str, entities_data: dict) -> str:
          masked = original_text
          for item in entities_data.get("entities", []):
            val = item["text"]
            cat = item["category"]
            masked = masked.replace(val, f"[{cat}]")
          return masked
        
        print(apply_masking(user_text, result_json))
        # Вывод: Клиент [FIO] (паспорт [PASSPORT]) подал заявление по адресу [ADDRESS].

        По хорошему, инструкт сеть еще файнтюнят для максимальной точности, но должно работать и так. Суть в использовании ансамбля специализированных моделей небольшого размера 4B-8B, т. к. чисто детерминированный подход (regex и тд) много пропускает при реальной работе.