← Back to DevBytes

How to Build a Multi-Model Serving Platform

How to Build a Multi-Model Serving Platform

As machine learning adoption grows inside organizations, teams inevitably face a familiar problem: dozens of trained models, each with different frameworks, dependencies, and hardware needs, all needing to be exposed as reliable APIs. Serving one model is easy. Serving fifty models efficiently, without drowning in infrastructure costs, is an entirely different challenge. That is where a multi-model serving platform comes in.

In this tutorial, you will learn what a multi-model serving platform is, why it matters, how to architect one, and how to build a working example using Python, FastAPI, and a simple model registry. By the end, you will have a clear blueprint for serving many models behind a single, scalable API gateway.

What Is a Multi-Model Serving Platform?

A multi-model serving platform is an infrastructure layer that hosts multiple machine learning models simultaneously and exposes them through a unified interface, typically HTTP or gRPC endpoints. Instead of deploying one container per model, the platform loads several models into shared or isolated runtimes, routes incoming requests to the correct model, and manages lifecycle operations such as loading, unloading, versioning, and scaling.

Key characteristics of a mature platform include:

Why It Matters

The naive approach of one container per model works for a handful of models, but it breaks down quickly. Each container carries its own runtime overhead, cold-start cost, and operational burden. When you have hundreds of models, many of which receive traffic only occasionally, the waste becomes significant.

A multi-model serving platform addresses several real problems:

Architecture Overview

Before writing code, it helps to understand the components of a multi-model serving platform. A typical architecture consists of the following layers:

In the next sections, we will build a simplified but functional version of this architecture.

Building the Platform: Step by Step

We will build our platform using Python and FastAPI. The example will support multiple scikit-learn models loaded dynamically from disk. The same pattern extends to PyTorch, TensorFlow, or ONNX models with minor adjustments.

Step 1: Project Setup

Create a new project directory and install the required dependencies:

mkdir multi-model-server && cd multi-model-server
python -m venv venv
source venv/bin/activate
pip install fastapi uvicorn scikit-learn joblib pydantic

Your project structure will look like this:

multi-model-server/
├── main.py
├── model_manager.py
├── registry.py
└── models/
    ├── iris_v1.joblib
    └── wine_v1.joblib

Step 2: Creating the Model Registry

The registry tracks metadata about each available model. In production, you would use a database or object store, but for this tutorial we use a simple in-memory dictionary backed by a local directory.

# registry.py
import os
import json
from pathlib import Path

MODEL_DIR = Path(__file__).parent / "models"

class ModelRegistry:
    def __init__(self, model_dir: Path = MODEL_DIR):
        self.model_dir = model_dir
        self.model_dir.mkdir(parents=True, exist_ok=True)
        self.metadata_file = self.model_dir / "registry.json"
        self.metadata = self._load_metadata()

    def _load_metadata(self):
        if self.metadata_file.exists():
            with open(self.metadata_file, "r") as f:
                return json.load(f)
        return {}

    def _save_metadata(self):
        with open(self.metadata_file, "w") as f:
            json.dump(self.metadata, f, indent=2)

    def register(self, name: str, version: str, filename: str, framework: str = "sklearn"):
        key = f"{name}:{version}"
        self.metadata[key] = {
            "name": name,
            "version": version,
            "filename": filename,
            "framework": framework,
            "path": str(self.model_dir / filename),
        }
        self._save_metadata()
        return self.metadata[key]

    def get(self, name: str, version: str):
        return self.metadata.get(f"{name}:{version}")

    def list_models(self):
        return list(self.metadata.values())

    def remove(self, name: str, version: str):
        key = f"{name}:{version}"
        if key in self.metadata:
            del self.metadata[key]
            self._save_metadata()
            return True
        return False

Step 3: Building the Model Manager

The model manager handles loading and unloading models into memory. It keeps a cache of loaded models and supports lazy loading, meaning a model is only loaded when first requested.

# model_manager.py
import joblib
import threading
from typing import Any, Dict, Optional
from registry import ModelRegistry

class ModelManager:
    def __init__(self, registry: ModelRegistry, max_loaded: int = 10):
        self.registry = registry
        self.max_loaded = max_loaded
        self.loaded: Dict[str, Any] = {}
        self.lock = threading.Lock()

    def _key(self, name: str, version: str) -> str:
        return f"{name}:{version}"

    def load(self, name: str, version: str) -> Any:
        key = self._key(name, version)
        with self.lock:
            if key in self.loaded:
                return self.loaded[key]

            meta = self.registry.get(name, version)
            if meta is None:
                raise ValueError(f"Model {key} not found in registry")

            if len(self.loaded) >= self.max_loaded:
                # Evict the oldest loaded model (simple LRU-ish strategy)
                oldest_key = next(iter(self.loaded))
                del self.loaded[oldest_key]

            model = joblib.load(meta["path"])
            self.loaded[key] = model
            return model

    def unload(self, name: str, version: str) -> bool:
        key = self._key(name, version)
        with self.lock:
            if key in self.loaded:
                del self.loaded[key]
                return True
            return False

    def get_loaded_models(self):
        with self.lock:
            return list(self.loaded.keys())

    def predict(self, name: str, version: str, input_data):
        model = self.load(name, version)
        return model.predict(input_data)

Notice the use of a threading lock. Even though FastAPI runs asynchronously, model inference is often CPU-bound and may run in thread pools, so protecting shared state is important.

Step 4: Creating the API Gateway

Now we build the FastAPI application that exposes endpoints for listing models, registering new ones, and running predictions.

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
from registry import ModelRegistry
from model_manager import ModelManager

app = FastAPI(title="Multi-Model Serving Platform")
registry = ModelRegistry()
manager = ModelManager(registry, max_loaded=10)

class RegisterRequest(BaseModel):
    name: str
    version: str
    filename: str
    framework: str = "sklearn"

class PredictRequest(BaseModel):
    inputs: List[List[float]]

@app.get("/models")
def list_models():
    return {"models": registry.list_models(), "loaded": manager.get_loaded_models()}

@app.post("/models/register")
def register_model(req: RegisterRequest):
    meta = registry.register(req.name, req.version, req.filename, req.framework)
    return {"status": "registered", "model": meta}

@app.delete("/models/{name}/{version}")
def remove_model(name: str, version: str):
    manager.unload(name, version)
    removed = registry.remove(name, version)
    if not removed:
        raise HTTPException(status_code=404, detail="Model not found")
    return {"status": "removed"}

@app.post("/models/{name}/{version}/predict")
def predict(name: str, version: str, req: PredictRequest):
    try:
        predictions = manager.predict(name, version, req.inputs)
        return {"model": f"{name}:{version}", "predictions": predictions.tolist()}
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Inference error: {str(e)}")

@app.delete("/models/{name}/{version}/unload")
def unload_model(name: str, version: str):
    success = manager.unload(name, version)
    if not success:
        raise HTTPException(status_code=404, detail="Model was not loaded")
    return {"status": "unloaded"}

Step 5: Training and Registering Sample Models

To test the platform, train two small scikit-learn models and save them to the models directory.

# train_samples.py
from sklearn.datasets import load_iris, load_wine
from sklearn.ensemble import RandomForestClassifier
import joblib
from pathlib import Path

model_dir = Path("models")
model_dir.mkdir(exist_ok=True)

# Train iris model
iris = load_iris()
iris_clf = RandomForestClassifier(n_estimators=50, random_state=42)
iris_clf.fit(iris.data, iris.target)
joblib.dump(iris_clf, model_dir / "iris_v1.joblib")

# Train wine model
wine = load_wine()
wine_clf = RandomForestClassifier(n_estimators=50, random_state=42)
wine_clf.fit(wine.data, wine.target)
joblib.dump(wine_clf, model_dir / "wine_v1.joblib")

print("Models trained and saved.")

Run the training script:

python train_samples.py

Step 6: Running the Server and Testing

Start the FastAPI server:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

Register the two models:

curl -X POST http://localhost:8000/models/register \
  -H "Content-Type: application/json" \
  -d '{"name": "iris", "version": "v1", "filename": "iris_v1.joblib"}'

curl -X POST http://localhost:8000/models/register \
  -H "Content-Type: application/json" \
  -d '{"name": "wine", "version": "v1", "filename": "wine_v1.joblib"}'

List available models:

curl http://localhost:8000/models

Run a prediction against the iris model:

curl -X POST http://localhost:8000/models/iris/v1/predict \
  -H "Content-Type: application/json" \
  -d '{"inputs": [[5.1, 3.5, 1.4, 0.2], [6.7, 3.0, 5.0, 1.7]]}'

You should receive a JSON response with predicted class labels for both input samples. The model is loaded lazily on first request and remains in memory for subsequent calls.

Best Practices

Building a toy platform is straightforward, but making it production-ready requires careful attention to several areas.

1. Use Specialized Inference Engines for Scale

For production workloads, especially with deep learning models, consider using dedicated serving engines rather than a custom Python loop. NVIDIA Triton Inference Server, BentoML, TorchServe, and KServe all provide mature multi-model serving capabilities with GPU sharing, dynamic batching, and model versioning built in.

2. Implement Dynamic Batching

Individual inference requests are inefficient for GPU workloads. Dynamic batching collects multiple requests arriving within a short time window and processes them together, dramatically improving throughput. Most production engines support this out of the box.

3. Add Model Health Checks

Each loaded model should have a health check endpoint or internal ping. A model can fail to load due to corrupted weights, missing dependencies, or version mismatches. The platform should report model health separately from service health.

4. Enforce Resource Limits

Without limits, a single large model can consume all available memory. Set per-model memory budgets, enforce maximum loaded model counts, and implement eviction policies such as LRU or time-based unloading for idle models.

5. Version Everything

Always track model versions explicitly. This enables rollback, canary deployments, and A/B testing. Store version metadata alongside the artifact, including training data references, hyperparameters, and evaluation metrics.

6. Secure the API

Model endpoints often handle sensitive data. Add authentication, rate limiting, and input validation. Consider using API keys or OAuth tokens, and log all prediction requests for audit purposes.

7. Monitor Per-Model Metrics

Track latency, throughput, error rate, and memory usage for each model individually. Tools like Prometheus and Grafana work well for this. Alert on anomalies such as sudden latency spikes, which may indicate model drift or resource contention.

8. Plan for Cold Starts

Lazy loading reduces memory usage but introduces latency on first request. For latency-sensitive models, preload them at startup or keep them pinned in memory. For infrequent models, accept the cold start but communicate expected latency to clients.

Extending the Platform

The example in this tutorial covers the fundamentals. To take it further, consider adding the following capabilities:

Conclusion

A multi-model serving platform is the foundation that allows organizations to move beyond one-off model deployments and treat machine learning serving as a shared, efficient infrastructure. By combining a model registry, a model manager with lazy loading and eviction, and a unified API gateway, you can serve many models from a single service while keeping costs and operational complexity under control. The example built in this tutorial demonstrates the core patterns, and the best practices outlined here will guide you toward a production-ready system. As your needs grow, layering in specialized inference engines, autoscaling, and observability will transform this foundation into a robust platform capable of supporting hundreds of models at scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles