В конце первого месяца активного роста продукта мы получили счет от провайдеров LLM. Цифра с четырьмя нулями заставила нас остановить разработку новых фич и открыть логи. Мы ожидали увидеть там сложные, уникальные запросы, отражающие невероятную креативность наших пользователей. Вместо этого мы увидели бесконечный день сурка.
«Напиши пост про котиков». «Сделай текст о котах для инстаграма». «Придумай смешной пост про кота».
Скрипт кластеризации показал суровую правду: около 60% запросов к нашей системе повторяли друг друга по смыслу. Мы сжигали тысячи долларов, заставляя тяжелые языковые модели раз за разом генерировать ответы на одни и те же вопросы. Каждая генерация стоила денег и, что хуже, времени — пользователи ждали по пять-десять секунд ответа, который мы могли бы отдать мгновенно.
Решение казалось очевидным: нужно кэшировать ответы. Мы подняли инстанс Redis, посчитали MD5-хэш от сырого текста пользовательского запроса и сделали его ключом. Если хэш совпадает — отдаем сохраненный ответ. Если нет — идем в RouterAPI, получаем генерацию, сохраняем в Redis с TTL на 24 часа.
Мы выкатили этот патч и стали смотреть на графики. Hit rate (процент попаданий в кэш) застрял на отметке 4%.
Наивный кэш разбился о человеческую природу. Люди делают опечатки. Люди ставят лишние пробелы. Люди используют синонимы. Запросы «Как варить кофе?» и «как сварить кофе» имеют абсолютно разные MD5-хэши, но требуют идентичного ответа. Точное совпадение строк в мире естественного языка не работает. Нам нужно было кэшировать не текст, а смысл.
Так мы пришли к концепции семантического кэширования.
Архитектурная идея звучит элегантно: вместо сравнения строк мы сравниваем их математические представления — эмбеддинги. Когда приходит запрос, мы прогоняем его через быструю и дешевую модель (например, text-embedding-3-small), получаем вектор из 1536 чисел и ищем в базе данных векторы, которые находятся близко к нашему.
Redis, который мы изначально использовали как тупое key-value хранилище, пришлось переосмыслить. Современный Redis — это не просто словарь. С модулем RediSearch он превращается в мощную векторную базу данных. Мы создали индекс, используя алгоритм HNSW (Hierarchical Navigable Small World). В отличие от FLAT-индекса, который сравнивает новый вектор с каждым существующим (что дает идеальную точность, но убивает CPU при миллионах записей), HNSW строит многослойный граф и находит ближайших соседей за логарифмическое время.
Архитектура запроса усложнилась. Теперь пайплайн выглядел так:
- Нормализация текста (удаление спецсимволов, приведение к нижнему регистру).
- Быстрая проверка точного совпадения (на случай, если запрос идентичен предыдущему).
- Генерация эмбеддинга (занимает около 30-50 мс).
- Векторный поиск в Redis с использованием синтаксиса DIALECT 2.
- Вычисление косинусного расстояния. Если сходство (cosine similarity) превышает заданный порог (например, 0.95), мы считаем это попаданием в кэш.
И вот здесь начался настоящий инженерный ад.
Первая проблема — выбор порога сходства. Установишь 0.98 — кэш почти не срабатывает, система продолжает тратить деньги. Опустишь до 0.90 — начинаются галлюцинации кэша. Пользователь спрашивает «Как удалить пользователя из базы», а система радостно отдает закэшированный ответ на запрос «Как добавить пользователя в базу», потому что семантически эти предложения почти идентичны — они оперируют одними и теми же сущностями в одном контексте. Многомерное векторное пространство отлично кластеризует темы, но отвратительно улавливает отрицания и смысловые антонимы.
Пришлось внедрять гибридный поиск. Мы добавили в Redis не только векторный индекс, но и полнотекстовый. Теперь мы искали не просто близкие векторы, но и требовали совпадения ключевых терминов.
Вторая проблема — системные промпты и изоляция контекста. Один и тот же запрос «Напиши код сортировки» должен получить разные ответы, если в одном случае системный промпт требует писать на Rust, а в другом — на PHP. Эмбеддинг пользовательского запроса будет идентичным. Если мы будем искать только по нему, произойдет утечка контекста. Чтобы этого избежать, мы начали вычислять легковесный хэш (SHA-256) от системного промпта и параметров модели (temperature, max_tokens). В Redis этот хэш сохраняется как отдельное текстовое поле (TAG). При векторном поиске мы используем синтаксис RediSearch: (@system_hash:{a1b2c3d4})=>[KNN 5 @embedding $query_vec]. Сначала база фильтрует записи по точному совпадению хэша системного промпта, и только среди них ищет семантически близкие векторы. Это спасло нас от сотен багов, связанных с кросс-контекстным загрязнением кэша.
Третья проблема — стриминг. Когда вы работаете с LLM в продакшене, пользователи ожидают потоковую передачу ответа. Они хотят видеть, как текст печатается в реальном времени. Это ломает классический паттерн кэширования. Вы не можете просто получить ответ и синхронно положить его в базу. Вам нужно проксировать чанки (chunks) данных от RouterAPI к клиенту через Server-Sent Events (SSE), параллельно собирая эти чанки в буфер в оперативной памяти шлюза. И только когда придет токен [DONE], шлюз должен взять собранный текст, сериализовать его и асинхронно отправить в Redis. Если процесс ноды упадет во время стриминга — кэш не запишется. Мы реализовали паттерн write-behind, где фоновый воркер отвечает за сохранение успешных генераций в векторную базу, чтобы не блокировать основной поток отдачи контента.
Хранение ответов тоже потребовало планирования. Текст, сгенерированный LLM, может занимать десятки килобайт. Если кэшировать всё подряд, оперативная память Redis закончится за несколько дней. Мы отказались от хранения сырых строк и перешли на структуру Hash, где в отдельных полях лежат: сам ответ, эмбеддинг запроса, ID модели и метаданные. Для управления памятью мы настроили политику вытеснения allkeys-lru (Least Recently Used). Redis сам удаляет старые, неиспользуемые ответы, когда память достигает лимита. Это избавило нас от необходимости писать собственные cron-скрипты для очистки базы.
Интеграция семантического кэша с RouterAPI стала ключевым архитектурным решением. RouterAPI сам по себе отлично решает задачу маршрутизации: он перенаправляет запросы к самому дешевому или быстрому провайдеру. Но самый дешевый запрос — это тот, который ты не отправил.
Мы обернули вызовы к RouterAPI в локальный Redis-кэш. Когда приходит запрос, шлюз сначала опрашивает Redis. Если происходит cache hit, мы возвращаем ответ за 60 миллисекунд. Стоимость такого ответа равна стоимости генерации эмбеддинга (доли цента) плюс амортизация нашего сервера. Если происходит cache miss, шлюз отправляет запрос в RouterAPI, дожидается ответа и асинхронно складывает его в Redis.
Четвертая проблема — инвалидация фактов. В классической разработке инвалидация кэша считается одной из главных болей. В мире LLM она возведена в абсолют. Как инвалидировать смысл? Допустим, мы закэшировали ответ на вопрос «Кто президент США?». Завтра проходят выборы. Текст запроса не изменился, его эмбеддинг не изменился, но ответ стал неверным. Использование TTL — это костыль. Мы ставим TTL на 7 дней, надеясь, что мир не слишком изменится за неделю. Для запросов, требующих актуальных данных (например, когда подключен RAG), мы полностью отключаем семантический кэш, полагаясь только на свежие генерации.
И, наконец, потеря креативности. LLM ценят за вариативность. Семантический кэш убивает эту вариативность. Если пользователь нажимает «Сгенерировать еще раз», он ожидает увидеть новый текст. Если мы отдадим ему ответ из кэша, он решит, что система сломалась. Нам пришлось добавить жесткое правило: запросы с temperature > 0.4 или явным флагом регенерации идут в обход кэша напрямую в RouterAPI.
Эта схема позволила нам срезать косты на 40%. Мы перестали платить за генерацию типовых SEO-текстов, стандартных приветствий и школьных сочинений.
Но мы заплатили за это усложнением инфраструктуры. Теперь мы поддерживаем кластер Redis с векторными индексами. Мы мониторим метрики качества эмбеддингов. Мы разбираем инциденты, когда гибридный поиск ошибается и отдает пользователю ответ из чужого контекста.
Семантическое кэширование не превратило нашу систему в идеальный механизм. Оно превратило финансовую проблему в инженерную. Мы обменяли прямые выплаты провайдерам API на зарплаты инженеров, которые настраивают HNSW-индексы и борются с ложными срабатываниями косинусного расстояния. В масштабах продукта это выигрышная сделка. Но иллюзий больше нет: хранить мысли нейросетей оказалось едва ли не сложнее, чем их генерировать.
***