Мы написали балансировщик для 4 реплик vLLM: почему NGINX не работает для LLM
Часть 4 серии «Наш опыт»: почему round-robin убивает throughput LLM-инференса и как собственный Python-балансировщик (soft sticky + least-loaded + health без user traffic) держит сессии на тёплой KV-cache. Четыре production-инцидента, которые сформировали алгоритм.
Хук: 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-балансировщик. Алгоритм выбора — пять шагов:
- Пул: кандидаты — только LIVE и PROBING. EJECTED не получает трафик (но скрейпится — см. инцидент 3).
- No-wait: если есть LIVE-реплика с
effective_waiting = 0— кандидаты только они. Забитая реплика (waiting > 0) новых запросов не берёт. Новая сессия так попадает на холодную реплику, а не в чужую очередь. - Least-loaded: минимум
effective_running; ничья — round-robin, LIVE предпочтительнее PROBING. - Pin (soft sticky): session-ключ (заголовки
x-session-id,x-kilo-session, … или bodyconversation_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). - 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-robin | Score не улучшал распределение, а усложнял выбор |
| Consistent hash, Power of Two, AffinityBreaker | least-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) | только Grafana | KV — для наблюдения, не для выбора |
Главный урок: в LLM-балансировке меньше — значит лучше. Score нужен дашборду, а не выбору.
Как мы проверяем, что работает
Вместо бенчмарк-таблицы — таблица поведенческих гарантий. Каждая строка — сценарий, который мы гоняем в нагрузочном тесте:
| Сценарий | Ожидание |
|---|---|
| 3 LIVE + 2 EJECTED, трафик 10 минут | Traffic Share не схлопывается в одну LIVE-реплику |
| prefix pin «горячий», остальные idle | spill на 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.
Уроки балансировки
- LLM-балансировка — это про состояние, а не про соединения. Round-robin «не сломан», он просто отвечает на другой вопрос.
- Sticky + prefix caching — бесплатный throughput. Сессия и префикс живут на тёплой реплике, prefill не пересчитывается — без изменения железа.
- Failover должен быть быстрее, чем терпение пользователя. Мёртвая реплика вылетает из пула активным скрейпом, а не по чужому запросу; занятая реплика новых запросов не берёт (no-wait) — это режет хвосты P99.
- Health не должен зависеть от пользовательского трафика. Мёртвую реплику проверяет scrape, а не чей-то чужой запрос.
- Sticky без TTL — это ловушка. У любого pin должны быть срок жизни, счётчик spill и путь «переезда» — иначе трафик кристаллизуется.
Для 30 реплик и multi-tenant нагрузки это уже K8s — наш расширенный формат настройки. Если у вас несколько инстансов vLLM и вопрос, как их балансировать, обсудим проект.
Хотите так же?
Расскажите о задаче — ответим с планом и оценкой в течение 2 рабочих дней.
Обсудить задачу