Как мы строили production banking AI chatbot и почему семантическое кэширование с Redis спасло проект. Практический опыт Node.js-разработчика.
Когда наша команда запустила проект банковского AI-чат-бота, я искренне считал свою задачу решённой. Я был Node.js-разработчиком. Моя часть звучала почти унизительно просто: открыть WebSocket при подключении пользователя, отправить его сообщение в AI-сервис, вернуть ответ потоком. И всё. WebSockets — моя вторая натура, я собирал real-time системы годами. Я вышел с kickoff-встречи, думая: «Это будет легко». Самая большая ложь в разработке.
Пользователь
│
▼
WebSocket
│
▼
LLM
│
▼
Пользователь
Так выглядела первая версия архитектуры. Четыре блока. Я гордился ей. Она продержалась меньше недели.
Через несколько дней на синхронизации с AI-командой я задал вопрос, оказавшийся пророческим: «Что будет, если кто-то спросит бота, кто выиграл вчерашний матч IPL?» Никто не ответил сразу. Кто-то засмеялся, решив, что я шучу. Лид AI задумался: «Подожди… а что на самом деле произойдёт?» Мы открыли приложение и попробовали прямо во время созвона. Бот взял вопрос, поискал в базе знаний, полной ставок FD и условий кредитов, ничего похожего не нашёл, но уверенно ответил — что-то расплывчатое вроде «пожалуйста, обратитесь в отделение по вопросам, связанным с матчами», склеенное из ближайших фрагментов векторного поиска. Сначала было смешно, потом — нет. У нашего чат-бота не было понятия «это не моё дело». А я уже знал из многолетнего опыта: дай пользователю половину шанса — и он задаст странный вопрос. Не со зла, просто чат-бокс провоцирует. Нам нужен был привратник — что-то, что сядет перед всем остальным и решит: это вообще наша проблема? Так появилось определение намерений (intent detection).
Пользователь
│
▼
Intent Detection
│
▼
Банковское?
│
├── Да → RAG → LLM → Пользователь
│
└── Нет → Отклонение / перенаправление
AI-команда дала мне один endpoint: отправляешь запрос, получаешь намерение и метаданные. С моей стороны — десять строк. Я был счастлив. Я ещё не знал, что подписался на долгую историю.
Слой определения намерений работал. Демо прошло отлично. Клиент был доволен. Все улыбались. А потом кто-то реально измерил нагрузку. Каждый запрос — даже скучный «какая сегодня ставка FD» — теперь проходил полный LLM-вызов только для того, чтобы понять, куда направить вопрос, прежде чем попасть к LLM, который даст ответ. Этот крюк стоил 200–300 мс каждый раз. Мы подняли вопрос на следующем ревью. Лид AI спросил: «Сурадж, можем это закэшировать?» Я уверенно ответил: «Да, конечно. Redis». Мгновенный ответ, который даёшь, когда сказал «Redis» так много раз в карьере, что он вылетает раньше, чем ты обдумал вопрос. Через пять секунд до меня дошло: «…А что именно кэшировать?»
Моим первым инстинктом было сохранить запрос и ответ как ключ-значение. «Какая сегодня ставка FD?» → кэш → HIT ✅. Здорово, пока следующий тест: «Не подскажете сегодняшнюю процентную ставку по фиксированному депозиту?» → кэш → MISS ❌. Тот же вопрос, то же намерение, другая строка. Пользователи не повторяют одну и ту же фразу дважды, а точное совпадение знает только символы, не смысл.
Вторая попытка: кэшировать по намерению, а не по тексту. Если классификатор уже сказал, что это FD_RATE, почему бы не использовать это как ключ? Выглядело гениально день. Потом кто-то задал два вопроса подряд: «Что такое ставка FD?» → FD_RATE, «Какие преимущества FD?» → FD_RATE. Одно намерение, совершенно разные вопросы и ответы. Если бы я отдал кэшированный ответ о ставке тому, кто спрашивает о преимуществах, я бы вручил неверный ответ с полной уверенностью — что, пожалуй, хуже, чем отсутствие кэша.
Мы были на кофе-брейке, когда наш архитектор взял салфетку со стола. Ничего не сказал. Просто написал два слова и подвинул: «Семантический кэш». — «Почитай об этом сегодня вечером», — сказал он и вернулся к кофе. Я кивнул, делая вид, что понял. Я не понял. В тот вечер я открыл документацию Redis. Прочитал страницу про векторный поиск раз. Закрыл вкладку. Открыл снова. Redis поддерживает векторный поиск? С каких пор? Я проверил changelog, наполовину уверенный, что неправильно прочитал. Оказалось, Redis поддерживает это уже давно. Просто я раньше не нуждался в этом, поэтому не искал. Около полуночи до меня дошло: вместо сопоставления точного текста запроса мы преобразуем его в эмбеддинг — вектор, который отражает смысл, — и сравниваем с ранее сохранёнными эмбеддингами. Если похоже, отдаём кэшированный ответ, не тратя ресурсы на поиск и генерацию.
Запрос пользователя
│
▼
Эмбеддинг
│
▼
Векторный поиск Redis
│
▼
Сходство ≥ 0.90 ?
│
├── ДА → Возвращаем кэшированный ответ (быстрый путь)
│
└── НЕТ → Идём в RAG (полный путь)
Теперь «какая сегодня ставка FD» и «не подскажете сегодняшнюю процентную ставку по депозиту» оказываются достаточно близко в векторном пространстве, чтобы попасть в ту же запись, а «преимущества FD» остаётся достаточно далеко, чтобы промахнуться. Смысл, а не символы. Мы собрали это за неделю. В пятницу я шёл домой, впервые за проект думая: «Я становлюсь хорошим backend-инженером».
Утро понедельника. Кофе в одной руке, ноутбук в другой. Открываю Grafana, ожидая победного круга. Cache Hit Ratio: 8%. Я смотрел на это десять секунд. Обновил — то же число. Как это вообще возможно? Оказалось, выбрать 0.90 как порог сходства только потому, что статья на Medium использовала 0.90 — не то же самое, что выбрать 0.90 на основе тестов с собственным трафиком. Некоторые действительно похожие вопросы оказывались чуть ниже линии и промахивались. Несколько не связанных проскальзывали чуть выше. Мы потратили почти два дня, вытаскивая реальные логи запросов и вручную определяя, где на самом деле должна проходить линия. Когда это сделали, hit ratio вырос. Затраты упали. Уверенность ненадолго вернулась. Потом мы измерили end-to-end задержку — она почти не изменилась. Мы попробовали стриминг — отправлять токены по мере генерации, не дожидаясь полного ответа. Пользователям стало казаться быстрее. Клиент обожал демо. Но фактическое время выполнения не уменьшилось ни на миллисекунду. Стриминг исправил ощущение ожидания, но не саму работу.
На следующем архитектурном ревью присутствовал старший инженер — приглашённый со стороны, чтобы проверить дизайн. Он посмотрел на диаграмму на экране, потом улыбнулся: «Вы рубите овощи мечом». Комната засмеялась, не совсем понимая почему. Он не стал объяснять.
Он выдержал паузу, затем указал на блок с надписью Intent Detection — тот самый LLM-вызов, всё ещё стоивший нам 200–300 мс на каждый запрос. «Меч выглядит внушительно, — сказал он. — Нож — лучший инструмент для этой работы. Это не работа для полной модели. Это работа для классификатора». Маленький классификатор — это не LLM. Он ничего не генерирует. Его задача — прочитать запрос и сказать, к какому ведру он относится. Думайте о нём как о секретаре: он не решает вашу проблему, а просто указывает на нужную комнату.
Пользователь спрашивает: «Какая сегодня ставка FD?»
Классификатор возвращает:
{
"intent": "FD_RATE",
"confidence": 0.99
}
Мы заменили LLM-шаг определения намерений на маленькую transformer-модель, обученную на наших категориях. Та же точность для того, что нам реально нужно. Задержка упала с ~300 мс до 10–15 мс. На стороне Node.js почти ничего не изменилось — мой слой просто вызывает другой, гораздо меньший сервис:
const intent = await intentService.predict(query);
if (intent === "FD_RATE") {
return getFDRate();
}
if (intent === "RESET_PIN") {
return resetPin();
}
Позже мы узнали, что более зрелые системы выстраивают это ещё глубже: сначала дешёвые правила, потом маленький классификатор, и только для действительно неоднозначных случаев — полный LLM:
Запрос пользователя
│
▼
Движок правил
│
▼
Маленький классификатор
│
▼
Низкая уверенность?
│
Да ──────────► LLM-роутер (редко, дорого, но иногда нужно)
│
Нет
▼
Маршрутизация запроса
Быстро и дёшево для 95% очевидных запросов. Дорогая модель — только для 5%, которым это действительно нужно.
Ничто из вышеперечисленного не происходило в вакууме. Пока мы чинили intent и кэш, накапливался более тихий список проблем — неприглядных, которые никогда не попадают на слайды архитектуры, но абсолютно решают, выживет ли система в продакшене.
Ни одна из этих проблем не попала в питч-дек. Все они попали в продакшен.
Однажды вечером, уже далеко в проекте, я посмотрел на диаграмму архитектуры и пересчитал блоки. WebSocket, аутентификация, маленький классификатор, семантический кэш, ретривер, RAG, LLM, стриминг, очередь, мониторинг, аналитика, лимитер запросов.
Пользователь
│
▼
Маленький классификатор
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Семантический кэш Банковские Вне домена
│ │
Cache Hit? ▼
│ RAG поиск
▼ │
Возврат ответа ▼
LLM генерация
То, что начиналось как четыре блока и «это будет легко», превратилось в систему, где каждый блок решает настоящую проблему, обнаруженную в бою. Ни один из них не появился из-за того, что «это звучит круто». Каждый появился, потому что что-то сломалось, и пришлось это чинить.
Если вы строите AI-чат-бота для продакшена, начните с малого: используйте маленький классификатор для определения намерений вместо полного LLM — это сэкономит 200+ мс на каждом запросе. Внедрите семантическое кэширование на Redis с векторами, но обязательно тестируйте порог сходства на своих данных, а не берите с потолка. И помните: 90% проблем вскроются только под нагрузкой, поэтому закладывайте мониторинг, очереди и fallback-механизмы с первого дня. Прямо сейчас откройте свою IDE и добавьте в проект intent-классификатор на базе простой модели — это окупится сторицей.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →