Дрейк тоже шарит за AI inference
Дрейк тоже шарит за AI inference

Для многих обывателей, да и инженеров, которые не углублялись в тему, работа с LLM выглядит как работа с обычным сервисом: мы просто кидаем запросы по нужному endpoint и получаем JSON с ответом. Но на деле появляется много вопросов: как здесь работает кэш? От чего зависит время ответа? Что делать с огромным контекстным окном? И если у нас один GPU-сервер, на котором происходят все вычисления, то это не так и важно. Но что делать с масштабными распределёнными системами? Обычный Kubernetes не понимает, как устроен запрос языковой модели. Однако за последний год платформенные инженеры очень хорошо продвинулись в этом вопросе. И в этой статье я хочу подробно разобрать, как именно строится K8s-кластер под высоконагруженные LLM.

Как я уже упоминал выше, последний год можно считать прорывом в вопросе AI inference в кубере. Сейчас он включает 3 компонента. Возможно, многие читатели слышали о них ранее, но я не так часто встречал описание того, как всё это работает вкупе, поэтому сейчас хочется вместе с вами глубже копнуть в эту тему и разобраться окончательно, что за 3 апостола держат на себе LLM в K8s и почему они нам так интересны.

Для начала пройдёмся кратко по каждому:

  • DRA (Dynamic Resource Allocation) — умный запрос на ресурсы. Мы хотим не просто просить 1 GPU, ведь так мы можем получить GTX 1060. Нам нужно определённое количество, возможно, нецелое, так как мы не против делить ресурс, с определёнными характеристиками, например, мы хотим минимум 80 GB памяти. Всё это реализует DRA. Причём не только для GPU: те же FPGA или спецсетевые адаптеры — всё, что не просто CPU и память, мы запрашиваем через это расширение.

  • Gateway API Inference Extension — балансировщик для LLM-специфичных запросов, который отлично не просто по очереди шлёт запросы на ноды, а выбирает путь с наименьшим сопротивлением за счёт анализа состояния ресурсов и кэша.

  • LLM-D — оркестатор запросов в оркестраторе. По сути, просто надстройка над всей архитектурой, которая даёт готовые рецепты для оптимизации на основе предыдущих двух компонентов.

Почему inference всё ломает

Начнём с главного отличия работы LLM и обычного сервиса: запрос к LLM — это действо из 2 актов, диаметрально противоположных по создаваемой на ноды нагрузке.

  • Prefill: когда мы отправляем наш запрос, модель должна его обработать, чтобы из человекочитаемого формата получить формат, доступный для понимания самой LLM. Этим форматом является KV-cache, о нём чуть ниже поговорим подробнее. Данный процесс, по сути, является одной большой параллельной операцией, соответственно, чем больше ядер задействовано, тем быстрее она выполнится. Ну и, как вы уже поняли, это compute-bound. Обычно, когда завершается этот процесс, мы видим сообщение thinking в интерфейсе взаимодействия с LLM.

  • Decode: после того, как модель поняла контекст, она генерирует ответ. Это происходит по токенам: модель смотрит на уже сохранённый KV-cache и на его основе подставляет новый токен. Этот процесс продолжается, пока LLM не решит, что ответ уже готов, или не упрётся в лимит. По описанию понятно, что это memory-bandwidth-bound, так как на каждой итерации мы перечитываем огромный кусок данных из памяти.

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

Как и обещал, расскажу чуть подробнее про KV-cache и почему он тут так важен. K (key) и V (value) — это два вектора, которые трансформируются из токенов. Модель их использует, чтобы «оглянуться» на прошлые токены при генерации новых. Генерировать их каждый раз заново не очень рационально, поэтому есть хранилище — KV-cache. Почему это тут важно? Давайте посчитаем вес для модели размером 70 млрд параметров:

На один токен:
  слои × KV-головы × размер головы × 2 (K и V) × 2 байта (FP16)
≈ 80     × 8        × 128          × 2         × 2
≈ 327 КБ

На промпт длиной 4096 токенов: 4096 × 327 КБ ≈ 1.34 ГБ

1.5 ГБ самой дорогой памяти на один запрос — даже для H100 с 80 ГБ памяти это очень ощутимая доля. И на больших контекстах именно KV-cache потребляет больше всего памяти.

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

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

DRA: GPU как PVC, а не как целое число

Как запросить GPU в кубере? Наверное, первое, что приходит в голову:

resources:
  limits:
    nvidia.com/GPU: 1

Это count-based модель, и она отлично работает для CPU. Но для GPU такой подход имеет ряд проблем: мы не можем использовать условия по типу «мне нужен GPU с минимум 50 GB», мы не можем расшарить GPU между несколькими подами, железо должно быть предопределено заранее.

И, как уже упоминалось выше, DRA отлично решает все эти проблемы. Модель очень похожа на PVC: мы описываем, что именно хотим получить, а планировщик и драйвер уже сами решают, откуда выдавать нам ресурсы. С версии Kubernetes 1.34 (сентябрь 2025) core DRA объявлен как GA.

