Материал подготовлен в рамках курса «MLOps».
Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про то, как снизить стоимость инференса LLM на собственных GPU, не меняя чекпойнт модели и парк карт.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
Картина, которую я видел уже не раз. Команда за вечер поднимает открытую модель на vLLM, подключает к ней ассистента поддержки, демо проходит отлично. Через месяц приходят два сообщения.
-
От финансов: аренда GPU обходится как зарплата трёх разработчиков.
-
От продукта: в час пик клиенты ждут первый токен по 15–20 секунд.
Вы открываете nvidia-smi, видите утилизацию под 100% и делаете вывод, что нужны ещё карты.
Стоп! Часто проблема не в количестве GPU, а в том, что они снова и снова пересчитывают одни и те же длинные промпты.
Ниже маршрут оптимизации инференса LLM из шести шагов: как найти узкое место, убрать повторную работу и доказать эффект цифрами.
На выходе будет стоимость GPU‑часов на миллион токенов до и после, TTFT в пределах SLA и обоснованный ответ на вопрос, нужны ли новые карты. Чекпойнт и парк GPU не меняются, меняются только конфигурация сервинга и точность весов.
Исходные условия
-
Модель: Llama 3.3 70B Instruct, BF16‑чекпойнт, развёрнутый у себя. На ней все расчёты ниже.
-
Железо: две реплики по 2×H100 SXM 80 GB. В каждой реплике оба GPU стоят на одной ноде и связаны NVLink, всего 4 GPU.
-
Стек: vLLM (ветка V1), Kubernetes, Envoy Gateway, Prometheus, Grafana, DCGM Exporter.
-
Нагрузка: ассистент поддержки с RAG, системный промпт около 3 000 токенов, многоходовые диалоги, сотни одновременных пользователей.
-
SLA: TTFT p95 (время до первого токена) не больше 2 секунд.
Маршрут рассчитан на нагрузку, где начало промпта повторяется от запроса к запросу. Если промпты уникальны, а ответы длинные, шаги 4 и 5 дадут мало.
Как это понять заранее, покажу в шаге 1.
План действий
Порядок шагов важен не меньше, чем сами шаги. На Рис. 2 показано, в какой последовательности я иду и где стоят развилки.
Главное на этой схеме — замкнутый контур «замер — изменение — повторный замер». Без него любая оптимизация превращается в спор мнений, а продвинутые рычаги стоит трогать только тогда, когда базовые уже выжаты и это видно в цифрах.
Шаг 1. Снимаем базовую линию и находим узкое место
Риск: оптимизировать вслепую и не суметь доказать эффект.
Сначала о том, почему разговор начинается с памяти. В статье MLOps‑инженера из hh.ru тезис сформулирован так: эффективность инференса определяется тем, как вы управляете памятью видеокарты, а выбор модели вторичен.
В разборе Cloud.ru приводятся характеристики H100: 3,35 ТБ/с пропускной способности памяти при 989 TFLOPS в FP16. По оценке автора, при генерации токенов используется лишь около 10–20% этой вычислительной мощности.
nvidia-smi здесь плохой советчик. Его GPU‑Util показывает долю времени, когда на карте выполнялось хоть какое‑то вычислительное ядро, а не то, насколько она загружена полезной работой.
Для оценки насыщения я смотрю DCGM‑метрики профилирования: DCGM_FI_PROF_DRAM_ACTIVE (загрузка памяти) и DCGM_FI_PROF_SM_ACTIVE (активность вычислительных блоков).
Главная экономическая метрика — GPU‑часы на миллион выходных токенов при соблюдении SLA. Считаю её как все сгенерированные токены, делённые на все GPU‑часы за один и тот же интервал, а в деньги перевожу через внутреннюю ставку часа GPU: аренда или амортизация, электричество, доля эксплуатации.
Пиковую пропускную способность тоже снимаю, но она нужна для планирования ёмкости, а не для стоимости: высокий перцентиль дал бы слишком оптимистичную цену токена.
Важная оговорка. Если после оптимизации улучшился только TTFT, а пропускная способность при том же SLA не выросла, токен не подешевел. Деньги экономятся, когда освобождённое время GPU превращается в большее число обслуженных запросов на тех же картах.
Оптимизация инференса — только одна часть работы с ML‑системами. Пройдите короткий бесплатный тест по MLOps, чтобы оценить свои знания и понять, где есть пробелы.
# (Python) GPU-only стоимость 1M выходных токенов за неделю; ставка условная
GPU_HOUR_PRICE = 2.0 # внутренняя ставка часа одной H100
GPUS = 4 # 2 реплики x 2 GPU
HOURS = 24 * 7 # тот же интервал, что и в PromQL ниже
TOKENS_WEEK = 907_200_000 # sum(increase(vllm:generation_tokens_total[7d]))
gpu_hours_per_1m = GPUS * HOURS / (TOKENS_WEEK / 1_000_000)
print(f"{gpu_hours_per_1m:.3f} GPU-ч на 1M токенов, "
f"{gpu_hours_per_1m * GPU_HOUR_PRICE:.2f} за 1M") # 0.741 GPU-ч, 1.48 за 1M
# (PromQL) недельная базовая линия; имена сверяйте с /metrics своей версии vLLM.
# В документации счётчики записаны без суффикса, в экспозиции Prometheus у них есть _total.
# все выходные токены за неделю — основа для стоимости
sum(increase(vllm:generation_tokens_total[7d]))
# p95 пропускной способности за неделю — для планирования ёмкости, не для стоимости
quantile_over_time(0.95, sum(rate(vllm:generation_tokens_total[5m]))[7d:5m])
# TTFT p95 за неделю
histogram_quantile(0.95, sum by (le) (increase(vllm:time_to_first_token_seconds_bucket[7d])))
# hit rate префиксного кэша: доля попаданий среди токенов, для которых проверялся кэш
sum(increase(vllm:prefix_cache_hits_total[7d])) / sum(increase(vllm:prefix_cache_queries_total[7d]))
# сколько входных токенов приходится на один выходной — быстрый индикатор
sum(increase(vllm:prompt_tokens_total[7d])) / sum(increase(vllm:generation_tokens_total[7d]))
# где тратится время запроса: prefill против decode (p95 за неделю)
histogram_quantile(0.95, sum by (le) (increase(vllm:request_prefill_time_seconds_bucket[7d])))
histogram_quantile(0.95, sum by (le) (increase(vllm:request_decode_time_seconds_bucket[7d])))
# пиковая очередь за неделю: по кластеру и по худшей реплике
max_over_time(sum(vllm:num_requests_waiting)[7d:1m])
max_over_time(max(vllm:num_requests_waiting)[7d:1m])
Соотношение входных и выходных токенов — мой быстрый предварительный индикатор. Если на один выходной токен приходится по десятку входных, а hit rate низкий, скорее всего, деньги уходят на повторный prefill.
Подтверждаю это фазовыми метриками vLLM: время prefill и decode на запрос.
-
Если заметную долю времени запроса занимает prefill, основной эффект дадут шаги 4 и 5.
-
Если доминирует decode, нагрузка упирается в генерацию, prefix caching почти не поможет, и работать нужно с памятью и батчингом (шаги 2, 3 и 6).
Каждый повторный замер я провожу по одному протоколу:
-
тот же чекпойнт, токенизатор и chat template;
-
та же топология GPU (в нашем случае NVLink внутри ноды); смена топологии — отдельный эксперимент;
-
то же распределение длин входа и выхода (лучше всего — повтор реального трафика);
-
та же интенсивность запросов и конкурентность;
-
прогрев перед замером, холодный старт в итог не входит;
-
не меньше трёх прогонов на конфигурацию;
-
фиксируем TTFT p95, время на токен, пропускную способность, долю ошибок и вытеснений (
vllm:num_preemptions_total).
Проверка: есть недельная базовая линия (стоимость 1M токенов, TTFT p95, hit rate, пиковая очередь) и понятно, во что упирается нагрузка: в prefill или в decode.
Шаг 2. Считаем KV‑кэш до того, как просить новые карты
Риск: упереться в память и лечить это покупкой железа.
KV‑кэш растёт линейно с длиной последовательности и числом одновременных запросов. Для Llama 3 70B на токен приходится около 320 КиБ кэша, последовательность в 4 096 токенов занимает примерно 1,25 ГиБ, а батч из 32 таких последовательностей — около 40 ГиБ.
Мой вариант, который я обычно использую, — короткий скрипт по config.json модели:
# (Python) теоретическая ёмкость KV-кэша одной реплики
import json
GIB = 1024**3
cfg = json.load(open("config.json")) # Llama 3.3 70B
layers = cfg["num_hidden_layers"] # 80
kv_heads = cfg.get("num_key_value_heads", cfg["num_attention_heads"]) # 8
head_dim = cfg.get("head_dim", cfg["hidden_size"] // cfg["num_attention_heads"]) # 128
seq_len = 8192 # полная длина: промпт + сгенерированные токены
gpu_gib = 80e9 / GIB # H100 80 GB ~ 74.5 GiB
gpus, util = 2, 0.90 # реплика: 2 x H100, gpu-memory-utilization
weights_gib = 70e9 / GIB # 70B в FP8, приблизительно
free_gib = gpu_gib * gpus * util - weights_gib # без активаций, CUDA graphs, NCCL
for name, dtype_bytes in (("FP16 KV", 2), ("FP8 KV", 1)):
per_token = 2 * layers * kv_heads * head_dim * dtype_bytes # K и V
per_seq_gib = per_token * seq_len / GIB
print(f"{name}: {per_seq_gib:.2f} GiB/посл., до ~{int(free_gib // per_seq_gib)} посл.")
# FP16 KV: 2.50 GiB/посл., до ~27 посл.
# FP8 KV: 1.25 GiB/посл., до ~55 посл.
Здесь важно правильно читать gpu-memory-utilization. Это бюджет памяти, который выделяется конкретному экземпляру vLLM целиком: под веса, активации, служебные структуры и KV‑кэш. Это не «90% памяти под кэш».
Поэтому результат скрипта — верхняя оценка при заданных допущениях. Реальную конкурентность определяет планировщик, а настоящий потолок вы увидите в нагрузочном тесте по vllm:kv_cache_usage_perc и росту вытеснений.
Проверка: оценка ёмкости, умноженная на число реплик, покрывает пиковую конкурентность с запасом. Как стартовый запас я беру 20–30%, а точнее его задают исторические p99 всплесков конкурентности и целевой SLA. Если запаса нет, не спешите за картами: сначала шаги 3 и 4.
Шаг 3. Настраиваем движок под свою нагрузку
Риск: держать дефолты, которые съедают память впустую.
Continuous batching vLLM даёт из коробки: новые запросы встают в батч, как только освобождается место. В разборе Cloud.ru приводится пример, где загрузка GPU выросла благодаря этому с 30–40% до 80–90%.
Остальное настраивается под нагрузку.
# (YAML) аргументы vLLM в Deployment одной реплики; флаги сверяйте с `vllm serve --help`
args:
- "--model=meta-llama/Llama-3.3-70B-Instruct"
- "--tensor-parallel-size=2" # оба GPU на одной ноде, NVLink
- "--quantization=fp8" # онлайн-квантование весов BF16 -> FP8; проверяется в шаге 6
- "--kv-cache-dtype=fp8" # KV-кэш вдвое компактнее; тоже через eval-гейт
- "--max-model-len=16384" # по контракту API, а не по окну модели
- "--gpu-memory-utilization=0.90" # бюджет памяти экземпляра vLLM целиком
- "--enable-prefix-caching" # в V1 включён по умолчанию, пишу явно
- "--max-num-seqs=64" # стартовое значение для нагрузочного теста
- "--max-num-batched-tokens=8192" # стартовое значение для этой нагрузки
Значения max-num-seqs и max-num-batched-tokens здесь — стартовые точки, а не выведенные из шага 2 константы. max-num-seqs одновременно определяет размер буферов и CUDA graphs и ограничивает число одновременно исполняемых запросов. max-num-batched-tokens — бюджет токенов на один шаг планировщика.
Оба подбираются нагрузочным тестом.
Разберу флаги, которые дают больше всего.
-
max-model-len. У Llama 3.3 70B окно контекста 128K, но лимит должен соответствовать контракту вашего API и реальному распределению контекстов. Помню, как однажды на ревью конфигурации увидел 128K при реальных диалогах до 6 тысяч токенов. Один клиент вставил в чат выгрузку логов на сотню тысяч токенов, забрал KV‑кэш у десятков соседей, и очередь выросла у всех. -
quantization=fp8. Онлайн‑квантование прежде всего вдвое сокращает память под веса и освобождает место под KV‑кэш, то есть под конкурентность. Выигрыш по латентности при динамическом масштабировании активаций может быть скромным, поэтому эффект подтверждаю замером. -
kv-cache-dtype=fp8. Из шага 2 видно, что он удваивает ёмкость. Но у FP8 KV есть масштабирующие коэффициенты для K и V. Проверьте, откуда они берутся в вашей версии vLLM: если откалиброванных масштабов нет, используются значения по умолчанию, и качество может просесть. Поэтому FP8 KV проходит тот же eval‑гейт, что и веса. -
Параметры планировщика. Их я подбираю по симптомам в метриках, а не наугад:
|
Симптом |
Куда смотреть |
|---|---|
|
TTFT растёт, длинные промпты забивают шаг планировщика |
|
|
Время на токен растёт при нормальном TTFT |
|
|
KV‑кэш почти заполнен, растут вытеснения |
|
|
Очередь растёт при нормальных TTFT и времени на токен |
не хватает ёмкости: реплики или шаги 4–5 |
Про выбор движка скажу коротко. Автор из hh.ru по своим бенчмаркам рекомендует SGLang для H100 и новее, а vLLM для Ampere и старше.
Я бы рассматривал это как гипотезу и сравнивал движки на своей нагрузке только после того, как узкое место найдено и флаги настроены. Переезд займёт месяц, а флаги — один вечер.
Проверка: в пик очередь по кластеру около нуля, заполненность KV‑кэша не прилипает к потолку, вытеснения не растут. Пропускная способность при соблюдении SLA выросла относительно шага 1.
Шаг 4. Делаем промпт кэшируемым
Риск: префиксный кэш включён, но ничего не кэширует.
Prefix caching в vLLM переиспользует KV‑блоки только для совпадающего начала последовательности токенов и ускоряет prefill, а не генерацию. Если от запроса к запросу меняются контекст, порядок чанков или текст инструкций, переиспользовать почти нечего.
Помню, как мы полдня искали, почему при включённом prefix caching доля попаданий держалась на уровне пары процентов.
Причина нашлась в первой строке системного промпта: туда подставляли текущие дату и время с точностью до минуты. Три тысячи токенов правил шли после неё, и каждую минуту кэш начинался с нуля.
# (Python) было: изменяющаяся строка в начале ломает префикс
from datetime import datetime, date
system = f"""Ты ассистент банка. Сейчас {datetime.now():%d.%m.%Y %H:%M}.
Клиент: {user.name}, сегмент {user.segment}.
{POLICY_TEXT}""" # 3 000 токенов правил после «живой» строки
# стало: неизменное в начале, меняющееся в конце
chunks = sorted(retrieved, key=lambda c: (-c.score, c.doc_id)) # ранжирование сохраняем,
context = "nn".join(c.text for c in chunks) # doc_id только для ничьих
def build_messages(question):
return [
{"role": "system", "content": POLICY_TEXT}, # одинаковые токены у всех
*history,
{"role": "user", "content": f"[дата: {date.today():%d.%m.%Y}, "
f"сегмент: {user.segment}]n"
f"Документы:n{context}nnВопрос: {question}"},
]
Правило простое. Всё, что одинаково у всех пользователей, стоит в начале и даёт одни и те же токены. Всё, что зависит от пользователя или времени, уходит в конец. Порядок найденных в RAG чанков должен быть детерминированным, но ранжирование по релевантности ломать ради кэша нельзя.
Проверять нужно итоговую последовательность токенов после chat template. В неё входят описания инструментов (tools) и всё, что шаблон добавляет в системный блок. Например, шаблон Llama 3.x пишет туда дату, и если передавать в него текущую, префикс будет меняться каждые сутки.
# (Python) сравниваем токены двух запросов после применения chat template
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.3-70B-Instruct")
a = tok.apply_chat_template(build_messages("Как закрыть вклад?"), tools=TOOLS,
tokenize=True, return_dict=False)
b = tok.apply_chat_template(build_messages("Где выписка?"), tools=TOOLS,
tokenize=True, return_dict=False)
common = next((i for i, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b)))
print(f"общий префикс: {common} токенов из {len(a)}")
Безопасность общего кэша. Prefix caching между разными пользователями создаёт побочный канал: по TTFT можно косвенно понять, лежит ли в кэше чужой префикс. Для финтеха это не теоретический вопрос. vLLM закрывает этот канал параметром запроса cache_salt: запросы с разной солью не делят кэш. Область соли выбирается по границе доверия.
-
Соль на пользователя даёт полную изоляцию, но отключает общий системный промпт между пользователями.
-
Общая случайная соль для группы (например, тенанта) сохраняет переиспользование внутри неё.
-
Без соли кэш делится между всеми — это допустимо только для однотенантной системы.
# (Python) cache_salt в запросе к vLLM через OpenAI-совместимый клиент
resp = client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct",
messages=build_messages(question),
extra_body={"cache_salt": tenant_salt}, # случайная соль на границу доверия
)
Проверка: hit rate из шага 1 приблизился к доле стабильного префикса во входных токенах. В нашем примере системный промпт — около 3 000 из 4 000 входных токенов первого хода, поэтому около 70% — это ориентировочная верхняя граница для первого хода. На неё влияют прогрев, вытеснения, размер блока, соль и роутинг, а многоходовые диалоги при удачном роутинге добавят сверху. Если значение заметно ниже, ищите изменяющиеся токены в начале промпта.
Шаг 5. Учим балансировщик помнить про кэш
Риск: на одной реплике всё работает, а на нескольких кэш размазан по кластеру.
В базовой конфигурации KV‑кэш принадлежит конкретной реплике vLLM (внешние KV‑коннекторы вроде LMCache меняют картину, но это отдельная архитектура).
Обычный Kubernetes Service ничего не знает о содержимом кэшей. Запрос с тем же системным промптом может попасть на под, который этот промпт ещё не видел, и кластер снова тратит GPU на тот же prefill.
В июне 2026 года мне попался показательный кейс.
Инженер поднял Qwen2.5–7B‑Instruct на vLLM в AWS EKS на восьми нодах с одной A10G на каждой. Он сравнил обычный ClusterIP Service, который в его конфигурации распределял запросы по кругу, с роутером llm‑d в режиме precise prefix‑cache routing. Поды были одни и те же. В нагрузке было 150 повторяющихся префиксов по 2 048 токенов и 512 одновременных запросов.
-
С llm‑d прогон занял 358,7 секунды вместо 840,2.
-
Доля попаданий в кэш выросла примерно с 11% до 93%, очередь сократилась примерно со 180 запросов до нуля, среднее TTFT упало на 95%.
Это результат одной нагрузки с сильно повторяющимися префиксами, а не универсальный коэффициент. Автор отдельно отмечает, что в итоговые перцентили попал прогрев холодного кэша. Нашлась и полезная деталь для эксплуатации: размер блока в vLLM и в скорере роутера должен совпадать.
Разница между двумя подходами показана на Рис. 3.
Главная мысль схемы: при нескольких репликах кэш работает только тогда, когда балансировщик знает, где что лежит. Без этого включённый prefix caching на каждой реплике даёт копейки.
Здесь важно различать два механизма.
-
Привязка сессии возвращает запросы одного диалога на одну реплику и сохраняет кэш его истории.
-
Роутинг по префиксу отправляет разных пользователей с одинаковым началом промпта туда, где этот префикс уже лежит.
Они не заменяют друг друга и могут работать вместе.
Идея не новая. Character.AI в посте об оптимизации инференса описывала кэширование KV между ходами диалога: средний запрос у них тянет за собой историю примерно из 180 сообщений.
На уровне парка серверов запросы одного диалога через sticky sessions уходят на один и тот же сервер.
В нашей ситуации я бы начал с привязки сессии через консистентное хеширование по идентификатору диалога:
# (YAML) Envoy Gateway: запросы одного диалога идут на одну реплику.
# Синтаксис по примеру из документации Envoy Gateway; сверяйте с API своей версии.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: llm-session-affinity
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: llm-route
loadBalancer:
type: ConsistentHash
consistentHash:
type: Header
header:
name: x-session-id
Заголовок x-session-id должен появиться до того, как Envoy выбирает под. Его проставляет доверенный слой перед балансировкой: auth‑прокси перед Envoy или сам Gateway из проверенного JWT.
Значение, пришедшее от внешнего клиента, отбрасывается. Консистентное хеширование при этом не даёт вечной привязки: при добавлении или удалении реплик часть сессий переедет на другие поды.
Переходить к роутингу по префиксу я бы стал по сигналу из метрик, а не по числу реплик. Сигнал такой: hit rate по кластеру заметно ниже, чем на одной реплике под той же нагрузкой, и вместе с этим растут очередь и TTFT.
Учтите, что llm‑d — не просто балансировщик. В precise‑режиме, как в кейсе выше, появляется control plane: реплики публикуют события о KV‑блоках, индексатор ведёт карту кэшей, а endpoint picker выбирает под. Есть и более лёгкий approximate‑режим, который оценивает расположение префиксов без событий от реплик. Оба режима добавляют поверхность для эксплуатации.
И про жизненный цикл подов. Rollout, рестарт, масштабирование и дренаж ноды дают холодный кэш и временный всплеск prefill и TTFT. Поэтому стратегия деплоя тоже входит в эксперимент: я выкатываю реплики по одной и смотрю, сколько времени hit rate возвращается к базовой линии.
Проверка: hit rate по кластеру близок к hit rate одной реплики под сопоставимой нагрузкой, очередь в пик около нуля, после rollout метрики возвращаются к норме за предсказуемое время.
Шаг 6. Квантуем через проверку качества
Риск: сэкономить память и потерять качество, которое заметит клиент, а не мониторинг.
В нашем сценарии первый кандидат — FP8 на H100. Для других GPU схему квантования нужно подбирать по поддерживаемому бэкенду и результатам сравнения. INT4 я рассматриваю как следующий шаг, когда нужно ещё сильнее сократить память.
Конкретный метод (AWQ, GPTQ или вариант под ваш бэкенд) выбирается по сравнению на целевой модели и нагрузке: качество зависит от модели, задачи, калибровочных данных и ядер.
Здесь я разделяю две проверки.
-
Производительность меряю по протоколу из шага 1: латентность, пропускная способность, доля ошибок.
-
Качество меряю отдельно, eval‑гейтом: одни и те же кейсы gold‑set, несколько прогонов на кейс, допустимое падение задано до эксперимента.
Интервал строится для разницы между моделями, а не для каждой модели отдельно.
# (Python) eval-гейт: FP8 не хуже BF16 больше, чем на заранее заданный порог
import random
MARGIN = -0.01 # пример: допустимое падение доли pass на 1 п.п.;
# порог — продуктовое решение, фиксируется до эксперимента
def pass_rate(runs): # runs: [True, False, ...] — прогоны одного кейса
return sum(runs) / len(runs)
def paired_lower_bound(base, cand, n=2000, alpha=0.05):
ids = sorted(base.keys() & cand.keys()) # только общие кейсы
deltas = {cid: pass_rate(cand[cid]) - pass_rate(base[cid]) for cid in ids}
means = []
for _ in range(n):
sample = [deltas[random.choice(ids)] for _ in ids] # ресэмплим кейсы
means.append(sum(sample) / len(sample))
means.sort()
return means[int(alpha * n)] # односторонняя нижняя граница 95%
lower = paired_lower_bound(bf16_results, fp8_results) # 5 прогонов на кейс
if lower < MARGIN:
raise SystemExit(f"FP8 не прошёл гейт: нижняя граница разницы {lower:+.1%}")
print(f"FP8 прошёл гейт: нижняя граница разницы {lower:+.1%}, порог {MARGIN:+.0%}")
Гейт проверяет комбинацию FP8 весов и FP8 KV, которую мы включили в шаге 3. Если он не пройден, следующий эксперимент их разделяет: отдельно FP8 веса с BF16 KV и наоборот.
Так видно, что именно даёт деградацию. Как собрать gold‑set и не обмануть себя одним прогоном, я подробно разбирал в статье «7 ошибок в оценке качества LLM‑систем в продакшене».
Проверка: гейт пройден, а стоимость 1M токенов при повторном замере ниже базовой линии из шага 1.
Если цель не достигнута: следующий рычаг по сигналу из метрик
Эти оптимизации нельзя включать безусловно: их эффект зависит от нагрузки. Поэтому я включаю их только по конкретному сигналу и по одной.
|
Сигнал после повторного замера |
Рычаг |
Как проверить |
|---|---|---|
|
Пропускная способность в норме, но TTFT и латентность выше SLA |
Больше Tensor Parallelism на реплику: по бенчмаркам автора из hh.ru TP даёт меньшую латентность |
TTFT p95 и время на токен снизились, стоимость 1M токенов выросла не больше допустимого |
|
Латентность в норме, но упираемся в RPS |
Больше независимых реплик или DP‑развёртывание vLLM: по тем же бенчмаркам DP даёт кратно больше пропускной способности и RPS |
Токенов в секунду на GPU больше, пиковая очередь около нуля |
|
Длинные промпты, и prefill мешает генерации |
Разделение prefill и decode по разным пулам GPU |
Пропускная способность выросла; поддержка требует заметно больше внимания и инженеров |
|
Мало одновременных пользователей и жёсткая латентность |
Спекулятивный декодинг |
Время на токен упало. С ростом конкурентности эффект пропадает: память уходит под draft‑модель вместо KV‑кэша |
Где этот подход не сработает
Если трафик маленький и карта половину суток простаивает, никакая настройка не спасёт экономику: дешевле API провайдера или serverless‑инференс.
Если промпты уникальны и общего префикса нет, а ответы длинные, нагрузка упирается в decode. Prefix caching и роутинг по префиксу дадут мало, основной эффект придёт от шагов 2, 3 и 6.
На контекстах порядка 100K+ токенов резко растёт доля памяти под KV‑кэш: для нашей модели в FP8 это около 15 ГиБ на одну последовательность.
Такой контекст может поместиться, но дальше приходится выбирать между ограничением контекста, сжатием истории, переиспользованием префиксов и выгрузкой KV‑кэша в CPU или хранилище. Выбор зависит от нагрузки, и это отдельная большая тема.
И ещё про финтех. Там я бы не спешил с INT4 даже при пройденном гейте: цена одной ошибки в ответе про ставку по кредиту выше, чем экономия на картах.
Чек‑лист перед тем, как просить новые GPU
-
Есть недельная базовая линия: GPU‑часы на 1M токенов по сумме за интервал, TTFT p95, hit rate, пиковая очередь.
-
По фазовым метрикам понятно, во что упирается нагрузка: в prefill или в decode.
-
KV‑кэш посчитан на реальной длине последовательности, а не на окне модели.
-
max-model-lenсоответствует контракту API; параметры планировщика подобраны нагрузочным тестом. -
Начало промпта даёт одинаковые токены после chat template, включая tools; ранжирование RAG сохранено.
-
Общий кэш не пересекает границу доверия:
cache_saltзадан по тенанту или пользователю. -
При нескольких репликах есть привязка сессии через доверенный заголовок, а при необходимости — роутинг по префиксу.
-
Rollout и масштабирование учтены в эксперименте.
-
FP8 весов и KV‑кэша прошёл парный eval‑гейт; масштабы FP8 KV проверены.
-
Повторный замер проведён по тому же протоколу и на той же топологии, следующий рычаг выбран по сигналу из метрик.
Главное, что стоит вынести из статьи: в нагрузке с повторяющимися префиксами значительная часть работы GPU может уходить на повторный prefill.
Поэтому сначала стоит проверить, работает ли переиспользование кэша, и только потом оценивать, сколько карт действительно нужно.
Источники

Когда стоимость инференса растёт, а качество и скорость моделей становятся сложнее контролировать, важно понимать, где именно система теряет ресурсы. Разобраться с экспериментами, автоматизацией ML‑процессов и контролем моделей в продакшене — следующий шаг после оптимизации инфраструктуры.
На открытых уроках мы разберём практические подходы к работе с ML‑проектами:
-
29 сентября в 20:00. «MLFlow — контроль над ML‑экспериментами». Записаться
-
15 октября в 18:00. «Автоматизация ML‑экспериментов с помощью GitLab CI/CD и CML». Записаться
-
26 октября в 20:00. «Data Drift в машинном обучении: почему модели деградируют в продакшене и как это контролировать». Записаться
Автор: sproshchaev


