Daily MaverickWHAT’S COOKING: Roast butternut soup with roasted garlic, spices and orange zestThe Jerusalem PostIsrael 'in the dark' over flydubai terror hijacker investigation as Mossad probes link to IranPunchAt 70, Alake has made Ekiti proud — OyebanjiBollywood HungamaEggoz onboards Boman Irani as brand ambassadorInquirerGroup hits ‘veiled threats’ to journalists in Sara Duterte’s trialZDF heuteEntdecken Sie das ZDF-NachrichtenstudioUOLTécnico de jiu-jítsu Bruno Formiga é acusado de assédio sexual e estupro por alunas no RJColliderThis Horror Legend's Biggest Bomb Got Better With Its Unrated VersionObservador DesportoHomem aterra avioneta em Serpa e sai de táxiFootball ItaliaLazio linked with move for ex Man Utd and Crystal Palace winger ZahaABC NewsSeeing threats to religious liberty, Alito calls same-sex marriage a 'decisive' turnCNN بالعربيةبرفقة والديها وسروال جينز.. هل تتعمد عارضة أزياء هندية لفت الأنظار في باريس؟
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

Маскирование ПДн для LLM: метрика, которую не считает никто, и шесть шлюзов против живого агента

Translate

Это первая статья серии: измеряем, что происходит с LLM-агентом, когда шлюз замаскировал данные. Дальше разберём находки по чужим системам и то, что нашлось у нас.

1. Зачем это мерить

Агент поддержки получает: «Здравствуйте, я Кузнецова Дарья, телефон 8 935 747 66 22, проверьте заказ». До модели доходит «я <PERSON_1>, телефон <PHONE_NUMBER_2>», и она вызывает check_order(phone=…).

Тут половина решений на рынке и ломается: инструменту нужен настоящий телефон, по <PHONE_NUMBER_2> заказ не найдётся. Шлюз обязан вернуть реальное значение в аргумент вызова ровно в той форме, в какой написала модель.

Мы собрали открытый бенчмарк и прогнали шесть систем: Cloud.ru guardrails-llm-filter, LiteLLM с guardrail Presidio, LLM Guard, Microsoft Presidio, наивный regex-маскер и наш продукт.

2. Почему маскировки мало

С маскированием текста всё решено: русский NER даёт 90%+ по именам, телефоны и СНИЛС ловятся контрольными суммами. Но агент, в отличие от чата, действует.

Что происходит за один ход:

клиент → [шлюз: подстановка] → модель → tool_call(phone=подстановка)
                                              ↓
инструмент ← [шлюз: восстановление] ← аргумент с подстановкой

Круг может порваться в четырёх местах:

  1. Значение не замаскировано. Модель видит реальные данные: классическая утечка, её мерят все.

  2. Замаскировано частично. В «Кзнецова Дарья» распознана только «Дарья», а recall по спанам засчитал это попаданием.

  3. Восстановление не сработало. Инструмент получил <PERSON_1> и ищет такого клиента в CRM.

  4. Замаскировано лишнее. check_order(order="ORD-<NUMBER_3>") не находит ничего: номер заказа, сумма и дата документа стали подстановками.

Пункты 2-4 не измеряет ни один известный нам бенчмарк: NER-корпуса (NEREL, RuNNE) размечают спаны, корпуса маскирования ПДн (hivetrace/pii-bench, ai4privacy) мерят только детекцию, бенчмарки вызова инструментов (BFCL, τ-bench, GorillaHard в MERA) ПДн не содержат.

Куда уходит значение и где возвращается

Куда уходит значение и где возвращается

Слева клиент, справа модель, внизу инструмент. Подстановка появляется на входе в модель и раскрывается в аргументе вызова. Шлюз, который умеет только первую половину, ломает агента на второй.

3. Что мы построили

AgentMask-RU: 272 диалога, 680 персональных значений, 624 негатива (маскировать нельзя), харнесс под любой маскировщик, семь гейтов и таблица. Код Apache-2.0, корпус CC BY 4.0, результаты CC0.

Шесть семей задач агента: оформить заказ, проверить статус, подтвердить личность, вернуть деньги, отправить СМС, второе упоминание того же человека. Регистров три:

регистр

пример

clean

«Кузнецова Дарья Игоревна, +7 935 747-66-22, ул. Блочная, д. 14»

