Проблема: один промпт — три модели, и все хотят ответить
Представьте: у вас в продакшене крутятся три 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
И получаете три независимых сервиса. Как связать их в единую систему? Вот где начинается архитектурная работа.
Три архитектурных варианта: от простого к сложному
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 мс — проблема.
Оптимизации:
- Используйте легкие embedding модели (all-MiniLM-L6-v2, not all-mpnet-base-v2)
- Кешируйте результаты для одинаковых промптов
- Параллелизуйте health checks моделей
- Считайте эмбеддинги на 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 мс неприемлемо
В остальных случаях — это эволюция системы. От хаотичного набора моделей к интеллектуальному кластеру.
Семантический роутинг — не магия, а инженерная задача. Связываете vLLM для быстрого инференса, KServe для оркестрации, embedding модель для понимания смысла. Получаете систему, которая умнее суммы ее частей.
И помните: лучшая архитектура та, которую можно объяснить за две минуты. Если ваш семантический роутер требует 10-слайдовой презентации чтобы понять как он работает — вы переусложнили.