-
Package the model WITH its exact preprocessing — this is the #1 cause of train/serve skew. The artifact must contain the same feature/transform code that produced training inputs, not a reimplementation. Fit transforms on train data, serialize the fitted objects, and apply the identical pipeline at serve time.
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
import mlflow, mlflow.sklearn
pipe = Pipeline([("prep", ColumnTransformer(...)), ("model", clf)]).fit(X_tr, y_tr)
with mlflow.start_run():
mlflow.sklearn.log_model(
pipe, "model",
registered_model_name="churn",
input_example=X_tr.iloc[:5],
signature=mlflow.models.infer_signature(X_tr, pipe.predict(X_tr)),
pip_requirements="requirements.lock",
)
Rules: never re-derive features in the serving codebase; serve the fitted prep+model as one object. For deep nets, save the transform graph (e.g. torchvision/torchaudio transforms or a tf.function preprocessing layer) inside the exported module so the runtime applies it. Stateful features (counts, embeddings, aggregates) computed from a feature store at train time must be read from the same store online — recomputing them in app code drifts.
-
Register and pin model + code + data together. A model version is meaningless without the code and data snapshot that produced it. Push to a registry (MLflow Model Registry, SageMaker, Vertex, or a tagged OCI artifact) and record, in the run/version metadata: git SHA, training-data version/hash (DVC/Delta/snapshot id), and the locked dependency file. Use registry stages (Staging → Production) or aliases; deploy by immutable version, never "latest".
-
Pick the serving pattern by latency need — decide, don't hedge.
| Pattern | Use when | Interface | Default runtime |
|---|
| Batch / offline | No realtime need; score a table/file on a schedule | Job writes predictions to warehouse/S3 | Spark / Ray / a plain container in cron/Airflow |
| Online (sync) | A user request blocks on the prediction; p99 budget < ~200 ms | REST (simple, debuggable) default; gRPC when p99 < 20 ms or high QPS | BentoML / TorchServe / Triton |
| Streaming | React to an event flow (clicks, transactions) continuously | Consume Kafka/Kinesis → predict → emit | Flink / Faust / a Ray Serve consumer |
Defaults: batch unless something blocks on the result — it's cheaper, simpler, and trivially reproducible. For online, start with REST + JSON and only move to gRPC/protobuf when a measured latency budget forces it. Do not build an online endpoint for a nightly report.
-
Choose the runtime; export to ONNX/TensorRT only when you need the speed. Server defaults: BentoML (Python-first, easy custom logic, batching) for most teams; Triton for multi-framework, GPU, dynamic batching at scale; TorchServe for pure PyTorch shops. Convert to ONNX Runtime (CPU) or TensorRT (GPU) when profiling shows the framework runtime is the bottleneck — and re-verify outputs match the original within tolerance (atol≈1e-4) before trusting it; quantization/op-set changes silently alter predictions.
import bentoml
from bentoml.io import JSON
runner = bentoml.mlflow.get("churn:prod").to_runner()
svc = bentoml.Service("churn", runners=[runner])
@svc.api(input=JSON(), output=JSON())
async def predict(rows: list[dict]) -> list[dict]:
return await runner.predict.async_run(rows)
-
Add warmup, resource limits, and autoscaling — in that order. Cold models cause p99 spikes: run a synthetic prediction at startup (load weights, JIT/CUDA-warm, fill caches) and gate the readiness probe on it so traffic only arrives warm. Set CPU/memory/GPU requests and limits from a load test (see load-stress-test), not by guessing. Autoscale on the right signal — request concurrency / queue depth / GPU util, not CPU% for GPU models — with minReplicas ≥ 2 (no cold-start on scale-from-zero for latency-critical paths) and a scale-down stabilization window so it doesn't flap. Pin threads (OMP_NUM_THREADS) to avoid oversubscription under the container limit.
-
Roll out shadow → canary against the current model; keep an instant rollback. Never hard-cut. Shadow first: mirror live traffic to the new version, log its predictions, serve the old model's response to users — compares behavior on real traffic at zero user risk. Then canary: route 1% → 10% → 50% → 100% by sticky hashed bucketing, watching guardrail metrics (latency, error rate, and prediction distribution vs the incumbent); auto-halt and revert on breach. Drive the ramp/kill switch with feature-flags-rollout. Rollback = repoint the alias/route to the previous registered version (still deployed) — must be one command, seconds, no rebuild.
-
Lock inference reproducibility end to end. Serve from the locked requirements captured at registration (same library versions, same op-set), pin the base image by digest, set seeds where any stochasticity exists, and freeze the feature-store read path. The contract: the same input row produces a bit-identical (or within-tolerance) prediction in the notebook, the batch job, and the online endpoint.
Done = served predictions match offline scoring on the fixed sample within tolerance, latency/throughput meet the SLO warm, shadow/canary ran with guardrails, and a one-command rollback to the prior registered version is proven.