Выше я уже упоминал про аналогию с PVC. Поэтому на компоненты DRA также предлагаю посмотреть в сравнении:

DRA

Storage

Что делает

DeviceClass

StorageClass

Класс устройств. Например, GPU-nvidia.com — это все GPU от NVIDIA, которые есть в кластере. Ручного создания не требует, драйвер после установки сделает все сам.

ResourceClaim

PersistentVolumeClaim

Заявка на ресурс с набором необходимых критериев

ResourceClaimTemplate

volumeClaimTemplate

Шаблон заявки. Особенно полезен, когда у нас несколько раз нужно запросить одинаковые ресурсы, а вручную не хочется исполнять ctrl+c ctrl+v

ResourceSlice

Инвентарь, включающий все реально доступные устройства и их атрибуты. Это зона ответственности драйвера, и для нас он read-only

Очень важной частью DRA является CEL (Common Expression Language) selector — язык для условий. Например, мы хотим запросить объект, имя которого начинается с H100 и памяти на нём минимум 80 GB:

device.attributes["GPU.nvidia.com"].productName.startsWith("H100") &&
device.attributes["GPU.nvidia.com"].memory >= 80737418240

Доступные атрибуты определяются драйвером. Например, у NVIDIA это productName, memory, computeCapability, поддерживаемые MIG-профили, NVLink-партнёры и так далее. Их вы можете увидеть в атрибутах ResourceSlice.

И куда же без примера. Давайте взглянем на простой вариант с шаблоном:

apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: one-GPU
spec:
  spec:
    devices:
      requests:
      — name: GPU                       # внутреннее имя заявки
        exactly:
          deviceClassName: GPU.nvidia.com
          allocationMode: ExactCount
          count: 1

И сама заявка:

apiVersion: v1
kind: Pod
metadata:
  name: my-GPU-pod
spec:
  resourceClaims:                       # под объявляет, какие заявки ему нужны
  — name: GPU                           # имя, по которому контейнер сошлётся
    resourceClaimTemplateName: one-GPU  # из какого шаблона создать заявку
  containers:
  — name: app
    image: my-image
    resources:
      claims:
      — name: GPU                       # просим заявку с именем GPU

Что произойдёт:

  1. Когда ты применяешь под, для него автоматически создаётся объект ResourceClaim по шаблону one-GPU.

  2. Scheduler видит под, у которого есть claim. Идёт смотреть ResourceSlice по кластеру: «где есть подходящее устройство?». Находит ноду, проверяет CEL-условия (тут их нет, любое устройство подойдёт).

  3. Scheduler резервирует конкретное устройство и кладёт результат аллокации в status claim.

  4. Под планируется на эту ноду.

  5. kubelet на ноде через DRA-плагин драйвера прокидывает устройство в контейнер (например, монтирует /dev/nvidia*, выставляет переменные окружения).

  6. Контейнер запускается с реальным доступом к GPU.

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

Gateway API Inference Extension: балансировка, которая говорит на одном языке с LLM

GIE (Gateway API Inference Extension), или, как его ещё называют, IGW (Inference Gateway), — это, как понятно по названию, расширение Gateway API, которое отвечает за принятие решения, куда именно полетит запрос к LLM.

Включает две CRD:

  • InferencePool — новый вид бэкенда, заменяет стандартный Service в HTTProute. Состоит из:

    • лейбл-селектора подов, показывает какие поды входят в этот пул;

    • порта, на который шлёт запросы;

    • ссылки на EPP (Endpoint Picker) — компонент, который и принимает решения о выборе пода;

    • политики поведения на отказ EPP (FailOpen — деградирую, но работаю; FailClose — отказываю).

  • InferenceObjective (раньше был InferenceModel) — описание модели и её SLO: что именно обслуживаем, какие целевые TTFT/TPOT, приоритеты.

Думаю, внимательный читатель уже догадался, что дизайн предполагает разделение на 2 слоя: один для платформенных инженеров, а второй — для разработчиков моделей. В результате обе команды могут работать независимо.

Выше я упоминал объект EPP (Endpoint Picker) — по сути, важнейший объект во всей этой схеме. EPP — это один или несколько отдельных подов, которые дёргают шлюз на каждый запрос. Он фоново скрапит метрики Prometheus с vLLM-подов из пула и потом прогоняет их через специальные фильтры, предлагая оценку каждому поду, в результате возвращая шлюзу самого эффективного.

Для ясности давайте посмотрим на жизненный цикл запроса в кластере:

клиент (POST /v1/chat/completions)
      │
      ▼
   Gateway (Envoy/kgateway/Istio/NGINX/GKE-GW)
      │   читает HTTPRoute → backend = InferencePool "vllm-qwen"
      │
      │   спрашивает EPP по ext_proc: «куда слать?»
      ▼
   EPP
      │   смотрит метрики подов пула:
      │     pod-A: очередь=3, KV-cache=78%, есть префикс X
      │     pod-B: очередь=12, KV-cache=40%, префикса X нет
      │     pod-C: очередь=5,  KV-cache=60%, есть префикс X
      │   плагины ранжируют → решение: «pod-A»
      │
      ▼ отвечает Gateway: endpoint = 10.0.3.7:8000
   Gateway
      │   проксирует запрос на 10.0.3.7:8000
      ▼
   vLLM (pod-A)
      │   получает запрос, видит префикс уже в кэше — экономия prefill
      │   стримит токены обратно
      ▼
   клиенту льётся ответ

И небольшой пример, как это может выглядеть на практике:

apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
  name: vllm-qwen
spec:
  targetPorts:
  — number: 8000
  selector:
    app: vllm-qwen           # лейбл, который стоит на vLLM-подах
  extensionRef:
    name: vllm-qwen-epp      # сервис EPP
    port: 9002
  failureMode: FailOpen      # EPP умер — деградируем до обычного роутинга
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: llm-route
spec:
  parentRefs: [{ name: my-gateway }]
  rules:
  — backendRefs:
    — group: inference.networking.k8s.io
      kind: InferencePool                # вот оно — нестандартный бэкенд
      name: vllm-qwen

LLM-D: рецепты вместо велосипедов

По сути весь стек, который мы обсуждаем в этой статье, можно представить как многоуровневую архитектуру:

  1. DRA — железо и его выдача.

  2. vLLM — программа, которая и генерирует токены, используя ресурсы.

  3. KServe — Kubernetes-native платформа сервинга моделей. Control plane, управляющий жизненным циклом моделей. Создает поды, внутри которых крутятся модели.

  4. GIE — нам умный роутинг.

  5. llm-d — оркестрация всех остальных слоев. Добавляет готовые архитектурные рецепты для 2 и 4 слоев. Например, как раскидать модель на несколько узлов, как разделить prefill и decode по разным пулам, как сделать многоуровневый KV-cache.

Важно: не стоит путать LLM-D и vLLM. Это разные сущности: LLM-D — опекун vLLM, который делает её жизнь проще, а результат работы — быстрее.

Well-lit paths — главная идея llm-d. «Протоптанные тропы» — готовые, оттестированные, забенчмарканные рецепты архитектуры.

Каждая тропа состоит из нескольких компонентов:

  • набор Helm-чартов и Helmfile-конфигов с реальными values;

  • документация с описанием сценариев использования;

  • воспроизводимые бенчмарки;

  • описание ограничений (например «требуется RDMA-сеть»).

На данный момент актуальны 5 троп:

  1. Intelligent Inference Scheduling — используем параметр prefix-cache-aware для EPP. Самый простой, но при этом очень эффективный вариант, который значительно повышает hit-rate KV-cache.

  2. Prefill/Decode Disaggregation — делим поды на 2 пула: одни под prefill-фазу, а вторые — под decode. Однако важно понимать, что, так как мы должны передавать KV-cache из первой фазы во вторую, между ними должен быть очень быстрый канал.

  3. Wide Expert-Parallelism — подходит для MoE-моделей (Mixture of Experts: DeepSeek-R1, Mixtral). Тропа раскидывает экспертов по разным нодам и связывает их через DeepEP с All-to-All RDMA.

  4. Tiered Prefix Cache — добавляем больше уровней для KV-cache: HBM → CPU DRAM → SSD или общий Lustre. Спасает, когда KV-cache разрастается и уже не влезает в быструю HBM.

  5. Workload Autoscaling (WVA) — скейлим не по CPU, как это принято, а по глубине очереди, занятости KV-cache, in-flight-запросам. Причём используется проактивная модель с предсказаниями.

Ну и самый главный плюс троп: они композируются. Вы можете комбинировать их как пожелаете.

Что я думаю про всё это

Стек выглядит очень круто и масштабно, но нужен далеко не всем. Для небольших чат-ботов это всё будет избыточно: просто поставьте один голый vLLM и ходите в него сколько душе угодно, вы не заметите просадок из-за огромных prefill-фаз. Но для больших проектов такой подход становится обязательным. Если у вас десятки реплик с кучей разных моделей, строить это всё на обычном ванильном Kubernetes становится очень тяжело. Собирать самому роутинг под KV-cache звучит как задача, за которую вы возьмётесь в последний момент.

Главный плюс этой всей архитектуры — её многослойность. Это не монолит, который вы ставите и используете только так, как это задумал автор. Мы получили гибкое решение, которое ещё и состоит не из небольших pet-проектов, а из полноценных компонентов Kubernetes. За исключением LLM-D, конечно, но он хорошо осел в CNCF, и над ним работает большая команда специалистов. Также стоит отметить универсальность — один раз поняв это всё, вы можете применять полученные знания у любого вендора.

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

© 2026 ООО «МТ ФИНАНС»