Ситуация, которая сводит с ума: три модели, одна карта и вечный 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-м запросе - возвращайся к оптимизациям.