Изоляция prefix cache нужна каждому арендатору
Изоляция prefix cache закрывает тайминговый канал между арендаторами: выбираем scope, назначаем salt на шлюзе и проверяем TTFT.

Prefix cache экономит дорогой prefill, когда запросы начинаютcя одинаково. В многопользовательском LLM-сервисе эта же оптимизация может выдать наблюдаемый сигнал: известный или угаданный префикс уже находился в кеше, поэтому ответ начал формироваться быстрее.
Это не чтение чужого KV cache и не прямой вывод промпта из памяти GPU. Атакующий получает более узкую, но всё равно полезную информацию: существовал ли конкретный префикс в недавно доступной области кеша. Для банковского документа, медицинской выписки, внутренней инструкции или системного промпта иногда уже сам факт такого совпадения раскрывает лишнее.
Изоляция prefix cache должна быть свойством маршрутизации запроса, а не опцией, на которую вы надеетесь в клиентском SDK. У каждого блока KV cache должна быть область доверия. Запрос из другой области не имеет права получить попадание, даже если все токены совпали.
Быстрый prefill становится оракулом присутствия
Атакующий не обязан знать чужой промпт целиком. Он берёт вероятное начало, например название проекта, шаблон договора, фрагмент системной инструкции или найденный в письме идентификатор, добавляет достаточно токенов до границы блока и отправляет серию запросов.
Если один вариант стабильно даёт более ранний time to first token, чем почти идентичные варианты, появляется гипотеза о попадании в prefix cache. Затем атакующий меняет фрагменты и сужает догадку. Самая неприятная часть такой проверки в том, что она хорошо автоматизируется и не требует доступа к ответам другого пользователя.
Упрощённая последовательность выглядит так:
- Жертва отправляет длинный приватный контекст, и движок сохраняет полные KV-блоки.
- Атакующий отправляет кандидаты с тем же началом и измеряет TTFT много раз.
- Кандидат с совпавшими блоками пропускает часть prefill и образует иной профиль задержки.
- Атакующий сравнивает варианты, учитывая очередь и обычный разброс времени ответа.
Один быстрый запрос ничего не доказывает. На задержку влияют очередь, непрерывный batching, холодная загрузка, вытеснение блоков, длина вывода и работа сети. Но повторяемая разница между контрольной и проверяемой группой может быть достаточной для классификатора. Не стоит успокаивать себя тем, что сеть добавляет шум. Шум усложняет эксперимент, но не меняет тип утечки.
Документация vLLM прямо описывает этот риск: reuse кеша можно определить по разнице задержек, а cache_salt ограничивает переиспользование запросами с одинаковой солью. Это важнее обычного утверждения, что KV cache расположен в памяти процесса и не виден пользователю. Канал идёт через поведение сервиса.
Хеш блока и граница арендатора решают разные задачи
Инженеры нередко видят SHA-256 в ключах prefix cache и считают задачу закрытой. Сильный хеш нужен, чтобы разные последовательности токенов практически не получили один ключ по случайности или из-за слабой функции. Он не запрещает двум арендаторам получить один и тот же ключ для одинакового текста.
Это две разные защиты:
- Хеш связывает конкретные токены и предыдущий блок с ключом KV cache.
- Область кеша отвечает, кто вправе переиспользовать этот ключ.
- Соль делает одинаковые токены разными ключами для разных областей.
В hash-based реализации блок обычно зависит от токенов текущего блока и хеша родительского блока. Поэтому достаточно добавить соль в первый защищаемый блок: она меняет его хеш, а цепочка меняет хеши всех последующих блоков. Документация vLLM описывает именно такую схему для cache_salt.
Из этого следует практическое правило: соль нельзя добавлять в лог или в текст промпта. Она не является данными модели. Она входит только во внутренний ключ кеша. Если вы вставляете tenant ID в system prompt ради изоляции, вы меняете поведение модели, расходуете токены и всё ещё не доказываете, что движок использует этот ID в ключе KV cache.
Область кеша должна следовать данным, а не названию клиента
Tenant scope подходит не для всех запросов организации. Один арендатор может включать десятки подразделений, внешних клиентов и пользователей с разными правами. Если все они делят prefix cache, то один пользователь способен проверять наличие контекста другого пользователя внутри той же компании.
Я использую четыре области, а не одну универсальную настройку.
| Область | Что можно переиспользовать | Что нельзя туда помещать |
|---|---|---|
global-public | Проверенный публичный контент и общая неизменяемая инструкция | Внутренние правила, PII, результаты RAG |
tenant | Общие материалы одного арендатора | Данные, разделённые между отделами или клиентами арендатора |
user | История и документы одного пользователя | Чужие чаты, общие секреты команды |
request | Одноразовый чувствительный контекст | Данные, где даже повторное использование самим пользователем нежелательно |
Политика должна выбирать область по происхождению данных. Например, общая инструкция ассистента, опубликованная для всех клиентов, может жить в global-public. Вложение из личного кабинета должно получить минимум user. Контекст с номером счёта, диагнозом, кадровой информацией или текстом договора я обычно сразу отношу к user либо request, даже если пользователь работает в корпоративном tenant.
Это различие часто размывают: «внутри клиента можно всё кешировать». Нет, клиентский договор не даёт всем сотрудникам одинаковое право узнавать, какие документы недавно обрабатывали коллеги. Граница доступа к данным и граница prefix cache должны совпадать настолько, насколько это возможно.
Соль должен назначать шлюз, а не вызывающий код
Клиентский параметр удобен для локального эксперимента. В продакшене он опасен, если пользователь может выбрать соль сам. Атакующий сможет подставить ожидаемую соль жертвы, общую строку вроде tenant-a, или намеренно переводить собственные запросы в более широкую область кеша.
Шлюз должен извлечь идентичность из проверенного API-ключа, JWT или mTLS-сертификата, а затем построить серверную cache identity. В неё обычно входят tenant, выбранная область и версия политики. Версия нужна, чтобы после изменения правил не смешивать старую и новую семантику кеша.
Ниже пример логики на Python. Это не готовая библиотека, а минимальный шаблон для ревью архитектуры.
import base64
import hashlib
import hmac
CACHE_SECRET = b"stored-outside-application-config"
def make_cache_salt(tenant_id: str, scope: str, principal_id: str | None) -> str:
if scope not in {"tenant", "user", "request"}:
raise ValueError("scope is not allowed for private content")
subject = principal_id if scope == "user" else "-"
material = f"cache-v3|{scope}|{tenant_id}|{subject}".encode()
digest = hmac.new(CACHE_SECRET, material, hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
Для request не используйте функцию выше без дополнительного идентификатора запроса или объекта данных. Иначе вы получите переиспользование на уровне tenant, только с другой меткой. Безопаснее добавить случайный request_nonce, который генерирует сервер и не принимает от клиента.
После аутентификации шлюз должен отбросить поле cache_salt, если клиент его прислал, и установить собственное. Полезно также записывать в аудит не саму соль, а безопасные поля: scope=user, идентификатор политики и факт назначения соли. Сырая соль в трассировке превращает внутренний разделитель в переносимый секрет.
Переносимый контекст требует более узкой границы
Большинство утечек появляются не в простом чате, а в маршрутах, где приложение собирает длинный промпт из нескольких источников. RAG, инструменты, агентные циклы и системные шаблоны создают контекст, который выглядит общим, хотя составлен из частных частей.
Разделите промпт по происхождению до того, как он попадёт в движок:
- публичная, неизменяемая инструкция может остаться без приватной соли;
- tenant-документы получают tenant salt;
- найденные по ACL фрагменты и история чата получают user salt;
- секрет, загруженный для одной операции, получает request salt.
Один cache_salt на весь запрос даёт простую и надёжную изоляцию, но сужает повторное использование: если приватный документ идёт после общей инструкции, соль на первом блоке закроет и общую часть. Это разумная цена для первого безопасного релиза.
Более сложная схема ставит барьер соли на границе сообщений или сегментов. Тогда общая system-инструкция может остаться в общей области, а после первого приватного сообщения начинается цепочка, доступная только нужной группе. В RFC vLLM о cache salting описывалась такая иерархия: организация может разделять свой документ внутри tenant, а следующий барьер ограничивает reuse одним пользователем. Используйте этот подход, только если движок действительно поддерживает сегментные барьеры. Иначе шлюз создаст красивую политику, которую runtime не исполняет.
Общая соль арендатора не должна быть угадываемой
Строка acme-prod удобна для отладки, но это плохой секретный материал. Если соль известна или выводится из публичного tenant ID, она всё равно разделяет области при честной работе шлюза, однако не даёт дополнительной защиты, когда где-то сохранился путь с клиентским управлением солью.
Лучше использовать результат HMAC с серверным секретом. Он имеет несколько свойств, которые нужны здесь:
- клиент не может вычислить соль другого арендатора;
- соль не раскрывает tenant ID в дампе ключей кеша;
- шлюз может воспроизвести её на любой реплике без общего хранилища с миллионами случайных значений;
- смена версии или секрета создаёт новую область и естественно инвалидирует старые попадания.
Не путайте HMAC с основной защитой. Самое важное правило остаётся прежним: только доверенный слой выбирает scope. HMAC лишь делает реализацию менее хрупкой.
Для нескольких реплик нужен одинаковый секрет на тех репликах, которые могут делить внешний или распределённый KV cache. Если кеш физически локален для каждой реплики, согласованность соли всё равно полезна для корректной семантики, но сама по себе не создаёт межрепличный reuse.
Проверьте канал экспериментом, а не графиком hit rate
Доля попаданий говорит о цене и производительности, но не доказывает изоляцию. Вам нужен отдельный тест, где один субъект прогревает известный длинный префикс, а другой пытается обнаружить его по задержке.
Проводите тест на стенде с той же моделью, длиной контекста, параметрами generation и схемой batching, что в рабочем маршруте. Контрольный и проверяемый запрос должны отличаться только областью кеша. Иначе вы измерите побочный эффект шаблона, а не границу арендатора.
Практический протокол выглядит так:
- Создайте два tenant:
redиblue, два пользователя вredи отдельный набор API-ключей. - Выберите длинный синтетический префикс, который пересекает несколько полных блоков, и прогрейте его от
red/user-1. - Отправьте этот же префикс сериями от
red/user-1,red/user-2иblue/user-1. Добавьте близкий, но несовпадающий контрольный вариант. - Для каждого запроса сохраните серверный TTFT, длину prompt, выбранный scope, очередь и отметку cache hit без текста префикса.
- Сравните распределения. Повторное использование допустимо только в тех группах, которым политика явно разрешает общую область.
Ожидаемая форма результата проста: red/user-1 может показать ускорение после прогрева user scope. red/user-2 и blue/user-1 не должны получить отдельную быструю группу только потому, что повторили тот же контекст. Если red/user-2 ускоряется, вы настроили tenant scope там, где ожидали user scope, либо движок игнорирует соль.
Не полагайтесь на среднее значение. Несколько запросов могут попасть в тихую очередь и искусственно улучшить среднее. Смотрите на квантили, число прогонов, порядок запусков и повторяемость при смене порядка групп. Лучше запускать запросы по перемешанному расписанию, чтобы прогрев и вытеснение не совпадали с одной тестовой группой.
Время ответа нельзя выровнять случайной задержкой
Иногда команда предлагает добавить jitter к ответам, чтобы скрыть cache hit. Это популярная идея, потому что её легко реализовать в gateway. Она не решает проблему.
Атакующий усреднит больше измерений, а вы ухудшите задержку для каждого честного пользователя. При достаточно сильном сигнале случайная пауза снижает точность, но не задаёт запрет на межарендное совпадение. Вы лечите наблюдаемость, оставляя причину внутри системы.
Фиксированная минимальная задержка выглядит лучше, но тоже имеет цену: сервис ждёт даже тогда, когда модель уже готова дать первый токен. Кроме того, разница может проявиться в длительности prefill, нагрузке на очередь, потреблении GPU или метриках, доступных через другой интерфейс.
Изоляция ключей кеша убирает именно тот reuse, который создаёт сигнал. Если риск остаётся высоким из-за других свойств системы, отключайте prefix cache для выбранной категории данных. Но не выдавайте искусственную задержку за контроль доступа.
Утечка часто начинается в соседнем сервисе
Даже идеальная соль в inference runtime не поможет, если другой слой повторно использует данные шире заданной политики. Проверьте не только KV cache на GPU.
Особого внимания требуют кеши embeddings, результаты retrieval, подготовленные шаблоны сообщений, ответы tool calling, очереди задач и observability-пайплайны. Например, backend может правильно использовать user scope для KV cache, но сохранять результат поиска по ключу из текста запроса без tenant ID. Тогда пользователь не увидит тайминговый сигнал, он получит чужой фрагмент напрямую.
Единая функция построения cache identity снижает риск расхождения. Каждый кеш выбирает свой состав полей, потому что данные и срок жизни различаются, но tenant и ACL-контекст не должны теряться по дороге. Назовите это явно в коде: retrieval_scope, response_scope, kv_scope. Слово cache_key без указания области слишком легко скрывает ошибку на ревью.
Для API-шлюза отдельная политика особенно полезна. AI Router может назначать cache scope до маршрутизации модели, сохраняя единый OpenAI-совместимый интерфейс для приложения. Но сам шлюз не угадает, является ли документ общим для tenant или личным: приложение должно передать классификацию данных через доверенный серверный контракт, а политика должна отклонить небезопасный scope.
Производительность надо считать после выбора доверенной области
Изоляция уменьшает число потенциальных попаданий, и это ожидаемо. Нельзя сначала максимизировать общий hit rate, а затем пытаться прикрыть последствия. Такой порядок приводит к глобальному кешу, который быстро становится скрытой базой индикаторов о чужих данных.
Сначала задайте разрешённые области, потом измерьте reuse внутри каждой. Часто потеря оказывается меньше, чем ожидают: многотурновые диалоги, повторные запросы к одному документу и общие инструкции одного tenant уже дают много локальности. Если tenant-wide reuse почти не нужен, не расширяйте область только ради красивой метрики.
Хорошая политика звучит скучно и конкретно: публичные префиксы делятся только после проверки; корпоративные материалы не пересекают tenant; личные документы не пересекают user; одноразовые секреты не переживают request. Если команда не может записать эти правила в виде тестов, она ещё не контролирует, кто получает ускорение и почему.
Откройте сегодня трассу одного длинного запроса и ответьте на один вопрос: какой именно компонент назначил ему область prefix cache. Если ответа нет в коде и в аудите, соль пока не защищает арендаторов. Она просто существует где-то в конфигурации.
Часто задаваемые вопросы
Может ли prefix cache раскрыть данные, если модель не показывает чужие ответы?
Да. Prefix cache обычно не возвращает чужой текст, но быстрый ответ на совпавший префикс может показать, что такой префикс уже был обработан. Для атакующего это оракул присутствия: он формулирует догадки и сравнивает распределения задержек.
Достаточно ли одной cache salt на всю LLM-платформу?
Нет, соль не должна быть постоянной для всей платформы. Такой вариант скрывает данные от внешнего мира только на бумаге, потому что все арендаторы остаются в одной области переиспользования. Минимальная безопасная грань для приватных данных - отдельный tenant cache domain.
Нужно ли разделять cache между пользователями одного арендатора?
Обычно нет. Пользователь внутри одного клиента часто не должен узнавать, какие документы, истории чатов или обращения обрабатывали другие пользователи той же организации. Для таких данных полезна область user или session, а для одноразовых секретов - request.
Как безопасно передавать cache_salt через OpenAI-совместимый API?
Превратите идентичность области в серверное решение. Шлюз должен получить tenant_id из проверенного ключа, выбрать cache scope по политике и сам передать производному движку непрозрачную соль. Поле cache_salt от клиента нельзя принимать как источник полномочий.
Решает ли SHA-256 проблему межарендной утечки?
Нет. Сильный хеш защищает от случайного совпадения ключей и намеренно подобранных коллизий, но не от корректного совпадения токенов двух арендаторов. Тайминговый канал возникает именно тогда, когда движок законно находит общий ключ и экономит вычисления.
Когда глобальный prefix cache допустим?
Для публичного, неизменяемого и действительно общего контента можно выделить отдельную global-public область. Туда подходят опубликованные инструкции продукта или открытая документация после тщательной проверки. В эту область нельзя отправлять персональные данные, внутренние шаблоны и результаты поиска по приватному индексу.
Как проверить, что другой арендатор не получает cache hit?
Сначала проверьте, что два набора запросов одинаково смешаны по модели, длине, очереди, региону и настройкам генерации. Затем прогрейте контрольный префикс одной областью, отправьте одинаковые догадки из другой и сравните не среднее, а распределения TTFT. Если разделение работает, совпадение чужого префикса не должно давать отдельный быстрый кластер.
Нужно ли полностью отключать prefix caching для чувствительных данных?
Отключение кеша убирает один канал, но вы платите GPU-временем и задержкой на каждом повторном длинном контексте. Это разумная временная мера для обработки особо чувствительных запросов, но плохая постоянная архитектура для всего сервиса. Лучше оставить переиспользование внутри явно определённых доверенных областей.
Какие метрики нужны для безопасного prefix cache?
Измеряйте раздельно TTFT, долю попаданий по scope, число запросов без соли и отказы политик. Нельзя публиковать сырые хеши, соли, префиксы или идентификаторы документов в метриках и трассировках. Метрика должна отвечать на вопрос о работе изоляции, а не становиться вторым каналом утечки.
Чем KV prefix cache отличается от prompt caching у провайдера?
Это разные объекты. KV cache хранит вычисленные состояния внимания модели, а prompt caching у провайдера может включать собственные правила, цены и область действия. В обоих случаях надо выяснить границу переиспользования, но нельзя переносить гарантии одного механизма на другой.