Вы запустили LLM-инференс в продакшене. Поток запросов не менялся, нагрузка та же, что вчера, — а Time to First Token внезапно вырос в три раза. Первая мысль: что-то с моделью. На деле причина почти никогда не в модели — она прячется в планировщике, аллокаторе GPU-памяти, очереди стримингового вывода или сети.

Чем длиннее контекст, тем больнее. Для классического attention вычислительная сложность растёт очень быстро. KV-кеш раздувается до десятков и сотен гигабайт, а в облаке всё это происходит на совершенно произвольном трафике: у одного клиента кодовый ассистент, у другого — аналитика на миллион запросов в день, у третьего — голосовой робот. На таких смешанных нагрузках всплывает то, чего не видно на референсных замерах вендора.

Привет! Меня зовут Андрей, я занимаюсь разработкой сервисов машинного обучения для Yandex AI Studio — платформы, которая объединяет модели и инструменты Яндекса, чтобы вы могли создавать собственные ИИ-решения и внедрять их в бизнес‑процессы и продукты. Чтобы запустить инференс флагманских моделей в продакшене, нам пришлось глубоко закопаться в опенсорс, найти и починить баги в чужом коде — суммарно больше 50 контрибьютов в разные репозитории — и переписать куски рантайма под свои конфигурации.

Эта статья точно для вас, если вы:

  • Уже запускали LLM-инференс в продакшене.

  • Видели, как метрика Time to First Token (TTFT) выросла в 3 раза под нагрузкой, — и не понимали, почему.

  • Думали, что проблема в модели, а оказалось — в планировщике или сети.

Дальше расскажу, на какие проблемы наткнулись на смешанной нагрузке и какие оптимизации внедрили: от разделения prefill и decode и контекстного параллелизма до спекулятивного декодирования, балансировки с учётом кешей и доставки 700-гигабайтной модели на кластер.

Как устроен инференс: метрики, примитивы, стек

Прежде всего скажу, что в машинном обучении есть две ключевые задачи: обучение моделей и их инференс. Про обучение LLM в этой статье говорить не будем: это отдельная большая область со своими командами, инфраструктурой и метриками. 

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

Какие метрики нас будут интересовать в этом случае? По сравнению с абстрактной бэкенд-разработкой в инференсе их больше. 

  1. Время до первого токена, TTFT — промежуток времени от момента, когда модель получила запрос и до того, как она написала первое слово. 

  2. Время между токенами, ТВТ (Time between tokens). Токен — это единица, на которую токенизатор разбивает текст: часть слова, слово, знак препинания или фрагмент кода. В среднем для обычного текста один токен часто соответствует нескольким символам, но точное соотношение зависит от языка и токенизатора.

  3. Время полного ответа, U2E (User to end) — время от получения пользовательского запроса до завершения генерации ответа. Эта метрика особенно важна для сценариев, где пользователю нужен не первый токен, а готовый результат целиком.

  4. Пропускная способность на единицу ресурсов, то есть на одну видеокарту — измеряется в токенах в секунду на GPU.

  5. Стоимость инференса миллиона или тысячи токенов.

Важно понимать, что нельзя оптимизировать все эти метрики одновременно. В зависимости от сценария, мы делаем выбор в пользу какой-то из метрик: времени до первого токена, или времени полной генерации, или же пропускной способности. 

Например, в случае голосового робота, который должен отвечать пользователю синхронно, требуется маленькое время до первого токена. Странно, если я куда-нибудь позвоню, и модель мне будет отвечать через десять секунд. 

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

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

Длинный контекст — главная боль

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

Для классического attention стоимость обработки контекста растёт очень быстро: чем длиннее вход, тем дороже prefill по вычислениям и памяти. В современных реализациях есть оптимизации attention, но общий эффект остаётся: длинные контексты резко увеличивают TTFT, объем KV-кеша и нагрузку на GPU.

Если контекст вырос с 4 000 до 32 000 токенов, нагрузка на обработку входа может вырасти кратно. Поэтому длинный и короткий контекст, обрабатываемые параллельно, начинают мешать друг другу. Для решения этой проблемы инференс часто разделяют на две стадии: prefill и decode — обработку входного запроса и генерацию ответа.

Более того, длинный контекст и короткий контекст, обрабатываемые параллельно, мешают друг другу. 

В нашей архитектуре мы отдельно управляем prefill- и decode-нагрузкой: для них важны разные ресурсы и разные SLO. Prefill чувствителен к длине входа и compute, decode — к памяти, batch scheduling и стабильному TBT. Такое разделение позволяет не давать длинным запросам «забивать» интерактивные сценарии и отдельно оптимизировать каждый этап.

