Три стадии ада: почему простая архитектура не справляется
Рекомендательная система, которая честно работает с одним типом данных и парой моделей, рассыпается, когда вы добавляете изображения, тексты отзывов, историю кликов и видеообзоры. 14 моделей — не предел, а реальность современного e-commerce. Каждая из них: ResNet-50 для визуальных фич, BERT для текстов, графовая свертка для социальных связей, трансформеры для последовательностей — все они требуют разных форматов, разных батчей и разного аппаратного ускорения.
Throw в одну кучу? Получите latency в секунды, оut-of-memory на первой же реплике и кандидатов, которых не просеять без потери качества.
Выход — мультистадийный пайплайн с разделением ответственности: кандидат-генерация, фильтрация, ранжирование, реранжирование и смешивание. А под капотом — Amazon EKS, NVIDIA Triton Inference Server для инференса нескольких моделей на одном GPU, Bloom-фильтры для дедупликации без хранения всех ID, и in-memory кэш (Redis), который снижает нагрузку на тяжёлые ранжирующие модели.
Эта статья — не про «поставь и магия». Это про то, как я (и вы) буду собирать этот конструктор в продакшене, наступать на грабли и выкатывать решение, которое выдержит 10k RPS без проседания P99.
Кстати, если вам интересно, как Amazon внутри переосмысляет каталог с помощью нейросетей, загляните в статью Catalog AI от Amazon: как нейросети переписывают правила e-commerce изнутри. Там про то, как мультимодальность меняет саму суть товарной выдачи.
Архитектура: разделяй и ранжируй
Весь пайплайн — это конвейер из пяти этапов. Каждый этап живёт в отдельном микросервисе, но модели могут быть объединены в ensemble внутри Triton.
- Stage 0 — Candidate Generation: лёгкие модели (коллаборативная фильтрация, Item2Vec, графовые эмбеддинги). Результат — 500-1000 кандидатов на пользователя.
- Stage 1 — Bloom-фильтр + Dedup: отсеиваем товары, которые пользователь уже купил, или те, что не прошли бизнес-правила. Bloom-фильтр хранит хеши просмотренных/купленных ID.
- Stage 2 — Reranking (тяжёлые модели): здесь запускаются все мультимодальные фичи: визуальный поиск (ResNet), текстовый матчинг (BERT), поведенческий трансформер. Используем Triton ensemble для распараллеливания.
- Stage 3 — Blending + Personalization: финальный scoring с учётом контекста (время, устройство, A/B-эксперимент).
- Stage 4 — Policy Filtering: вырезаем товары по регуляторным или бизнес-правилам (например, возрастные ограничения).
Зачем Bloom-фильтр на втором этапе? Потому что хранить в Redis миллиарды ID — дорого. Bloom-фильтр даёт гарантированное отсутствие ложных отрицаний и controllable false positive rate (0.1% — норма). Размер — ~1GB на 1 млрд элементов, что влезает в память одного worker'а.
Разворачиваем EKS с горячими GPU
Для кластера нам нужно: Kubernetes 1.30+, Karpenter для автоскейлинга GPU-нод, и AWS Load Balancer Controller для ingress.
# Создаём кластер с managed node group для system pods и Karpenter для GPU
export CLUSTER_NAME=recsys-eks
export REGION=us-east-2
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
cat <<EOF | eksctl create cluster -f -
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ${CLUSTER_NAME}
region: ${REGION}
version: "1.30"
managedNodeGroups:
- name: system
instanceTypes: ["m6i.large"]
desiredCapacity: 2
labels:
role: system
iam:
withOIDC: true
addons:
- name: aws-ebs-csi-driver
karpenter:
version: v1.1.3
createServiceAccount: true
EOFПосле создания кластера настраиваем Karpenter provisioner для GPU-нод, чтобы они запускались только когда есть pods с ресурсом nvidia.com/gpu. Это экономит деньги — пустые GPU не висят.
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: gpu
spec:
template:
spec:
requirements:
- key: "karpenter.k8s.aws/instance-family"
operator: In
values: ["g5", "p4d", "p5"]
- key: "kubernetes.io/arch"
operator: In
values: ["amd64"]
- key: "nvidia.com/gpu"
operator: Exists
limits:
cpu: 1000
memory: 8000Gi
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 5mГрабли: не забудьте установить NVIDIA Device Plugin и настройте runtime class для контейнеров, которые будут использовать GPU. Иначе Karpenter запустит ноду, но pods не встанут.
14 моделей на одном GPU? Triton знает как
NVIDIA Triton Inference Server (версия 24.12 на момент 2026 года) позволяет запускать несколько моделей параллельно на одном GPU, динамически батчить запросы и использовать ensemble pipelines. Для нашего пайплайна мы определяем ensemble из трёх моделей: feature extractor (ResNet + BERT), cross-attention ранжировщик и output adapter.
# model_repository/recsys_ensemble/config.pbtxt
name: "recsys_ensemble"
platform: "ensemble"
input [
{
name: "user_features"
data_type: TYPE_FP32
dims: [256]
},
{
name: "item_features"
data_type: TYPE_FP32
dims: [512]
},
{
name: "image_embedding"
data_type: TYPE_FP32
dims: [2048]
}
]
output [
{
name: "score"
data_type: TYPE_FP32
dims: [1]
}
]
ensemble_scheduling {
step [
{
model_name: "feature_fusion"
model_version: 1
input_map: { key: "user_features", value: "user_features" }
output_map: { key: "fused", value: "fused" }
},
{
model_name: "cross_attention_ranker"
model_version: 1
input_map: { key: "fused", value: "fused" }
output_map: { key: "logits", value: "logits" }
},
{
model_name: "score_adapter"
model_version: 1
input_map: { key: "logits", value: "logits" }
output_map: { key: "score", value: "score" }
}
]
}Чтобы Triton эффективно распределял память, используем --model-control-mode=explicit и загружаем модели через API. Это позволяет выгружать неиспользуемые модели без перезапуска.
Деплоим Triton в EKS:
apiVersion: apps/v1
kind: Deployment
metadata:
name: triton-server
spec:
replicas: 2
selector:
matchLabels:
app: triton
template:
metadata:
labels:
app: triton
spec:
containers:
- name: triton
image: nvcr.io/nvidia/tritonserver:24.12-py3
args: ["tritonserver", "--model-repository=/models", "--model-control-mode=explicit", "--pinned-memory-pool-byte-size=536870912", "--cuda-memory-pool-byte-size=4294967296"]
resources:
limits:
nvidia.com/gpu: 1
ports:
- containerPort: 8000
- containerPort: 8001
- containerPort: 8002
volumeMounts:
- name: models
mountPath: /models
volumes:
- name: models
persistentVolumeClaim:
claimName: model-storageBloom-фильтр в памяти: без Redis, но с гарантиями
Для этапа дедупликации мы не хотим делать лишний вызов к базе. Bloom-фильтр, построенный на основе PyBloom, живёт внутри sidecar-контейнера рядом с сервисом ранжирования. Фильтр реплицируется через gossip-протокол (или обновляется из S3 раз в 5 минут).
from pybloom_live import BloomFilter
import mmap
import pickle
class DedupFilter:
def __init__(self, capacity=10_000_000, error_rate=0.001):
self.bf = BloomFilter(capacity, error_rate)
def load_from_s3(self, bucket, key):
import boto3
s3 = boto3.client('s3')
obj = s3.get_object(Bucket=bucket, Key=key)
data = obj['Body'].read()
self.bf = pickle.loads(data)
def check_and_add(self, item_id):
if item_id in self.bf:
return True # already seen
self.bf.add(item_id)
return FalseBloom-фильтр не даёт ложных отрицаний — то есть мы никогда не пропустим товар, который пользователь уже видел. Ложные срабатывания (0.1%) допустимы: пользователь просто не увидит пару релевантных товаров.
Почему не Redis? Redis с миллиардами ключей жрёт гигабайты памяти и требует кластеризации. Bloom-фильтр занимает ~1.2 GB, не требует сети (in-process), и восстановление из S3 занимает 2-3 секунды.
In-memory кэш для результатов ранжирования
Самая тяжёлая часть — cross-attention ранжировщик. Типичный запрос: для пользователя с 500 кандидатами нужно прогнать каждую пару через модель. Без кэша — 500 вызовов Triton. С кэшом — только для новых или изменившихся товаров.
Используем Redis Cluster как распределённый кэш. Ключ — хеш от {user_id, item_id, item_features_hash}, TTL — 1 час. Попадание в кэш даёт latency ~1ms вместо 50ms на инференс.
import redis
import hashlib
import json
cache = redis.RedisCluster(host='redis-cluster', port=6379, decode_responses=True)
def get_rerank_score(user_id, item_id, features):
key = hashlib.md5(f"{user_id}:{item_id}:{json.dumps(features, sort_keys=True)}".encode()).hexdigest()
cached = cache.get(key)
if cached:
return float(cached)
score = call_triton_ensemble(user_id, item_id, features)
cache.setex(key, 3600, score)
return scoreОсобенно эффективно для товаров с длинным хвостом: популярные товары кэшируются быстро, а редкие — почти не попадают в кэш, но их доля мала.
Автоскейлинг: от 1 до 100 реплик за минуту
Рекомендательные системы страдают от резких пиков: акции, запуск новых категорий, вечерний час-пик. HPA на основе CPU/GPU не успевает. Используем KEDA с триггером по Prometheus-метрике latency.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: triton-scaledobject
spec:
scaleTargetRef:
name: triton-server
minReplicaCount: 1
maxReplicaCount: 50
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: triton_inference_queue_duration_seconds
threshold: "0.1"
query: |
avg(rate(triton_inference_queue_duration_seconds_sum[1m]) / rate(triton_inference_queue_duration_seconds_count[1m]) > 0) by (model_name)Но просто увеличивать реплики — путь к хаосу: каждая реплика Triton занимает GPU. Поэтому используем scaleDownBehavior: waitForStabilization и минимальное время жизни pods — 5 минут, чтобы не убивать только что созданные.
Kubeflow для пайплайнов обучения и батч-инференса
Модели нужно обновлять. Мы используем Kubeflow Pipelines для периодического переобучения (раз в день) и валидации. Пайплайн: feature engineering → train candidate generator → train ranker → evaluate на offline-метриках (NDCG@k) → если метрики не упали, push новой версии модели в S3 и триггер rolling update Triton.
apiVersion: pipelines.kubeflow.org/v1beta1
kind: Pipeline
metadata:
name: recsys-retrain
spec:
tasks:
- name: prepare-data
component:
spec:
implementation:
container:
image: 123456789.dkr.ecr.us-east-2.amazonaws.com/recsys-prepare:latest
command: ["python", "prepare.py"]
inputs:
parameters:
date: {type: String}
- name: train-candidate
dependsOn: [prepare-data]
...
- name: deploy-if-better
dependsOn: [evaluate]
component:
spec:
implementation:
container:
image: 123456789.dkr.ecr.us-east-2.amazonaws.com/recsys-deploy:latest
command: ["python", "deploy.py"]Шесть ошибок, которые я совершил (чтобы вы не совершали)
- Одна модель на один GPU — так проще, но дорого. Triton позволяет делать dynamic batching и мульти-модель ensemble.
- Bloom-фильтр не обновлялся. Если пользователь совершил покупку, фильтр должен обновиться за секунды, а не через batch. Добавьте потоковое обновление через SQS.
- Кэш Redis без типов ключей. Все ключи в одной куче — сложно инвалидировать. Используйте префиксы:
rerank:user:item:features_hash. - HPA по CPU. GPU-задачи нагружают память, а не процессор. Мониторьте
triton_inference_queue_duration— это истинная метрика. - Одна реплика Triton на старте — отказоустойчивость нулевая. Ставьте minReplicas=2 с anti-affinity.
- Игнорирование cold start для новых пользователей. Bloom-фильтр пуст, кэш пуст. Используйте fallback-стратегию — популярные товары, пока не накопится история.
Собираем всё вместе: live-демо
Как выглядит полноценный запрос?
- API Gateway получает user_id + контекст.
- Candidate Generator (лёгкая модель в Triton) возвращает 1000 item_id.
- Bloom-фильтр (sidecar) отсеивает 200 уже просмотренных.
- Оставшиеся 800 идут в кэш-прокси: 200 из кэша, 600 требуют инференса.
- Triton ensemble обрабатывает 600 запросов, динамический батчинг уменьшает число вызовов до ~10 батчей.
- Ранжированные scores склеиваются, Policy Filtering (наружный сервис) вырезает 5 товаров по ограничениям.
- Финальный список (20 товаров) возвращается за 120ms P50, 300ms P99.
Нагрузочное тестирование показало, что с Bloom + Redis кэшом latency падает в 3 раза, а стоимость инференса — в 2.5 раза (меньше вызовов GPU).
Если вы хотите углубиться в тему визуального поиска для рекомендаций, прочитайте про виртуальную примерку на AWS с Nova Canvas и OpenSearch. Там как раз про мультимодальность.
А если вы задумаетесь, не заменить ли ANN на что-то другое, посмотрите кейс Ozon — как они отказались от векторного поиска в пользу Query Prediction. Идеи можно адаптировать.
Неочевидный совет напоследок
Не пытайтесь реплицировать всю логику в одном namespace. Разделите пайплайн на логические блоки: candidate-gen, ranker, blender. Каждый блок живёт в своём namespace с собственным HPA и собственным бюджетом на ресурсы. Так вы не будете гадать, какой компонент сожрал всю память.
И ещё: научитесь читать дашборды GPU — utilization 80% не значит «всё хорошо», если queue duration растёт. Иногда дешевле добавить реплику, чем оптимизировать батч.
Соберите эту конструкцию — и ваша рекомендательная система перестанет быть чёрным ящиком с тормозами. Она станет предсказуемым конвейером, который вы сможете отлаживать, масштабировать и объяснять бизнесу.