MAATRIX / Блог / Контекст вырос до 100 тысяч токенов, и ответы стали по четыре минуты

Контекст вырос до 100 тысяч токенов, и ответы стали по четыре минуты

MAATRIX

Ассистент отвечал за полторы секунды всю неделю, а потом внутри одной сессии начал думать по четыре минуты — без деплоя, без изменений в коде, без алертов от Prometheus про CPU или диск. Разбираем, как безобидная привычка "не обрезать историю диалога" тихо довела контекст до 100 тысяч токенов и что с этим пришлось делать на живом сервере.

Что случилось

Внутренний ИИ-агент для поддержки — самостоятельно хостился на GPU-сервере, модель на 13B параметров, backend vLLM, фронт — обычный чат с сохранением истории на сессию. Пользователи — операторы поддержки, которые весь день ведут один и тот же диалог с ассистентом: подкидывают тикеты, просят резюме, уточняют детали, иногда переспрашивают то же самое другими словами.

Первые жалобы пришли не от всех сразу, а от двух-трёх операторов, которые сидели в системе с утра и не закрывали вкладку. К обеду их ответы стали приходить не за секунду-две, а за минуту, потом за две, к вечеру — за четыре. У остальных, кто заходил заново или обновлял страницу, всё было быстро. Это сразу отсекло версию "модель сломалась" — модель отвечала нормально всем, кроме тех, кто долго не начинал сессию заново.

Формально ничего не падало: сервис отвечал 200, healthcheck был зелёным, GPU не переходил в OOM-краш. Просто конкретные ответы шли невыносимо долго, и это медленно убивало доверие операторов к инструменту — они начали жаловаться, что "бот тупит", хотя по факту бот отвечал корректно, просто очень поздно.

Что показали логи и метрики

Первым делом посмотрели на дашборд vLLM в Grafana. Там сразу бросились в глаза две вещи:

  • num_requests_running держался в пределах нормы — очередь не копилась горой;
  • зато время до первого токена (time to first token) у "старых" сессий росло почти линейно в течение дня, а у новых — оставалось стабильным.

Это уже намекало, что дело не в общей нагрузке на сервер, а в чём-то, что привязано к конкретной сессии. Полезли в логи backend'а чата — там пишется длина запроса в токенах перед отправкой в модель. У проблемных операторов на утро это было 3-4 тысячи токенов, а к вечеру — за 90 тысяч. У тех, кто перезапускал диалог, длина каждый раз падала обратно к паре тысяч.

Дальше — метрика использования GPU-памяти (nvidia-smi в связке с экспортёром метрик для GPU и метрика gpu_cache_usage_perc самого vLLM). Использование KV-cache к вечеру подбиралось к максимуму, выделенному под --gpu-memory-utilization. Одновременно в логах vLLM стали появляться записи о preemption — движок вытеснял часть последовательностей из GPU-памяти, чтобы освободить место под новые запросы, а при возврате пересчитывал их заново.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Какие гипотезы отбросили

По порядку, что проверили и вычеркнули:

  • Сеть и балансировщик. Посмотрели время в очереди на уровне nginx и таймауты upstream — там всё укладывалось в миллисекунды, задержка возникала строго внутри времени обработки самой модели, а не до неё.
  • Троттлинг GPU по температуре. Проверили частоты и температуру карты за весь день — карта не проседала по clock speed, троттлинга не было. Это отдельный класс инцидентов (мы разбирали похожий случай, когда причиной оказалась банально забитая пылью система охлаждения), но здесь совпадений не нашли.
  • "Шумный сосед" на том же GPU. На сервере крутился ещё один сервис с редкими batch-задачами. Сопоставили тайминги — пики batch-задач не совпадали с моментами деградации у конкретных операторов, отбросили.
  • Устаревшая версия модели или битый чекпоинт. Проверили хэш весов и версию образа — не менялись уже три недели, инцидент начался внезапно посреди недели без деплоя.
  • Утечка памяти в самом сервисе чата (backend). Смотрели RSS процесса backend — рос предсказуемо и не коррелировал с задержками ответов модели.

