01.09.2026

Мы написали балансировщик для 4 реплик vLLM: почему NGINX не работает для LLM

Часть 4 серии «Наш опыт»: почему round-robin убивает throughput LLM-инференса и как собственный Python-балансировщик (soft sticky + least-loaded + health без user traffic) держит сессии на тёплой KV-cache. Четыре production-инцидента, которые сформировали алгоритм.

load-balancing nginx python vllm kv-cache architecture

Хук: NGINX не понимает LLM

NGINX round-robin для LLM — это как раздавать билеты в кино: кто пришёл, тот сел. Но LLM-запросы — не билеты.

Запрос с 256K контекстом — это не то же самое, что запрос с 2K. И если у реплики A уже 256K в KV-cache, а у B — пусто, round-robin отправит запрос на A и убьёт throughput. Нам был нужен балансировщик, который понимает LLM.

Почему не NGINX / HAProxy / Envoy

  • Round-robin не учитывает нагрузку: KV-cache, контекст, длину генерации.
  • Least connections не учитывает размер запроса: 10 «лёгких» запросов весят меньше, чем 1 «тяжёлый».

Ключевая проблема: LLM-запрос — это не HTTP-запрос. Это состояние (KV-cache). Реплика с полным KV-cache — не «занята», она «дорогая»: каждый новый токен на ней стоит дороже, чем на пустой.

Аналогия: NGINX — это кондуктор в автобусе. Он знает, сколько людей в салоне, но не знает, кто из них едет 3 остановки, а кто — 30.

Что ломалось в первые недели: четыре инцидента

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

1. Кристаллизация: «через 10 минут всё на одной ноде»

Через 10–30 минут после старта трафика Traffic Share в Grafana схлопывался: одна «горячая» реплика брала почти все запросы, три остальные простаивали. Ферма не падала — она кристаллизовалась.

Диагноз: сессионные и prefix-pin’ы жили вечно. Агентская сессия — это сотни запросов с одним и тем же system prompt; общий промпт «цементировал» на одной ноде целые проекты. Pin без срока жизни — трафик без права переезда.

Правила, которые остались в коде:

  • TTL: session — 60 минут без запросов, prefix — 5 минут. Старые pin’ы умирают сами.
  • Spill-счётчик: если балансировщик вынужден отправить запрос сессии на другую реплику (pin «переливается»), это учитывается. 3 spill подряд — сессия переезжает на другую ноду.
  • Force-rebind: если running(pin) − min_r ≥ 3 — pin переезжает немедленно, без ожидания серии spill.
  • Prefix отпускается жёстче: повторяющийся spill — ключ удаляется сразу. Общий system prompt не должен держать горячую ноду.

Именно это чинит «через 10 минут всё на одной»: старые pin’ы не живут вечно, pin после серии spill переезжает, мёртвый pin не блокирует распределение.

2. Слепое пятно WLC: два запроса на одну перегретую реплику

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

Диагноз: мы скрейпим /metrics каждые 3 секунды. Нагрузка, которую балансировщик сам только что отправил, между скрейпами невидима: num_running в скрейпе устарел, и два параллельных запроса «видят» одинаковую (устаревшую) нагрузку — и обоим кажется, что реплика свободна.

Правила:

  • Effective load: max(scraped_num_running, wlc_in_flight) — балансировщик учитывает собственные in-flight запросы, взвешенные по токенам (100K токенов весит в 100 раз больше 1K).
  • Атомарный WLC-резерв: in-flight слот резервируется в момент выбора под блокировкой — два одновременных запроса больше не могут «увидеть» одинаковую устаревшую нагрузку.

3. Health через user traffic: пользователь платил за «проверку»

Симптом: первую «проверку» упавшей или только что выжившей реплики получал реальный пользователь: его запрос уходил на «подозреваемую» ноду и возвращался 5xx или долгим таймаутом.

Диагноз: recovery был завязан на пользовательский трафик — «пришёл клиент, попробуем на нём». Пользователь платил за health check терпением.

Правило: health не зависит от пользовательского трафика. Scrape по мёртвой (EJECTED) реплике не останавливается — это единственный путь её возвращения:

2 успешных scrape → PROBING → 15 секунд чистого окна → LIVE

