Ch. 25
MLOps & Observability
The ML system lifecycle, drift detection, on-call discipline, eval-driven development. The 70% of the job.
The model is not the system. The system is: the data pipeline that fed the model, the One piece of information about an example that the model looks at when making a guess.Full glossary → store that hydrates the features at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, the registry that knows which model version is live, the container that serves it, the autoscaler that schedules the container, the log pipeline that captures every request and response, the eval that runs nightly against a fresh slice, the dashboard that shows the operator that yesterday's deploy degraded Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → by three points on the largest customer segment, the on-call rotation that pages the operator when the drift detector trips, and the rollback button that they hit at 3am. MLOps is what happens when "the model has 87% The share of guesses the model got right out of all its guesses.Full glossary → on the A batch of examples you hide away and use only once at the very end to get an honest score.Full glossary →" stops being interesting and "the model has 87% accuracy on the production slice from last Tuesday" starts being the only interesting number. This chapter is the discipline of running that whole thing, with the deliberate posture of someone who has been paged in production before.
FIG 25.1 · Learning outcomes
By the end of this chapter you will be able to:
- Draw the ML system lifecycle from data ingestion through retirement and locate where each tool you have heard of (DVC, Feast, MLflow, W&B, Ray, Modal, Triton, Arize, LangSmith) lives on it.
- Version a dataset and a model together so that any production prediction is traceable back to the exact training data, code, and config that produced it.
- Set up a Weights & Biases project that logs training runs, hyperparameter sweeps, and a model registry, and use it to compare two runs across 17 metrics in one screen.
- Train a model on a single GPU, then scale the same code to a multi-node Ray cluster without rewriting the training loop.
- Deploy a model behind a FastAPI service on Modal in under 100 lines of code, with an autoscaling configuration that costs you nothing when idle.
- Compute population-stability index (PSI) and Kullback-Leibler divergence between a training feature distribution and a production feature distribution, and decide whether the drift is real.
- Write an eval-driven-development loop for an LLM application: a frozen golden set, a CI gate, a regression detector.
- Distinguish data drift, concept drift, label drift, and prediction drift, and name the right mitigation for each.
- Run a blameless post-mortem on a production model failure.
- Articulate why MLOps is not DevOps with a
.ptfile in the artifact store.
FIG 25.2 · What you need first
- Ch 2 — End-to-end ML project — you need to have built one model, all the way through, in any framework, before this chapter makes sense.
- Ch 10 — PyTorch — the training-loop scaffolding in this chapter is PyTorch-native.
- Ch 11 — Training Deep Neural Networks — checkpointing, AdamW, gradient accumulation, mixed precision: you should know these.
- Ch 17 — Efficient Inference — production serving (vLLM, TGI, paged attention, continuous batching, quantization recipes) is the inference-side of MLOps. The serving sub-section assumes another chapter's vocabulary.
- Ch 20 — Agents — the observability section's LLM-specific content (LangSmith, eval-driven dev for prompts) assumes you have shipped an agent.
- Ch 23 — Eval Science — drift detection and regression evals are evals; the hygiene rules are load-bearing here.
- Ch 24 — AI Safety & Red-Team — the red-team CI gate sub-section operationalizes the another chapter attack patterns as regression tests. You need to know what to test before you can wire it into the pipeline.
You can read this chapter cold if you have shipped any service to production in any field. The translations are obvious. The discipline transfers.
FIG 25.3.1
What MLOps is, and what it is not
MLOps is the practice of running ML systems in production. It is not "DevOps for ML"; it is DevOps plus the parts of running ML that have no DevOps analog. The parts that have no analog:
- The artifact is learned from data, so the data is part of the source code. Any bug in the data is a bug in the model.
- The artifact is probabilistic, so "correct" is replaced by "good enough on the relevant distribution", and the relevant distribution drifts.
- The artifact is expensive to recompute, so rollbacks, hotfixes, and A/B tests are all colored by the cost of a training run.
- The artifact's behavior on inputs you have not seen is not knowable in advance, so production monitoring is not optional; it is the only way you find out.
Chip Huyen's "Real-time machine learning" and "Why data distribution shifts matter" (24-founder-blogs/huyenchip-*) make this case at length. Eugene Yan's "Design patterns for ML systems" (24-founder-blogs/eugeneyan-*) is the practical companion. The combined point: most of the production failures of ML systems happen at the data layer, not at the model layer. Most ML team time, post-launch, is data work.
The mental model that worked best for me, lifted from 06-practice/madewithml-mlops-design: an ML system is a directed graph from raw events through derived features through training through evaluation through deployment through monitoring back to retraining. Every edge in that graph is a place a bug can live. MLOps is the discipline of instrumenting every edge.
No code path here. The fixed set of all chunks a model is allowed to read or produce.Full glossary → section. Citations:
FIG 25.3.2
The ML system lifecycle (one diagram in your head)
Memorize this sequence. Every section in this chapter hangs off it:
- Problem framing. What decision does the model support? Who is the user? What is the success metric, in business units?
- Data collection. From where, with what schema, at what frequency, with what privacy posture.
- Data labeling. When required. Eugene Yan's "Bootstrapping data labels" essay is the canonical entry point.
- EDA + One piece of information about an example that the model looks at when making a guess.Full glossary → engineering. Distributions, correlations, leakage checks, transformations.
- Modeling + training. The part you have been doing for the last twenty chapters.
- Evaluation. Holdout, cross-validation, slice analysis, fairness analysis, How well a model's stated confidence matches how often it's actually right.Full glossary →.
- Deployment. Pick a serving pattern (A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →, online, streaming), pick infrastructure, write the service.
- Monitoring. Inputs (feature drift), outputs (prediction drift), and outcomes (eventual The correct, human-given answer for a piece of data, used to judge the model's guess.Full glossary →) all measured against trained baselines.
- Retraining trigger. Time-based, drift-based, performance-based.
- Retirement. Eventually, you turn the model off. Do so deliberately.
This loop is not waterfall; the practical version cycles dozens of times per quarter. Géron's "ML Project Checklist" (06-practice/geron-ml-project-checklist) is the canonical printable version, and you should print it.
Library path (a single Python module that asserts the lifecycle has been followed for every shipped model):
# lifecycle_gate.py — gates a shipping model on lifecycle artifacts being present
from pathlib import Path
import json, hashlib
REQUIRED = [
"problem_framing.md", # one-pager: decision, user, success metric
"data_schema.json", # data versioning lineage
"eda.ipynb", # the EDA artifact
"training_config.yaml", # hyperparameters, seeds, code SHA
"eval_report.json", # holdout + slice metrics
"model_card.md", # intended use, limitations, training data
"monitoring_plan.yaml", # what to track, thresholds, alert routes
"rollback_plan.md", # how to disable in <5 minutes
]
def assert_ready(model_dir: Path):
missing = [r for r in REQUIRED if not (model_dir / r).exists()]
assert not missing, f"missing lifecycle artifacts: {missing}"FIG 25.3.3
Data versioning: DVC, Pachyderm, lakeFS
Code without version control is amateur hour. Data without version control is the same thing, with worse failure modes. The unit of versioning for an ML system is (code, data, config, environment) → model. If any of those is unpinned, you cannot reproduce.
DVC is the most-used tool. It stores small metadata files in git that point at large data blobs in an object store (S3, GCS, local). The workflow is dvc add data/train.parquet (which produces data/train.parquet.dvc you commit), dvc push (which uploads the blob), and dvc pull (which downloads what dvc.lock says you need). Pipelines (dvc.yaml) define stages with inputs, outputs, and commands; DVC tracks the DAG and re-runs stale stages.
Pachyderm does the same thing as Kubernetes-native object pipelines, with stronger lineage guarantees, at higher operational cost.
lakeFS treats your data lake itself like a git repo with branches and atomic commits. Good when "I want to branch the data" is a frequent operation.
For most teams, dvc + git + S3 is the right floor. Add Pachyderm or lakeFS only when you have hit a wall.
Library path (a minimal dvc pipeline):
# dvc.yaml
stages:
prepare:
cmd: python src/prepare.py
deps:
- data/raw.csv
- src/prepare.py
outs:
- data/prepared.parquet
train:
cmd: python src/train.py
deps:
- data/prepared.parquet
- src/train.py
- config.yaml
outs:
- models/model.pt
metrics:
- metrics.json:
cache: false# Initialize and run
dvc init
dvc remote add -d s3 s3://bucket/path
dvc add data/raw.csv
git add data/raw.csv.dvc .gitignore
git commit -m "track raw data"
dvc push
dvc repro # runs only stale stages
git add dvc.lock metrics.json
git commit -m "trained run-001"From-scratch path (a 30-line content-addressed store, for understanding):
import hashlib, shutil, json
from pathlib import Path
class CAS:
"""Content-addressed store. The 'manifest' is a JSON file mapping logical
names to content hashes. To check out a version, look up the hash and
copy the blob to the working path."""
def __init__(self, store: Path):
self.store = store
self.store.mkdir(parents=True, exist_ok=True)
def hash_file(self, path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def put(self, path: Path) -> str:
h = self.hash_file(path)
dest = self.store / h[:2] / h[2:]
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(path, dest)
return h
def checkout(self, manifest: Path, into: Path):
m = json.loads(manifest.read_text())
for name, h in m.items():
shutil.copy(self.store / h[:2] / h[2:], into / name)FIG 25.3.4
Feature stores: Feast, Tecton, the home-grown alternative
A One piece of information about an example that the model looks at when making a guess.Full glossary → store is a system that computes features once and serves them to both training and Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, ensuring the same feature has the same value in both places. The thing it prevents: training-serving skew, the most-common silent ML bug.
Eugene Yan's "Feature stores: a hierarchy of needs" (24-founder-blogs/eugeneyan-eugeneyan-com-writing-feature-stores) is the canonical taxonomy. The three layers:
- Offline store: historical features for training. Usually a data warehouse — a queryable store for large analytical tables (Snowflake, BigQuery, Redshift) — or a data lake: cheap bulk file storage (an object store like Amazon S3 or Google Cloud Storage, GCS) holding columnar
parquetfiles. - Online store: low-latency features for inference. A key-value store you can read in single-digit milliseconds (Redis, DynamoDB, Cassandra).
- Feature transformation: the logic that produces both. Defined once; executed in two places (A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → backfill into offline store, streaming or request-time compute into online store).
Feast is the open-source canonical. You define entities (user, item), features (last-7-day clicks), and sources (a Snowflake table). Feast handles backfills, retrieval for training, and online lookup at inference. Tecton is the commercial best-in-class. Home-grown is fine for many teams; the discipline is what matters, not the tool.
Library path (a minimal Feast definition):
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64
from datetime import timedelta
user = Entity(name="user_id", join_keys=["user_id"])
source = FileSource(
path="s3://bucket/user_features.parquet",
timestamp_field="event_timestamp",
)
user_clicks_7d = FeatureView(
name="user_clicks_7d",
entities=[user],
ttl=timedelta(days=1),
schema=[
Field(name="clicks_7d", dtype=Int64),
Field(name="ctr_7d", dtype=Float32),
],
source=source,
)# At training time
from feast import FeatureStore
fs = FeatureStore(repo_path=".")
training_df = fs.get_historical_features(
entity_df=entity_df_with_user_id_and_ts,
features=["user_clicks_7d:clicks_7d", "user_clicks_7d:ctr_7d"],
).to_df()
# At inference time
features = fs.get_online_features(
features=["user_clicks_7d:clicks_7d", "user_clicks_7d:ctr_7d"],
entity_rows=[{"user_id": 12345}],
).to_dict()FIG 25.3.5
Experiment tracking: W&B, MLflow
The minimum: every training run logs its hyperparameters, its metrics over time, its config, the code SHA, the A starting number that makes a program's 'random' choices come out the same every time.Full glossary →, and the produced artifacts. Without this you cannot answer "what was the best A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → setting from last quarter" and you cannot diff two runs.
Weights & Biases is the dominant SaaS. Two lines of code in your training loop give you a hosted dashboard with metric curves, system metrics (GPU, memory), config diffs, artifact lineage, hyperparameter sweeps, and a model registry. The free tier is generous; the enterprise tier handles on-prem.
MLflow is the dominant open-source. Same primitives, self-hosted, more friction to set up, no vendor lock-in. Many teams run MLflow because the ops team is comfortable with it.
The decision is rarely about features; it is about who is going to operate the tracking server.
Library path (W&B):
import wandb
import torch
run = wandb.init(
project="ch25-mlops",
config={
"lr": 3e-4,
"batch_size": 64,
"model": "resnet50",
"seed": 0,
},
)
config = wandb.config
# ... train loop ...
for step, (x, y) in enumerate(dataloader):
loss = train_step(model, x, y, optimizer)
if step % 100 == 0:
wandb.log({"train/loss": loss.item(), "train/lr": optimizer.param_groups[0]["lr"]}, step=step)
# Log the trained model as an artifact for later promotion to the registry
artifact = wandb.Artifact("resnet50-classifier", type="model")
artifact.add_file("model.pt")
run.log_artifact(artifact)
run.finish()Library path (MLflow — same idea, different API):
import mlflow
mlflow.set_experiment("ch25-mlops")
with mlflow.start_run():
mlflow.log_params({"lr": 3e-4, "batch_size": 64, "model": "resnet50", "seed": 0})
for step in range(num_steps):
loss = train_step(...)
mlflow.log_metric("train_loss", loss, step=step)
mlflow.pytorch.log_model(model, artifact_path="model")Hyperparameter sweeps are where W&B earns its keep. Define a sweep config; the agent process pulls suggestions from the W&B Bayesian-optimization server; you run many agents in parallel; W&B updates the parallel-coordinate plot in real time.
# sweep.yaml
program: train.py
method: bayes
metric:
name: val/accuracy
goal: maximize
parameters:
lr:
distribution: log_uniform_values
min: 1e-5
max: 1e-2
weight_decay:
distribution: log_uniform_values
min: 1e-6
max: 1e-2
dropout:
distribution: uniform
min: 0.0
max: 0.5wandb sweep sweep.yaml # registers the sweep, prints SWEEP_ID
wandb agent <SWEEP_ID> # run one or more in parallelFIG 25.3.6
Model registries and promotion
After tracking, you need a place to say "this version is the production version". A model registry is a small database of model artifacts annotated with stage (development, staging, production), version, lineage (which run produced it, which data), and approvals.
W&B Artifacts, MLflow Model Registry, Vertex AI Model Registry, and SageMaker Model Registry all implement the same primitives:
- Versioned model artifacts — every registered model has a
(name, version)pair. - Stages —
Development,Staging,Production,Archivedper version, transitionable by RBAC-protected actions. - Aliases —
prodandcanaryaliases that point at versions and can be flipped atomically. - Lineage — every version links back to the run that produced it and the data version it trained on.
The discipline: serving code references models by alias, not version. The prod alias is what model_loader.py resolves. Promotion is a single API call. Rollback is the same API call to the previous version.
Library path:
import wandb
api = wandb.Api()
# Promote a registered model
model_artifact = api.artifact("team/resnet50-classifier:v17")
model_artifact.aliases.append("prod")
model_artifact.save()# In serving code:
def load_prod_model():
api = wandb.Api()
artifact = api.artifact("team/resnet50-classifier:prod")
artifact.download(root="/tmp/model")
return torch.load("/tmp/model/model.pt")FIG 25.3.7
Training infrastructure: Ray, Modal, distributed training
A single GPU gets you far. For most teams, single-GPU training is the right answer; if you need more, you usually need more efficient single-GPU code first. When you actually need multi-GPU or multi-node, the landscape is:
PyTorch native: torch.distributed + torchrun. Lowest abstraction. The pattern is distributed data-parallel (DDP): one process per GPU, each holding a full copy of the model, each fed a different slice of the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →, with gradients averaged across them every step. You write the rank logic (each process gets a rank, its integer ID in the group), manage the process group (the set of processes that coordinate, set up by init_process_group), and place the model; torchrun launches one process per GPU, possibly across multiple machines (multi-node). Works. Painful.
Lightning / Accelerate: thin wrappers around torch.distributed that handle the rank logic so you write a single-machine-shaped training loop and run it on any cluster.
Ray Train: the higher-level framework. Define a training function; Ray schedules it across a cluster. Works for PyTorch, TensorFlow, XGBoost, LightGBM. Pairs with Ray Tune for A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → search and Ray Data for distributed data loading.
Modal: serverless GPU compute — you never provision machines; the platform's autoscaler starts containers when requests arrive, scales them to zero when idle, and bills per second of actual use. Functions you decorate run in containers — isolated processes packaged with their own filesystem and dependencies, built from an image (the frozen recipe of OS, libraries, and code) — which Modal spins up on demand. Two-second cold starts (the delay while a fresh container boots from its image) on small images. Free tier exists. The "I don't want to manage a cluster" answer for most non-frontier teams.
SLURM / Kubernetes: the on-prem alternatives. SLURM if your shop is HPC-origin; K8s if your shop is cloud-native.
Library path (Ray Train, distributed PyTorch in 30 lines):
import ray
from ray.train.torch import TorchTrainer
from ray.train import ScalingConfig
def train_loop_per_worker(config):
import torch, torch.nn as nn
from ray import train
from ray.train.torch import prepare_model, prepare_data_loader
model = nn.Linear(100, 10)
model = prepare_model(model) # wraps in DDP across the cluster
optimizer = torch.optim.AdamW(model.parameters(), lr=config["lr"])
loader = prepare_data_loader(get_loader(config["batch_size"]))
for epoch in range(config["epochs"]):
for x, y in loader:
loss = nn.functional.cross_entropy(model(x), y)
optimizer.zero_grad(); loss.backward(); optimizer.step()
train.report({"epoch": epoch, "loss": loss.item()})
trainer = TorchTrainer(
train_loop_per_worker=train_loop_per_worker,
train_loop_config={"lr": 1e-3, "batch_size": 64, "epochs": 10},
scaling_config=ScalingConfig(num_workers=4, use_gpu=True),
)
result = trainer.fit()Library path (Modal — serverless GPU function):
import modal
app = modal.App("ch25-train")
image = (
modal.Image.debian_slim()
.pip_install("torch", "torchvision", "wandb")
)
@app.function(image=image, gpu="A10G", timeout=3600)
def train(lr: float, epochs: int) -> dict:
import torch
# ... your training code ...
return {"final_loss": 0.42}
@app.local_entrypoint()
def main():
result = train.remote(lr=3e-4, epochs=10)
print(result)Run modal run train.py from your laptop. Modal builds the image, schedules an A10G GPU, runs the function, returns the result. You pay seconds.
FIG 25.3.8
Serving patterns: batch, online, streaming
Three ways to serve predictions, three latency/freshness trade-offs.
A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →. You score everything once a day. Cheap, simple, no online-store complexity. The right answer for daily emails, weekly recommendations, churn-risk lists. Limits: predictions are stale by up to the batch interval, you cannot react to events that happened after the batch ran.
Online (request-response). A service accepts a request, computes features (from an online store), runs the model, returns a prediction. Latency budget typically <100ms. The default for "user is about to see a thing, score it now": ranking, fraud detection, real-time recommendations.
Streaming. Predictions are emitted in response to events flowing through a stream transport — a system that stores and forwards a continuous log of events as they happen, the way Kafka, Kinesis, or Pulsar do. Used when "an event happened and a downstream system needs the model's response asap": real-time anomaly detection, real-time bidding, real-time fraud.
The decision is downstream of "what does the consumer of the prediction need". Huyen's "Real-time machine learning" (24-founder-blogs/huyenchip-huyenchip-com-2020-12-27-real-time-machine-learning-html) is the canonical taxonomy.
Library path (online Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → with FastAPI + Modal):
import modal
app = modal.App("ch25-serving")
image = modal.Image.debian_slim().pip_install("torch", "transformers", "fastapi")
@app.cls(image=image, gpu="T4", min_containers=1, max_containers=10)
class Classifier:
@modal.enter()
def load(self):
import torch
self.model = torch.load("/models/model.pt")
self.model.eval()
@modal.method()
def predict(self, x: list[float]) -> dict:
import torch
with torch.no_grad():
t = torch.tensor(x).unsqueeze(0)
logits = self.model(t)
probs = torch.softmax(logits, dim=-1).squeeze().tolist()
return {"probs": probs, "argmax": int(max(range(len(probs)), key=probs.__getitem__))}
@app.function(image=image)
@modal.fastapi_endpoint(method="POST")
def predict(payload: dict) -> dict:
return Classifier().predict.remote(payload["features"])FastAPI is a Python web framework: it turns a function into an HTTP endpoint — a named URL (here POST /predict, meaning a request that sends data to the /predict path) that any client can call over the network and get a JSON reply. Deploy: modal deploy serve.py. You get an HTTPS endpoint, autoscaling from min_containers=1 (keep one container warm to avoid cold starts) to max_containers=10, and per-second billing.
Library path (batch inference with Ray Data):
import ray
ds = ray.data.read_parquet("s3://bucket/features/2026-05-15/*.parquet")
class Predictor:
def __init__(self):
import torch
self.model = torch.load("model.pt").eval()
def __call__(self, batch):
import torch
with torch.no_grad():
x = torch.tensor(batch["features"])
batch["prediction"] = self.model(x).argmax(-1).tolist()
return batch
predictions = ds.map_batches(Predictor, concurrency=8, num_gpus=1)
predictions.write_parquet("s3://bucket/predictions/2026-05-15/")FIG 25.3.9
Latency budgets and the inference cost equation
Online serving lives inside a latency budget. SLAs are written on percentiles, not averages: p99 is the latency that 99% of requests come in under (so only the slowest 1% are worse), and you target the tail because a fast average hides the users who time out. A budget like "p99 < 100ms" decomposes into: One piece of information about an example that the model looks at when making a guess.Full glossary → fetch + preprocessing + model forward + postprocessing + network. The model forward is rarely more than half. If your p99 is 200ms and your model forward is 30ms, you are not optimizing the model.
The components, with rough order-of-magnitude numbers for a typical recommender serving 8B parameters at fp16 on an A100:
| Component | Typical contribution |
|---|---|
| Network (client to LB to service) | 5-30ms |
| Feature fetch (online store) | 1-20ms per entity, batched |
| Preprocessing (tokenize, normalize) | 1-10ms |
| Model Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → | depends — ~30ms for an 8B at fp16 with KV cache |
| Postprocessing (sample, decode, format) | 1-20ms |
| Logging + monitoring | 1-5ms |
Optimization order (cheap → expensive):
- A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →. Most servers underuse the GPU. Dynamic batching with a 5-10ms window often doubles throughput at sub-1% latency cost.
- Quantize. fp16 → int8 → int4. Each step is roughly 2× faster with manageable The share of guesses the model got right out of all its guesses.Full glossary → loss. AWQ, GPTQ, BitsAndBytes are the canonical libraries.
- Speculative decoding for LLMs. A small draft model proposes tokens; the big model verifies. ~2× speedup at parity.
- KV-cache management. For LLM serving, the KV cache is most of the memory and most of the per-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → cost. vLLM's PagedAttention is the canonical implementation.
- Distillation. Train a smaller student against the production model's outputs.
- Architecture change. MoE, sliding-window A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →, linear attention — only after exhausting the above.
Lilian Weng's "Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → Optimization" survey (18-lilian-weng/2023-01-10-inference-optimization) is the canonical reading.
Library path (vLLM, the standard LLM serving runtime in 2026):
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
dtype="float16",
gpu_memory_utilization=0.9,
max_model_len=8192,
enable_prefix_caching=True, # shared prompt prefixes cached
)
params = SamplingParams(temperature=0.0, max_tokens=256)
outputs = llm.generate(["Hello, who are you?"], params)
print(outputs[0].outputs[0].text)vLLM handles continuous batching (swapping finished sequences out of the running batch and new ones in every step, instead of waiting for the whole batch to finish), the paged-attention KV cache (storing the KV cache in fixed-size pages like OS virtual memory, so GPU memory is not fragmented or over-reserved), and prefix caching (reusing the cached KV of a shared prompt prefix across requests), and serves an OpenAI-compatible HTTP API by default. It is the closest thing to a default for self-hosted LLM serving in 2026.
FIG 25.3.10
Monitoring: feature drift, label drift, prediction drift, performance drift
The four monitoring questions, in order of how quickly the signal arrives:
- Are my inputs the same as in training? One piece of information about an example that the model looks at when making a guess.Full glossary → distributions. Detectable in seconds.
- Are my predictions distributed the same as in training? Prediction histograms. Detectable in minutes.
- Are my labels (when they arrive) the same as in training? Label distributions. Detectable in hours to days, depending on ground-truth latency.
- Is my model still accurate? Per-slice The share of guesses the model got right out of all its guesses.Full glossary → on labeled data. Detectable only after labels arrive.
The metrics:
Population Stability Index (PSI). Bin a feature on the reference distribution; compare bin proportions in production. . Industry rule of thumb: PSI < 0.1 is no drift, 0.1-0.25 is moderate, > 0.25 is significant. Loose, but useful as a first-pass alert.
KL divergence. . PSI is symmetrized KL. Use KL when the asymmetry matters.
Kolmogorov-Smirnov test. For continuous features. Computes the maximum gap between the two empirical CDFs (a cumulative distribution function maps a value to the fraction of samples at or below it; the KS statistic is the largest vertical distance between the reference curve and the production curve). Outputs a p-value. Be careful with sample sizes: with millions of rows, any tiny shift is "statistically significant"; you want effect size, not p-value.
Wasserstein distance. Better-behaved than KL when distributions barely overlap. Slower to compute.
Chi-squared for categorical features.
Population Stability Index (drift score)
DL glueedges = np.quantile(reference, np.linspace(0, 1, n_bins + 1))
edges[0] -= 1e-9; edges[-1] += 1e-9
rp = np.histogram(reference, edges)[0] / len(reference) + 1e-9
pp = np.histogram(production, edges)[0] / len(production) + 1e-9
psi = float(entropy(rp, pp) + entropy(pp, rp)) # symmetrized KL = PSIdef psi(reference: np.ndarray, production: np.ndarray, n_bins: int = 10) -> float:
ref = np.asarray(reference, dtype=float)
prod = np.asarray(production, dtype=float)
edges = np.quantile(ref, np.linspace(0, 1, n_bins + 1))
edges[0] -= 1e-9
edges[-1] += 1e-9
r, _ = np.histogram(ref, edges)
p, _ = np.histogram(prod, edges)
rp = r / r.sum() + 1e-9
pp = p / p.sum() + 1e-9
return float(np.sum((rp - pp) * np.log(rp / pp)))from scratch: lab/solution.py: psi
- 1
np.quantile(reference, np.linspace(0,1,n_bins+1))edges = np.quantile(ref, np.linspace(0, 1, n_bins + 1)) - 2
edges[0] -= 1e-9; edges[-1] += 1e-9edges[0] -= 1e-9 / edges[-1] += 1e-9 (open the outer bins so min/max land inside) - 3
np.histogram(reference, edges)[0] / len(reference)r, _ = np.histogram(ref, edges); rp = r / r.sum() - 4
+ 1e-9 on rp and pp+ 1e-9 (Laplace floor so empty production bins do not blow up log) - 5
entropy(rp, pp) + entropy(pp, rp)np.sum((rp - pp) * np.log(rp / pp))
What the one call hides
- scipy.stats.entropy(pk, qk) is KL(pk||qk) = sum(pk*log(pk/qk)); summing both directions gives the symmetrized KL, which is algebraically identical to the PSI sum((p-q)*log(p/q)).
- entropy() re-normalizes pk and qk to sum to 1 internally, so it absorbs the r/r.sum() step (but you still must add the 1e-9 floor BEFORE calling it, or normalize-then-floor will shift the values).
- the natural-log default of entropy() matches the np.log in the scratch; PSI is conventionally reported in nats with this choice.
- neither scipy.stats.entropy nor any installed library does the binning for you — the quantile edges from the reference are still hand-rolled numpy.
- Gotcha: scipy.stats.entropy gives you only the divergence math, not the binning. The load-bearing choice in PSI is the binning strategy (quantile-from-reference here), which no installed library packages as PSI.
- Gotcha: Evidently AI — the tool a real MLOps team reaches for — will NOT reproduce this number: its _psi bins equal-width over the *concatenated* reference+current range (not reference quantiles), defaults its drift flag at PSI>0.1 (not 0.25 used in this chapter's alert()), and has a known bug where its n_bins arg is ignored (evidentlyai/evidently#1400).
- Gotcha: alibi-detect has NO PSI detector at all (it ships KS, MMD, Chi2, CVM, FET, Classifier, Tabular) — do not reach for it expecting a drop-in PSI.
- Gotcha: The 1e-9 epsilon makes PSI non-zero by a hair even on identical inputs and biases high when a bin is genuinely empty; that is the standard PSI smoothing, not a defect, but it is why the test asserts <1e-6 rather than ==0.
On the job use scipy.stats.entropy (or just np) for the divergence term but keep the quantile binning explicit; reach for Evidently when you want a hosted drift *report* across many features and don't care that its PSI number differs from the textbook one.
On the job: Nightly drift job: bin each production feature against the frozen training reference and page on-call when PSI crosses the runbook threshold.
Chip Huyen's "Data distribution shifts and monitoring" (24-founder-blogs/huyenchip-huyenchip-com-2022-02-07-data-distribution-shifts-and-monitoring-html) is the canonical taxonomy. It pins precise A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → statements on the informal "When the data a model meets in the real world differs from the data it trained on, so it stumbles.Full glossary →" ideas: write the inputs as , the label as . Covariate shift is when the input distribution changes but the input→label relationship holds (your users changed, the world did not); label shift is when the label distribution changes; concept drift is when itself changes (the same input now means a different answer). Each is detectable by different signals and addressed by different mitigations.
FIG 25.3.11
Observability for LLMs: Arize, Fiddler, LangSmith
Traditional ML observability tools (Arize Phoenix, Fiddler) compute the metrics above against tabular features. LLM observability tools (LangSmith, Arize Phoenix's LLM tracing, OpenTelemetry GenAI semantic conventions) layer in:
- Trace of every span in a complex prompt chain. A trace is the full recording of one request's path through the system; a span is one timed step inside it — a tool call, a retrieval, a model call — so a trace is a tree of spans, each carrying its own timings, A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → counts, and full prompts/responses.
- Eval scores automatically computed against each trace: faithfulness, relevance, answer-quality, When a model confidently makes up information that isn't true or wasn't in the input.Full glossary →-flag.
- Cohort analysis: A small grid of weights that slides across an image to spot a particular pattern.Full glossary → traces by user, by tool used, by output property. Find the 12 sessions where the agent hallucinated a SKU.
- Dataset builder: promote interesting traces into a regression-eval set.
- Prompt-version A/B: ship a new prompt, watch the eval-score delta in real time.
The most-used in 2026 is LangSmith (LangChain's). It is opinionated but deeply integrated with the LangChain ecosystem. Arize Phoenix is the open-source neutral alternative; it speaks OpenTelemetry GenAI traces, which is the emerging standard.
Library path (LangSmith for tracing an LLM call):
import os
from langsmith import traceable
from openai import OpenAI
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = "ch25-mlops"
client = OpenAI()
@traceable(name="answer_question")
def answer(question: str) -> str:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": question}],
)
return response.choices[0].message.content
answer("What's the capital of Burkina Faso?")
# Trace shows up in LangSmith with full request/response, latency, token counts, cost.Library path (OpenTelemetry GenAI tracing, vendor-neutral):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
tracer = trace.get_tracer(__name__)
def answer(question: str) -> str:
with tracer.start_as_current_span("llm.chat") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.prompt.0.content", question)
# ... call the model ...
response = "..."
span.set_attribute("gen_ai.response.0.content", response)
return responseThe OTel-GenAI spec is converging across Arize, Honeycomb, Datadog, Grafana. Because every vendor speaks the same wire format, you point your app at one collector — a service that receives spans and forwards them to your chosen backend(s) — and the data flows everywhere.
FIG 25.3.12
Eval-driven development
The discipline LLM teams converge on, eventually. The skeleton:
- Golden set. A frozen list of (input, expected behavior) pairs that captures the things you care about. 50-500 examples. Hand-curated. Maintained.
- Scorer. A function that takes (input, output) and returns a score. May be exact-match, regex, LLM-as-judge with a rubric, or a downstream task metric.
- CI gate. Every prompt or model change runs the golden set. The gate fails if the score drops below a threshold (usually "no regression on >5% of cases").
- Online sampling. Production traces feed a queue. A weekly review session promotes interesting traces (failures, edge cases, novel inputs) into the golden set.
- Regression archive. When a case breaks, it goes into the golden set and never leaves.
Eugene Yan's "Evals, the most important thing in LLM apps" (24-founder-blogs/eugeneyan-eugeneyan-com-writing-evals) is the manifesto. The reason eval-driven development matters more for LLMs than for traditional ML: LLMs have so many failure modes that no single The share of guesses the model got right out of all its guesses.Full glossary → number captures them. The eval suite is the spec.
Library path (a minimal eval harness with LangSmith):
from langsmith import Client
from langsmith.evaluation import evaluate
client = Client()
# Golden dataset, one-time
client.create_dataset("burkina-qa", description="Geography Q&A golden set")
client.create_examples(
dataset_name="burkina-qa",
inputs=[{"question": "Capital of Burkina Faso?"}, ...],
outputs=[{"answer": "Ouagadougou"}, ...],
)
# Scorer
def correctness(run, example) -> dict:
expected = example.outputs["answer"].lower()
actual = run.outputs["output"].lower()
return {"key": "correct", "score": int(expected in actual)}
# Run
result = evaluate(
answer, # the function under test from §11
data="burkina-qa",
evaluators=[correctness],
experiment_prefix="gpt-4o-baseline",
)
print(result)From-scratch path (a 40-line eval harness with no dependencies):
import json, hashlib
from pathlib import Path
def eval_run(fn, golden_path: str, scorer) -> dict:
"""Run fn on every example in golden_path; score and aggregate."""
examples = [json.loads(l) for l in open(golden_path)]
results = []
for ex in examples:
out = fn(**ex["inputs"])
s = scorer(out, ex["expected"])
results.append({"id": ex.get("id"), "score": s})
agg = sum(r["score"] for r in results) / len(results)
return {"per_example": results, "aggregate": agg, "n": len(results)}
def regression_gate(current: dict, baseline: dict, threshold: float = 0.95) -> None:
"""Fail if the aggregate score dropped by more than (1-threshold)."""
ratio = current["aggregate"] / max(baseline["aggregate"], 1e-9)
assert ratio >= threshold, (
f"Regression: aggregate score {current['aggregate']:.3f} is "
f"{ratio:.1%} of baseline {baseline['aggregate']:.3f}"
)FIG 25.3.13
On-call, alerting, and incident response
Once you have monitoring and evals, you need a person who looks at the alerts. Most teams skip this. Most teams' models silently degrade.
The minimum on-call discipline for an ML system:
- Page-worthy alerts only. If the alert fires more than once a week and is non-actionable, it is noise; tune it down.
- Runbook per alert. Each alert has a one-page runbook: what the alert means, how to diagnose, the three most likely causes, the rollback action.
- Rollback button. A single command (or a single button in your CI/CD) that reverts the prod alias to the previous model version. Pre-tested. Audited monthly.
- Blameless post-mortem. Every page produces a post-mortem within a week. The output is a) what happened, b) why the monitoring/eval did not catch it earlier, c) a new test that would have caught it.
- Game days. Quarterly, you intentionally break something in staging and run the rotation through diagnosing it. If you have never done one, schedule one.
The set of failures that an ML on-call rotation handles, in rough order of frequency:
- Drift. Input distribution shifted; predictions look weird.
- Upstream data corruption. A producer of features started emitting NaNs.
- Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → latency spike. Backend dependency slow; KV cache thrashing; GPU contention.
- Cost anomaly. A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → spend up 10× because someone shipped a bad prompt.
- Capability regression. Eval suite dropped on the latest model swap.
- Adversarial input. The another chapter red-team alerts. (Yes, you should have one. Yes, on-call should triage it.)
Library path (PagerDuty integration via opsgenie/slack — same shape):
import requests
def page(severity: str, title: str, details: dict):
"""Page the on-call rotation. severity in {'critical', 'high', 'low'}."""
payload = {
"payload": {"summary": title, "severity": severity, "source": "ml-monitor",
"custom_details": details},
"routing_key": os.environ["PAGERDUTY_ROUTING_KEY"],
"event_action": "trigger",
}
r = requests.post("https://events.pagerduty.com/v2/enqueue", json=payload)
r.raise_for_status()
# In your drift detector:
if psi_value > 0.25:
page("high", f"PSI {psi_value:.2f} on feature ctr_7d",
{"feature": "ctr_7d", "model": "ranker-v17", "psi": psi_value,
"runbook": "https://wiki/runbooks/feature-drift"})FIG 25.3.14
The unique MLOps challenges (and what makes them harder than DevOps)
The CS-curriculum version of MLOps treats it as DevOps + a model artifact. The lived experience is that several of the hardest parts have no DevOps analog. The list:
Being able to run the same code again and get exactly the same result.Full glossary → is harder than determinism. In conventional software, a bug-free system produces identical outputs for identical inputs. In ML, the same code on the same data with the same seeds produces slightly different outputs across machines (different The system that lets code run on a graphics chip instead of the main processor.Full glossary → versions, different cuDNN heuristics, different hardware FP behavior). The discipline is to log every version of every component and accept a tolerance window in your equality checks.
Testing is harder than asserting. You cannot write a test that says "the model should return the correct answer for query Q" because you do not know the correct answer for most Qs. The eval suite is the closest analog. The hardest part is curating the suite so that it tracks the thing you care about.
The model is a function of training data you may no longer have. GDPR right-to-be-forgotten requests imply that any training row may need to be deleted. Strict implementations require retraining without that row. The infrastructure cost is real.
The model can be wrong in ways that are not bugs. A traditional bug is a deviation from spec. An ML failure may be the model doing exactly what it was trained to do, on a query it was never trained on. The fix may be to gather more data, not to change the code.
Costs are non-linear. A 10% larger model is not 10% more expensive to serve; it may be 30% slower per A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →, occupy 10% more GPU memory, and trigger autoscaling that 2× the fleet. The cost model has to be a first-class deployment concern.
Huyen's "Real-time machine learning challenges" (24-founder-blogs/huyenchip-huyenchip-com-2022-01-02-real-time-machine-learning-challenges-and-solutions-htm) and "ML systems design" (24-founder-blogs/huyenchip-huyenchip-com-2020-10-27-ml-systems-design-stanford-html) are the two essential reads here.
FIG 25.3.15
The GenAI application lifecycle (the LLM-specific layer)
Most of the above generalizes to LLM applications, with five modifications:
- Prompts are code. Version them, lint them, test them. Treat the prompt registry like a model registry.
- The "training" step is the prompt-engineering step. Iteration is faster (seconds vs hours), the iteration loop is governed by the eval suite, not by loss curves.
- The model is usually rented. OpenAI/Anthropic/Google models update without you. Re-run the eval suite on every API-side notification, plus a monthly cadence regardless.
- Cost is metered per A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →, not per request. Cost monitoring needs per-request token attribution.
- The output is unstructured. Output schemas (JSON mode, structured outputs) are part of the system. Schema-validation failures are a primary alert category.
Microsoft's "GenAI application lifecycle" (10-microsoft-lessons/genai-14-the-generative-ai-application-lifecycle) maps the same five phases as classical MLOps onto the LLM-rented-model world. The translation is mechanical once you have done it once.
Library path (a prompt registry — small, but the right shape):
# prompts.py — version-controlled, tested
from dataclasses import dataclass
@dataclass(frozen=True)
class Prompt:
name: str
version: str
template: str
description: str
CUSTOMER_SUPPORT_V1 = Prompt(
name="customer_support",
version="v1.2.0",
description="Triage incoming support tickets into one of {billing, technical, other}.",
template=(
"You are a triage agent. Read the ticket and return a JSON object with "
"keys 'category' and 'confidence'. Categories: billing, technical, other.\n"
"\nTicket:\n{ticket}\n"
),
)
def call(prompt: Prompt, **kwargs) -> str:
return openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt.template.format(**kwargs)}],
).choices[0].message.contentFIG 25.4 · Safety lens · this chapter
What can go wrong with MLOps specifically? Three failure modes that are MLOps-shaped, not generic-ML-shaped.
Silent model swaps. The most common production-LLM incident I have seen across multiple companies is this: the vendor (OpenAI, Anthropic, etc.) silently updates the underlying model behind a name like gpt-4o. Your application code does not change. Your eval suite has not been re-run in a month. The new model has different refusal behavior, different reasoning style, different latency profile. Users notice. You did not. The fix: schedule the eval suite to run weekly even when nothing on your side has changed, and gate any version pin to a specific dated A saved snapshot of a model partway through training so you can stop and pick up later.Full glossary → where the vendor supports it (gpt-4o-2024-08-06 rather than gpt-4o).
Eval contamination. Your golden set sat in a Google doc that someone copied into a training-data-prep notebook two quarters ago. It is now in the next model release's The batch of examples the model actually studies and learns from.Full glossary →. Your eval scores are now memorization scores. The fix: cryptographically hash your golden set, search public training corpora for the hashes, rotate the set periodically, hold private "ladder" sets that you only run against final candidates.
Monitoring blind spots for adversarial inputs. Standard drift detectors (PSI on One piece of information about an example that the model looks at when making a guess.Full glossary → distributions, KS-test on prediction distributions) are tuned to detect natural shifts. An adversarial input crafted to evade the model is by construction in-distribution; your drift detector will not flag it. The another chapter red-team suite is what catches this category. Ship it as part of the monitoring stack, not as a one-time audit. Specifically, allocate a small fraction of production traffic to adversarial probes (synthetic prompts injected through the same path as real users) and track attack-success-rate as a first-class production metric. See 26-pentest-redteam/embracethered-com-blog §normalization-of-deviance-in-ai-2025 for the canonical writeup on why "we did a red-team once" is a failure mode in itself.
What habits to adopt:
- Pin model versions to dated checkpoints, and re-evaluate every checkpoint before promotion.
- Hash and rotate your golden set, and treat memorization as a contamination signal you actively search for.
- Run the red-team CI suite from Ch 24 on every model swap. Drift detectors find natural shifts; adversarial detectors find attackers.
FIG 25.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A synthetic recommender's data -> train -> serve -> monitor -> retrain loop, in code, where the "production" stream has a drift event planted on a known day so every alarm has a ground truth to check against.
- Population Stability Index, KL divergence, and a Kolmogorov-Smirnov test built from scratch and checked against scipy, then turned into a drift detector that fires on the planted day and stays quiet before it.
- A regression gate: a frozen golden set, a scorer, and a CI-style check that ships a model only when it does not regress, demonstrated by watching it block a bad model.
- An annotated read of a real training loop (nanoGPT's), where you locate the optimizer.zero_grad placement, the set_to_none flag, and the AFTER_DEBUG-style early-break that the spec names as a landmine.
- A faithful reproduction of this repository's own CI outage: np.trapz deprecated in NumPy 2.0 and later removed, the 8-runs-red post-mortem, and the one-line np.trapezoid fix, with the pinned-versus-unpinned dependency lesson made executable.
~1 min on CPU · 109 cells · 17 checked exercises · runs in Colab
FIG 25.7 · Going further
06-practice/madewithml-mlops-*(the full MLOps curriculum, 7 lessons) — the canonical free MLOps curriculum. Read end to end.24-founder-blogs/huyenchip-huyenchip-com-2020-12-30-mlops-v2-htmlChip Huyen's industry-scan post that effectively defined the MLOps tooling taxonomy. Pair with
2020-10-27-ml-systems-design-stanford-htmlfor the structural view.24-founder-blogs/eugeneyan-eugeneyan-com-writing-design-patternsEugene Yan's "Design patterns for ML systems". The patterns themselves are recurring shapes you will recognize across teams.
24-founder-blogs/eugeneyan-eugeneyan-com-writing-feature-storesthe hierarchy-of-needs framing for feature stores. Stops you from building a Feast before you need one.
24-founder-blogs/huyenchip-huyenchip-com-2022-02-07-data-distribution-shifts-and-monitoring-htmlthe drift taxonomy in one post.
24-founder-blogs/huyenchip-huyenchip-com-2024-07-25-genai-platform-htmlthe GenAI-specific platform layer. The clearest current breakdown of LLM-app infrastructure.
27-framework-docs/wandb-docs-wandb-aiW&B's own docs are unusually good. The "Weave" section in particular for LLM observability.
27-framework-docs/modal-modal-com-docs-guideModal's guide. Best "serverless GPU" mental model in the field.
27-framework-docs/ray-docs-ray-io-en-latest-train-train-htmland…tune-index-html— Ray Train and Ray Tune for distributed compute.13-fastbook/02_productionthe fastbook chapter on production. Old but the principles hold.
FIG 25.8 · What this enables
Chapters you can now read, with the connecting idea written out.
- Independent practice
you can now ship any model you have builts. The training is a fraction of the work; the rest is in this chapter.
the eval-driven-development pattern lets you operationalize claims from papers as regression tests. The another chapter paper-re-implementation discipline becomes a CI job.
- The job
most production ML roles are 70% MLOps. You can take this chapter to an interview and discuss every level of the stack.
FIG 25.9 · 45 sources
- 06-practice/geron-ml-project-checklist
- 06-practice/madewithml-foundations-notebooks
- 06-practice/madewithml-home
- 06-practice/madewithml-mlops-design
- 06-practice/madewithml-mlops-eda
- 06-practice/madewithml-mlops-evaluation
- 06-practice/madewithml-mlops-monitoring
- 06-practice/madewithml-mlops-preparation
- 06-practice/madewithml-mlops-serving
- 06-practice/madewithml-mlops-training
- 10-microsoft-lessons/genai-14-the-generative-ai-application-lifecycle
- 13-fastbook/02_production
- 18-lilian-weng/2021-09-25-train-large
- 18-lilian-weng/2023-01-10-inference-optimization
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-challenges-after-deploying-machine-learning
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-design-patterns
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-end-to-end-data-science
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-eval-process
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-evals
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-experimentation-workflow-with-jupyter-papermill-mlflow
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-feature-stores
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-llm-evaluators
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-llm-patterns
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-mechanisms-for-projects
- 24-founder-blogs/eugeneyan-eugeneyan-com-writing-ml-design-docs
- 24-founder-blogs/huyenchip-huyenchip-com-2020-06-22-mlops-html
- 24-founder-blogs/huyenchip-huyenchip-com-2020-10-27-ml-systems-design-stanford-html
- 24-founder-blogs/huyenchip-huyenchip-com-2020-12-27-real-time-machine-learning-html
- 24-founder-blogs/huyenchip-huyenchip-com-2020-12-30-mlops-v2-html
- 24-founder-blogs/huyenchip-huyenchip-com-2021-09-13-data-science-infrastructure-html
- 24-founder-blogs/huyenchip-huyenchip-com-2022-01-02-real-time-machine-learning-challenges-and-solutions-htm
- 24-founder-blogs/huyenchip-huyenchip-com-2022-02-07-data-distribution-shifts-and-monitoring-html
- 24-founder-blogs/huyenchip-huyenchip-com-2023-01-08-self-serve-feature-platforms-html
- 24-founder-blogs/huyenchip-huyenchip-com-2023-04-11-llm-engineering-html
- 24-founder-blogs/huyenchip-huyenchip-com-2024-01-16-sampling-html
- 24-founder-blogs/huyenchip-huyenchip-com-2024-07-25-genai-platform-html
- 24-founder-blogs/huyenchip-huyenchip-com-2025-01-16-ai-engineering-pitfalls-html
- 24-founder-blogs/willison-simonwillison-net-2025-dec-10-normalization-of-deviance
- 25-alignment-canon/www-anthropic-com-news-core-views-on-ai-safety
- 26-pentest-redteam/embracethered-com-blog
- 27-framework-docs/modal-modal-com-docs-guide
- 27-framework-docs/ray-docs-ray-io-en-latest
- 27-framework-docs/ray-docs-ray-io-en-latest-train-train-html
- 27-framework-docs/ray-docs-ray-io-en-latest-tune-index-html
- 27-framework-docs/wandb-docs-wandb-ai