Оптимизация памяти для AI сервисов: Embed, Rerank и классификатор на 8GB VRAM | AiManual
AiManual Logo Ai / Manual.
19 Мар 2026 Гайд

8GB VRAM и три модели: как заставить работать Embed, Rerank и классификатор без Out of Memory

Подробный гайд по развертыванию трех моделей на одной видеокарте 8GB. Nomic Embed, BGE Reranker, ModernBERT без OOM ошибок с FastAPI и Docker.

Ситуация, которая сводит с ума: три модели, одна карта и вечный Out of Memory

Ты решил собрать продвинутый RAG-сервис. Embedding для векторизации текста, reranker для уточнения релевантности, классификатор для категоризации запросов. Логика простая - пользователь задает вопрос, система находит похожие документы, ранжирует их и определяет тип запроса. Идеально, пока не посмотришь на требования к памяти.

На бумаге каждая модель скромная: Nomic Embed v2.5 - 137M параметров, BGE Reranker v2.5 - 278M, ModernBERT-2025 - 110M. Но в реальности они пожирают память как голодные студенты в столовой. Особенно когда пытаешься загрузить их одновременно на RTX 3070 или 4060 Ti с их 8GB VRAM.

Самая частая ошибка: загружаешь все три модели сразу, и сервис падает при первом же запросе. PyTorch аллоцирует память с запасом, CUDA context занимает место, и получается классический OOM при batch size больше 1.

Почему 8GB - это не 8GB, а где-то 6.5GB доступных

Первое разочарование: системная память CUDA и реальная доступная VRAM - разные вещи. На 19 марта 2026 года последние драйверы NVIDIA резервируют под системные нужды около 300-500MB. Плюс PyTorch создает memory pools, которые не освобождаются полностью даже после torch.cuda.empty_cache().

💡
Проверь реальную доступную память через nvidia-smi после загрузки только одной модели. Увидишь, что свободной памяти меньше, чем ожидал. Это норма - учитывай overhead в 15-20% при планировании.

Стратегия: не хранить, а подгружать. Lazy loading моделей

Ключевая идея - модели живут в памяти только когда работают. Нет запросов на классификацию? ModernBERT выгружается. Нужен embedding? Загружаем Nomic, делаем работу, возможно, оставляем в памяти если ожидаем следующий запрос. Это не кэширование в классическом понимании - это точечная загрузка под задачу.

Звучит просто. На деле нужно решить три проблемы:

  • Время загрузки модели - 3-5 секунд неприемлемо для реального сервиса
  • Конфликты при параллельных запросах - два запроса одновременно хотят разные модели
  • Память не освобождается полностью после выгрузки

1 Готовим окружение: что установить в 2026 году

Забудь про старые версии библиотек. На 19 марта 2026 года используй:

# requirements.txt
# Версии актуальны на март 2026
torch==2.3.0+cu121
transformers==4.40.0
sentence-transformers==3.0.0
fastapi==0.110.0
uvicorn[standard]==0.29.0
pydantic==2.6.0
python-multipart==0.0.9

Важный момент: PyTorch 2.3.0 улучшил управление памятью для небольших моделей. В частности, уменьшил overhead при загрузке/выгрузке. Не используй версии ниже 2.2.0 - там были проблемы с memory fragmentation.

2 Архитектура сервиса: один менеджер для трех моделей

Создаем ModelManager - центральный компонент, который решает, какую модель когда загружать. Его задача - балансировать между скоростью ответа и потреблением памяти.

import asyncio
import gc
from typing import Optional, Dict, Tuple
import torch
from transformers import AutoModel, AutoTokenizer
import logging

logger = logging.getLogger(__name__)

