Хук: 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 latency2.1s1.4s
P99 latency8.7s3.2s
Throughput (tok/s, aggregate)180210
KV-cache hit rate12%34%
Failover time30s (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.

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

  1. LLM-балансировка — это про состояние, а не про соединения. Round-robin «не сломан», он просто отвечает на другой вопрос.
  2. Prefix caching — бесплатный throughput. 12% → 34% hit rate без изменения железа.
  3. Failover должен быть быстрее, чем терпение пользователя. 5s против 30s — разница между «сбою» и «просто задержка».

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

← vLLM на 2×RTX 3090: от 18 до 100 tok/s — MTP, TP, KV-cache, OMP_NUM_THREADS 1 млрд токенов/сутки: финальная архитектура, тономика и что мы бы сделали по-другому →