Prefill — это compute‑bound задача. Она считает все эти матрицы с квадратичной сложностью, где есть большие контексты. 

Decode — это скорее memory-bound задача. Там модель генерирует токены по одному, так оптимизируется та самая метрика TBT. 

Современные GPU так устроены, что у операций на GPU нет приоритетов, неважно, prefill или decode — всё выполняется независимо. Если делать всё в рамках одного сервера, все будут страдать. Раньше в Yandex Cloud мы разделяли инференс на пользователей с короткими и длинными запросами: чтобы короткие запросы не провисали. 

Важно сказать пару слов про KV-кеш. Всем знаком кеш в бэкенд-разработке: пришёл запрос, мы видим, что такой запрос уже был, и возвращаем ответ. В случае с инференсом LLM это работает не так, потому что запрос представляет собой абстрактный текст. Кешировать полный текст запроса почти бесполезно: целые запросы редко совпадают байт-в-байт. Зато в диалогах, агентских сценариях и RAG часто повторяются длинные префиксы: системный промпт, история диалога, инструкции, документы. Именно такие префиксы и имеет смысл переиспользовать через KV-кеш.

Без маршрутизации с учетом локальности кеш есть, но доля попаданий в него остаётся низкой.
Без маршрутизации с учетом локальности кеш есть, но доля попаданий в него остаётся низкой.

KV-кеш в LLM — это не кеш готовых ответов. Во время обработки входного текста модель строит промежуточные key/value-состояния для attention. Если в следующем запросе повторяется длинный префикс — например, системный промпт, история диалога или набор документов в RAG, — эти состояния можно переиспользовать и не пересчитывать префикс заново.

Цена такого ускорения — память. Для больших моделей и длинных контекстов KV-кеш занимает десятки и сотни гигабайт, поэтому в продакшене важно не только уметь кешировать, но и правильно управлять памятью, вытеснением и маршрутизацией запросов.

Например, модель DeepSeek-V3.2 весит 700 ГБ, а кеши занимают десятки и сотни гигабайт. Как правило, в LLM-моделях надо заниматься тем, чтобы как-то оптимизировать объём кеша и улучшать кеш-хит. 

Целевое решение: может ли помочь опенсорс из коробки

Самое время определиться с архитектурой — образом того, что мы строим в облаке. У нас есть большая языковая модель, стадия prefill и decode, есть некоторый кеш. Задача построить такую систему, чтобы у нас был кластер, состоящий из виртуальных машин с GPU. Все эти виртуальные машины связаны некоторой быстрой сетью, и над ними поставлен умный балансировщик, который умеет учитывать распределение кешей.

Многие в мире уже разрабатывают подобные системы, есть решения с открытым исходным кодом: например, vLLM, SGLang, TRT-LLM. Всё уже есть, но эти фреймворки дают данные на референсных замерах. А в облачной платформе мы сталкиваемся с тем, что нагрузка совершенно произвольна. У кого-то может быть сценарий аналитики, у кого-то сценарий кода, у кого-то сценарий голосового робота. 

Поэтому на таких смешанных нагрузках всплывает много подводных камней, которые нам предстояло решить. 

Мы начали с того, что взяли модель DeepSeek-V3.2 и развернули опенсорс-решение. Померили и начали проводить внутреннее тестирование. Дальше возникли определённые проблемы: расскажу, как их решали и какие оптимизации для этого внедряли. 

Кейс 1: замедление decode и узкое место в аллокаторе

В начале мы столкнулись с тем, что после выпуска рабочей модели все тексты в какой-то момент начали писаться в три раза медленнее. Нагрузка на карты была стабильно высокая, поток запросов внешне никак не поменялся, а производительность очень сильно просела. 

Мы достаточно глубоко закапывались в опенсорс-проект и правили код: суммарно больше 50 контрибьюшенов в разные репозитории. Однако покопавшись в строках, мы смогли выяснить, что при смешении коротких и длинных запросов происходили частые аллокации и освобождения GPU-памяти для промежуточных буферов в одном из kernel path, специфичном для DeepSeek. На смешанной нагрузке это било по decode: память становилась узким местом, и TBT резко рос.

Эту локацию мы поправили, законтрибьютили в опенсорс и получили то время между токенами, которое указывал DeepSeek в своих отчётах. 

Кейс 2: потеря токенов очереди потокового вывода

Но на этом наши проблемы не закончились. Дальше пришли ребята, которые хотели писать код с использованием всевозможных кодовых ассистентов. Здесь часто бывает потребность генерировать такой ответ модели, который имел бы какую-то структуру, например JSON, — потом его можно распарсить и дальше с этим работать. 

Внезапно выяснилось, что на 5% запросов JSON оказывается неполным. Там буквально не было куска текста, естественно, это портило всю бизнес-логику клиентов. 

Мы снова разбирались в опенсорс-коде: с кодовыми ассистентами, дебагом и логами. Проблема оказалась в streaming path SGLang и воспроизводилась только при высокой конкуренции запросов и определённой комбинации параметров. Очередь могла содержать несколько готовых фрагментов, а обработчик после чтения одного элемента очищал буфер целиком. В результате часть токенов терялась, и структурированный ответ, например JSON, становился неполным.

Фикс достался нам: проблема не проявлялась на простых сценариях и долго оставалась незаметной. Мы поправили реализацию и отправили изменение в опенсорс.

Кейс 3: полное дублирование GPU vRAM в RAM

Дальше мы натолкнулись на работу с KV-кешами, у которых в инференсе три уровня: 

  • уровень памяти видеокарты, 

  • уровень оперативной памяти, 

  • уровень дисков. 

А раз KV-кеши большие по объёму, нам нужно использовать всю память, которая есть. 

Реализация кеширования в SGLang архитектурно была устроена так, что объём памяти, выделенный под GPU-кеш, полностью дублировался в оперативной памяти. Порядок чисел около 700 ГБ — это суммарная память видеокарт, а 2 ТБ — это оперативная память. Мы на ровном месте теряли почти треть оперативки (которая нынче дорогая). 

Такое решение хоть и упрощало реализацию, но для наших конфигураций оказалось слишком дорогим: RAM фактически резервировалась под зеркало GPU-кеша, хотя мы хотели использовать её как самостоятельный уровень иерархии KV-кеша.

Мы изменили реализацию так, чтобы не дублировать GPU cache в RAM, и освободили порядка 700 ГБ оперативной памяти на узел, тем самым увеличив кеш и улучшив кеш-хит.

Кейс 4: параллелизм по контексту для DeepSeek 

Кажется, что мы поправили все проблемы, связанные с багами в опенсорсе на конкретной модели, нагрузках и сценариях. Однако скорость ответов нас всё равно не устроила, и мы продолжили оптимизацию. 

Итак, есть длинный контекст на входе, prefill и decode разделены. Но инференс устроен так, что prefill запроса всё равно обрабатывается на одной видеокарте и может упираться в её вычислительную способность. Получается большой темп TTFT. 

Однако есть такая техника, как параллелизм по контексту (CP – context parallelism): длинный prefill считается сразу на восьми видеокартах, как правило, в рамках одного вычислительного узла. Технику уже внедрили в vLLM, в SGLang — но не поддержали её вместе с DeepSeek-V3.2, потому что модель и архитектура новые, там был новый sparse attention. 

Пришлось поддерживать контекстный параллелизм для DeepSeek-V3.2 и контрибьютить его в SGLang. В ходе наших тестов на восьмикарточном сервере удалось сократить Time to First Token в четыре раза при работе с очень длинным контекстом, достигающим 160K токенов. Это максимальный контекст, поддерживаемый DeepSeek. 

Вот какие замеры получаются для одного запроса на 160K токенов:

  • без CP (TP = 8): TTFT = 36,9 с

  • с CP (TP = 8, CP = 8): TTFT = 8,8 с (−76%)

Кейс 5: спекулятивное декодирование в продакшене 

Помимо этого мы занимались внедрением спекулятивного декодирования — это оптимизация decode-стадии.

Современная модель, когда происходит decode, генерирует ответ по одному токену. Спекулятивное декодирование работает так: обучается специальная маленькая модель, условно в 100 раз меньше, чем основная. Она заранее пытается угадать токены, которые сгенерировала бы большая модель. При этом маленькая модель генерирует не один токен, а например 20. Большая LLM за один проход валидирует все 20 токенов и в лучшем случае говорит: да, они совпадают с тем, что сгенерировано. Если же совпадает только часть, тогда генерация продолжается с определённого момента. 

Это позволяет очень сильно сэкономить время на decode. Понятно, что всё зависит от профиля нагрузки и ещё очень многих факторов. В конкретной конфигурации и на нашем профиле нагрузки TBT снижался примерно до 5 раз относительно baseline без speculative decoding. На некоторых сценариях эта цифра гораздо меньше, на других — гораздо больше, но в среднем примерно так. 

Итак, у нас получилось внедрить новую модель и провести её оптимизацию с точки зрения стадий prefill и decode, а также починить разные баги. 

Куда ещё посмотреть в поисках эффективности

Осталось оптимизировать KV-кеш, но не только. Нужно помнить, что мы на самом деле работаем в облаке, и есть некоторое количество ограничений, которые накладываются. Поэтому также взглянем в сторону железа и сетей.

Липкие сессии 