Всё это отняло больше времени, чем хотелось бы, потому что первая интуиция была "надо смотреть на инфраструктуру", а не на то, что именно отправляется в модель на каждый запрос.

Настоящая причина: контекст растёт быстрее, чем кажется

Backend чата на каждый новый вопрос пользователя отправлял в модель всю историю диалога целиком — все предыдущие сообщения оператора и все ответы ассистента, без обрезки и без суммаризации. Задумывалось это как "фича": ассистент должен помнить весь контекст тикета. На коротких сессиях это работало отлично и было незаметно.

Проблема в том, что стоимость обработки запроса растёт не линейно и не бесплатно с длиной контекста:

  • Префилл (prefill) — обработка входного контекста перед генерацией первого токена — по вычислениям масштабируется хуже, чем сама генерация: механизм внимания трансформера обсчитывает пары токенов, поэтому с ростом длины диалога время на "прочитать всю историю заново" растёт заметно быстрее, чем линейно.
  • Каждый токен контекста хранится в KV-cache на GPU. Чем длиннее диалог, тем больше памяти он занимает — и тем меньше остаётся места под KV-cache других одновременных сессий.
  • Когда суммарный KV-cache всех активных запросов упирается в лимит, заданный флагом --gpu-memory-utilization, vLLM вынужден вытеснять (preemption) часть последовательностей из GPU-памяти. При следующем обращении такой сессии её контекст не подгружается мгновенно, а пересчитывается заново с нуля — то есть весь префилл на 90+ тысяч токенов выполняется повторно.

Вот и получился порочный круг: чем дольше жила сессия оператора, тем длиннее становился контекст → тем больше памяти он занимал → тем чаще его вытесняли под более "свежие" запросы → тем чаще приходилось пересчитывать префилл с нуля на всё большем и большем объёме текста. Именно поэтому у операторов, которые не закрывали вкладку весь день, деградация нарастала почти монотонно, а у тех, кто перезапускал сессию, всё оставалось быстрым — их контекст каждый раз начинался заново с нуля.

Отдельно выяснилось, что prefix caching (кеширование префикса запроса между обращениями, чтобы не пересчитывать одинаковое начало диалога заново) в конфигурации сервиса не был включён. Без него даже без вытеснения каждый новый вопрос в длинном диалоге означал пересчёт всей предыдущей истории с нуля, а не только нового хвоста сообщения.

Как разложили по цифрам: почему именно так

Мы намеренно не приводим здесь точные цифры "столько-то токенов в секунду" или "столько-то миллисекунд на тысячу токенов" — это сильно зависит от модели, квантования, конкретной видеокарты и версии vLLM, и на вашем железе числа будут другими. Но качественная картина воспроизводится стабильно на любом self-hosted инференсе:

  • задержка ответа в длинном диалоге без prefix caching растёт заметно быстрее, чем длина диалога — не line in line, а с ускорением;
  • при упоре в лимит GPU-памяти добавляется вторая, более резкая ступенька: не просто "медленнее", а "пересчитать всё с нуля";
  • эти два эффекта складываются, и на сессии, разросшейся до сотни тысяч токенов, суммарное время ответа может улетать в единицы минут там, где на коротком диалоге всё укладывалось в секунды.

Мы проверили это на собственном стенде: подняли vLLM на VPS с GPU и прогнали один и тот же диалог, постепенно наращивая историю без обрезки — с включённым и выключенным --enable-prefix-caching. Без кеширования префикса время до первого токена на длинном контексте ощутимо просаживалось при каждом повторном обращении к вытесненной сессии; с включённым — просадка была заметно мягче, потому что повторяющуюся часть диалога не нужно было гонять через модель заново. Абсолютные цифры вашего окружения будут другими — но направление эффекта воспроизводится надёжно.

