Запуск нескольких LLM на одном GPU: FastAPI, экономия памяти, оркестрация | AiManual
AiManual Logo Ai / Manual.
12 Янв 2026 Гайд

Почему ваш GPU задыхается: архитектура для запуска нескольких LLM на одном ускорителе

Практическое руководство по развертыванию нескольких LLM-моделей на одном GPU (16 ГБ VRAM) с FastAPI. Архитектура, код, оптимизация нагрузки.

GPU не резиновый: проблема, которая всех достала

У вас есть RTX 4090 или A6000 с 24 ГБ памяти. Или, что более вероятно, RTX 4080 Super с 16 ГБ. Вы скачали Llama 3 8B (5 ГБ), Mistral 7B (4 ГБ) и Qwen 2.5 Coder 7B (еще 4 ГБ). Запускаете одну модель — работает идеально. Пытаетесь запустить вторую параллельно — получаете классическую ошибку CUDA out of memory. Видеокарта кричит о помощи.

Типичное «решение»: запускать модели по очереди, выгружая одну перед загрузкой другой. Это занимает 10-20 секунд на каждое переключение. Для API — вечность. Пользователи не будут ждать. Особенно если вы строите очередь запросов к локальной LLM, которая должна выдерживать нагрузку.

Главное заблуждение: «У меня 16 ГБ VRAM, значит, я могу запустить две модели по 8 ГБ». Нет. Не можете. Память GPU фрагментирована, плюс драйверы резервируют память под системные нужды, плюс сам фреймворк (PyTorch, TensorFlow) съедает накладные расходы. Реальная доступная память — примерно 90% от заявленной.

Что на самом деле происходит в памяти GPU?

Когда модель загружается в GPU, она занимает два типа памяти:

  • Веса модели: Основные параметры. Для 7B-модели в FP16 — примерно 14 ГБ. В 4-битном квантовании — 3.5-4 ГБ.
  • Память для вычислений (активации): Временные данные, которые создаются во время инференса. Зависит от длины контекста и размера батча. Может быть сравнима с размером весов.

Когда вы пытаетесь загрузить вторую модель, PyTorch выделяет для нее новый регион памяти. Если непрерывного блока нужного размера нет — получаете ошибку. Даже если суммарно свободной памяти хватает.

💡
Альтернатива — CPU+RAM инференс. Но он в 10-50 раз медленнее. Для продакшена не подходит, только для тестирования.

Архитектура: не грузи все сразу

Секрет не в магии, а в правильной оркестрации. Мы не будем держать все модели в памяти одновременно. Вместо этого создадим менеджера, который:

  1. Загружает модель в GPU только когда приходит запрос к ней.
  2. Выгружает модель из памяти, если она не используется дольше заданного таймаута.
  3. Ограничивает количество одновременно загруженных моделей (например, максимум 2 из 5).
  4. Использует общий пул памяти для всех моделей.

Звучит просто. Но дьявол в деталях.

1Выбираем инструменты: почему FastAPI, а не Flask?

FastAPI дает асинхронность из коробки. Это критично, потому что пока одна модель генерирует ответ (это может занять 10-30 секунд), сервер должен принимать новые запросы и ставить их в очередь. Flask в таком сценарии блокируется.

Еще важнее — встроенная валидация данных через Pydantic. Когда к вам приходит запрос на генерацию, вы должны проверить параметры (temperature, max_tokens, model_name) до того, как передадите их в модель. Иначе получите падение сервера.

from pydantic import BaseModel, Field
from typing import Optional

class GenerationRequest(BaseModel):
    prompt: str
    model_name: str = Field(..., description="Название модели из конфига")
    temperature: float = Field(0.7, ge=0.0, le=2.0)
    max_tokens: int = Field(512, ge=1, le=4096)
    stream: bool = False

Flask заставит вас писать эту валидацию вручную. И вы обязательно где-нибудь ошибетесь.

2Сердце системы: ModelManager

Этот класс будет отслеживать, какая модель загружена, сколько памяти занимает, когда последний раз использовалась. Плюс — выгружать наименее используемые модели при нехватке памяти.

import torch
import asyncio
from typing import Dict, Optional
from dataclasses import dataclass
from datetime import datetime, timedelta

@dataclass
class ModelInstance:
    model: any  # HuggingFace pipeline или аналогичный объект
    loaded_at: datetime
    last_used: datetime
    memory_usage_mb: int