chat

«кузнецова дарья, 89357476622, блочная 14»

sloppy

«кзнецова дарья, 8 935 7476622, блоная 14», одна описка

clean даёт красивую цифру любому маскировщику, а живые люди пишут строчными, без разделителей и с опечатками.

Имена: четыре яруса (частотные, редкие, уменьшительные, иностранные) из Wikidata, NEN, mimesis, Faker. Телефоны из резервных кодов DEF плана нумерации (Приказ Минцифры № 75): libphonenumber считает их валидными, абонента за ними нет.

4. Как измерить прокси

Библиотеку (Presidio, LLM Guard) измерить легко: вызвал anonymize, сравнил. Прокси (Cloud.ru, LiteLLM, наш) сложнее: «что увидела модель» видно только в HTTP-запросе к OpenAI.

Харнесс выкручивается так: он сам становится провайдером, поднимает OpenAI-совместимый сервер на localhost, прокси направляют на него, и весь запрос к «модели» виден целиком; вместо модели скрипт, копирующий увиденные значения в вызов инструмента.

харнесс ──запрос──▶ прокси ──замаскировано──▶ харнесс-как-провайдер
                                                      │ tool_call(копия)
харнесс ◀─восстановлено─ прокси ◀──────────────────────┘

Модели нет, значит нет недетерминизма, затрат и вечного вопроса «это модель ошиблась или шлюз». Политик две: «копировальщик» и вторая, переформатирующая телефон (+7 … → 8 …) как реальные модели.

Прогон харнесса: восемь строк отчёта и их абсолютные значения

Прогон харнесса: восемь строк отчёта и их абсолютные значения

Так выглядит прогон. Команду можно повторить у себя: харнесс поднимает фальшивого провайдера, движок получает запросы по HTTP и не знает, что его измеряют.

5. Восемь строк вместо балла

Каждый прогон печатает восемь строк. Общего балла нет намеренно:

строка

что значит

leak

значение изменено до того, как его увидела модель

coverage

ни один кусок значения не дожил до модели (слово ≥3 букв, цифры ≥3)

round_trip

инструмент получил настоящее значение, из тех, что были скрыты

end_to_end

то же по всем значениям: «агент не сломан»

overmask

номер заказа, трек, сумма, дата документа, голый город не тронуты

consistency

второе упоминание человека получило ту же подстановку

stability

подстановка не менялась между запросами

typed

тип определён верно (если маскер отдаёт спаны)

Шлюз, который маскирует всё подряд, выигрывает leak и проигрывает overmask с round_trip.

Строка без единой ошибки печатает не «100%», а нижнюю границу подтверждаемого: 52/52 сертифицирует ≥94.4% по интервалу Клоппера-Пирсона. 100% на 52 случаях и 100% на 5 200 не одно и то же.

6. Результаты

noop не делает ничего (проверка корпуса), placeholder_regex наивный regex, написанный за час как нижняя планка.

система

скрыто

скрыто целиком

круг (из скрытых)

круг (из всех)

не тронуто лишнего

одна подстановка

noop

0.0%

0.0%

нет

100%

100%

нет

placeholder_regex

53.1%

51.5%

98.0%

98.9%

79.8%

26.7%

Cloud.ru guardrails

43.7%

42.8%

100%

100%

100%

22.2%

LiteLLM + Presidio

63.8%

46.0%

0.0%

36.8%

70.0%

0.0%

LLM Guard¹

42.6%

34.9%

99.5%

99.8%

96.8%

8.9%

Presidio

58.1%

50.4%

90.8%

94.7%

72.0%

46.8%

Pseudex

94.7%

86.2%

96.9%

97.0%

98.4%

74.5%

¹ LLM Guard не поддерживает русский (Anonymize принимает только en и zh): прогон на английском конвейере, и это его честная цифра на русском.

Числа Pseudex сняты на выпущенном образе pseudex:0.3.21 (sha256:162ca2598b03…): адаптер openai_proxy, лицензия с сайта, корпус целиком, политика copier.

Круг: инструмент получил настоящее значение

Круг: инструмент получил настоящее значение

Одна и та же метрика для всех. Ноль у LiteLLM с Presidio это не сбой настройки: их маскирование заменяет значение подстановкой <PERSON_1>, а вернуть настоящее в аргумент вызова нечем.

