Кэш для POST-запросов к LLM: как шлюз на Java 21 выдал 4 900 RPS на Redis
Команда разработки описала собственный реактивный шлюз, который научил Redis кэшировать POST-запросы к OpenAI, DeepSeek и гео-сервисам, где стандартные прокси бессильны. Решение на Java 21, Spring WebFlux и Netty позволило выжать 4 900 RPS и убрать до 40% дублирующихся обращений к платным API.
Инженерная команда крупного enterprise-продукта столкнулась с нелинейным ростом счетов за токены LLM и лимитов внешних API: корпоративный чат-бот и аналитические модули начали потреблять шестизначные суммы в месяц. Анализ логов показал, что до 40% запросов от пользователей и внутренних сервисов представляют собой дубли — люди многократно переспрашивают у модели схожие аналитические промты, а фронтенд-модули массово отправляют идентичные запросы к геокодерам. Первым порывом было закрыть проблему классическим кэшем, но команда уперлась в архитектурный тупик, связанный с природой POST-трафика.
Стандартные инструменты вроде Nginx, Varnish или HAProxy создавались для веба, где ключевым является GET-запрос, а уникальным ключом кэширования выступает URL. Современные ИИ-модели и гео-сервисы работают строго через POST, а полезная нагрузка лежит в теле запроса в виде тяжелого JSON. Этот JSON постоянно мутирует: клиентские SDK добавляют динамический мусор вроде request_id, UUID сессий, nonce и временных меток, порядок ключей может меняться случайным образом, а лишние пробелы и переносы строк ломают побайтовое сравнение. Для обычного прокси два запроса с одинаковым промтом, но разными request_id — совершенно разные сущности, поэтому кэш улетает в мусорку, а писать кастомные Lua-скрипты под Nginx для глубокого парсинга и сортировки тяжелых JSON на лету команда сочла занятием для сильных духом.
В качестве альтернативы был разработан собственный легковесный реактивный On-Premise шлюз на Java 21 с использованием Project Loom и виртуальных потоков, Spring WebFlux и Netty. Цель — удерживать тысячи параллельных RPS на одном-двух ядрах процессора за счет неблокирующего ввода-вывода и не добавлять задержек. Сердцем системы стал CacheProxyHandler: он перехватывает входящий POST-запрос, очищает JSON от метаданных по гибкому конфигу провайдера, сортирует ключи по алфавиту, считает SHA-256 и проверяет хеш в Redis. Конфигурация маршрутов задается в application.yaml с отдельными base-url, TTL и списками exclude-fields для OpenAI и DeepSeek — например, для первой модели исключаются request_id, user_session_id и created_at, для второй — generation_id, client_timestamp и trace_id.
Первая реализация конвейера на стандартном ReactiveRedisTemplate<String, String> с нормализацией через Jackson показала неожиданные результаты при нагрузочном тестировании. На 3000+ RPS графики утилизации памяти поползли вверх, время отклика p(99) хаотично прыгало от 5 до 120 мс, а приложение регулярно ловило микро-паузы Stop-The-World. Профайлер выявил причину: StringRedisSerializer на каждый запрос выделял память в куче под новые массивы char[] и строки, перекодируя бинарные данные из Redis в Java-строки UTF-8, и при высокой плотности запросов сборщик мусора просто не успевал вычищать этот строковый мусор. Решением стал полный отказ от промежуточной конвертации JSON в строки при общении с базой: прокси-сервер перекладывает сырые байты из сетевых сокетов Netty напрямую в Redis и обратно.
Для этого конфигурацию Redis переписали, жестко зафиксировав бинарный raw-пасс для значений: ключ остался строковым, а значение стало сырым byte[] с эффектом zero-copy. В хэндлере логика чтения и записи перешла на работу с массивом байт — байты из Redis превращаются в строку ответа для клиента напрямую, а свежий ответ от API сохраняется в кэш уже в бинарном виде. Такой подход устранил лишние аллокации объектов и снизил давление на Garbage Collector, что критично для удержания стабильной latency при тысячах параллельных соединений. По сути, команда применила принципы, знакомые по высоконагруженным бинарным протоколам, к области, где традиционно доминируют текстовые форматы и тяжелые SDK. В результате шлюз достиг 4 900 RPS на одном-двух ядрах, а доля попаданий в кэш среди дублирующихся запросов приблизилась к 40%, что напрямую сократило расходы на токены и снизило нагрузку на внешние API.
Контекст проблемы типичен для российского enterprise-рынка: интеграция LLM-моделей и тяжелой гео-инфраструктуры вроде Google Maps, Яндекс.Карт и DaData есть практически в каждом крупном продукте, а счета за токены и лимиты API растут нелинейно. Для российских компаний, работающих с OpenAI и DeepSeek через посредников или собственные On-Premise развертывания, экономия на дублирующихся запросах означает не только снижение затрат, но и снятие рисков упереться в квоты внешних провайдеров. Собственный шлюз на Java 21 выглядит прагматичной альтернативой как облачным API-шлюзам с биллингом за каждый вызов, так и самописным Lua-решениям на Nginx, требующим глубокой экспертизы в парсинге JSON на лету. Кроме того, решение полностью соответствует курсу на импортозамещение и контроль данных: On-Premise-развертывание позволяет не отдавать чувствительные промты третьим лицам.
Открытыми остаются вопросы универсальности подхода: насколько гибко можно описывать правила нормализации для разных провайдеров без роста сложности конфигурации, как вести себя при потоковых ответах LLM, где кэширование частично неприменимо, и какие стратегии инвалидации нужны при изменении промтов или версий моделей. Тем не менее кейс показывает, что значительная часть затрат на ИИ-инфраструктуру устраняется на уровне грамотно спроектированного кэша, а не за счет урезания функциональности продукта. Дальнейшее развитие подобных шлюзов, вероятно, пойдет в сторону встроенной аналитики попаданий, адаптивных TTL и поддержки распределенных кластеров Redis с сохранением бинарного пути данных.