PROBING получает ограниченный трафик и не принимает sticky. Любой scrape-fail или 5xx в этом окне — обратно в EJECTED. Возврат — после успешных проб, а не после «пришёл клиент».

4. Flap: реплика, которая «оживает» каждые 10–20 секунд

Симптом: реплика на границе (таймауты, 5xx) болталась: умерла — «выжила» — умерла, каждые 10–20 секунд. При каждом «возврате» она тут же начинала принимать pin’ы — и умирала под первым же burst.

Диагноз: короткий фиксированный cooldown давал «льготный» возврат flap-реплике, а гистерезиса не было: порог возврата совпадал с порогом eject.

Правила:

  • Экспоненциальный backoff: повторный eject в коротком окне (10 минут) удлиняет cooldown: 30s · 2^(n−1), потолок 300s.
  • Streak сбрасывается только после 600 секунд устойчивого LIVE — flap-реплика не получает «льготный» cooldown.
  • Гистерезис: порог возврата строже порога eject — 2 успешных scrape плюс 15-секундное окно, а не один «хороший» scrape.

Это нужно не для «усложнения», а чтобы после краткого восстановления реплика не успевала принять sticky и снова умереть под первым burst.

Финальная архитектура

Слой 1: 4×vLLM — каждая реплика на 2×3090, TP=2, конфиг из части 3.

Слой 2: собственный Python-балансировщик. Алгоритм выбора — пять шагов:

  1. Пул: кандидаты — только LIVE и PROBING. EJECTED не получает трафик (но скрейпится — см. инцидент 3).
  2. No-wait: если есть LIVE-реплика с effective_waiting = 0 — кандидаты только они. Забитая реплика (waiting > 0) новых запросов не берёт. Новая сессия так попадает на холодную реплику, а не в чужую очередь.
  3. Least-loaded: минимум effective_running; ничья — round-robin, LIVE предпочтительнее PROBING.
  4. Pin (soft sticky): session-ключ (заголовки x-session-id, x-kilo-session, … или body conversation_id/thread_id) проверяется раньше prefix-ключа (MD5 первых 4096 символов system prompt; для multi-tenant — плюс project_id). Pin «используемый» только если реплика LIVE, без очереди и running ≤ min_r + slack (session — 1, prefix — 0). Иначе — least-loaded, и pin «переливается» (spill).
  5. Fallback: если LIVE ∪ PROBING пусты, но известные реплики есть — random из них (last resort). Если пул GPUStack пуст — no_healthy_instances.

Набор сигналов — sticky + load + health — совпадает с индустриальной практикой: llm-d, NVIDIA Dynamo, SGLang (radix-кэш роутера), Gateway API Inference Extension. Колесо не изобретали — дорабатывали под 2×3090 и агентов.

Слой 3: GPUStack — управление репликами, auto-restart.

Код: реальный пайплайн выбора

Упрощённая, но верная версия реального пайплайна (в коде — плюс атомарный WLC-резерв, spill-счётчики и TTL-housekeeping):

def select(self, profile) -> SelectionResult:
    # 1. Пул: только LIVE и PROBING (EJECTED — без трафика, но со скрейпом)
    routable = [i for i in self.pool if i.routing_state in (LIVE, PROBING)]
    if not routable:
        return random_fallback()  # или no_healthy_instances

    # 2. No-wait: если есть LIVE без очереди — только они
    live = [i for i in routable if i.routing_state == LIVE]
    no_wait = [i for i in live if i.effective_waiting == 0]
    candidates = no_wait or live or routable

    # 3. Least-loaded: минимум effective_running, ничья — round-robin (LIVE preferred)
    min_r = min(i.effective_running for i in candidates)
    best = round_robin([i for i in candidates if i.effective_running == min_r],
                       prefer_live=True)

    # 4. Pin: сначала session, потом prefix. Только LIVE, только если не перегружена
    pin, kind = self.resolve_pin(profile)          # kind: session | prefix
    slack = 1 if kind == "session" else 0
    if pin and pin.routing_state == LIVE and pin.effective_waiting == 0 \
            and pin.effective_running <= min_r + slack:
        return SelectionResult(pin, reason="affinity")

    # 5. Иначе — least-loaded. Pin «переливается»: 3 spill подряд — rebind,
    #    running(pin) - min_r >= 3 — немедленный rebind
    return SelectionResult(best, reason="load")