По типам:

система

ФИО

телефон

адрес

дата рожд.

СНИЛС

карта

паспорт

Cloud.ru

12%

79%

25%

0%

82%

78%

82%

LiteLLM + Presidio

30%

94%

50%

100%

54%

66%

95%

LLM Guard

61%

47%

28%

0%

0%

25%

0%

Presidio

74%

50%

60%

68%

11%

3%

73%

placeholder_regex

24%

80%

47%

68%

36%

75%

82%

Pseudex

88%

100%

98%

100%

96%

97%

91%

Cloud.ru как зеркало. Замаскировал и восстановил 247 из 247, лишнего не тронул: 624 из 624. Но маскирует он 43.7%: имена 12%, адреса 25%, даты рождения 0%. Регулярки находят телефоны и документы, а имя «Дарья» в чате нет. Трафик из номеров? Отличный выбор. Трафик из людей? Половина дойдёт до модели.

Наивный regex за час даёт 53% и обгоняет Cloud.ru по leak, а LLM Guard по всему, кроме overmask. Это мера того, сколько в задаче делает форма значения (телефон, СНИЛС, карта с контрольной суммой), а сколько язык (имя, адрес).

Сколько скрыто и сколько вернулось

Сколько скрыто и сколько вернулось

Две полосы рядом стоят не для красоты. Высокая левая без правой это шлюз, который спрятал данные и сломал агента: инструменту нечего искать.

Pseudex маскирует больше всех и восстанавливает почти всё. 94.7% против 63.8% у ближайшего, 96.9% по кругу против 100% у Cloud.ru. Знаменатели разные: Cloud.ru прячет 43.7%, мы 94.7%, поэтому в наш круг попадает вдвое больше значений (488 против 247, в абсолюте 388 против 247).

7. Три находки в чужих системах

LiteLLM + Presidio: 0 из 327

Guardrail presidio с output_parse_pii: true обещает восстановить подстановки в ответе: в message.content восстанавливает, в tool_calls[].function.arguments не восстановил ни разу за 327 попыток.

Мы полезли в исходники main-stable 1.102.0: восстановление идёт через UnifiedLLMGuardrails → apply_guardrail(inputs, input_type="response"), в inputs лежат и тексты, и вызовы инструментов, а PresidioPIIMasking.apply_guardrail читает только inputs["texts"] и отдаёт tool_calls нетронутыми. Хук async_post_call_success_hook, где аргументы обрабатываются правильно, не вызывается.

И это известно: issue #31950 открыт с июля, PR #32014 с починкой не влит. Мы оставили там подтверждение с логами.

Второе хуже, и про него не заявлял никто. Из 327 замаскированных значений 257 дошли до модели вот в таком виде:

8 935 <DATE_TIME_6>VER_LICENSE_6>
<US_BANK_NUMBER_7>LICENSE_7>
<PHONE_NUMBER_6>MBER_8>LICENSE_8>

Presidio отдаёт на одну строку цифр несколько типов сразу (PHONE_NUMBER, US_BANK_NUMBER, US_DRIVER_LICENSE, DATE_TIME) с пересекающимися границами. LiteLLM заменяет спаны «в обратном порядке, чтобы не сдвигать координаты»: для пересекающихся вторая замена режет уже вставленный плейсхолдер. Минимальное воспроизведение: одна английская фраза, у которой после телефона пропадает запятая с пробелом (issue #42130).

Вывод: с таким guardrail агент с инструментами не работает.

LLM Guard: русского нет

Сканер Anonymize принимает language только en и zh: на русском 61% имён (часть кириллических английская NER узнаёт по форме), 47% телефонов, остальное почти ноль. Это не дефект, а заявленная область применения, но цифру 42.6% полезно знать.

Presidio: 28% лишнего

Microsoft Presidio с русской моделью spaCy единственный из чужих ищет имена (74%). Плата: overmask 72.0%, из 624 негативов 175 стали подстановками. Для агента это check_order(order="<NUMBER_3>"): заказ не найдётся, хотя ПДн в нём не было.

8. Почему у нас возврат доезжает

Круг 96.9% считается от скрытых значений: доехало ли настоящее до инструмента. Остальные строки: end_to_end 97.0%, overmask 98.4%, stability 100%. По типам: телефон 100%, дата рождения 100%, адрес 98%, карта 97%, СНИЛС 96%, паспорт 91%, ФИО 88%.

Разница в том, ЧТО возвращается:

  • подстановка сохраняет ФОРМУ: падеж, регистр, транслит, разделители. Модель написала «филатову мию», инструмент получит «Мию Филатову», а не «Мия Филатова». Такие случаи round_trip_repaired считает доставленными: 98.2%, и разница между 96.9% и 98.2% это цена нормализации, наша;

  • подстановка проходит проверку контрольной суммы: фейк из диапазона, которого у человека быть не может, но валидацию СНИЛС или карты он проходит;

  • подстановка устойчива между запросами, stability 100%: значение не превращается в нового человека на следующем шаге.

И главное: ключ к подстановкам не покидает контур клиента: настоящее значение возвращается в аргумент вызова там, где стоит шлюз.

Мы закрываем восемь типов: ФИО, телефон, адрес, дата рождения, СНИЛС, ИНН, паспорт, карта, и у каждого своя проверка.

LiteLLM с guardrail Presidio: их конфиг и их результат

LiteLLM с guardrail Presidio: их конфиг и их результат

Их конфиг и их результат на нашем корпусе. Маскирование подставляет <PERSON>, инструменту возвращать нечем: адрес в вызове модель придумала сама.

9. Чтобы прибор мог провалиться

Наш внутренний бенчмарк предыдущего поколения не мог провалиться в четырёх местах: not any([]) на пустом списке полей, нечитаемая обязательность полей, отсутствие уровня «вызвал ли агент инструмент» и непроверенные негативы. Все четыре работали в пользу шлюза.

Поэтому в открытом бенчмарке гейты стоят в CI. Код и корпус лежат тут: github.com/pseudex-ai/agentmask-ru.

  • G6: маскер, который ничего не делает, обязан пройти каждый случай: не пережившее passthrough значение значит сломанный случай, а не маскер.

  • G7: наивный regex обязан оставить след на каждом случае, кроме объяснённых в hard.why: иначе корпус не различает ничего.

  • G8: ни один регистр меньше 20%, ни один ярус имён не пуст, hard не больше половины.

  • Признак hard выводится из шаблона случая, а не из прогона детектора: отбор на сигналах измеряемой системы делает его непроваливаемым.

  • Пулы значений не пересекаются с нашими словарями по построению, но манифест печатает, сколько имён корпуса газетир знает: 29 из 206 личных, 111 из 168 фамилий.

  • Корпус генерируется из зерна, --check доказывает в CI, что committed-версия воспроизводится.

Ни McNemar, ни доверительный интервал не доказывают «эффекта нет»: они отвечают на вопрос о разнице, а не найти её на 272 случаях так же легко, как найти. Поэтому парные сравнения в таблице есть, а «эквивалентно» нет; TOST будет в следующей версии.

10. Раскрытие

Бенчмарк написала команда Pseudex, того самого шлюза, который в нём измеряется: мы делаем обратимую подстановку ПДн для LLM-агентов.

  • правило отбора случаев зафиксировано до прогонов и вычислимо без маскировщика;

  • прогон помечен vendor-submitted и не отзывается;

  • наша строка воспроизводится тем же путём, что и чужие: выпущенный образ, пробный ключ, адаптер openai_proxy;

  • чужие системы измерены по их документации, рецепты в baselines/.

11. Чего бенчмарк не измеряет и как участвовать

Версия 0.1 не измеряет стриминг, ПДн обратно из инструмента (результат поиска в CRM тоже маскировать), память агента между диалогами, восстановление просклонённой или транслитерированной подстановки, эквивалентность по TOST: всё это в следующих версиях.

Единица вклада это один JSON-файл со случаем, CI прогонит гейты. Ревью оплачивается очками наравне с вкладом, правило взято у MMTEB: иначе очередь PR встаёт на мейнтейнере. Прогон своей системы это один адаптер и один запуск, результат ложится в таблицу с манифестом.

Репозиторий: github.com/pseudex-ai/agentmask-ru.

Пишите в комментариях, если хотите прогнать свою систему через корпус.

Источники

Наш продукт и бенчмарк

Системы, которые мы прогнали

Чем собирали корпус

Корпуса и стенды, о которых говорим в тексте

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.