class ModelManager:
    """
    Менеджер для загрузки и выгрузки моделей по требованию.
    Одновременно в памяти только одна модель.
    """
    
    def __init__(self, max_memory_gb: float = 7.0):
        self.current_model: Optional[str] = None
        self.models: Dict[str, Tuple[AutoModel, AutoTokenizer]] = {}
        self.lock = asyncio.Lock()
        self.max_memory_bytes = int(max_memory_gb * 1024**3)
        
        # Конфигурация моделей на март 2026
        self.model_configs = {
            "embed": {
                "id": "nomic-ai/nomic-embed-text-v2.5",
                "max_length": 8192,
                "precision": torch.float16  # FP16 для экономии памяти
            },
            "rerank": {
                "id": "BAAI/bge-reranker-v2.5",
                "max_length": 512,
                "precision": torch.float16
            },
            "classify": {
                "id": "allenai/modernbert-2025",
                "max_length": 512,
                "precision": torch.float32  # ModernBERT лучше в FP32
            }
        }
    
    async def get_model(self, model_name: str):
        """
        Получить модель по имени. Если не загружена - загружает.
        Если другая модель загружена - выгружает ее.
        """
        async with self.lock:
            # Если запрашиваемая модель уже загружена
            if model_name == self.current_model:
                return self.models[model_name]
            
            # Освобождаем память от предыдущей модели
            await self._cleanup_current()
            
            # Загружаем новую модель
            model, tokenizer = await self._load_model(model_name)
            self.models[model_name] = (model, tokenizer)
            self.current_model = model_name
            
            logger.info(f"Загружена модель {model_name}, свободно VRAM: {self._get_free_memory_mb()}MB")
            return model, tokenizer
    
    async def _cleanup_current(self):
        """Выгрузить текущую модель и почистить память"""
        if self.current_model and self.current_model in self.models:
            model, tokenizer = self.models.pop(self.current_model)
            
            # Важно: удаляем ссылки в правильном порядке
            del model
            del tokenizer
            
            # Сборщик мусора
            gc.collect()
            
            # Очистка кэша CUDA - работает, но не всегда полностью
            if torch.cuda.is_available():
                torch.cuda.empty_cache()
                torch.cuda.synchronize()  # Ждем завершения операций
            
            logger.info(f"Выгружена модель {self.current_model}")
            self.current_model = None
    
    async def _load_model(self, model_name: str):
        """Загрузка конкретной модели"""
        config = self.model_configs[model_name]
        
        # Проверяем, хватит ли памяти
        free_memory = self._get_free_memory_mb()
        if free_memory < 1000:  # Меньше 1GB свободно
            logger.warning(f"Мало свободной памяти: {free_memory}MB")
            # Пытаемся освободить дополнительно
            gc.collect()
            torch.cuda.empty_cache()
        
        # Загрузка токенизатора (не требует GPU)
        tokenizer = AutoTokenizer.from_pretrained(config["id"])
        
        # Загрузка модели с учетом precision
        model = AutoModel.from_pretrained(
            config["id"],
            torch_dtype=config["precision"],
            device_map="auto"  # Позволяет PyTorch самому выбрать размещение
        )
        
        # Переводим в eval режим (меньше памяти)
        model.eval()
        
        # Отключаем градиенты для экономии памяти
        for param in model.parameters():
            param.requires_grad = False
        
        return model, tokenizer
    
    def _get_free_memory_mb(self) -> float:
        """Получить количество свободной памяти VRAM в MB"""
        if torch.cuda.is_available():
            return torch.cuda.mem_get_info()[0] / (1024**2)
        return 0

Этот менеджер решает главную проблему - модели не конфликтуют за память. Но есть нюанс: если к сервису одновременно придут запросы на embedding и классификацию, второй запрос будет ждать. Для большинства RAG-систем это приемлемо - запросы идут последовательно.

3 FastAPI эндпоинты с умным управлением памятью

Теперь создаем API, которое использует наш менеджер. Ключевой момент - обработка батчей. Большие батчи убивают память быстрее всего.

from fastapi import FastAPI, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import List
import numpy as np

app = FastAPI(title="Multi-Model RAG Service")
manager = ModelManager(max_memory_gb=7.0)  # Оставляем 1GB для системы

class EmbedRequest(BaseModel):
    texts: List[str]
    batch_size: int = 4  # Маленькие батчи для 8GB VRAM

class RerankRequest(BaseModel):
    query: str
    documents: List[str]

class ClassifyRequest(BaseModel):
    text: str
    labels: List[str]

@app.post("/embed")
async def embed_texts(request: EmbedRequest):
    """
    Эмбеддинг текстов. Обрабатываем маленькими батчами.
    """
    try:
        model, tokenizer = await manager.get_model("embed")
        
        all_embeddings = []
        
        # Обработка маленькими батчами
        for i in range(0, len(request.texts), request.batch_size):
            batch = request.texts[i:i + request.batch_size]
            
            # Токенизация
            inputs = tokenizer(
                batch,
                padding=True,
                truncation=True,
                max_length=8192,
                return_tensors="pt"
            ).to(model.device)
            
            # Инференс без градиентов
            with torch.no_grad():
                outputs = model(**inputs)
                # Для Nomic Embed v2.5 используем последний hidden state
                embeddings = outputs.last_hidden_state[:, 0, :]
                
            # Перемещаем на CPU и конвертируем в numpy
            embeddings = embeddings.cpu().numpy()
            all_embeddings.append(embeddings)
            
            # Очистка промежуточных переменных
            del inputs, outputs
            
        # Объединяем результаты
        result = np.vstack(all_embeddings).tolist()
        
        # После обработки можно освободить модель, если память критична
        # Но лучше оставить - вдруг следующий запрос тоже embedding
        
        return {"embeddings": result}
        
    except torch.cuda.OutOfMemoryError:
        # Экстренная очистка
        torch.cuda.empty_cache()
        raise HTTPException(
            status_code=500,
            detail="Недостаточно памяти GPU. Уменьшите batch_size."
        )

