Как я анонимизирую ПДн перед 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.