Перед отправкой текста во внешнюю LLM можно заменить имена и контакты метками, а после ответа вернуть значения. Для этого я проверил Presidio: настроил русский язык, подключил две NER-модели и LiteLLM Proxy.
Ниже — устройство конвейера и результаты на 20 вымышленных текстах. Полные запуски есть в видео, код и зависимости — в репозитории.
Поиск и замена — два отдельных шага
AnalyzerEngine находит чувствительные фрагменты и возвращает их тип, границы start/end и оценку score. AnonymizerEngine получает эти находки вместе с исходным текстом и применяет замену, маскирование или удаление.
Поиск фрагментов и отдельная операция замены
Anonymizer меняет найденные интервалы. Если Analyzer пропустил часть адреса, она останется в тексте.
Такое разделение позволяет менять распознаватели и способ замены независимо друг от друга. score при этом служит оценкой распознавателя, а не гарантией правильности находки.
Что настроить для русского языка
В Analyzer есть реестр распознавателей и NLP-движок для языкового разбора. Распознаватели используют регулярные выражения, проверки формата или NER — модель поиска именованных сущностей. NLP-движок подготавливает токены, начальные формы слов и сущности.
Компоненты Analyzer
Реестр выбирает распознаватели для языка и типов сущностей. После поиска применяются контекстная обработка и фильтры.
Одного language="ru" недостаточно: русский язык нужно согласовать с NLP-моделью и распознавателями. В примерах использовались Presidio 2.2.366, spaCy 3.8.18 и ru_core_news_lg 3.8.0.
В коде ниже make_analyzer("baseline") — функция из репозитория, которая собирает русскую конфигурацию со штатными правилами и spaCy:
from support.detection import make_analyzerfrom presidio_anonymizer import AnonymizerEngineanalyzer = make_analyzer("baseline")text = "Иван Петров подготовит отчет. Его почта: ivan.petrov@example.com."found = analyzer.analyze( text=text, language="ru", entities=["PERSON", "EMAIL_ADDRESS"])result = AnonymizerEngine().anonymize(text=text, analyzer_results=found)print(result.text)
Результат:
подготовит отчет. Его почта: .
entities ограничивает типы, которые ищем в этом вызове. Границы находок относятся к исходной строке: text[start:end] возвращает найденный фрагмент, правая граница не включается.
Добавляем собственное правило
Для номера клиента CUST-4821 достаточно регулярного выражения. Зарегистрируем новый тип и добавим его в список поиска:
from presidio_analyzer import Pattern, PatternRecognizeranalyzer.registry.add_recognizer(PatternRecognizer( supported_entity="CUSTOMER_ID", supported_language="ru", patterns=[Pattern("Номер клиента", r"bCUST-[0-9]{4}b", 0.95)],))customer_text = text + " Номер клиента: CUST-4821."found = analyzer.analyze( text=customer_text, language="ru", entities=["PERSON", "EMAIL_ADDRESS", "CUSTOMER_ID"],)print(AnonymizerEngine().anonymize(customer_text, found).text)
До правила номер оставался открытым. После замены:
подготовит отчет. Его почта: . Номер клиента: .
Оценка 0.95 здесь задана нами, а не измерена на выборке. Способ обработки выбирается отдельно: replace подставляет метку, mask скрывает символы, redact удаляет фрагмент.
Меняем модель и сравниваем результат
Я подключил Fastino GLiNER2.5 Multi и Metrika RU PII через собственный LocalRecognizer. Адаптер вызывает модель, переводит ее метки в типы Presidio и возвращает координаты с оценками.
Подключение другой модели
В каждом прогоне работает одна модель. Общие правила и последующая замена сохраняются.
На одних и тех же 20 текстах сравнил три конфигурации: общие правила со spaCy, с Fastino и с Metrika. В двух последних SpacyRecognizer заменен новым распознавателем; русский NLP-движок остается. Общими были правила почты, телефона, IP и даты.
В наборе 25 размеченных упоминаний и четыре отрицательных текста. Полное скрытие означает, что закрыты все непробельные символы упоминания независимо от предсказанного типа. Лишние скрытые символы считаются отдельно.
Модели работали на CPU, по одному тексту, с четырьмя потоками и в FP32 — 32-битной точности. Порог 0,5 закреплен до запуска; одинаковое число не означает одинаковую калибровку моделей. Пересекающиеся интервалы объединялись по одной политике. Полные условия и ревизии весов.
Сравнение полноты скрытия и лишних замен
Квадрат — одно упоминание; позиции совпадают у всех конфигураций. Бирюзовое скрыто полностью, светлое с крестом — не целиком. На четырех отрицательных текстах у всех было 0 срабатываний.
На этом небольшом наборе Metrika закрыла все упоминания. Это не оценка качества на любых документах. Пять лишних символов у spaCy — слово “Логин”, ошибочно принятое за имя.
Разницу показывает адрес:
Исходный текст:Адрес доставки: г. Тверь, ул. Лесная, д. 14, кв. 23.Общие правила + ru spaCy:Адрес доставки: г. , ул. , д. 14, кв. 23.Общие правила + Fastino:Адрес доставки: , ул. Лесная, д. 14, кв. 23.Общие правила + Metrika:Адрес доставки: .
В первых двух вариантах есть , но часть адреса осталась открытой. Поэтому наличие находки и полное скрытие — разные результаты.
Возвращаем значения после ответа
Одинаковая метка для всех имен теряет различие между людьми. В демонстрации использован локальный словарь: пара “тип + точное значение” получает свою метку, повторы — ту же самую.
Обратная подстановка по словарю
Учебный пример: словарь создается при замене, а позиции меток заново определяются в ответе модели. Формат метки сокращен.
Собственный оператор AnonymizerEngine выдает метки. После ответа приложение находит их новые координаты и передает в DeanonymizeEngine с оператором восстановления. Возвращаются только известные метки разрешенных типов. Обычные mask и redact оригиналы для этого не сохраняют.
Словарь в памяти процесса выбран для демонстрации. Для продакшена нужно защищенное хранение с разграничением доступа, изоляцией сессий, шифрованием и сроком удаления. Также нужно определить работу при перезапуске и нескольких экземплярах сервиса. Разные написания имени, например “Иван” и “Ивану”, такой словарь автоматически не связывает.
Подключаем LiteLLM
Обработку запросов разных клиентов можно вынести в прокси. В примере LiteLLM 1.108.1 вызывает локальные /analyze и /anonymize, за которыми работают движки Presidio, затем отправляет запрос в DeepSeek.
Запрос и ответ через LiteLLM
До модели LiteLLM вызывает Presidio, после ответа восстанавливает значения. Это механизм интеграции LiteLLM, отдельный от собственного оператора выше.
Часть конфигурации:
guardrails: - guardrail_name: presidio-restore litellm_params: guardrail: presidio mode: pre_call default_on: false presidio_language: ru presidio_filter_scope: input output_parse_pii: true pii_entities_config: PERSON: MASK EMAIL_ADDRESS: MASK presidio_score_thresholds: ALL: 0.5
Обработчик включается явно. В теле запроса POST /v1/chat/completions к прокси нужно указать его имя:
text = ( "Клиент Марина Соколова, почта marina.sokolova@example.com. " "Подготовьте одну короткую фразу: кому нужно ответить. " "Сохраните полученные метки буквально.")payload = { "model": "demo-deepseek", "messages": [{"role": "user", "content": text}], "guardrails": ["presidio-restore"],}
В сохраненном запросе вместо имени и почты провайдер получил и . Он ответил:
Ответить нужно клиенту на почту .
Клиент после восстановления получил:
Ответить нужно клиенту Марина Соколова на почту marina.sokolova@example.com.
Форма имени сохранена как в ответе. output_parse_pii возвращает известные значения; поиск новых персональных данных в ответе задается отдельно через presidio_filter_scope. В одной из попыток режим both повторно скрыл восстановленные значения, поэтому итоговый пример использует input. Потоковые ответы и произвольные вызовы инструментов здесь не проверялись. Конфигурация и запуск, описание параметров.
Итог
Presidio связывает распознаватели и операции над найденными фрагментами. Свои форматы можно добавить правилами, другие сущности искать моделью, а обработку встроить в прокси. Качество скрытия зависит от того, какие границы нашел распознаватель: это видно даже на одном адресе.
Дообучение под домен, кеширование, хранение соответствий и влияние маскирования на качество агента хочу разобрать отдельно — для каждой темы нужен свой опыт.
Источник: habr.com