@app.post("/rerank")
async def rerank_documents(request: RerankRequest):
    """
    Ранжирование документов по релевантности запросу.
    BGE Reranker v2.5 работает с парами (query, document).
    """
    try:
        model, tokenizer = await manager.get_model("rerank")
        
        scores = []
        
        # Обрабатываем по одному документу за раз
        for doc in request.documents:
            # Формируем пару для reranker
            pair = f"{request.query}[SEP]{doc}"
            
            inputs = tokenizer(
                pair,
                padding=True,
                truncation=True,
                max_length=512,
                return_tensors="pt"
            ).to(model.device)
            
            with torch.no_grad():
                outputs = model(**inputs)
                score = outputs.logits.item()
            
            scores.append({"document": doc, "score": float(score)})
            
            del inputs, outputs
            
        # Сортируем по убыванию релевантности
        scores.sort(key=lambda x: x["score"], reverse=True)
        
        return {"ranked_results": scores}
        
    except torch.cuda.OutOfMemoryError:
        torch.cuda.empty_cache()
        raise HTTPException(
            status_code=500,
            detail="OOM при ранжировании. Уменьшите количество документов."
        )

@app.post("/classify")
async def classify_text(request: ClassifyRequest):
    """
    Zero-shot классификация ModernBERT.
    Модель определяет наиболее подходящую категорию.
    """
    try:
        model, tokenizer = await manager.get_model("classify")
        
        # Формируем пары (текст, label) для классификации
        pairs = [f"{request.text} [SEP] {label}" for label in request.labels]
        
        inputs = tokenizer(
            pairs,
            padding=True,
            truncation=True,
            max_length=512,
            return_tensors="pt"
        ).to(model.device)
        
        with torch.no_grad():
            outputs = model(**inputs)
            logits = outputs.logits
            
        # Получаем вероятности
        probabilities = torch.softmax(logits[:, 0], dim=-1)
        best_idx = torch.argmax(probabilities).item()
        
        result = {
            "predicted_label": request.labels[best_idx],
            "confidence": float(probabilities[best_idx]),
            "all_scores": [
                {"label": label, "score": float(score)}
                for label, score in zip(request.labels, probabilities)
            ]
        }
        
        return result
        
    except torch.cuda.OutOfMemoryError:
        torch.cuda.empty_cache()
        raise HTTPException(
            status_code=500,
            detail="Недостаточно памяти для классификации."
        )

@app.on_event("startup")
async def startup_event():
    """Предзагрузка самой часто используемой модели (embedding)"""
    # Можно начать с загрузки embedding модели - она используется чаще всего
    # Но в нашем случае лучше ничего не загружать заранее
    logger.info("Service starting up...")

@app.on_event("shutdown")
async def shutdown_event():
    """Очистка памяти при завершении"""
    if torch.cuda.is_available():
        torch.cuda.empty_cache()

Обрати внимание на обработку OOM исключений. Вместо падения всего сервиса возвращаем понятную ошибку и предлагаем решение (уменьшить batch_size). Это профессиональный подход - пользователь понимает, что происходит.

4 Docker с настройками памяти под 8GB VRAM

