Архитектура семантического роутинга: vLLM + KServe + выбор модели | AiManual
AiManual Logo Ai / Manual.
19 Янв 2026 Гайд

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

Практический гайд по построению семантического роутинга для LLM в продакшене. Интеграция vLLM, KServe, оптимизация латентности, выбор модели на лету.

Проблема: один промпт — три модели, и все хотят ответить

Представьте: у вас в продакшене крутятся три LLM. Llama-3 для общего диалога, Qwen-2.5 для кодинга, Mixtral для творческих задач. Пользователь пишет "Напиши функцию на Python". Какая модель должна ответить? Очевидно, Qwen. Но как система это поймет?

Классический подход — хардкодить роутинг по endpoint'ам. /api/chat для диалога, /api/code для кодинга. Это работает, пока у вас три модели и пять типов запросов. Добавьте десяток моделей и сотню сценариев — система превращается в спагетти из if-else.

Самый частый антипаттерн: создавать отдельный endpoint под каждую модель. Через месяц у вас 15 эндпоинтов, документация устарела, а клиенты все равно шлют запросы не туда.

Семантический роутинг решает это элегантно: анализируем смысл запроса (семантику) и выбираем оптимальную модель на лету. Не по endpoint'у, не по заголовкам, а по содержанию.

Почему vLLM и KServe — не панацея

vLLM — отличный inference engine. KServe — удобная обертка для развертывания. Но вместе они не умеют в семантический роутинг из коробки. KServe маршрутизирует трафик между репликами одной модели, но не между разными моделями.

Вы ставите три InferenceService в KServe:

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llama-3-70b
spec:
  predictor:
    containers:
    - name: kserve-container
      image: vllm/vllm-openai:latest
      args:
      - --model=meta-llama/Llama-3-70b
      - --served-model-name=llama-3-70b

И получаете три независимых сервиса. Как связать их в единую систему? Вот где начинается архитектурная работа.

💡
KServe отлично масштабирует одну модель, но не умеет выбирать между моделями. Это как иметь три отдельных ресторана вместо одного с тремя кухнями.

Три архитектурных варианта: от простого к сложному

1 Клиентский роутинг (самый простой, самый хрупкий)

Логика выбора модели живет в клиенте. Получили промпт → посчитали эмбеддинг → сравнили с эталонными → выбрали модель → отправили запрос в нужный endpoint.

# ПЛОХОЙ ПРИМЕР: как НЕ надо делать
class ClientRouter:
    def route(self, prompt: str) -> str:
        if "код" in prompt or "python" in prompt:
            return "http://qwen-service:8080"
        elif "поэзия" in prompt or "стихи" in prompt:
            return "http://mixtral-service:8080"
        else:
            return "http://llama-service:8080"

Проблемы очевидны: хардкод ключевых слов, обновление логики требует перевыпуска всех клиентов, нет централизованного управления. Работает только в pet-проектах.

2 Кастомный предиктор в KServe (уже лучше)

Создаем свой InferenceService с кастомным контейнером, который внутри решает, какую модель вызывать. KServe передает запрос нашему контейнеру, а мы уже роутим дальше.

# Пример кастомного предиктора
from sentence_transformers import SentenceTransformer
import numpy as np

class SemanticRouter:
    def __init__(self):
        self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
        self.model_embeddings = {
            'qwen': self.embedder.encode("код программирование python функция алгоритм"),
            'mixtral': self.embedder.encode("поэзия творчество стих рассказ художественный"),
            'llama': self.embedder.encode("общий диалог вопрос ответ объяснение")
        }
    
    def predict(self, prompt: str) -> str:
        prompt_embedding = self.embedder.encode(prompt)
        similarities = {
            name: np.dot(prompt_embedding, emb) 
            for name, emb in self.model_embeddings.items()
        }
        best_model = max(similarities, key=similarities.get)
        # Вызываем соответствующую модель через внутренний API
        return call_model(best_model, prompt)

Плюсы: централизованная логика, можно A/B тестировать модели, мониторинг в одном месте. Минусы: single point of failure, добавляет latency (дополнительный embedding + сравнение).

