Как я анонимизирую ПДн перед LLM, чтобы не словить проблемы по 152-ФЗ

Как я анонимизирую ПДн перед LLM, чтобы не словить проблемы по 152-ФЗ

Когда мой AI-агент начал жрать корпоративную переписку, заявки и звонки, я быстро понял: в каждый промпт неизбежно попадают персональные данные. ФИО, телефоны, адреса, иногда даже паспорта и ИНН. По 152-ФЗ отдавать это стороннему LLM-провайдеру можно только с основанием, и даже с российским провайдером лучше минимизировать объём. Я решил задачу через отдельный слой между сервисом и моделью. Расскажу, как оно устроено, какие грабли поймал и что в итоге вышло.

Какие данные обычно торчат в B2B-промпте

В типичной заявке или транскрипте модель видит:

  • ФИО клиента и менеджера
  • телефон, email
  • номер заявки и ID
  • адрес доставки
  • иногда паспорт, ИНН или номер карты

Часть этого вообще не нужна для ответа. Имя в обращении — ок, а ИНН посреди описания проблемы — уже лишнее. Идея простая: всё, что не влияет на качество, заменяю псевдонимами, а после ответа подставляю обратно.

Три уровня детекции

Одними регулярками не обойдёшься. «Сидоров» паттерном не поймаешь, а телефон — запросто. Я использую три уровня подряд.

Regex

Первым делом закрываю простое: телефоны, почты, ИНН, СНИЛС, карты и паспорта. Тут почти нет неоднозначностей.

PATTERNS = {
    "PHONE": re.compile(r"(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}"),
    "EMAIL": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
    ...
}

Ловушка: regex для карт часто цепляет артикулы. После него я прогоняю через Луна-чексумму и отсеиваю ложняк.

NER на русской модели

Для имён, компаний и адресов использую локальный RuBERT из реестра российского ПО. На одном ядре тянет 30–40 документов в секунду. Даёт PER, ORG, LOC и закрывает примерно 90 % сущностей. Редкие фамилии и типа «Михалыч» иногда проскакивают.

Словарь из CRM

Выгружаю актуальный список клиентов и адресов, матчу с учётом склонений через pymorphy3. Это ловит то, что упустил NER, и даёт стабильные псевдонимы для одного и того же человека в разных документах.

Таблица псевдонимов

Каждой найденной сущности присваиваю читаемый псевдоним вроде PERSON_17 или PHONE_2. Модели с ними проще работать, чем с UUID.

CREATE TABLE pii_aliases (
  alias_id     bigserial PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  entity_type  text NOT NULL,
  original     text NOT NULL,
  alias        text NOT NULL,
  ...
);

Хранить только хеши нельзя — нужен обратный маппинг. Поэтому таблица зашифрована на уровне столбца, ключ в KMS.

Обратная подстановка

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

def can_reveal(alias, channel, recipient):
    if channel.is_internal:
        return True
    ...

Что ломает качество ответа

За время тестов поймал три типичные ошибки:

  • Слишком много псевдонимов в одном промпте — модель путается, кто есть кто.
  • Маскировка важного контекста (например, адрес офиса компании).
  • Неправильный порядок: NER перед regex даёт мусор.

Решения простые: агрегирую дубликаты, держу белый список своих данных и всегда запускаю regex первым.

Что получилось на проде

На 12 тысячах документов в сутки слой даёт:

  • Задержку 38 мс медиана, 110 мс на p95 (на документах до 4 КБ)
  • Precision 0.97, recall 0.93 на размеченной выборке
  • На 22 % меньше токенов в промпте

Юридически это позволяет сократить объём передаваемых ПДн и спокойно показывать службу безопасности, как именно мы минимизируем данные.

Что дальше

Хочу сделать динамическую политику: для классификации заявок вырезать почти всё, а для ответа клиенту оставлять имя. Плюс не забывать про анонимайзер даже на российских моделях — требование 152-ФЗ лежит на операторе, а не на провайдере LLM.