class ModelManager:
    def __init__(self, max_loaded_models: int = 2, idle_timeout_minutes: int = 10):
        self.loaded_models: Dict[str, ModelInstance] = {}
        self.max_loaded_models = max_loaded_models
        self.idle_timeout = timedelta(minutes=idle_timeout_minutes)
        self.lock = asyncio.Lock()
    
    async def get_model(self, model_name: str) -> Optional[ModelInstance]:
        async with self.lock:
            # 1. Если модель уже загружена — обновляем время использования и возвращаем
            if model_name in self.loaded_models:
                instance = self.loaded_models[model_name]
                instance.last_used = datetime.now()
                return instance
            
            # 2. Если достигли лимита — выгружаем самую старую неиспользуемую модель
            if len(self.loaded_models) >= self.max_loaded_models:
                await self._unload_idle_model()
            
            # 3. Загружаем новую модель
            model = await self._load_model_to_gpu(model_name)
            if not model:
                return None
            
            memory_usage = torch.cuda.memory_allocated() / 1024**2
            instance = ModelInstance(
                model=model,
                loaded_at=datetime.now(),
                last_used=datetime.now(),
                memory_usage_mb=int(memory_usage)
            )
            self.loaded_models[model_name] = instance
            return instance
    
    async def _unload_idle_model(self):
        now = datetime.now()
        candidates = []
        
        for name, instance in self.loaded_models.items():
            if now - instance.last_used > self.idle_timeout:
                candidates.append((name, instance.last_used))
        
        if candidates:
            # Выгружаем самую старую
            candidates.sort(key=lambda x: x[1])
            name_to_unload = candidates[0][0]
            del self.loaded_models[name_to_unload]
            torch.cuda.empty_cache()
            # Принудительно вызываем сборщик мусора
            import gc
            gc.collect()
            torch.cuda.empty_cache()

Важно: torch.cuda.empty_cache() не освобождает память, занятую загруженными моделями. Он очищает только кеш выделителя памяти CUDA. Чтобы выгрузить модель, нужно удалить все ссылки на объект модели и вызвать сборщик мусора. Только после этого empty_cache() освободит память.

3Интеграция с FastAPI: эндпоинты и middleware

Создаем два основных эндпоинта: /generate для обычной генерации и /generate/stream для потоковой. Плюс служебный /models/status для мониторинга.

from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
import json

app = FastAPI(title="Multi-LLM Orchestrator")
model_manager = ModelManager()

# Конфигурация моделей
MODEL_CONFIG = {
    "llama3-8b": {
        "path": "/models/llama-3-8b-instruct",
        "type": "transformers",
        "max_context": 8192,
        "quantization": "q4_k_m"  # Для llama.cpp
    },
    "mistral-7b": {
        "path": "/models/mistral-7b-instruct-v0.3",
        "type": "transformers",
        "max_context": 32768
    }
}

@app.post("/generate")
async def generate(request: GenerationRequest):
    # Проверяем, что модель есть в конфиге
    if request.model_name not in MODEL_CONFIG:
        raise HTTPException(status_code=404, detail=f"Model {request.model_name} not found")
    
    # Получаем экземпляр модели (загружаем если нужно)
    instance = await model_manager.get_model(request.model_name)
    if not instance:
        raise HTTPException(status_code=503, detail="Failed to load model, GPU memory full")
    
    # Выполняем генерацию
    try:
        result = await generate_text(
            instance.model,
            request.prompt,
            temperature=request.temperature,
            max_tokens=request.max_tokens
        )
        return {"text": result, "model": request.model_name}
    except torch.cuda.OutOfMemoryError:
        # Экстренная ситуация: память закончилась во время генерации
        # Выгружаем все модели и очищаем кеш
        model_manager.loaded_models.clear()
        torch.cuda.empty_cache()
        raise HTTPException(status_code=500, detail="GPU out of memory, models cleared")

@app.get("/models/status")
async def get_status():
    status = []
    for name, instance in model_manager.loaded_models.items():
        status.append({
            "name": name,
            "loaded_at": instance.loaded_at.isoformat(),
            "last_used": instance.last_used.isoformat(),
            "memory_mb": instance.memory_usage_mb,
            "idle_minutes": (datetime.now() - instance.last_used).total_seconds() / 60
        })
    
    # Информация о памяти GPU
    gpu_info = {
        "total_mb": torch.cuda.get_device_properties(0).total_memory / 1024**2,
        "allocated_mb": torch.cuda.memory_allocated() / 1024**2,
        "cached_mb": torch.cuda.memory_reserved() / 1024**2
    }
    
    return {"loaded_models": status, "gpu": gpu_info}

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