Где effective load:

effective_running = max(scraped_num_running, wlc_in_flight)
effective_waiting = scraped_num_waiting + max(0, wlc_in_flight - scraped_num_running)

Что мы сознательно убрали (и почему)

Мы начинали с «умного» скоринга: composite score из трёх компонент (нагрузка, давление очереди, давление KV), KV EWMA, slow-start, consistent hashing, Power of Two Choices, пять health-состояний как фильтр выбора и жёсткое правило «never rebind». Собрали, замерили — и выкинули почти всё. Что осталось и почему:

УбралиЧем заменилиПочему
Composite score как первичный критерийargmin(effective_running) + round-robinScore не улучшал распределение, а усложнял выбор
Consistent hash, Power of Two, AffinityBreakerleast-loaded + pin-slackПростой детерминированный выбор, affinity не ломается
5 health-состояний как фильтр select()3 routing-состояния (LIVE/PROBING/EJECTED)Метки (healthy/degraded/suspect/stale/poisoned) — для Grafana, не для роутинга
«Never rebind»soft rebind (TTL + spill + force)Мёртвый pin не должен блокировать распределение
KV-сигналы в выборе (EWMA, slow-start, роутинг по свободному KV)только GrafanaKV — для наблюдения, не для выбора

Главный урок: в LLM-балансировке меньше — значит лучше. Score нужен дашборду, а не выбору.

Как мы проверяем, что работает

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

СценарийОжидание
3 LIVE + 2 EJECTED, трафик 10 минутTraffic Share не схлопывается в одну LIVE-реплику
prefix pin «горячий», остальные idlespill на idle; через 3 spill или 5 минут TTL prefix отпускает ноду
session pin running=4, остальные 0немедленный force-rebind без ожидания streak
реплика flap-ает каждые 10–20 секундне успевает собрать sticky; возврат откладывается растущим cooldown
все реплики EJECTED (panic)random_fallback на известную реплику, а не 503

И алерты, которые говорят, что система жива:

  • imbalance index > 3 в течение 5 минут — нагрузка начинает кристаллизоваться;
  • весь трафик на одной реплике (при N > 1) в течение 5 минут;
  • EJECTED > 0 и ноль успешных scrape за минуту — probe сломан;
  • ejection cooldown реплики достиг потолка 300s — она flap-ает.

Что мы НЕ делали (и почему)

  • Kubernetes + Gateway API Inference Extension: «Мы не хотим K8s для 4 реплик. Это как купить экскаватор, чтобы выкопать яму под дерево».
  • LLM-D (Solo.io): хороший проект — если начинаете с нуля, стоит начинать с него. Мы хотели контролировать каждый байт на наших 2×3090 и агентов.
  • Честно: наш балансировщик — компактный Python-сервис, не платформа. Для 4 реплик это достаточно. Для 30 — нужен K8s.

Смежные материалы: Особенности LLM-трафика, Multi-tenant Serving, PagedAttention и prefix caching, Observability.

Уроки балансировки

  1. LLM-балансировка — это про состояние, а не про соединения. Round-robin «не сломан», он просто отвечает на другой вопрос.
  2. Sticky + prefix caching — бесплатный throughput. Сессия и префикс живут на тёплой реплике, prefill не пересчитывается — без изменения железа.
  3. Failover должен быть быстрее, чем терпение пользователя. Мёртвая реплика вылетает из пула активным скрейпом, а не по чужому запросу; занятая реплика новых запросов не берёт (no-wait) — это режет хвосты P99.
  4. Health не должен зависеть от пользовательского трафика. Мёртвую реплику проверяет scrape, а не чей-то чужой запрос.
  5. Sticky без TTL — это ловушка. У любого pin должны быть срок жизни, счётчик spill и путь «переезда» — иначе трафик кристаллизуется.

Для 30 реплик и multi-tenant нагрузки это уже K8s — наш расширенный формат настройки. Если у вас несколько инстансов vLLM и вопрос, как их балансировать, обсудим проект.

Хотите так же?

Расскажите о задаче — ответим с планом и оценкой в течение 2 рабочих дней.

Обсудить задачу

Расскажите о задаче

Ответим с планом и оценкой в течение 24 часов.