Важно: embedding модель должна быть легкой. all-MiniLM-L6-v2 весит 80 МБ и работает за 10-20 мс. Не используйте тяжелые модели для роутинга — они съедят всю выгоду.

3 Отдельный сервис роутинга (продакшен-гред)

Выносим логику выбора модели в отдельный микросервис. Получаем архитектуру:

Пользователь → Router Service → [vLLM+KServe кластер]

Router Service делает:

  • Семантический анализ промпта
  • Проверку доступности моделей (health check)
  • Балансировку нагрузки между репликами одной модели
  • Кеширование эмбеддингов частых запросов
  • Сбор метрик: какие модели чаще выбираются, latency роутинга

Этот подход масштабируется лучше всего. Router Service можно реплицировать, обновлять без остановки инференса, тестировать новые алгоритмы роутинга.

Пошаговый план: строим систему с нуля

1 Разворачиваем vLLM с KServe

Сначала ставим модели. Для каждой создаем InferenceService с vLLM runtime. Важно настроить ресурсы правильно:

# qwen-inference.yaml
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: qwen-2.5-32b
spec:
  predictor:
    minReplicas: 2
    maxReplicas: 10
    containers:
    - name: kserve-container
      image: vllm/vllm-openai:latest
      args:
      - --model=Qwen/Qwen2.5-32B-Instruct
      - --tensor-parallel-size=2
      - --gpu-memory-utilization=0.9
      resources:
        limits:
          nvidia.com/gpu: "2"
          memory: 64Gi
        requests:
          nvidia.com/gpu: "2"
          memory: 64Gi

Повторяем для каждой модели. Теперь у нас есть кластер инференса, но нет интеллекта для выбора между ними.

2 Создаем сервис семантического роутинга

Пишем на FastAPI (он легкий и быстрый):

from fastapi import FastAPI
from pydantic import BaseModel
import numpy as np
from sentence_transformers import SentenceTransformer
import httpx

app = FastAPI()

class RouterConfig:
    MODELS = {
        "qwen": "http://qwen-2.5-32b-predictor.default.svc.cluster.local:8080",
        "llama": "http://llama-3-70b-predictor.default.svc.cluster.local:8080",
        "mixtral": "http://mixtral-8x7b-predictor.default.svc.cluster.local:8080"
    }
    EMBEDDING_MODEL = "all-MiniLM-L6-v2"

router = RouterConfig()
embedder = SentenceTransformer(router.EMBEDDING_MODEL)

# Предрассчитываем эмбеддинги для типовых запросов каждого класса
class_embeddings = {
    "coding": embedder.encode("напиши функцию код программирование алгоритм баг фикс"),
    "creative": embedder.encode("сочини стихотворение рассказ сценарий диалог персонаж"),
    "general": embedder.encode("объясни расскажи что такое как работает почему")
}

class_to_model = {
    "coding": "qwen",
    "creative": "mixtral",
    "general": "llama"
}

class PromptRequest(BaseModel):
    prompt: str
    temperature: float = 0.7
    max_tokens: int = 1024

@app.post("/route")
async def route_and_execute(request: PromptRequest):
    # 1. Семантический анализ
    prompt_embedding = embedder.encode(request.prompt)
    
    similarities = {}
    for class_name, class_emb in class_embeddings.items():
        similarity = np.dot(prompt_embedding, class_emb)
        similarities[class_name] = similarity
    
    best_class = max(similarities, key=similarities.get)
    model_name = class_to_model[best_class]
    
    # 2. Вызов выбранной модели
    async with httpx.AsyncClient(timeout=30.0) as client:
        model_url = router.MODELS[model_name]
        payload = {
            "prompt": request.prompt,
            "temperature": request.temperature,
            "max_tokens": request.max_tokens
        }
        response = await client.post(
            f"{model_url}/v1/completions",
            json=payload
        )
        
    return {
        "model_used": model_name,
        "similarity_score": similarities[best_class],
        "response": response.json()
    }

3 Настраиваем мониторинг и кеширование

Без мониторинга вы слепы. Добавляем метрики:

  • Время роутинга (от получения промпта до выбора модели)
  • Точность выбора (сколько раз выбрана "правильная" модель)
  • Латентность каждой модели
  • Загрузка GPU по моделям