ОшибкаПочему происходитКак исправить
CUDA out of memory после выгрузки моделиСсылки на тензоры остались в Python-объектах (например, в логгере). Сборщик мусора не может очистить память.Использовать del model и явный gc.collect() перед torch.cuda.empty_cache().
Модель загружается 2 минуты при каждом запросеВы установили idle_timeout = 1 минуту. Модель постоянно выгружается и загружается заново.Увеличить таймаут до 10-30 минут или реализовать предзагрузку популярных моделей.
Ошибка "RuntimeError: Cannot re-initialize CUDA in forked subprocess"Используете Gunicorn/Uvicorn с работниками (workers). CUDA не любит форки после инициализации.Запускать с одним воркером (--workers 1) или использовать spawn вместо fork.
Потоковая генерация работает, но соединение обрываетсяNginx или другой прокси имеет таймаут на стриминг (по умолчанию 30 секунд).Настроить proxy_read_timeout 300s; в Nginx.

Оптимизации для 16 ГБ VRAM

Если у вас именно 16 ГБ (RTX 4080 Super, RTX 4060 Ti 16GB), вот конкретные цифры:

  • Квантование — ваш лучший друг. Модель 7B в FP16 = 14 ГБ. В INT8 = 7 ГБ. В 4-битном = 3.5-4 ГБ. Используйте GGUF формат для llama.cpp или bitsandbytes для Transformers.
  • Одновременно можно держать: Две 7B-модели в 4-битном (8 ГБ) + память для активаций (2-4 ГБ) + запас (1-2 ГБ). Итого 13-15 ГБ.
  • Или одна 13B-модель в 4-битном (6.5 ГБ) + одна 7B-модель в 4-битном (3.5 ГБ) = 10 ГБ на веса. Остальное — на активации.

Для больших моделей (например, 70B) на одном GPU придется использовать распределенные вычисления на нескольких видеокартах. Или собирать бюджетную 4-GPU ферму.

💡
Проверьте, действительно ли ваша видеокарта использует всю память. Иногда драйвер резервирует 1-2 ГБ под графический вывод (если у вас монитор подключен к этой же карте). В таком случае подумайте о выделенном сервере без монитора.

Деплой: как не убить сервер

Самый простой способ — Docker + systemd. Но есть нюансы:

FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Важно: устанавливаем лимит на использование памяти CUDA
ENV PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128
ENV CUDA_VISIBLE_DEVICES=0

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

Почему --workers 1? Потому что несколько воркеров будут пытаться загрузить модели независимо, и каждый займет свою память. CUDA поделит память между процессами, и каждый получит только часть. В результате ни один процесс не сможет загрузить модель полностью.

Лучше использовать один процесс, но с асинхронными вызовами. FastAPI справится.

Что делать, когда придут 1000 пользователей?

Эта архитектура — для умеренной нагрузки (10-50 одновременных запросов). Если вам нужно больше — добавляете балансировщик нагрузки и несколько инстансов сервиса. Каждый инстанс будет обслуживать свой набор моделей.

Но тогда возникает проблема консистентности: если пользователь отправил запрос к модели A на инстанс 1, а следующий запрос попал на инстанс 2, ему придется ждать загрузки модели заново. Решение — sticky sessions или внешний кеш состояний.

Для настоящего масштабирования до 1000 одновременных запросов нужна отдельная статья. Но этот гайд — ваш фундамент.

Итог: что вы получили

  • Сервис, который может держать в памяти 2-3 модели на одном 16 ГБ GPU, а в конфиге иметь 5-10 моделей.
  • Автоматическую выгрузку неиспользуемых моделей.
  • API, совместимый с OpenAI (можно добавить роут /v1/chat/completions).
  • Мониторинг через /models/status.
  • Защиту от типичных ошибок памяти.

Самое важное — вы теперь понимаете, как работает память GPU при загрузке нескольких моделей. Не как «черный ящик», а как управляемый ресурс. Это знание дороже любого кода.

P.S. Если вы запускаете это на домашнем железе, посмотрите гайд по домашней LLM-инфраструктуре. Там есть про охлаждение, питание и почему ваш сосед снизу стучит по батарее, когда вы запускаете инференс на 4 GPU одновременно.