Начать можно с того, что изначально у нас есть кеш в рамках каждого узла, и мы маршрутизируем запросы пользователей как попало. 

Когда мы делаем это с помощью Round-robin, стандартного способа балансировки, то как-то попадаем в кеш, как-то не попадаем. Кеш-хит 5–10% — означает, что из кеша вынуто 5–10% всех токенов. А современные агентские сценарии устроены так, что есть чат, и он постоянно пополняется: всегда есть общий префикс, в который просто доливаются тексты, и понятно, что префикс постоянно повторяется. 

Хочется такие запросы всегда обрабатывать в рамках одного и того же узла, и ввести привязку по сессиям, когда один и тот же диалог обрабатывается одним узлом. Мы это внедрили и поддержали из коробки Responses API, который сделан под агентский сценарий. 

На нашем профиле трафика это дало кеш-хит порядка 30% по токенам и заметно снизило затраты на prefill. В сценариях с длинными повторяющимися префиксами это напрямую конвертируется в меньший TTFT и более эффективное использование GPU.

Однако этот способ балансировки имеет проблемы, потому что происходит неравномерная нагрузка на железо: если узел вышел из строя и кеш потерялся, произойдет перебалансировка. 

Балансировка с учётом кешей

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

Такой более умный способ балансировки, во-первых, решает проблему неравномерной нагрузки на железо, во-вторых, ещё сильнее улучшает кеш-хит. В наших замерах кеш-хит доходил примерно до 54% токенов на характерном профиле нагрузки.

Естественно, это зависит от нагрузки и её профиля: если засылать одинаковые запросы на синтетике, то будет почти 100%, если совсем рандомные, то 0%. Но вот в среднем получилось 54%. 

Конечно, это сказывается на TTFT, потому что модель не пересчитывает уже обработанную часть префикса, а переиспользует сохранённые KV-состояния. Поэтому prefill становится короче, и TTFT уменьшается.

Проблемы IPv6, железа и доставки данных

Помимо проблем с железом, когда перегрелась или сгорела сетевая карта, или выпала GPU, существует трудность, связанная с тем, что многие фреймворки по-прежнему плохо работают с Dual-Stack-сетями. В паре мест поддержку Dual-Stack приходилось коммитить: некоторые фреймворки хотели только IPv4, другие не работали с Dual-Stack. 

Ещё одна проблема: модели надо как-то доставлять на кластер. 

DeepSeek весит 700 ГБ. Если скачивать из какого-нибудь S3 или с Hugging Face, может оказаться долго, да и скачивание может упасть. В итоге часть серверов запустится, часть не запустится. Поэтому надо здесь правильно настроить подгрузку модели. Например, заранее разложить её на какие-то быстрые диски и подгружать LLM в память уже оттуда, или как-то обмениваться частями модели между серверами по интерконнекту. 

Помимо этого необходимо сразу же делать прогрев модели и создать несколько запросов. Проверить, что они работают, чтобы пользователи не видели никаких проблем в продакшен-среде. Часто бывает, что первые несколько запросов модели работают медленно, а потом быстро. 

Также важный блок в продакшен-сервисе — это observability. Здесь мы тоже столкнулись с некоторыми сложностями. Например, vLLM-сервер падал при включённом трейсинге, если пользователь делал отмену запроса. Здесь тоже внесли некоторое количество контрибьюшенов, чтобы у нас заработал трейсинг и логи были нормальными. Это позволяло быстрее обнаруживать, где находится проблема, и быстрее вносить фиксы. Все наши 50 контрибьюшенов во многом состоялись благодаря хорошей observability. 

Выводы

Подводя итог, в этой истории не было одной волшебной оптимизации, которая сразу сделала инференс быстрым и дешёвым. Результат сложился из большого числа инженерных решений: разделения prefill и decode, исправлений в рантайме, контекстного параллелизма, спекулятивного декодирования, работы с KV-кешем и липкими сессиями, маршрутизации с учётом кешей, прогрева моделей, observability и инфраструктурных доработок — от GPU-памяти до сети и доставки весов.

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

Главный вывод: инференс больших моделей — это не только про саму модель. Это отдельная инженерная система вокруг неё. И прежде чем инвестировать в собственный LLM-инференс, важно понимать, что здесь нужны не только GPU, но и экспертиза, которая копится месяцами или даже годами.

Если вам интересно узнать больше о том, что происходит у нас на кухне разработки, — заходите в сообщество Yandex AI Studio в Telegram. Там можно позадавать вопросы, обсудить детали и первыми узнавать о новых возможностях платформы. А если хочется следить за тем, что вообще происходит в Yandex Cloud, — подписывайтесь на канал Inside Yandex Cloud.