Хук: 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.
Что мы сделали (архитектура)
Слой 1: 3×vLLM — каждая реплика на 2×3090, TP=2, конфиг из части 3.
Слой 2: собственный балансировщик (Go, Gin):
- Health check:
/v1/models+ собственный/health(VRAM usage, active seqs). - Routing: least-loaded (не round-robin) — выбираем реплику с минимальным
active_seqs / max_seqs. - Prefix-aware routing: если запрос начинается с того же system prompt, что уже в KV-cache реплики, — отправляем туда (выигрыш от prefix caching).
- Context-aware: если
prompt_tokens > 100K— отправляем на реплику с максимальным свободным KV-cache. - Failover: если реплика не отвечает 5s — remove из пула, retry на другой.
Слой 3: GPUStack — управление репликами, auto-restart.
Код: ключевой фрагмент роутинга
// Router: least-loaded + prefix-aware
func (r *Router) SelectReplica(req *Request) *Replica {
// 1. Filter: only healthy replicas
healthy := r.FilterHealthy()
// 2. Prefix match: find replica with matching system prompt in KV-cache
if prefix := ExtractPrefix(req); prefix != "" {
if r := r.FindPrefixMatch(prefix, healthy); r != nil {
return r
}
}
// 3. Context-aware: large prompts → replica with most free KV-cache
if req.PromptTokens > 100000 {
return r.MaxFreeKVCache(healthy)
}
// 4. Default: least loaded
return r.LeastLoaded(healthy)
}
Бенчмарк: NGINX vs наш балансировщик
| Метрика | NGINX RR | Наш балансировщик |
|---|---|---|
| P50 latency | 2.1s | 1.4s |
| P99 latency | 8.7s | 3.2s |
| Throughput (tok/s, aggregate) | 180 | 210 |
| KV-cache hit rate | 12% | 34% |
| Failover time | 30s (timeout) | 5s |
P99 упал в 2.7 раза. KV-cache hit rate вырос почти в 3 раза — это прямая работа prefix-aware routing.
Что мы НЕ делали (и почему)
- Kubernetes + Gateway API Inference Extension: «Мы не хотим K8s для 3 реплик. Это как купить экскаватор, чтобы выкопать яму под дерево».
- LLM-D (Solo.io): хороший проект, но мы хотели контролировать каждый байт.
- Честно: наш балансировщик — 400 строк Go. Не 4000. Для 3 реплик это достаточно. Для 30 — нужен K8s.
Смежные материалы: Multi-tenant Serving, PagedAttention и prefix caching, Observability.
Уроки балансировки
- LLM-балансировка — это про состояние, а не про соединения. Round-robin «не сломан», он просто отвечает на другой вопрос.
- Prefix caching — бесплатный throughput. 12% → 34% hit rate без изменения железа.
- Failover должен быть быстрее, чем терпение пользователя. 5s против 30s — разница между «сбою» и «просто задержка».
Для 30 реплик и multi-tenant нагрузки это уже K8s — наш расширенный формат настройки. Если у вас несколько инстансов vLLM и вопрос, как их балансировать, обсудим проект.