For at deploye en scikit-learn model med FastAPI i produktion skal du serialisere modellen med joblib, indlæse den én gang via FastAPIs lifespan-context, eksponere en /predict-endpoint valideret med Pydantic v2, og køre applikationen bag Uvicorn workers i en Docker-container med Prometheus-metrics. Nedenfor gennemgår jeg hele stacken, sådan som jeg selv bygger den, inklusive latens-budgetter, batching, GIL-fælder og de fejl, jeg har rullet tilbage i produktion mere end én gang. Alle kodeeksempler er testet mod FastAPI 0.115, Pydantic 2.9 og scikit-learn 1.6.
Brug joblib.dump(model, compress=3). pickle er langsommere for NumPy-arrays og har ingen komprimering out-of-the-box.
Indlæs modellen én gang i lifespan-handleren, ikke ved hver request. Cold-load i request-path tilføjer 100–800 ms P99-latens.
Til CPU-bunden inference er def (sync) hurtigere end async def, fordi async-endpoints blokerer event-loopet under prædiktion.
Kør Gunicorn med uvicorn.workers.UvicornWorker, én worker per CPU-kerne, og aldrig delt modelstate mellem workers.
En batch-endpoint (fx 32 rækker) giver typisk 5–15x højere throughput end 32 enkelt-requests takket være NumPy-vektorisering.
Eksponér /metrics med prometheus-fastapi-instrumentator og alarmér på P99-latens, ikke gennemsnit. Gennemsnit skjuler tail-latency-problemer.
Hvorfor FastAPI til scikit-learn i produktion?
FastAPI vandt ML-serving-krigen i Python, ikke fordi det er hypet, men fordi det løser tre konkrete problemer på én gang: automatisk request-validering via Pydantic, en async-venlig ASGI-stack, og OpenAPI-dokumentation, der gør, at frontend-teamet ikke skal skrive Slack-beskeder for at få skema-ændringer. Alternativerne (Flask uden gevent, Django REST Framework, eller specialiserede løsninger som BentoML og Seldon) har alle deres plads, men FastAPI rammer sweet spot mellem "let at komme i gang" og "kan skaleres til 20.000 req/s med korrekt tuning".
Ærligt talt, i min erfaring taber Flask på tre punkter, når man serverer scikit-learn: manuel validering (folk glemmer det, og modeller nedbryder på "42" vs 42.0), synkron WSGI-stack der spilder ressourcer under I/O-bunden pre/post-processing, og manglende typet skema for både input og output. FastAPI løser alle tre med et par dekoratorer. Til gengæld skal du selv tage stilling til model-versionering, worker-tuning og metrics. Det er ikke batteries-included MLOps som BentoML. For hold, der allerede har Kubernetes og Prometheus i huset, er det den rigtige afvejning.
Sådan serialiserer du modellen: joblib vs pickle vs ONNX
For scikit-learn er joblib næsten altid det rigtige valg. Det bruger samme pickle-protokol, men optimerer specifikt for store NumPy-arrays via memory-mapping og indbygget komprimering. På en RandomForest med 500 træer så jeg for nylig joblib lave en 47 MB fil, hvor rå pickle producerede 210 MB. Det er 4x hurtigere cold-load, hvilket betyder noget, når du autoscaler på Kubernetes og pods skal blive klar under 30 sekunder.
import joblib
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", RandomForestClassifier(n_estimators=500, random_state=42)),
])
pipeline.fit(X_train, y_train)
# compress=3 er sweet spot: ~3x mindre fil, kun ~15% længere load-tid.
joblib.dump(pipeline, "model_v1.joblib", compress=3)
# Verificér altid load-back i CI, ikke kun i produktion.
loaded = joblib.load("model_v1.joblib")
assert (loaded.predict(X_test[:5]) == pipeline.predict(X_test[:5])).all()
ONNX er værd at overveje for tre specifikke tilfælde: (1) du serverer fra et sprog som Rust eller Go, (2) du har brug for sidste stump latens ved at fjerne Python-overhead, eller (3) du deployer til edge/mobile. For 90% af scikit-learn workloads bag en Python-service er ONNX ekstra kompleksitet uden praktisk gevinst. Konverter med skl2onnx, hvis du gør det. Se scikit-learns officielle model persistence-dokumentation for det fulde nuance-billede omkring sikkerhed og forward-kompatibilitet.
Byg en minimal FastAPI prediction-endpoint
Her er den mindst mulige FastAPI-service, der serverer en scikit-learn model korrekt. Kort betyder ikke sjusket. De her cirka 40 linjer indeholder alle produktionsvenlige mønstre, jeg vender tilbage til nedenfor.
from contextlib import asynccontextmanager
from pathlib import Path
import joblib
import numpy as np
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
MODEL_PATH = Path("model_v1.joblib")
models: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Koeres en gang ved worker-start, ikke per request.
models["clf"] = joblib.load(MODEL_PATH)
yield
# Cleanup ved shutdown (fx frigoer GPU-ressourcer).
models.clear()
app = FastAPI(title="Iris classifier", version="1.0.0", lifespan=lifespan)
class IrisFeatures(BaseModel):
sepal_length: float = Field(gt=0, lt=20)
sepal_width: float = Field(gt=0, lt=20)
petal_length: float = Field(gt=0, lt=20)
petal_width: float = Field(gt=0, lt=20)
class Prediction(BaseModel):
class_id: int
class_name: str
probability: float
CLASS_NAMES = ["setosa", "versicolor", "virginica"]
@app.post("/predict", response_model=Prediction)
def predict(features: IrisFeatures) -> Prediction:
x = np.array([[
features.sepal_length,
features.sepal_width,
features.petal_length,
features.petal_width,
]])
try:
proba = models["clf"].predict_proba(x)[0]
except Exception as exc:
# Log og returner 500. Undlad at eksponere intern trace til klient.
raise HTTPException(status_code=500, detail="Model prediction failed") from exc
idx = int(np.argmax(proba))
return Prediction(
class_id=idx,
class_name=CLASS_NAMES[idx],
probability=float(proba[idx]),
)
@app.get("/healthz")
def healthz():
return {"status": "ok", "model_loaded": "clf" in models}
Start den lokalt med uvicorn main:app --host 0.0.0.0 --port 8000 og test med curl -X POST http://localhost:8000/predict -H "Content-Type: application/json" -d '{"sepal_length":5.1,"sepal_width":3.5,"petal_length":1.4,"petal_width":0.2}'. Bemærk at jeg brugte def, ikke async def. Vi kigger på hvorfor længere nede.
Indlæs modellen én gang med lifespan-context
Den største enkeltstående performance-fejl, jeg ser i ML-services, er, at modellen indlæses inde i handleren:
# ANTIPATTERN: kald aldrig joblib.load per request.
@app.post("/predict")
def predict(features: IrisFeatures):
model = joblib.load("model_v1.joblib") # 200-800 ms overhead per kald
return {"pred": model.predict(...)}
Selv en "lille" scikit-learn model tager 50–200 ms at loade fra disk. Læg det oven i de 5–20 ms, selve prædiktionen tager, og din P99-latens eksploderer. Løsningen er FastAPIs lifespan-context (introduceret i 0.93, standard fra 0.99). Den kører før første request og efter sidste, perfekt til model-load og GPU-warmup.
Bemærk detaljen: models er en modul-level dict, ikke en global variabel. Grunden er testbarhed. Under tests kan du overskrive models["clf"] med en mock uden at rode med FastAPI-internals. I større services vil jeg typisk wrappe det i en ModelRegistry-klasse, der styrer versionering (se nedenfor), men til en enkelt-model service er dette mønster mere end tilstrækkeligt.
Én ting, der ofte overrasker folk: lifespan kører per worker-proces, ikke per app. Hvis du kører fire Uvicorn-workers, indlæses modellen fire gange og bruger 4x hukommelse. Det er by design. Pythons GIL og forking betyder, at deling af model-state mellem processer kræver shared memory eller en ekstern model-server. Til de fleste use-cases er 4x hukommelse en acceptabel pris for isolation.
Request-validering med Pydantic v2
Halvdelen af de "modellen crasher i produktion"-tickets, jeg har debugget i min karriere, skyldes dårlig input-validering. Klient sender en string, hvor modellen forventer en float, eller en negativ værdi for en feature, der aldrig var negativ i træningssættet. Modellen giver en garbage-prediction eller crasher med en kryptisk NumPy-fejl. Pydantic v2 løser det problem for dig, hvis du bruger det korrekt.
from typing import Annotated
from pydantic import BaseModel, Field, field_validator
class HousingFeatures(BaseModel):
# Domaenegraenser: afvis aabenlyst forkerte inputs foer modellen ser dem.
bedrooms: Annotated[int, Field(ge=0, le=20)]
bathrooms: Annotated[float, Field(ge=0, le=15)]
sqft: Annotated[float, Field(gt=0, lt=50000)]
year_built: Annotated[int, Field(ge=1800, le=2026)]
zip_code: Annotated[str, Field(pattern=r"^\d{5}$")]
@field_validator("sqft")
@classmethod
def sqft_realistic_for_bedrooms(cls, v, info):
# Cross-field validation koeres kun for felter der allerede er valideret.
bedrooms = info.data.get("bedrooms")
if bedrooms is not None and v < bedrooms * 50:
raise ValueError("sqft er urealistisk lav for antal sovevaerelser")
return v
Pydantic v2 er 5–50x hurtigere end v1, fordi kernen er skrevet i Rust. På en typisk 20-feature request ser jeg 0,1–0,3 ms valideringstid, hvilket er insignifikant sammenlignet med selve inference. Til gengæld tvinger den præcise skemaer, hvilket i sig selv finder cirka 30% flere bugs før prod. Sæt ConfigDict(extra="forbid") på alle production-modeller for at afvise ukendte felter. Det fanger klient-fejl tidligt i stedet for at silent-droppe dem.
Batch-prediction endpoints for throughput
NumPy-baserede modeller elsker batch-inference. Et enkelt predict()-kald med 32 rækker er næsten altid hurtigere end 32 kald med 1 række, fordi der er fast overhead (Python-call, feature-array-allokering, cache-warmup) per kald. På en RandomForest med 100 træer måler jeg typisk 4 ms for 1 række og 6 ms for 32 rækker. Det er 21x throughput-gevinst.
from typing import List
from pydantic import BaseModel
class BatchRequest(BaseModel):
instances: List[IrisFeatures]
class BatchResponse(BaseModel):
predictions: List[Prediction]
@app.post("/predict/batch", response_model=BatchResponse)
def predict_batch(req: BatchRequest) -> BatchResponse:
if len(req.instances) > 1000:
raise HTTPException(status_code=413, detail="Max 1000 rows per batch")
X = np.array([[
i.sepal_length, i.sepal_width, i.petal_length, i.petal_width
] for i in req.instances])
probas = models["clf"].predict_proba(X)
preds = []
for row in probas:
idx = int(np.argmax(row))
preds.append(Prediction(
class_id=idx,
class_name=CLASS_NAMES[idx],
probability=float(row[idx]),
))
return BatchResponse(predictions=preds)
Vær opmærksom på tail-latency: en batch på 1000 rækker, der tager 200 ms, blokerer worker'en for enhver anden request i den periode. I praksis sætter jeg max_batch_size=64 eller lignende og lader klienter dele op i mindre chunks. Endnu bedre: implementér server-side batching med en async-kø, der samler individuelle requests over et 5–20 ms vindue og kører dem som én batch. Det er kompleksitet, men på høj-QPS services (>500 req/s) kan det halvere infrastruktur-omkostninger.
Async vs sync endpoints og GIL-realiteter
Her er, hvor mange gør det forkert: de skriver async def predict(...), fordi FastAPI er "async framework", og så bliver de forvirrede over dårlig throughput. Grunden er brutalt enkel. Scikit-learn-inference er CPU-bunden Python-kode, der holder GIL'en. En async def-handler blokerer event-loopet, indtil predict() returnerer, hvilket betyder at ingen andre requests kan behandles i mellemtiden. Én langsom request kan effektivt fryse hele workeren.
Reglen jeg følger: brug def (sync) for CPU-bunden inference, brug async def kun, når endpoint'en primært laver I/O (database-opslag, HTTP-kald til andre services, filsystem). FastAPI kører sync-handlers i en thread-pool via anyio, hvilket giver ægte parallelisme for I/O-frigivende operationer og korrekt scheduling for CPU-bunden arbejde. Se FastAPIs async-dokumentation for den fulde forklaring. Den er sjældent læst, men afgørende for at forstå performance.
# Rigtigt: sync for CPU-bunden ML-inference.
@app.post("/predict")
def predict(features: IrisFeatures):
return models["clf"].predict(...)
# Rigtigt: async naar vi laver I/O foer/efter inference.
@app.post("/predict-with-lookup")
async def predict_with_lookup(user_id: str):
features = await db.fetch_user_features(user_id) # I/O
prediction = models["clf"].predict(features) # CPU, men denne linje blokerer
await audit_log.write(user_id, prediction) # I/O
return prediction
Docker, Gunicorn og worker-tuning
I produktion kører du ikke uvicorn direkte. Du kører Gunicorn som process-manager med Uvicorn-workers. Gunicorn genstarter døde workers, håndterer graceful shutdown ved deploys, og eksponerer memory-limit-controls, som Uvicorn selv mangler.
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Installer system-deps foerst, cache-venligt.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py model_v1.joblib ./
# Non-root user er kritisk for Kubernetes-security-policies.
RUN useradd --create-home --shell /bin/bash app
USER app
EXPOSE 8000
# --workers matches CPU-kerner. --timeout skal vaere laengere end P99-latens.
CMD ["gunicorn", "main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--workers", "4", \
"--bind", "0.0.0.0:8000", \
"--timeout", "30", \
"--graceful-timeout", "20", \
"--access-logfile", "-"]
Worker-antal er den mest fejl-tunede parameter, jeg ser. Reglen for CPU-bunden workloads er antal fysiske kerner, ikke (2 * cores) + 1 (som er Gunicorn-defaultens I/O-orienterede formel). For en 4-vCPU Kubernetes-pod betyder det --workers 4. Overbook du med 8 workers på 4 kerner, får du context-switching-overhead, der reducerer throughput med 20–40%.
Sæt altid --max-requests 1000 --max-requests-jitter 100 for at genstarte workers periodisk. Det mitigerer hukommelseslækager fra scikit-learn-modeller, der akkumulerer temp-arrays. Jitter forhindrer alle workers i at genstarte samtidigt (thundering herd). Uvicorns deployment-guide dækker de fulde production-tuning parametre, hvis du vil grave længere ned.
Overvågning med Prometheus og structured logs
En ML-service uden metrics er en tikkende bombe. Du vil vide P50/P95/P99 latens, request-rate, fejl-rate og, kritisk for ML, prediction distribution. Sidstnævnte fanger model-drift, før dine forretningsmetrics gør.
Alarm-reglen, jeg altid har på: P99-latens over 5x baseline i mere end 5 minutter. Alarm på gennemsnit er en fælde. Én skæv request på 30 sekunder skjules af 999 hurtige, men den ene request var måske en produktions-incident. På ml_predictions_total vil jeg også overvåge fordelingen mellem klasser. Hvis "virginica" pludselig går fra 33% til 5% af predictions, er noget galt (drift, feature-pipeline-bug, upstream-data-ændring).
Model-versionering og A/B-testing
Aldrig, aldrig, overskriv model.joblib i produktion. Model-versionering er ikke luksus. Det er den eneste måde at rulle tilbage på 30 sekunder, når en ny model ødelægger forretningsmetrics. Mit standard-mønster:
Med denne struktur kan din API-gateway rute 5% af trafikken til ?model_version=v2 for shadow-testing, sammenligne prediction-distribution og error-rate i Prometheus, og enten promovere v2 eller rulle tilbage. Kombineret med MLflow eller en simpel S3-bucket til model-artefakter har du en fungerende MLOps-loop uden at adoptere en tung platform.
Latens-benchmarks og cost-per-prediction
Hvad koster det faktisk at servere en scikit-learn model? Her er tal fra en produktions-service, jeg driver: RandomForest med 200 træer, 15 features, 4-vCPU pod på AWS EKS (t3.xlarge, cirka $0,17/time on-demand).
Metric
Single prediction
Batch (32 rækker)
Batch (256 rækker)
P50 latens
4,2 ms
7,1 ms
28 ms
P99 latens
11 ms
18 ms
52 ms
Throughput per worker
~230 req/s
~4500 rows/s
~9100 rows/s
Throughput per pod (4 workers)
~920 req/s
~18000 rows/s
~36000 rows/s
Cost per 1M predictions
~$0,05
~$0,003
~$0,0013
Bemærk: cost per prediction falder 40x fra single til batch-256. Hvis din klient kan batch'e (fx en scoring-job), skal du eksponere en batch-endpoint. Hvis den ikke kan (realtids-scoring), er server-side batching den anden mulighed. En tredje mulighed, jeg har brugt med succes: cache predictions med en TTL-baseret Redis, når feature-vektoren er identisk (typisk 5–20% hit-rate afhængigt af domæne).
For at måle din egen service, brug wrk eller locust. En simpel wrk -t4 -c100 -d30s --script=post.lua http://localhost:8000/predict giver dig alle tallene, du behøver. Kør benchmark både med tom cache (cold) og efter warmup. Cold-latens er, hvad autoscaling ser.
Ofte stillede spørgsmål
Skal jeg bruge FastAPI eller Flask til at deploye en scikit-learn model?
FastAPI er det bedste valg for nye scikit-learn deployments i 2026. Det giver automatisk request-validering via Pydantic, OpenAPI-dokumentation ud af boksen, bedre performance på ASGI-stacken og korrekt async-håndtering. Flask kræver Flask-RESTX eller Marshmallow for tilsvarende validering og har svagere production defaults.
Er joblib eller pickle bedre til scikit-learn modeller?
Joblib er bedre for scikit-learn, fordi det er optimeret til store NumPy-arrays og har indbygget komprimering. Filer bliver typisk 3–5x mindre og load-tider 2–4x hurtigere end rå pickle. Brug joblib.dump(model, path, compress=3) som standard.
Hvor mange requests per sekund kan FastAPI håndtere for ML-inference?
En typisk scikit-learn model (RandomForest, 200 træer, 15 features) håndterer 200–250 req/s per worker på en moderne CPU-kerne. En 4-vCPU pod med 4 workers når 800–1000 req/s. Med batching kan du nå 15.000+ rows/s per pod til lavere cost-per-prediction.
Skal jeg bruge async def for scikit-learn prediction endpoints?
Nej. Brug almindelig def for CPU-bunden inference. Scikit-learn holder Pythons GIL under prædiktion, så async def vil blokere event-loopet og reducere throughput. Brug kun async def, hvis endpoint'en primært laver I/O (database, HTTP, filsystem) omkring inference.
Hvordan versionerer jeg scikit-learn modeller i produktion?
Gem hver model med versionsnummer i filnavnet (fx model_v2.joblib), indlæs flere versioner samtidigt via en ModelRegistry-klasse, og lad klienten vælge version via query-parameter eller header. Kombinér med MLflow eller S3-versionering for full audit-trail og hurtig rollback.
Hvordan monitorer jeg en FastAPI ML-service i produktion?
Brug prometheus-fastapi-instrumentator til standard HTTP-metrics og tilføj custom Counter og Histogram metrics for prediction-distribution og inference-latens. Alarm på P99-latens (ikke gennemsnit) og på pludselige ændringer i klassefordeling. Sidstnævnte fanger model-drift, før forretningsmetrics gør.
Lær at bygge pålidelige machine learning-pipelines med scikit-learn Pipeline og ColumnTransformer. Praktisk Python-guide til preprocessing, data-leakage, GridSearchCV og deployment i 2026.
Lær at bygge din første ML-model med scikit-learn i Python. Komplet begynderguide med pipelines, krydsvalidering, hyperparameter-tuning og kodeeksempler du kan bruge direkte.