Кэш LLM сэкономил токены и показал чужой ответ: пишем безопасный cache key на Python
Два клиента задают одинаковый вопрос: «Какой у меня лимит?». Текст совпадает, но права доступа и данные разные. Если кэшировать только промпт или искать похожий запрос по embedding, экономия может превратиться в утечку. Разберём, что включать в ключ, когда обходить кэш и как проверить изоляцию до продакшена.
Сначала разделим три разных кэша
Ниже учебный сценарий, а не рассказ о реальной утечке у конкретного сервиса. Его легко воспроизвести: сохранить ответ по hash(question), затем отправить тот же вопрос из другого аккаунта.
Кэш префикса у провайдера, кэш итогового ответа в вашем приложении и кэш результатов инструментов решают разные задачи. Попадание в кэш префикса не означает, что вы повторно выдаёте готовый ответ. В этой статье речь именно о вашем response cache: он может обойти генерацию целиком, поэтому вы становитесь ответственны за актуальность и разрешённую область повторного использования.
Ключ должен описывать не вопрос, а условия ответа
Для консервативного exact-match кэша я бы включал в ключ tenant, пользователя или доказанно эквивалентную группу доступа, ревизию прав, модель и провайдера, полный набор параметров генерации, системные инструкции, историю сообщений, schema/tools и версию данных.
Для RAG одной версии индекса мало. Нужны идентификаторы и ревизии реально использованных документов, с учётом фильтра доступа. Если до поиска это неизвестно, безопаснее кэшировать после retrieval либо использовать надёжный snapshot всего корпуса. Иначе документ изменился, а старый ответ продолжает жить.
Область доступа вычисляет сервер по текущей сессии. tenant_id из JSON клиента или tool call модели нельзя принимать за доказательство права читать чужую запись. Даже корректный ключ не заменяет авторизацию перед выдачей ответа.
Минимальный ключ на Python
Функция ниже строит только ключ. Это не готовый security boundary и не реализация Redis. contract должен содержать весь фактический запрос к модели, а scope — доверенный серверный контекст. Если важная зависимость не описана, кэш нужно обойти, а не подставлять пустую строку.
Хеш скрывает длинный текст из имени ключа, но не шифрует данные. Секреты и токены доступа в payload добавлять не надо: используйте ревизию политики и fingerprint разрешённого контекста. Значения кэша требуют собственных ограничений доступа, срока хранения и защиты. Нормализация JSON здесь сортирует ключи объектов, но сохраняет порядок сообщений: переставлять реплики или бездумно чистить пробелы в коде нельзя.
TTL не заменяет отзыв доступа
Запись полезно хранить с created_at, expires_at, ревизиями прав и данных. На чтении повторно проверяем текущую авторизацию и срок годности. После изменения прав старая запись должна стать недоступной сразу, даже если TTL ещё не истёк. Если нужна строгая гарантия при гонке с отзывом прав, её обеспечивает согласованный механизм авторизации и инвалидации, а не один hash.
При недоступности кэша чтение обычно превращаем в miss с ограниченным таймаутом. Но отказ проверки прав нельзя превращать в разрешение доступа. Это две разные зависимости.
Семантическое сходство не даёт право на ответ
Сначала сервер ограничивает поиск той же областью доступа, версиями контракта и данных. Только внутри разрешённого набора сравниваем embeddings. Высокая похожесть вопросов «Какой лимит у меня?» и «Какой лимит у коллеги?» не доказывает равенство ответа.
Для начала я бы включал semantic cache только для публичного FAQ с одной версией источника. Для персональных данных и изменяемых остатков безопаснее exact-match с полной зависимостью от данных или полный bypass. Универсального порога сходства нет: его проверяют на своих парах запросов и ошибочных совпадениях.
Что не нужно кэшировать как готовый ответ
Не сохраняйте частичный stream, транспортную ошибку или непроверенный structured output как успешную запись. Сначала завершение, валидация схемы и бизнес-правил, потом атомарная публикация результата в кэш.
Команды оплаты, отправки письма и изменения CRM не становятся безопасными от response cache. Повторное выполнение контролирует журнал операций и idempotency key. Для остатков, балансов и других меняющихся данных используйте надёжную ревизию или откажитесь от повторной выдачи.
Проверки перед включением
1. Одинаковый вопрос у двух tenant и двух пользователей не пересекает границу доступа.
2. Отзыв роли меняет допустимый результат до истечения TTL.
3. Изменение документа, системного промпта, schema и модели даёт miss.
4. Прерванная генерация не создаёт успешную запись.
5. Ошибка кэша не отключает авторизацию и не запускает неограниченные retries.
6. Параллельные одинаковые запросы имеют ограниченный single-flight, а ожидание блокировки входит в общий deadline. В нескольких процессах локального asyncio.Lock для этого недостаточно.
Экономия считается после проверки корректности
Смотрите не только hit rate, но и долю устаревших/неверных попаданий, latency проверки доступа и retrieval, стоимость embedding-поиска, памяти и инвалидации. Высокий hit rate легко получить, если забыть разделить пользователей. Это не оптимизация.
Начните с exact cache на разрешённых read-only сценариях, добавьте тесты границ и метрики, затем расширяйте область повторного использования. Отдельно измеряйте экономию на принятую задачу, а не на число возвращённых строк.
Источник и границы примера
RedisVL в официальном руководстве Cache LLM Responses показывает фильтрацию кэша по пользователю и отдельно описывает TTL. Важно проверять реальное поведение библиотеки: например, sliding TTL продлевает жизнь часто используемой записи и сам по себе не гарантирует максимальный возраст данных. Наша функция выше — независимый учебный exact-match пример на стандартной библиотеке Python, а не код RedisVL.
Вывод
Безопасный кэш повторно использует не просто похожий текст, а результат при тех же разрешённых условиях. Если эти условия нельзя надёжно описать и проверить, лучше заплатить за новый вызов, чем ускорить неверный ответ.
Что в вашем LLM-кэше оказалось сложнее: инвалидация данных, изоляция пользователей или ложные попадания semantic search?