AI Deployment and MLOps — Serving Models in Production, MLOps Best Practices, Monitoring, Cloud Strategies, and an End-to-End AI Application (with Code)
The MLOps playbook: deploying models (REST/batch/streaming), versioning + registry + CI/CD, drift monitoring, cloud strategies, and a full end-to-end app.
- Author
- Randhir Jassal
- Published
- Reading time
- 37 min read
- Views
- 5 views
AI Deployment and MLOps — Serving Models in Production, MLOps Best Practices, Monitoring, Cloud Strategies, and an End-to-End AI Application (with Code)
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 guide is the MLOps playbook. We cover how to deploy AI models to production (REST, batch, streaming, serverless), MLOps best practices (versioning, CI/CD, reproducibility, the registry), monitoring and maintaining live systems (drift, performance, cost, alerts), cloud deployment strategies (containers, Kubernetes, managed endpoints, scale-to-zero), and a complete end-to-end AI application — train, register, serve, monitor, retrain — with real code and infra config throughout.
TL;DR
- Deployment is not a
model.pklon a server. It''s a versioned artifact + reproducible serving image + an API + health checks + autoscaling + monitoring + a rollback path. - MLOps = DevOps for ML + the things ML adds: data versioning, model registry, experiment tracking, and retraining (because models go stale in a way code never does).
- Three serving patterns: real-time (REST/gRPC, low latency), batch (scheduled bulk scoring), and streaming (event-driven). Pick by latency need.
- Monitor four things: system health (latency, errors), data drift (inputs change), concept drift (input→output relationship changes), and business metrics (the thing you actually care about).
- Cloud strategy: containerize once, deploy on managed endpoints (fastest), Kubernetes (most control), or serverless (cheapest at low/spiky traffic).
- The project: an end-to-end churn-prediction app — train → MLflow registry → FastAPI service in Docker → drift monitoring → GitHub Actions CI/CD → scheduled retraining.
1. The problem MLOps solves
In normal software, code is deterministic: same input, same output, forever. ML breaks two assumptions:
- Models depend on data, and data changes. A fraud model trained on last year''s patterns degrades silently as fraud evolves. The code didn''t change — the world did.
- Models are probabilistic and opaque. You can''t unit-test "is this prediction correct?" the way you test
add(2, 2) == 4.
So ML needs everything DevOps has (CI/CD, containers, monitoring) plus four ML-specific disciplines:
| Discipline | Why ML needs it (and code doesn''t) |
|---|---|
| Data versioning | A model is only reproducible if you can reproduce its training data |
| Experiment tracking | You run 100 experiments; you must know which produced the deployed model |
| Model registry | A versioned store of models with stage (staging/prod) + lineage |
| Retraining | Models decay over time; code doesn''t. You need a retrain trigger + pipeline |
MLOps is the practice of doing all of this reliably. Skip it and you ship a model that works on launch day and silently rots.
2. Deploying AI Models to Production
2.1 The three serving patterns
Pick the pattern by how fresh the prediction must be:
| Pattern | Latency | When to use | Example |
|---|---|---|---|
| Real-time (online) | ms | Prediction needed per-request, now | Fraud check at checkout, chatbot, recommendation |
| Batch (offline) | minutes–hours | Score many rows on a schedule | Nightly churn scores, weekly lead scoring |
| Streaming | sub-second, continuous | React to events as they arrive | Anomaly detection on a sensor stream |
Most production ML is real-time REST or batch. We''ll focus on those.
2.2 The deployable artifact — package preprocessing WITH the model
The #1 production bug: the serving code preprocesses input differently than training did. Avoid it by saving the entire pipeline (preprocessing + model) as one artifact.
import joblib
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from lightgbm import LGBMClassifier
preprocessor = ColumnTransformer([
("num", Pipeline([("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler())]), NUM_COLS),
("cat", Pipeline([("impute", SimpleImputer(strategy="constant", fill_value="UNK")),
("ohe", OneHotEncoder(handle_unknown="ignore"))]), CAT_COLS),
])
model = Pipeline([("prep", preprocessor), ("clf", LGBMClassifier(n_estimators=400))])
model.fit(X_train, y_train)
# ONE artifact — preprocessing + model travel together. Cannot drift apart.
joblib.dump(model, "model.joblib")
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.
2.3 A real-time REST service (FastAPI)
# serve.py
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Churn Model")
MODEL = joblib.load("model.joblib") # load once at startup
MODEL_VERSION = "1.4.0"
class CustomerFeatures(BaseModel):
tenure_months: int
monthly_charges: float
contract_type: str
num_support_tickets: int
@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, # ALWAYS return which model made the call
}
@app.get("/health/ready")
def ready(): return {"status": "ready", "model_version": MODEL_VERSION}
@app.get("/health/live")
def live(): return {"status": "alive"}
Three non-negotiables: load the model once at startup, return the model version in every response, and expose readiness + liveness health endpoints.
2.4 Batch scoring
For "score all customers every night," a scheduled job is simpler and cheaper than an API.
# batch_score.py — run nightly via cron / Airflow / GitHub Actions schedule
import joblib
import pandas as pd
def run_batch():
model = joblib.load("model.joblib")
customers = pd.read_parquet("s3://data/customers_active.parquet")
customers["churn_proba"] = model.predict_proba(customers)[:, 1]
(customers[["customer_id", "churn_proba"]]
.assign(scored_at=pd.Timestamp.utcnow(), model_version="1.4.0")
.to_parquet("s3://predictions/churn_scores.parquet"))
if __name__ == "__main__":
run_batch()
2.5 Serving deep learning / LLMs
For neural models, raw model.generate is too slow for production. Use a purpose-built server:
- LLMs: vLLM or TGI (continuous batching, paged attention — 5–20× throughput).
- General DL: NVIDIA Triton (multi-framework, dynamic batching).
- Encoders (BERT etc.): ONNX Runtime / quantization for fast CPU serving.
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct --dtype bfloat16 --max-model-len 8192
3. MLOps Best Practices
3.1 Version everything — the three artifacts
code (git SHA) + data (DVC / dataset hash) + model (registry version)
= a fully reproducible prediction
- Code — git, like any software.
- Data — DVC, LakeFS, or dataset snapshots. "Which data trained this model?" must have an answer.
- Model — a registry (MLflow, W&B, SageMaker Model Registry).
3.2 Experiment tracking + model registry (MLflow)
import mlflow
import mlflow.sklearn
from sklearn.metrics import f1_score, roc_auc_score
mlflow.set_experiment("churn-prediction")
with mlflow.start_run():
model.fit(X_train, y_train)
preds = model.predict(X_val)
proba = model.predict_proba(X_val)[:, 1]
mlflow.log_params({"n_estimators": 400, "learning_rate": 0.05})
mlflow.log_metrics({"f1": f1_score(y_val, preds), "roc_auc": roc_auc_score(y_val, proba)})
mlflow.sklearn.log_model(model, "model", registered_model_name="churn-model")
from mlflow import MlflowClient
client = MlflowClient()
client.transition_model_version_stage("churn-model", version=5, stage="Production")
Serving loads from the registry by stage, not a file path:
import mlflow.pyfunc
MODEL = mlflow.pyfunc.load_model("models:/churn-model/Production")
Swapping models becomes "promote version 6 to Production" — no redeploy, full audit trail.
3.3 CI/CD for ML
# .github/workflows/ml-ci.yml
name: ML CI
on: { push: { branches: [main] } }
jobs:
test-and-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- name: Unit tests
run: pytest tests/ -q
- name: Train + evaluate model
run: python train.py
- 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"
print("Quality gate passed:", m)
PY
- name: Build serving image
run: docker build -t churn-model:${{ github.sha }} .
The quality gate is the ML-specific piece: a new model only ships if it meets minimum metrics on a held-out set.
3.4 Reproducibility checklist
- Every library version pinned.
- Random seeds set.
- Training data versioned (DVC / snapshot hash logged).
- Code SHA logged with the model.
- Serving environment matches training (same base image).
- Preprocessing packaged with the model (one artifact).
3.5 The model registry lifecycle
Train → log run → register version → Staging → (shadow / A/B) → Production → Archived
↑________________________________|
rollback = promote previous version
Rollback is just "promote the previous version to Production" — instant, auditable.
4. Monitoring and Maintaining AI Systems
A deployed model is not "done." Monitor four layers.
4.1 Layer 1 — system health
Latency (p50/p95/p99), error rate, throughput, resource usage. Standard APM (Prometheus + Grafana, Datadog).
from prometheus_client import Counter, Histogram
import time
PREDICTIONS = Counter("predictions_total", "Total predictions", ["model_version"])
LATENCY = Histogram("prediction_latency_seconds", "Latency")
@app.post("/predict")
def predict(features: CustomerFeatures):
start = time.perf_counter()
df = pd.DataFrame([features.model_dump()])
proba = float(MODEL.predict_proba(df)[0, 1])
LATENCY.observe(time.perf_counter() - start)
PREDICTIONS.labels(model_version=MODEL_VERSION).inc()
return {"churn_probability": proba, "model_version": MODEL_VERSION}
4.2 Layer 2 — data drift (inputs change)
When production inputs drift from training inputs, accuracy silently drops with no error thrown.
from scipy.stats import ks_2samp
import numpy as np
def detect_drift(reference, current, alpha=0.05):
drift = {}
for i in range(reference.shape[1]):
_, p = ks_2samp(reference[:, i], current[:, i])
drift[f"feature_{i}"] = {"p_value": float(p), "drifted": p < alpha}
return drift
Tools: Evidently AI, NannyML, WhyLabs.
4.3 Layer 3 — concept drift (input→output relationship changes)
Even if inputs look the same, the meaning can change. Shows up as falling accuracy once you observe true outcomes.
from sklearn.metrics import roc_auc_score
def monitor_performance(predictions_with_labels):
live_auc = roc_auc_score(predictions_with_labels["true_label"],
predictions_with_labels["predicted_proba"])
if live_auc < TRAINING_AUC - 0.05:
alert(f"Concept drift: live AUC {live_auc:.3f} vs train {TRAINING_AUC:.3f}")
return live_auc
Ground truth often arrives late — build a feedback loop that joins predictions to outcomes as they become known.
4.4 Layer 4 — business metrics
The metric that matters isn''t AUC — it''s 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.
4.5 Alerts that matter
ALERTS = {
"p99_latency > 500ms for 5min": "page on-call",
"error_rate > 2% for 5min": "page on-call",
"data_drift on > 30% of features": "notify ML team",
"live_AUC < train_AUC - 0.05": "notify ML team — consider retrain",
"no predictions for 10min": "page on-call (pipeline down)",
}
4.6 Maintenance: the retraining loop
| Trigger | When to use |
|---|---|
| Scheduled (weekly/monthly) | Stable domains, simplest |
| Performance-based | When you have timely ground truth |
| Drift-based | When ground truth is slow to arrive |
| Data-volume | High-volume labeling pipelines |
Whatever the trigger, the retraining pipeline must run the same train → evaluate → quality-gate → register flow. Never hand-retrain into production.
5. Cloud Deployment Strategies
5.1 Step 0 — containerize (the universal foundation)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY model.joblib serve.py ./
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
5.2 The three deployment options
| Option | Best for | Ops burden | Cost shape |
|---|---|---|---|
| Managed endpoints (SageMaker, Vertex AI, Azure ML, HF Endpoints) | Fastest to production | Lowest | Per-instance-hour |
| Kubernetes (AKS/EKS/GKE) | Full control, multi-model | Highest | Cluster you manage |
| Serverless (Cloud Run, Lambda) | Low/spiky traffic, scale-to-zero | Low | Per-request |
5.3 Option A — managed endpoint (Azure ML example)
from azure.ai.ml import MLClient
from azure.ai.ml.entities import ManagedOnlineEndpoint, ManagedOnlineDeployment
ml = MLClient.from_config(credential=...)
ml.online_endpoints.begin_create_or_update(
ManagedOnlineEndpoint(name="churn-endpoint", auth_mode="key")).result()
ml.online_deployments.begin_create_or_update(ManagedOnlineDeployment(
name="blue", endpoint_name="churn-endpoint",
model="azureml:churn-model:5", instance_type="Standard_DS3_v2", instance_count=2,
)).result()
Autoscaling, blue/green deploys, built-in monitoring — almost no infra code. Start here unless you have a reason not to.
5.4 Option B — Kubernetes (full control)
apiVersion: apps/v1
kind: Deployment
metadata: { name: churn-model }
spec:
replicas: 3
selector: { matchLabels: { app: churn-model } }
template:
metadata: { labels: { app: churn-model } }
spec:
containers:
- name: app
image: myregistry.azurecr.io/churn-model:1.4.0
ports: [{ containerPort: 8000 }]
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
readinessProbe: { httpGet: { path: /health/ready, port: 8000 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /health/live, port: 8000 }, periodSeconds: 10 }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: churn-model }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: churn-model }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
Use Kubernetes when you''re already on it or need GPU sharing / multi-model serving.
5.5 Option C — serverless (scale to zero)
gcloud run deploy churn-model --image gcr.io/proj/churn-model --max-instances 20
Scales to zero when idle — pay nothing between requests. Perfect for internal tools and spiky workloads. Trade-off: cold starts.
5.6 Picking a strategy
Traffic shape?
├── Steady, high → Kubernetes or managed endpoint
├── Spiky / low → Serverless (scale to zero)
└── Just ship it → Managed endpoint (least ops)
GPU / large model? → managed GPU endpoint or K8s + GPU nodes + vLLM/Triton
Already on K8s? → Kubernetes
6. Final Project: an End-to-End AI Application
Train → register → serve → monitor → CI/CD → retrain. A customer-churn system.
6.1 The full lifecycle
┌──────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐
│ train.py │ → │ MLflow │ → │ FastAPI │ → │ monitor │ → │ retrain │
│ + gate │ │ registry │ │ + Docker │ │ (drift, │ │ (scheduled │
│ │ │ (Prod) │ │ on cloud │ │ perf) │ │ trigger) │
└──────────┘ └───────────┘ └──────────┘ └────┬─────┘ └─────┬──────┘
▲ │ alert │
└────────────────────────────────────────────────────────────┘
6.2 Project structure
churn-mlops/
├── train.py # train + evaluate + quality gate + register to MLflow
├── serve.py # FastAPI service (loads from registry)
├── monitor.py # drift + performance monitoring job
├── batch_score.py # nightly batch scoring
├── Dockerfile
├── requirements.txt
├── k8s/deployment.yaml
└── .github/workflows/ml-ci.yml
6.3 train.py — train, gate, register
import json
import mlflow
import mlflow.sklearn
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score, roc_auc_score
from lightgbm import LGBMClassifier
NUM_COLS = ["tenure_months", "monthly_charges", "num_support_tickets"]
CAT_COLS = ["contract_type", "payment_method"]
THRESHOLD_AUC, THRESHOLD_F1 = 0.82, 0.70
def build_pipeline():
pre = ColumnTransformer([
("num", Pipeline([("i", SimpleImputer(strategy="median")), ("s", StandardScaler())]), NUM_COLS),
("cat", Pipeline([("i", SimpleImputer(strategy="constant", fill_value="UNK")),
("o", OneHotEncoder(handle_unknown="ignore"))]), CAT_COLS),
])
return Pipeline([("prep", pre), ("clf", LGBMClassifier(
n_estimators=400, learning_rate=0.05, num_leaves=31,
is_unbalance=True, random_state=42))])
def main():
df = pd.read_parquet("data/churn.parquet") # versioned via DVC
X, y = df[NUM_COLS + CAT_COLS], df["churned"]
X_tr, X_val, y_tr, y_val = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
mlflow.set_experiment("churn-prediction")
with mlflow.start_run():
model = build_pipeline().fit(X_tr, y_tr)
proba = model.predict_proba(X_val)[:, 1]
preds = (proba > 0.5).astype(int)
auc = roc_auc_score(y_val, proba)
f1 = f1_score(y_val, preds)
mlflow.log_params({"n_estimators": 400, "learning_rate": 0.05})
mlflow.log_metrics({"roc_auc": auc, "f1": f1})
json.dump({"roc_auc": auc, "f1": f1}, open("metrics.json", "w"))
if auc >= THRESHOLD_AUC and f1 >= THRESHOLD_F1:
mlflow.sklearn.log_model(model, "model", registered_model_name="churn-model")
print(f"Registered. AUC={auc:.3f} F1={f1:.3f}")
else:
raise SystemExit(f"Quality gate FAILED: AUC={auc:.3f} F1={f1:.3f}")
if __name__ == "__main__":
main()
6.4 serve.py — production service
import os
import time
import mlflow.pyfunc
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel
from prometheus_client import Counter, Histogram, make_asgi_app
app = FastAPI(title="Churn Prediction")
app.mount("/metrics", make_asgi_app())
MODEL = mlflow.pyfunc.load_model("models:/churn-model/Production")
MODEL_VERSION = os.getenv("MODEL_VERSION", "prod")
PREDICTIONS = Counter("predictions_total", "Total predictions", ["model_version"])
LATENCY = Histogram("prediction_latency_seconds", "Latency")
class Features(BaseModel):
tenure_months: int
monthly_charges: float
num_support_tickets: int
contract_type: str
payment_method: str
@app.post("/predict")
def predict(f: Features):
start = time.perf_counter()
df = pd.DataFrame([f.model_dump()])
proba = float(MODEL.predict(df)[0])
LATENCY.observe(time.perf_counter() - start)
PREDICTIONS.labels(model_version=MODEL_VERSION).inc()
return {"churn_probability": proba, "will_churn": proba > 0.5, "model_version": MODEL_VERSION}
@app.get("/health/ready")
def ready(): return {"status": "ready", "model_version": MODEL_VERSION}
@app.get("/health/live")
def live(): return {"status": "alive"}
6.5 monitor.py — drift + performance
import pandas as pd
from scipy.stats import ks_2samp
from sklearn.metrics import roc_auc_score
REFERENCE = pd.read_parquet("data/churn.parquet")
TRAINING_AUC = 0.85
def check_data_drift(current, alpha=0.05):
drifted = []
for col in ["tenure_months", "monthly_charges", "num_support_tickets"]:
_, p = ks_2samp(REFERENCE[col], current[col])
if p < alpha:
drifted.append(col)
return drifted
def run():
recent = pd.read_parquet("predictions/recent_inputs.parquet")
drifted = check_data_drift(recent)
if len(drifted) >= 2:
alert(f"DATA DRIFT on: {drifted} — consider retraining")
labeled = pd.read_parquet("predictions/with_outcomes.parquet")
if not labeled.empty:
live_auc = roc_auc_score(labeled["true_label"], labeled["churn_proba"])
if live_auc < TRAINING_AUC - 0.05:
alert(f"PERFORMANCE DROP: live AUC {live_auc:.3f} vs train {TRAINING_AUC:.3f}")
def alert(msg):
print(f"[ALERT] {msg}") # wire to Slack / PagerDuty in production
if __name__ == "__main__":
run()
6.6 CI/CD + scheduled retrain
name: ML CI/CD
on:
push: { branches: [main] }
schedule:
- cron: "0 2 * * 1" # retrain every Monday 02:00
jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- name: Tests
run: pytest tests/ -q
- name: Train + quality gate
run: python train.py # fails the job if metrics regress
- name: Build + push image
if: github.event_name == 'push'
run: docker build -t myregistry.azurecr.io/churn-model:${{ github.sha }} .
- name: Deploy
if: github.event_name == 'push'
run: echo "kubectl set image / az ml online-deployment update ..."
The schedule block makes retraining automatic — every Monday it pulls fresh data, retrains, runs the quality gate, and only ships if the new model clears the bar.
6.7 Run the whole thing
# requirements.txt:
# mlflow lightgbm scikit-learn pandas fastapi uvicorn pydantic
# prometheus-client scipy joblib pyarrow
pip install -r requirements.txt
mlflow server --host 0.0.0.0 --port 5000 &
python train.py # train → gate → register
# (promote the version to Production in the MLflow UI or via the client)
docker build -t churn-model:1.4.0 .
docker run -p 8000:8000 -e MLFLOW_TRACKING_URI=http://host.docker.internal:5000 churn-model:1.4.0
curl -X POST localhost:8000/predict -H "Content-Type: application/json" -d '{
"tenure_months": 3, "monthly_charges": 89.5, "num_support_tickets": 4,
"contract_type": "month-to-month", "payment_method": "credit_card"
}'
python monitor.py
You now have the full MLOps loop: a versioned, registered model; a containerized service with metrics and health checks; drift + performance monitoring; and a CI/CD pipeline that retrains on a schedule and gates on quality. This is what "in production" actually means.
7. The MLOps maturity ladder
| Level | What it looks like | Risk |
|---|---|---|
| 0 — Manual | Notebook → model.pkl → manual upload | Breaks silently |
| 1 — Automated training | train.py + experiment tracking | Deploy still manual |
| 2 — CI/CD + registry | Quality gate, registry, automated deploy | Safe deploys + rollback |
| 3 — Monitored | Drift + performance + business monitoring | Know before users complain |
| 4 — Auto-retraining | Triggered retraining through the gated pipeline | Self-healing against drift |
Most teams should target Level 2–3. Level 4 only for high-value, fast-drifting models. Match the maturity to the stakes.
8. The honest stuff
- Deployment is where ML projects die, not where they''re born. Budget as much for serving + monitoring as for modeling.
- The serving/training skew bug is the most common production failure. Package preprocessing with the model. One artifact.
- Models decay; plan for it from day one. A retrain pipeline you build later is one you build during an incident.
- Monitor business metrics, not just AUC.
- Start simpler than you think. A FastAPI container on a managed endpoint + weekly retrain beats a half-built Kubeflow platform.
- Always return the model version.
- Quality-gate every deploy. This one rule prevents most production regressions.
9. The mental checklist
Before calling an AI system "in production":
- Preprocessing + model packaged as one versioned artifact.
- Model in a registry with stage + lineage.
- Serving returns the model version in every response.
- Readiness + liveness health endpoints.
- CI/CD with a quality gate (block deploy on metric regression).
- System health monitoring (latency, errors).
- Data-drift + performance-drift monitoring with alerts.
- Business metric tracked alongside model metrics.
- A retraining trigger + pipeline.
- A one-click rollback path (promote previous registry version).
- Reproducible: code SHA + data version + env pinned.
10. Closing — the right mental model
MLOps is the discipline of treating a model as a living production system, not a deliverable. The model is one component; the registry, the serving image, the monitoring, and the retraining loop are what make it survive contact with the real world.
Three habits that make you good at MLOps fast:
- One artifact, one version, always returned. Package preprocessing with the model, register every version, stamp every prediction with its model version.
- Gate every deploy on quality. No model ships without clearing the metric bar on held-out data.
- Assume the model will rot — monitor and retrain. Drift is the default, not an edge case. Build the feedback loop before you need it.
Internalize those, build the end-to-end project above, and "deploying AI" stops being the scary last mile — it becomes the reliable backbone of everything you ship.
Further reading
- Designing Machine Learning Systems — Chip Huyen. The single best MLOps book.
- Google''s Rules of Machine Learning — hard-won production wisdom, free.
- MLflow docs — tracking + registry + deployment.
- Evidently AI and NannyML — drift + performance monitoring.
- vLLM and NVIDIA Triton — high-performance serving.
- The MLOps Stack — a living map of the tooling landscape.
Stuck deploying a model — serving/training skew, drift you can''t explain, or a retrain pipeline that won''t behave? Email randhir.jassal@gmail.com with what you''re seeing and I''ll help you debug it.
Get the next issue
A short, curated email with the newest posts and questions.