A model that gets 0.94 F1 in a notebook is worth nothing until it's serving real requests, staying healthy under load, and being retrained before it goes stale. The gap between "trained" and "in production" is where most AI projects quietly die — not because the model was bad, but because nobody owned deployment, monitoring, and maintenance.
This is the MLOps playbook: deploying models (REST/batch/streaming/serverless), best practices (versioning, CI/CD, reproducibility, the registry), monitoring live systems (drift, performance, business metrics), cloud strategies, and a complete end-to-end app — with real code throughout.
The one idea
Deployment is not a model.pkl on a server. It's a versioned artifact + reproducible serving image + an API + health checks + autoscaling + monitoring + a rollback path. MLOps = DevOps for ML plus the things ML adds: data versioning, a model registry, experiment tracking, and retraining (because models go stale in a way code never does).
Three serving patterns — pick by latency
| Pattern | Latency | When |
|---|---|---|
| Real-time (REST/gRPC) | ms | per-request, now (fraud check, chatbot) |
| Batch | minutes-hours | scheduled bulk scoring (nightly churn) |
| Streaming | sub-second | react to events (anomaly on a sensor stream) |
The bug that kills more models than any other: serving/training skew
The serving code preprocesses input differently than training did. Fix it by saving the entire pipeline — preprocessing + model — as one artifact:
model = Pipeline([("prep", preprocessor), ("clf", LGBMClassifier(n_estimators=400))])
model.fit(X_train, y_train)
joblib.dump(model, "model.joblib") # preprocessing + model travel together. Cannot drift apart.
At serving time, joblib.load("model.joblib").predict(raw_df) applies the exact training-time preprocessing. This single discipline prevents the most common production failure.
A real-time REST service (FastAPI) — three non-negotiables
MODEL = joblib.load("model.joblib") # 1. load once at startup
MODEL_VERSION = "1.4.0"
@app.post("/predict")def predict(features: CustomerFeatures):
df = pd.DataFrame([features.model_dump()])
proba = float(MODEL.predict_proba(df)[0, 1])
return {"churn_probability": proba, "will_churn": proba > 0.5,
"model_version": MODEL_VERSION} # 2. ALWAYS return the version
@app.get("/health/ready")def ready(): return {"status": "ready"} # 3. readiness + liveness endpoints
Version everything — the three artifacts
code (git SHA) + data (DVC / dataset hash) + model (registry version) = a reproducible prediction
With a registry (MLflow), serving loads by stage, not a file path:
MODEL = mlflow.pyfunc.load_model("models:/churn-model/Production")
Swapping models becomes "promote version 6 to Production" — no redeploy, full audit trail, and rollback is just promoting the previous version.
The ML-specific CI/CD piece: the quality gate
- name: Quality gate - block deploy if metrics regress
run: |
python - <<'PY'
import json; m = json.load(open("metrics.json"))
assert m["roc_auc"] >= 0.82, f"ROC-AUC {m['roc_auc']} below 0.82"
assert m["f1"] >= 0.70, f"F1 {m['f1']} below 0.70"
PY
A new model only ships if it meets minimum metrics on a held-out set.
Monitor four layers (not just accuracy)
- System health - latency (p50/p95/p99), error rate, throughput (Prometheus + Grafana).
- Data drift - production inputs drift from training inputs; accuracy silently drops with no error thrown. Detect with a KS-test per feature; tools: Evidently, NannyML, WhyLabs.
- Concept drift - inputs look the same but the input->output relationship changed. Shows up as falling live AUC once true outcomes arrive.
- Business metrics - revenue saved, fraud caught, tickets deflected. A model with great AUC that doesn't move the business metric is a science project, not a product.
Cloud strategy: containerize once, then choose
| Option | Best for | Ops |
|---|---|---|
| Managed endpoints (SageMaker, Vertex, Azure ML) | fastest to prod | lowest |
| Kubernetes (AKS/EKS/GKE) | full control, multi-model, GPU sharing | highest |
| Serverless (Cloud Run, Lambda) | low/spiky traffic, scale-to-zero | low |
Start with a managed endpoint unless you have a reason not to.
The end-to-end project
A customer-churn system wired end to end: train.py (train -> quality gate -> register to MLflow) -> FastAPI service in Docker (loads from the registry, exports Prometheus metrics) -> monitor.py (KS-test drift + live-AUC performance) -> GitHub Actions CI/CD with a schedule block that retrains every Monday, runs the same quality gate, and only ships if the new model clears the bar.
The MLOps maturity ladder
Level 0 (notebook -> model.pkl -> manual upload) -> Level 1 (automated training) -> Level 2 (CI/CD + registry + quality gate) -> Level 3 (drift + performance + business monitoring) -> Level 4 (auto-retraining). Most teams should target Level 2-3. Match the maturity to the stakes.
The right mental model
MLOps is treating a model as a living production system, not a deliverable. Three habits:
- One artifact, one version, always returned.
- Gate every deploy on quality.
- Assume the model will rot - monitor and retrain. Drift is the default, not an edge case. Build the feedback loop before you need it.
The full guide has every file (train/serve/monitor/CI), the Dockerfile, the K8s manifest with HPA, the Azure ML managed-endpoint code, and the mental checklist for \"in production\":
Originally published on PrepStack.
Top comments (0)