Тот самый момент, когда ваш агент начинает тормозить
Вы запускаете локального агента с долговременной памятью. Первые несколько диалогов — летает. Потом начинает задумываться. Ещё через час вы уже успеваете сходить за кофе, пока он генерирует ответ. Знакомо? Проблема не в модели и не в железе. Проблема в том, что каждый новый запрос пересчитывает KV-cache с нуля.
KV-cache — это кэш ключей и значений внимания (Key-Value cache). Модель вычисляет его один раз для каждого токена контекста, и если контекст повторяется — можно не пересчитывать. В теории. На практике с долговременной памятью всё сложнее.
Что на самом деле происходит с памятью агента
Допустим, вы используете векторное хранилище для долговременной памяти. Агент получает запрос, ищет релевантные фрагменты из прошлого, добавляет их в промпт. Казалось бы — идеально. Но каждый раз, когда вы добавляете в промпт историю, модель заново вычисляет KV-cache для всей этой истории. Даже если 90% контекста не изменилось.
Представьте: у вас 16К токенов контекста. Из них 14К — системный промпт и история диалогов. 2К — новый запрос пользователя. Модель каждый раз пересчитывает те самые 14К. А это — время. И память. И терпение.
Почему стандартные подходы не работают
1 Наивное кэширование
Первая мысль — закешировать KV-cache на диск. Сохранить тензоры, загрузить при следующем запуске. Звучит логично, пока не сталкиваесь с реальностью:
- Размер. KV-cache для Llama 3.1 70B с контекстом 32К — это десятки гигабайт. На диске.
- Структура. Контекст не статичен. Вы добавляете новые диалоги, удаляете старые. Индексы смещаются.
- Совместимость. Перезагрузили модель с другими параметрами квантования? Прощай, кэш.
2 Разделение контекста
Другая идея — разделить контекст на статичную и динамичную части. Системный промпт и базовая история — в статичную. Новые диалоги — в динамичную. Вычисляем KV-cache для статичной части один раз, потом только добавляем к ней динамику.
Но здесь поджидает ловушка внимания. Механизм внимания в трансформерах работает со всем контекстом целиком. Если вы изолируете части — модель не увидит связей между ними. Это как дать человеку читать книгу, где каждая глава на отдельном листе, и запретить перелистывать.
Особенно критично для RAG-систем, где именно связи между историей и текущим запросом определяют качество ответа.
Практическое решение: гибридный подход
1 Слоистая архитектура KV-cache
Вместо того чтобы кэшировать весь контекст целиком, работаем с уровнями:
| Уровень | Что хранит | Частота обновления | Примерный размер |
|---|---|---|---|
| Базовый | Системный промпт, инструкции агента | Никогда (или при изменении промпта) | 2-4К токенов |
| Сессионный | Текущая сессия диалога (последние N сообщений) | Каждое новое сообщение | 4-8К токенов |
| Долговременный | Релевантные фрагменты из векторного хранилища | При изменении запроса | 2-6К токенов |
Базовый уровень вычисляем один раз и храним в памяти. Сессионный — обновляем инкрементально. Долговременный — пересчитываем только когда действительно меняется суть запроса.
2 Инкрементальное обновление с sliding window
Вот где начинается магия. Вместо полного пересчёта сессионного KV-cache используем sliding window:
- Храним KV-cache для последних N токенов диалога
- При новом сообщении пользователя добавляем его токены в конец
- Если окно переполняется — удаляем самые старые токены из начала
- Обновляем только attention для новых токенов
Это работает, потому что внимание в трансформерах — авторегрессионное. Новые токены зависят от старых, но старые не зависят от новых. Можно безопасно добавлять в конец, не пересчитывая всё.
3 Умный выбор из долговременной памяти
Самая ресурсоёмкая часть — поиск в векторизованной памяти. Вы же не хотите каждый раз пересчитывать KV-cache для всей вашей истории жизни?
Решение: кэшировать не сами фрагменты, а их эмбеддинги и метаданные. При новом запросе:
- Быстрый поиск по эмбеддингам (это дешевле, чем LLM-инференс)
- Проверка, не использовали ли мы уже эти фрагменты в предыдущих запросах
- Если фрагмент уже в кэше — берём его KV-cache из памяти
- Если нет — вычисляем и кэшируем
Получается двухуровневое кэширование: эмбеддинги на диске, KV-cache в оперативке.
Техническая реализация: что нужно знать
Выбор библиотеки и фреймворка
Не все фреймворки поддерживают манипуляции с KV-cache. vLLM — поддерживает. Text Generation Inference — поддерживает с оговорками. Ollama — вроде бы нет (по крайней мере, в публичном API).
Если вы используете трансформеры напрямую — у вас полный контроль. И полная головная боль. Потому что нужно:
- Следить за форматами тензоров (они различаются между моделями)
- Управлять памятью GPU (особенно актуально для карт с ограниченной VRAM)
- Обрабатывать edge cases (например, когда модель переключается между режимами)
Сериализация и десериализация
Хранить KV-cache на диске — не просто сохранить numpy array. Нужно:
- Сжимать (квантовать в меньший тип данных)
- Добавлять метаданные (размеры, версия модели, хеш промпта)
- Обеспечивать миграцию при обновлении модели
Квантование KV-cache — отдельная тема. В статье про Q8 KV-cache для vision-моделей есть хорошие примеры, но для языковых моделей пороги другие.
Не пытайтесь сохранять KV-cache в pickle. Серьёзно. Формат тензоров меняется между версиями PyTorch, да и безопасность оставляет желать лучшего. Используйте специализированные форматы вроде safetensors.
Типичные ошибки и как их избежать
Ошибка 1: Кэширование без валидации
Сохранили KV-cache, перезагрузили систему, загрузили кэш — а модель выдаёт бред. Почему? Потому что изменился системный промпт, или вы обновили лора-адаптеры, или сменили температуру генерации.
Решение: добавлять цифровой отпечаток (fingerprint) в ключ кэша. Хеш от:
- Модели (название + версия + параметры квантования)
- Системного промпта
- Параметров генерации (temperature, top_p, etc)
- Лора-адаптеров (если есть)
Ошибка 2: Игнорирование памяти
KV-cache растёт. И растёт. И вот уже 48 ГБ RAM из статьи про выживание на 48 ГБ кажутся смешной цифрой.
Решение: политика вытеснения (eviction policy). Самые простые варианты:
| Политика | Как работает | Когда использовать |
|---|---|---|
| LRU (Least Recently Used) | Удаляем то, что дольше всего не использовалось | Для сессионного кэша |
| LFU (Least Frequently Used) | Удаляем то, что реже всего использовалось | Для долговременного кэша |
| По размеру | Удаляем самые большие фрагменты | Когда важна скорость, а не релевантность |
Ошибка 3: Слепое доверие кэшу
Кэш ускоряет работу, но может привести к артефактам. Особенно если в долговременной памяти есть противоречивая информация.
Пример из практики: агент запомнил, что пользователь любит кофе. Потом пользователь сказал, что перешёл на чай. Но в векторном поиске всё равно всплывает "кофе", потому что этот фрагмент был в более ранних диалогах. Кэш сохраняет старое представление.
Решение: механизм инвалидации. Помечать фрагменты как "устаревшие" при явном противоречии. Или просто не кэшировать спорные фрагменты.
Интеграция с существующими системами
Если вы уже используете локальную LLM с долговременной памятью, добавление KV-cache кэширования потребует:
- Модификации пайплайна генерации (вставить хук между сборкой промпта и вызовом модели)
- Системы хранения (память + диск для холодного хранения)
- Механизма инвалидации (когда кэш устаревает)
Самый простой способ начать — обернуть вызов модели в декоратор, который проверяет кэш. Грубо, но работает для прототипа.
Когда это НЕ нужно делать
Да, бывают случаи, когда оптимизация KV-cache только вредит:
- Короткие диалоги (менее 10 сообщений) — оверхед от кэширования перевесит выгоду
- Часто меняющийся системный промпт — если инструкции агента меняются каждый запрос, кэш бесполезен
- Очень маленькие модели (до 7B параметров) — они и так быстрые, оптимизация не даст заметного прироста
- Когда память дороже времени — если у вас ограниченная RAM/VRAM, лучше потратить время на пересчёт, чем место на хранение
И главное — не начинайте с оптимизации. Сначала убедитесь, что у вас действительно есть проблема. Замерьте время генерации до и после добавления долговременной памяти. Если разница меньше 20% — возможно, проблема не в KV-cache.
Что будет дальше
Сохранять KV-cache вручную — это костыль. Пусть и эффективный. Настоящее решение придёт со стороны фреймворков и самих моделей.
Уже сейчас появляются модели с архитектурными улучшениями для долговременной памяти. Mamba, RWKV — у них вообще нет квадратичной сложности внимания. А значит, и проблемы KV-cache в классическом понимании.
Другой путь — аппаратный. Специализированные AI-ускорители с огромной быстрой памятью. Когда можно хранить весь KV-cache в SRAM, а не выгружать на диск.
А пока что — да, приходится изобретать велосипеды. Но зато какие велосипеды получаются.
И последний совет — не зацикливайтесь на оптимизации. Основные ошибки при запуске локальных LLM обычно лежат в более простых вещах: неправильная квантовка, неоптимальные параметры генерации, кривой системный промпт. Сначала исправьте их, потом беритесь за KV-cache.