В прошлый раз я рассказывал, как работает Guardrails Filter: зачем вообще понадобился отдельный слой защиты данных при работе с LLM и какие задачи он решает.

В этот раз хочу обсудить то, какие подводные камни могут оказаться под капотом этой технологии. Пока мы делали свой Guardrails Filter, быстро выяснилось, что найти персональные данные — далеко не самая сложная часть задачи. Гораздо сложнее оказалось встроиться между приложением и моделью так, чтобы ничего не сломать. Нужно корректно обработать обычные запросы, стриминг, tool calling, сохранить структуру сообщений, не потерять служебную информацию и при этом успеть изменить данные до того, как их увидит модель или пользователь.

Сейчас расскажу подробнее.

Почему простой замены данных оказалось недостаточно

На первый взгляд задача заменить данные выглядела довольно простой. В Guardrails Filter приходит обычный JSON с запросом к модели. Внутри — история переписки и новое сообщение пользователя. Нужно найти персональные данные, заменить их до отправки в LLM, а после получения ответа вернуть обратно.

С поиском данных особых проблем не возникло. Мы проходим по тексту регулярными выражениями, находим номера телефонов, адреса электронной почты, паспортные данные, СНИЛС, имена и заменяем их на специальные плейсхолдеры.

Например, вместо настоящего номера телефона модель увидит что-то вроде:

<PHONE_1>

Но довольно быстро выяснилось, что сама маскировка — это только половина дела.

Допустим, пользователь отправил два разных номера телефона:

+7 999...

+7 888...

Если заменить оба значения просто на <PHONE>, то после ответа модели уже невозможно понять, какой номер куда вернуть.

Мы перебрали несколько вариантов. Например, думали оставлять часть номера или генерировать похожие, но несуществующие значения. В итоге отказались от этой идеи.

Вместо этого сделали обычную таблицу соответствий.

Что видит модель

Что хранится в Guardrails Filter

<PHONE_1>

+7 999...

<PHONE_2>

+7 888...

<PERSON_1>

Иван Иванов

Во время маскировки Guardrails Filter проверяет, встречалось ли уже такое значение раньше. Если да — использует существующий идентификатор. Если нет — создает новый.

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

Но тут возникла следующая проблема — LLM вообще ничего не помнит между запросами.

Когда пользователь открывает чат, кажется, что модель постепенно накапливает историю диалога. На самом деле это не так.

Каждый новый запрос отправляется вместе со всей историей переписки. Модель каждый раз получает полный массив сообщений, генерирует ответ и сразу забывает все, что только что увидела.

Получается, что Guardrails Filter тоже не может работать только с последним сообщением. Перед каждым запросом приходится заново проходить по всей истории диалога, искать персональные данные, строить таблицу соответствий, маскировать данные, а после получения ответа — заново восстанавливать исходные значения.

С обычными JSON-ответами на этом сложности почти заканчиваются: мы получаем ответ целиком, находим плейсхолдеры, подставляем исходные значения и возвращаем результат пользователю. Но со стримингом эта схема уже не работает: ответ приходит по частям и плейсхолдер может оказаться разрезан между несколькими чанками. Здесь и начинается основная сложность.

Как мы демаскируем данные в стриме

В SSE (стриминге) ответ приходит отдельными чанками. В одном может быть буква, в другом — несколько символов, в третьем — целое слово или предложение. При этом плейсхолдер тоже может разрезаться между несколькими чанками.

Например, <PHONE_NUMBER_1> может прийти так:

<PHO

NE_NUM

BER_1>

Поэтому мы не отправляем каждый чанк пользователю сразу. Guardrails Filter сначала накапливает несколько событий, объединяет их и только потом передает дальше. Из-за этого текст появляется с небольшой дополнительной задержкой, зато приходит более крупными фрагментами — условно, по 10–15 символов вместо двух-трех. При этом ждать завершения всего ответа модели не нужно.

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

  • есть ли в нем открывающая угловая скобка;

  • закрылась ли она;

  • находится ли внутри известный нам ключ;

  • можно ли уже восстановить исходное значение.

Если это действительно плейсхолдер, Guardrails Filter подставляет исходные данные и формирует собственный чанк. Если совпадения нет, текст уходит дальше без изменений.

Но ответ модели — это не только текст. В одном JSON могут находиться reasoning, итоговый content, один или несколько tool_calls и данные об использовании токенов. Поэтому при демаскировании недостаточно обработать одно поле — нужно корректно собрать весь ответ.

Самый показательный пример — работа с файлами. Допустим, пользователь отправляет модели конфигурацию сервиса, в которой есть пароль. Guardrails Filter маскирует его, и модель видит в файле что-то вроде <PASSWORD_1>.

Затем пользователь просит изменить конфигурацию и сохранить ее в новый файл. Модель делает tool call на создание файла и передает туда уже обработанное содержимое. Но настоящего пароля она не знает, поэтому в аргументах tool call остается <PASSWORD_1>.

Если демаскировать только обычный текст ответа, пользователь увидит правильное объяснение, но сам файл создастся с плейсхолдером вместо пароля.

Поэтому нам пришлось восстанавливать данные не только в content, но и в reasoning и аргументах tool calls. Причем вызовов инструментов может быть несколько, а в Chat Completions API модель может вернуть сразу несколько choices. Они могут стримиться по очереди или параллельно: один вариант уже вызывает инструмент, а второй еще продолжает рассуждать.

Здесь появилась еще одна проблема: как понять, что поток действительно закончился?

Допустим, мы отдаем данные блоками по 15 символов, а в конце буфера осталось еще 10. Их нужно отправить пользователю, но только когда мы уверены, что продолжения уже не будет.

По самому тексту это определить нельзя. Модель могла закончить ответ, перейти к tool call, ждать результат инструмента или просто еще не прислать следующий чанк. Кроме того, служебные данные и usage могут приходить в разном порядке.

В итоге Guardrails Filter должен не просто искать плейсхолдеры в потоке. Ему приходится отдельно следить за состоянием каждого choice, reasoning, контента и tool calls, а затем корректно определять момент, когда оставшийся буфер уже можно безопасно отдать пользователю.

Почему обработка стрима заняла 1 500 строк кода

На этом этапе стало понятно, что самая сложная часть Guardrails Filter — не поиск персональных данных, а обработка потока событий, который возвращает модель.

Хотя алгоритм демаскировки выглядит довольно простым, на практике приходится учитывать десятки разных сценариев, которые возникают во время генерации ответа.

Например, большинство моделей сначала отправляют reasoning, а уже потом начинают генерировать content. Пока идет reasoning, Guardrails Filter накапливает данные в своем буфере. Как только начинается content, становится понятно, что reasoning закончился и его можно окончательно обработать и отдать пользователю.

Но даже здесь нет единых правил.

По спецификации OpenAI поле называется reasoning. На практике разные модели используют разные варианты (reasoning, reasoning_content), а некоторые вообще отправляют оба поля одновременно. Все эти случаи пришлось поддерживать отдельно.

С tool calling история оказалась еще сложнее.

Во время стриминга модель сначала отправляет служебную информацию о вызове инструмента — его идентификатор и название, — а затем начинает постепенно передавать аргументы.

Причем аргументы не приходят готовым JSON, а буквально собираются по кусочкам:

{
  "path":
следующим чанком:
"/tmp/config",
потом:
"content":

И так далее, пока не сформируется весь объект.

При этом сами аргументы — это JSON-строка внутри другого JSON. То есть фактически приходится работать с JSON внутри JSON, со всеми экранированиями, переносами строк и служебными символами.

Именно здесь мы поймали одну из самых неприятных ошибок.

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

Такие ошибки сложно заметить самим. Обычно они проявляются уже на стороне клиента. 

Например, агент получает невалидный JSON и просто перестает работать. Поэтому в какой-то момент мы перестали доверять любым предположениям и начали проверять структуру каждого tool call целиком.

После получения всех аргументов Guardrails Filter проверяет, что JSON действительно собрался полностью — все фигурные скобки закрыты, структура валидна, — и только после этого выполняет демаскировку и передает данные дальше.

Это оказалось важнее, чем кажется на первый взгляд. Если отдать клиенту невалидный JSON, можно сломать всю агентную цепочку.

Из-за большого количества подобных сценариев код обработки одного только стриминга в Chat Completions вырос примерно до 1 500 строк. А чтобы убедиться, что мы ничего не упустили, пришлось написать более 3 000 строк тестов.

По сути, большая часть работы ушла не на саму демаскировку, а на поиск всех возможных вариантов завершения потока и ответов на вопросы:

  • Где заканчивается reasoning?

  • Когда завершился content?

  • Как понять, что tool call действительно закончен? 

  • Что делать, если модель остановилась после вызова инструмента? 

  • В каком порядке могут прийти usage, data: [DONE] и остальные служебные события?

Именно такие сценарии в итоге определяют, будет ли Guardrails Filter работать незаметно для пользователя или начнет ломать агентные пайплайны.

Почему одной OpenAI-схемы оказалось недостаточно

Когда мы закончили поддержку Chat Completions API, казалось, что основная работа позади. Но довольно быстро выяснилось, что это только одна из возможных схем взаимодействия с LLM.

Вторая схема — Messages API, который используют модели Anthropic.

На первый взгляд оба API решают одну и ту же задачу, но внутри устроены совершенно по-разному.

В Chat Completions поток состоит из больших JSON-объектов. Чтобы понять, что сейчас происходит, приходится анализировать поля, проверять их содержимое и отслеживать изменения состояния.

В Messages API подход другой. Поток состоит из отдельных событий: начало tool call, часть аргументов, окончание tool call, следующий tool call и так далее. Такая схема компактнее и проще для обработки, но требует отдельной реализации.

Аспект

Chat Completions

Messages API

Провайдер

OpenAI-совместимый

Anthropic

Формат фрейма

Одна строка

data: {json}

Пара строк

event: <name>

data: {json}

Маршрутизация

Полная сериализация в типизированную структуру, анализ полей

Точечный gjson.Get без полной десериализации

Модель контента

Плоские дельты content, reasoning, tool_calls в одном JSON

Блоки с жизненным циклом: start → N delta → stop

Агрегация

Полноценное слияние всех полей события: usage, reasoning/content, tool calls, created

Минимальное слияние: только text

Сборка выхода

Пересобирает весь JSON из слитого состояния

Патчит одно поле в оригинальном JSON, остальные не трогает

Порядок демаскировки

Строгий: reasoning → content → function_call → tool_calls

По мере поступления ивентов

Метаданные

Буферизуются, отдаются только после flush-контента

Пробрасываются сразу как есть

Tool calls

Новый tool_calls + старый function_call

Только tool_use

Из-за этого поддержку Messages API нельзя было получить просто адаптацией существующего кода. Нам пришлось реализовать отдельный обработчик и написать для него свой набор тестов — примерно 1 000 строк.

При этом отличается не только формат данных, но и сама логика работы моделей.

Например, Claude активно использует встроенные tool calls. Даже простой запрос может превратиться в цепочку действий: сначала модель вызывает инструмент, затем анализирует результат, при необходимости делает следующий вызов и только после этого формирует ответ.

Для Guardrails Filter это означает, что маскировать и демаскировать приходится не только сообщения пользователя и финальный ответ модели, но и весь промежуточный обмен данными между моделью и инструментами.

Именно поэтому Guardrails Filter одинаково работает и с OpenAI Chat Completions, и с Anthropic Messages. Несмотря на совершенно разные структуры API, для пользователя результаты остаются одинаковыми — персональные данные не попадают в модель, а после обработки корректно возвращаются обратно.


А вот еще одна статья про Guardrails Filter — про то, как мы вывели проект в опенсорс. Ее тоже можно чекнуть, коллеги.