Что изменили после инцидента

Правки внесли в три слоя — конфигурацию инференса, backend чата и мониторинг.

Конфигурация vLLM. Включили кеширование префикса и явно ограничили максимальную длину контекста, чтобы не давать сессиям расти бесконтрольно:

vllm serve /models/support-13b \
  --max-model-len 32768 \
  --enable-prefix-caching \
  --gpu-memory-utilization 0.90 \
  --max-num-seqs 24 \
  --swap-space 8

--max-model-len здесь работает как жёсткий потолок: запрос, который вместе с историей превышает лимит, backend обязан обрезать до отправки в модель, а не надеяться, что модель сама как-нибудь справится с чем угодно.

Backend чата. Убрали привычку слать всю историю целиком. Сделали два уровня памяти:

  • "рабочий" контекст — последние N сообщений диалога (подобрали по факту, ориентируясь на характер тикетов у конкретной команды поддержки);
  • "долгая" память — краткое резюме более старой части диалога, которое пересобирается раз в несколько сообщений отдельным быстрым вызовом модели, а не хранится как сырой текст.

Это тот же принцип, что описан в материале про управление контекстным окном для агентов — контекст не должен расти бесконтрольно только потому, что "так проще было запрограммировать".

Мониторинг. Раньше в Grafana следили за общей загрузкой GPU и очередью запросов, но не следили за распределением длины контекста по активным сессиям. Добавили отдельную панель с длиной контекста по сессиям (перцентили, а не только среднее) и алерт на долю запросов с preemption — раньше эта метрика вообще не выводилась на дашборд, хотя vLLM её отдаёт.

Заодно пересчитали, сколько памяти реально нужно под KV-cache при ожидаемой у команды длине диалогов — расчёт похож на тот, что описан в статье про цену контекста в 128k токенов в памяти и скорости: выяснилось, что текущая видеокарта тянет нужное количество параллельных длинных сессий с запасом, только если контекст действительно ограничен потолком, а не растёт бесконтрольно — так что апгрейд железа не понадобился, обошлись конфигурацией и логикой backend'а.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему вытеснение (preemption) приводит именно к пересчёту, а не к паузе?

Потому что PagedAttention в vLLM хранит KV-cache постранично на GPU; когда страницы физически освобождаются под другой запрос, восстановить состояние можно либо подкачкой из CPU-памяти (--swap-space), либо полным пересчётом префилла. Если swap-пространства не хватает или оно не настроено, движок откатывается к пересчёту — это дороже по времени, но не роняет запрос с ошибкой.

Разве увеличение --gpu-memory-utilization не решает проблему?

Частично отодвигает порог, но не устраняет саму причину — контекст всё равно растёт без ограничений, и вы просто позже упрётесь в тот же эффект, когда сессий станет больше или диалоги — длиннее. Лимит контекста и суммаризация истории решают проблему по существу, а не только отодвигают её.

Как понять, что у нас похожая проблема, до того как операторы начнут жаловаться?

Смотреть не только на среднюю задержку, а на перцентили времени ответа в разрезе возраста сессии и на долю запросов с preemption. Если 95-й перцентиль растёт в течение дня, а не только в пиковые часы нагрузки — это характерный признак накапливающегося контекста, а не общей нехватки мощности.

Нужен ли prefix caching, если диалоги у нас короткие?

Эффект от него тем заметнее, чем длиннее типичный диалог и чем чаще повторяется общий системный промпт между запросами. На коротких разовых обращениях выигрыш скромный, но включать его всё равно стоит — накладных расходов на короткий контекст он практически не добавляет.

Можно ли просто не хранить историю на сервере, а хранить на клиенте?

Технически да, но проблему это не решает — модель на бэкенде всё равно получит тот же объём контекста при каждом запросе, если клиент присылает его целиком. Ограничивать нужно именно то, что реально уходит в модель, а не место хранения истории.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →