Это первая статья серии: измеряем, что происходит с 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=подстановка) ↓ инструмент ← [шлюз: восстановление] ← аргумент с подстановкой
Круг может порваться в четырёх местах:
Значение не замаскировано. Модель видит реальные данные: классическая утечка, её мерят все.
Замаскировано частично. В «Кзнецова Дарья» распознана только «Дарья», а recall по спанам засчитал это попаданием.
Восстановление не сработало. Инструмент получил
<PERSON_1>и ищет такого клиента в CRM.Замаскировано лишнее.
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.
Шесть семей задач агента: оформить заказ, проверить статус, подтвердить личность, вернуть деньги, отправить СМС, второе упоминание того же человека. Регистров три:
регистр | пример |
|---|---|
| «Кузнецова Дарья Игоревна, +7 935 747-66-22, ул. Блочная, д. 14» |
| «кузнецова дарья, 89357476622, блочная 14» |
| «кзнецова дарья, 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. Восемь строк вместо балла
Каждый прогон печатает восемь строк. Общего балла нет намеренно:
строка | что значит |
|---|---|
| значение изменено до того, как его увидела модель |
| ни один кусок значения не дожил до модели (слово ≥3 букв, цифры ≥3) |
| инструмент получил настоящее значение, из тех, что были скрыты |
| то же по всем значениям: «агент не сломан» |
| номер заказа, трек, сумма, дата документа, голый город не тронуты |
| второе упоминание человека получило ту же подстановку |
| подстановка не менялась между запросами |
| тип определён верно (если маскер отдаёт спаны) |
Шлюз, который маскирует всё подряд, выигрывает 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%: значение не превращается в нового человека на следующем шаге.
И главное: ключ к подстановкам не покидает контур клиента: настоящее значение возвращается в аргумент вызова там, где стоит шлюз.
Мы закрываем восемь типов: ФИО, телефон, адрес, дата рождения, СНИЛС, ИНН, паспорт, карта, и у каждого своя проверка.

Их конфиг и их результат на нашем корпусе. Маскирование подставляет <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.
Пишите в комментариях, если хотите прогнать свою систему через корпус.
Источники
Наш продукт и бенчмарк
Pseudex: pseudex.ru
AgentMask-RU, код и корпус: github.com/pseudex-ai/agentmask-ru
Системы, которые мы прогнали
Cloud.ru guardrails-llm-filter: github.com/KAP-AI/guardrails-llm-filter
LiteLLM и его guardrails: github.com/BerriAI/litellm, документация по guardrails. Обсуждения, о которых речь в тексте: #31950, #32014, #42130
LLM Guard: github.com/protectai/llm-guard
Microsoft Presidio: github.com/microsoft/presidio, русская модель spaCy ru
Чем собирали корпус
Natasha и размеченный ею корпус имён (NEN)
Резервные DEF-коды российского плана нумерации (Приказ Минцифры России № 75)
Корпуса и стенды, о которых говорим в тексте
NEREL: github.com/nerel-ds/NEREL
RuNNE-2022: разбор задания
hivetrace/pii-bench: huggingface.co/datasets/hivetrace/pii-bench
ai4privacy/pii-masking-200k: huggingface.co/datasets/ai4privacy/pii-masking-200k
BFCL: gorilla.cs.berkeley.edu
τ-bench: github.com/sierra-research/tau-bench
GorillaHard в MERA: mera.a-ai.ru