Кеширование эмбеддингов — обязательная оптимизация. 80% запросов повторяются: "напиши код", "объясни", "переведи". Redis идеально подходит:

import redis
import pickle

redis_client = redis.Redis(host='redis', port=6379, db=0)

def get_cached_embedding(prompt: str):
    key = f"embedding:{hash(prompt)}"
    cached = redis_client.get(key)
    if cached:
        return pickle.loads(cached)
    embedding = embedder.encode(prompt)
    redis_client.setex(key, 3600, pickle.dumps(embedding))  # TTL 1 час
    return embedding

Нюансы, которые сломают вашу систему

Латентность убивает UX

Семантический роутинг добавляет задержку: embedding + сравнение + сетевой вызов. Если это 100 мс, а инференс занимает 500 мс, пользователь не заметит. Если роутинг тянет 300 мс — проблема.

Оптимизации:

  1. Используйте легкие embedding модели (all-MiniLM-L6-v2, not all-mpnet-base-v2)
  2. Кешируйте результаты для одинаковых промптов
  3. Параллелизуйте health checks моделей
  4. Считайте эмбеддинги на GPU если volume высокий

Проблема холодного старта

Новая модель добавлена в кластер. У нее нет эмбеддингов в роутере. Запросы к ней не идут. Решение: система автоматического обновления эталонных эмбеддингов.

# Автообновление эталонов при добавлении модели
def update_reference_embeddings(new_model_name: str, sample_prompts: List[str]):
    """Генерируем эталонные эмбеддинги для новой модели"""
    combined_prompt = " ".join(sample_prompts)
    embedding = embedder.encode(combined_prompt)
    
    # Сохраняем в конфиг роутера
    class_embeddings[new_model_name] = embedding
    
    # Релоад конфига без перезапуска
    hot_reload_router_config()

Вырожденные случаи: когда все модели одинаково хороши

Промпт "Напиши код для генерации поэзии". И кодинг, и творчество. Какую модель выбрать? Два подхода:

Стратегия Плюсы Минусы
Выбор по максимальному сходству Простота, детерминизм Может выбрать неоптимально
Взвешенная случайность Диверсификация, A/B тестирование Непредсказуемость
Каскадный вызов Лучший результат Удваивает latency и cost

Мой выбор: взвешенная случайность с логированием. Если сходства близки (разница < 0.1), выбираем случайно с весами. Собираем метрики качества ответов, потом анализируем.

Интеграция с существующими системами

У вас уже есть LLMRouter для облачных API? Семантический роутинг можно добавить как дополнительный слой. Сначала определяем, нужна ли облачная модель (по сложности промпта), затем выбираем локальную.

Работаете с семантическими пайплайнами? Роутинг становится естественным расширением: после извлечения сущностей из текста выбираем модель для обработки.

Интересный кейс: комбинирование моделей. Сначала Qwen генерирует код, потом Llama проверяет его на безопасность. Для этого нужен оркестратор сложнее простого роутера.

Когда семантический роутинг не нужен

Да, бывает и так. Если:

  • У вас одна модель (очевидно)
  • Разные модели для разных клиентов (роутинг по API ключу проще)
  • Запросы строго типизированы (отдельные endpoint'ы работают лучше)
  • Латентность критична, а добавление 50 мс неприемлемо

В остальных случаях — это эволюция системы. От хаотичного набора моделей к интеллектуальному кластеру.

💡
Самый неочевидный совет: начните с логирования выбора модели без реального роутинга. Неделю собирайте данные: какую модель выбрал бы алгоритм vs какую выбрали бы вы. Увидите расхождения — поймете, где доработать эмбеддинги.

Семантический роутинг — не магия, а инженерная задача. Связываете vLLM для быстрого инференса, KServe для оркестрации, embedding модель для понимания смысла. Получаете систему, которая умнее суммы ее частей.

И помните: лучшая архитектура та, которую можно объяснить за две минуты. Если ваш семантический роутер требует 10-слайдовой презентации чтобы понять как он работает — вы переусложнили.