Dockerfile и docker-compose с правильными параметрами - половина успеха. Без настроек контейнер может использовать больше памяти, чем доступно.

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Устанавливаем системные зависимости для PyTorch
RUN apt-get update && apt-get install -y \
    gcc \
    g++ \
    make \
    git \
    && rm -rf /var/lib/apt/lists/*

# Копируем requirements
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
    && pip install --no-cache-dir -r requirements.txt

# Копируем код
COPY . .

# Переменные окружения для оптимизации памяти PyTorch
ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
ENV TOKENIZERS_PARALLELISM=false
ENV CUDA_LAUNCH_BLOCKING=0

# Запуск
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
# docker-compose.yml
version: '3.8'

services:
  rag-service:
    build: .
    container_name: rag-multi-model
    ports:
      - "8000:8000"
    environment:
      - NVIDIA_VISIBLE_DEVICES=0  # Используем только первую GPU
      - PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
      - TOKENIZERS_PARALLELISM=false
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    # Ограничиваем использование памяти CPU
    mem_limit: 8g
    mem_reservation: 4g
    restart: unless-stopped
    # Мониторинг памяти
    healthcheck:
      test: ["CMD", "python", "-c", "import torch; print(torch.cuda.is_available())"]
      interval: 30s
      timeout: 10s
      retries: 3

Что делать, когда ничего не помогает: продвинутые техники

Если даже lazy loading не спасает от OOM, иди глубже. Эти методы требуют больше кода, но дают результат.

Техника Экономия памяти Сложность Когда использовать
8-bit quantization До 50% Средняя Когда точно не хватает памяти
Gradient checkpointing 25-30% Высокая Только для обучения
CPU offloading До 70% VRAM Высокая Когда есть много RAM
Model pruning 30-40% Очень высокая Для production систем

Вот пример 8-bit quantization для ModernBERT (самая голодная модель из трех):

# Альтернативная загрузка ModernBERT с квантованием
from transformers import BitsAndBytesConfig
import torch

quantization_config = BitsAndBytesConfig(
    load_in_8bit=True,
    llm_int8_threshold=6.0,
    llm_int8_has_fp16_weight=False
)

model = AutoModel.from_pretrained(
    "allenai/modernbert-2025",
    quantization_config=quantization_config,
    device_map="auto"
)

Внимание: 8-bit quantization уменьшает точность. Для BGE Reranker и Nomic Embed это может критично повлиять на качество ранжирования и эмбеддингов. Тестируй перед использованием в продакшене.

Мониторинг и дебаг: как понять, что именно жрет память

Слепые оптимизации - путь в никуда. Используй эти инструменты, чтобы видеть картину целиком:

# Утилита для мониторинга памяти
import torch

def print_memory_stats(prefix=""):
    """Вывод статистики использования памяти CUDA"""
    if not torch.cuda.is_available():
        print(f"{prefix} CUDA недоступна")
        return
    
    allocated = torch.cuda.memory_allocated() / 1024**3
    reserved = torch.cuda.memory_reserved() / 1024**3
    max_allocated = torch.cuda.max_memory_allocated() / 1024**3
    
    print(f"{prefix} Allocated: {allocated:.2f}GB")
    print(f"{prefix} Reserved: {reserved:.2f}GB")
    print(f"{prefix} Max allocated: {max_allocated:.2f}GB")
    
    # Сбрасываем счетчик максимума
    torch.cuda.reset_peak_memory_stats()

# Используй в критических местах
print_memory_stats("До загрузки модели")
model = load_model()
print_memory_stats("После загрузки модели")

Запусти сервис и отправь тестовые запросы. Смотри на вывод nvidia-smi в реальном времени:

# Мониторинг в реальном времени
watch -n 1 nvidia-smi

# Или более детально
nvidia-smi --query-gpu=memory.used,memory.free,memory.total --format=csv -l 1

Частые ошибки и как их избежать

Я собрал топ ошибок, которые встречал на проектах:

Ошибка Причина Решение
Memory leak при частой загрузке/выгрузке PyTorch не освобождает memory pools Использовать torch.cuda.empty_cache() после gc.collect()
OOM при batch processing Слишком большой batch size Начинать с batch_size=1, увеличивать постепенно
Медленная загрузка модели Загрузка с Hugging Face каждый раз Кэшировать модели локально, использовать .from_pretrained(..., local_files_only=True)
CUDA out of memory после первого запроса Не очищаются градиенты Всегда использовать with torch.no_grad(): для инференса

Итог: можно ли реально работать с тремя моделями на 8GB VRAM?

Да, но с условиями. Твой сервис никогда не будет обрабатывать параллельные запросы к разным моделям. Latenсy увеличится на 2-3 секунды при переключении между моделями. Зато все работает стабильно, без падений.

Главный урок: 8GB VRAM в 2026 году - это как 4GB RAM в 2015. Хватает для базовых задач, но для production систем с несколькими моделями лучше смотреть на карты с 12GB+.

Если интересно глубже разобраться в оптимизации памяти, рекомендую посмотреть мою статью про ZAGORA: как тренировать 70B модели на 4 видеокартах без OOM ошибок. Там описаны более продвинутые техники распределения моделей по нескольким GPU.

А если ты только начинаешь погружаться в тему локальных LLM, почитай гайд по минимальным требованиям VRAM для домашнего сервера - там основы, без которых дальше не двинуться.

Последний совет: всегда тестируй свой сервис под максимальной нагрузкой. Запусти 100 последовательных запросов с разными типами задач (embed, rerank, classify). Если выживет - можно показывать клиенту. Если упадет на 15-м запросе - возвращайся к оптимизациям.

Подписаться на канал