MLOps, Deployment & Experiment Tracking
The discipline of shipping and operating AI systems reliably. LLM apps have their own MLOps flavour — the "model" is often an API, and your real artifacts are prompts, eval sets, and configs. This part covers the lifecycle, experiment tracking, prompt/model versioning, packaging & serving, eval-gated CI/CD, deploy strategies, and production monitoring for quality, drift, and cost.
Learning objectives
- Map the LLM-app lifecycle and what "MLOps" means when the model is an API.
- Track experiments (prompt/model/param → metric) so changes are comparable.
- Version prompts and configs like code, with a registry and rollback.
- Package an agent and serve it (FastAPI), then gate deploys on evals in CI.
- Use canary/blue-green/shadow deploys and roll back safely.
- Monitor quality, latency, cost, and drift in production.
MLOps for LLM apps — what's different motivation
Classic MLOps centers on training pipelines and model weights. For LLM apps you usually don't train — you call a model API. So your versioned, tested, monitored artifacts shift to prompts, tool definitions, retrieval configs, and eval suites. The rigor is the same (reproducibility, testing, monitoring, rollback); the objects are different. This page is the operational wrapper around everything in Ch 5–6 and the capstone.
1 · The LLM-app lifecycle intermediate
| Stage | Artifact | Course link |
|---|---|---|
| Develop | prompts, tools, schemas, retrieval config | Ch 2, Ch 4 |
| Evaluate | golden set, judges, metrics | Ch 5 |
| Package | versioned app + deps | P6 |
| Deploy | service, canary, rollback plan | Ch 6 |
| Monitor | traces, metrics, cost, drift | Ch 6, Ch 8 |
| Iterate | feedback → new golden cases | back to Evaluate |
2 · Experiment tracking advanced
You'll try many prompt/model/param combinations. Without tracking, you can't say which was better or reproduce a win. Experiment tracking logs each run's inputs (prompt version, model, temperature, retrieval settings) and outputs (eval scores, cost, latency) so comparisons are apples-to-apples.
pythonimport json, hashlib
def run_id(config):
return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()[:8]
def log_run(store, config, metrics):
store.append({"id": run_id(config), "config": config, "metrics": metrics})
runs = []
log_run(runs, {"model": "claude-opus-4-8", "prompt": "v3", "k": 5},
{"pass_rate": 0.92, "cost_per_1k": 1.80, "p50_latency": 1.2})
log_run(runs, {"model": "claude-opus-4-8", "prompt": "v4", "k": 8},
{"pass_rate": 0.95, "cost_per_1k": 2.40, "p50_latency": 1.6})
best = max(runs, key=lambda r: r["metrics"]["pass_rate"])
print(best["id"], best["config"]["prompt"]) # the winning prompt version
# Real tools: MLflow (mlflow.log_params/log_metrics) or Weights & Biases (wandb.log)
# give you a UI, run comparison, and artifact storage over exactly this data.
These two comment lines aren't code you run — they're a signpost. They tell you the little tracker above is the same idea as the real tools you'll use on the job.
- MLflow and Weights & Biases (W&B) are popular experiment-tracking products. You call
mlflow.log_params/log_metricsorwandb.loginstead of your ownlog_run. - In return they give you a web dashboard, side-by-side run comparison, and storage for artifacts (files) — all on top of exactly the config-plus-metrics data you just modelled by hand.
Try this: Skim the MLflow quickstart and spot log_params / log_metrics — you'll recognise them as the fancy version of log_run.
When you tune an AI app you try lots of combinations — different prompts, models, and settings. Experiment tracking is just keeping a labelled record of what you tried and how well it scored, so later you can say which version was best and reproduce it. This is a tiny hand-rolled version of what tools like MLflow do.
run_id(config)turns the settings you used (model, prompt, k) into a short 8-character fingerprint. Same settings always give the same id — a stable label for one experiment.log_run(store, config, metrics)appends one record to a list: its id, the config (what you tried) and the metrics (how it did — pass rate, cost, latency).- The two
log_run(...)calls record two experiments: promptv3vsv4. Each stores real-world numbers you'd care about — how often it passed, dollars per 1000 calls, and typical latency. max(runs, key=lambda r: r["metrics"]["pass_rate"])scans every recorded run and picks the one with the highest pass rate — the winner.
What the output means: It prints the winning run's id and prompt version. Here v4 wins because its pass_rate (0.95) beats v3's (0.92) — even though v4 costs more, which is the kind of trade-off tracking makes visible.
Try this: Lower v4's pass_rate to 0.90 and re-run — now v3 should win. That flip is the whole point: the data, not your memory, tells you which prompt to ship.
3 · Prompt & model registry — version prompts like code advanced
A prompt is production logic — it deserves version control, review, and rollback just like code. A registry stores named, versioned prompts/configs so a deploy pins an exact version, and a bad prompt can be rolled back in seconds without a code change.
pythonfrom dataclasses import dataclass
@dataclass(frozen=True)
class PromptVersion:
name: str
version: int
template: str
class PromptRegistry:
def __init__(self):
self._store = {} # (name, version) -> PromptVersion
self._active = {} # name -> active version
def publish(self, name, template):
version = 1 + max([v for (n, v) in self._store if n == name], default=0)
self._store[(name, version)] = PromptVersion(name, version, template)
self._active[name] = version
return version
def get(self, name, version=None):
return self._store[(name, version or self._active[name])]
def rollback(self, name, version):
self._active[name] = version # instant revert, no redeploy
reg = PromptRegistry()
reg.publish("diagnose", "You are an SRE... {ctx}") # v1
reg.publish("diagnose", "You are a senior SRE... {ctx}") # v2 (active)
reg.rollback("diagnose", 1) # back to v1 in one call
A prompt is really production logic, so it deserves the same care as code: named, versioned, and reversible. A registry is a small store that keeps every version of every prompt and remembers which one is currently 'active'. This lets you swap or roll back a prompt in seconds without shipping new code.
@dataclass(frozen=True) class PromptVersiondefines one immutable record: a prompt'sname, itsversionnumber, and itstemplatetext. frozen means once made it can't be changed — a safe, fixed snapshot.publish(name, template)computes the next version number (one higher than the highest existing version for that name), stores the new version, and makes it the active one. First publish becomes v1, next v2, and so on.get(name, version=None)hands back a specific version, or — if you don't ask for one — the current active version. That's what your app calls at request time.rollback(name, version)just points 'active' back at an older version. No code change, no redeploy — the fix is instant.
What the output means: Nothing prints. The three calls at the bottom publish v1, publish v2 (now active), then roll back to v1 — so after this runs, get("diagnose") would return the v1 template again.
Try this: After the rollback, add print(reg.get("diagnose").version) — it prints 1, proving the active pointer moved back. Then call reg.rollback("diagnose", 2) to jump forward again.
diagnose@v2 so behaviour is reproducible and a rollback is deterministic. The same applies to the model ID (claude-opus-4-8, not an alias) and retrieval config — pin everything that affects output.4 · Packaging & serving advanced
Package the agent as an installable unit (P6's pyproject.toml), then serve it behind an API. FastAPI is the standard — async (A3), typed with Pydantic (A6), streaming-friendly.
python# pip install fastapi uvicorn
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Query(BaseModel): # request validated by Pydantic (A6)
question: str
prompt_version: int | None = None
@app.post("/ask")
async def ask(q: Query): # async endpoint (A3)
# pin the prompt version, call the agent, return structured output
return {"answer": f"(stub) answering: {q.question}", "prompt": q.prompt_version}
@app.get("/health")
async def health(): # liveness probe for k8s (Ch 8)
return {"status": "ok"}
# run: uvicorn app:app --host 0.0.0.0 --port 8000
# containerize with a Dockerfile (Ch 8) and deploy to k8s/ECS.
Once your agent works as a script, you serve it behind a web API so other programs (or a front-end) can call it over HTTP. FastAPI is the standard Python tool for this: you declare the shape of the request, then write a function per URL. This stub shows the skeleton of a real service.
app = FastAPI()creates the web application object you attach routes to.class Query(BaseModel)declares the expected request body: a requiredquestionstring and an optionalprompt_version. Pydantic (A6) auto-validates incoming JSON against this — bad input is rejected before your code runs.@app.post("/ask")registers theaskfunction to handle POST requests at/ask.async deflets the server handle many callers at once (A3). Here it returns a stub answer; in reality it would pin the prompt version and call the agent.@app.get("/health")is a tiny health check: orchestration systems like Kubernetes ping it to confirm the service is alive and restart it if not.
What the output means: No output on its own — this defines a service. The bottom comment shows how you'd start it: uvicorn app:app --host 0.0.0.0 --port 8000, after which POSTing JSON to /ask returns the answer object.
Try this: Run the uvicorn command, then open http://localhost:8000/docs — FastAPI auto-generates an interactive page where you can call /ask and /health from the browser.
5 · CI/CD with eval gates expert expert
The defining MLOps practice for LLM apps: every change runs the eval suite, and a deploy is blocked if quality regresses. This is what lets you move fast without silently degrading — the regression gate from Ch 5, wired into CI.
pythonimport sys, json
BASELINE = {"pass_rate": 0.90} # committed alongside the code
def gate(results, tolerance=0.02):
pr = results["pass_rate"]
if pr < BASELINE["pass_rate"] - tolerance:
print(f"❌ regression: {pr:.3f} < baseline {BASELINE['pass_rate']:.3f}")
sys.exit(1) # non-zero exit FAILS the CI job -> blocks deploy
print(f"✅ evals pass: {pr:.3f}")
# gate(run_evals()) # in CI, after building the app
An eval gate is the signature MLOps move for LLM apps: before any change ships, you run your quality tests (the golden set from Ch 5) and refuse to deploy if quality dropped. This function is that gate — it decides pass or fail and, on fail, stops the pipeline.
BASELINE = {"pass_rate": 0.90}is the quality bar, committed next to your code so everyone agrees on 'good enough'.gate(results, tolerance=0.02)reads the new run'spass_rateand compares it to the baseline. Thetoleranceallows tiny, harmless wobble (here 2%) so normal noise doesn't block every deploy.- If the score falls below
baseline − tolerance, it prints a regression message and callssys.exit(1). A non-zero exit code is how a program tells the CI system 'I failed' — which stops the deploy. - Otherwise it prints a pass message (and exits 0 implicitly), letting the pipeline continue.
What the output means: Nothing runs yet — the real call is the commented gate(run_evals()). With a pass_rate of 0.85 it would print the ❌ regression line and fail; with 0.91 it prints ✅ and passes.
Try this: Call gate({"pass_rate": 0.85}) and watch it exit with an error; then try 0.89 — it still passes because 0.89 is within the 0.02 tolerance of 0.90.
.github/workflows/ci.yml (sketch)jobs:
test-and-eval:
steps:
- run: pytest -q # unit tests (P6)
- run: python evals.py --json # run golden-set evals (Ch 5)
- run: python gate.py results.json # BLOCK deploy on regression
This is a CI pipeline config (a GitHub Actions workflow), not Python. It describes the ordered steps a server runs automatically on every push. The point is where the gate sits: tests and evals run first, and the deploy is blocked if any step fails.
jobs:andsteps:are YAML keys defining a job made of steps that run top-to-bottom. If any step exits non-zero, the whole job fails and later steps (and the deploy) don't happen.run: pytest -qruns your unit tests (P6) — ordinary code correctness.run: python evals.py --jsonruns the golden-set quality evals (Ch 5) and writes the scores to a file.run: python gate.py results.jsonfeeds those scores to the gate from the previous block. If quality regressed,gate.pyexits non-zero and the deploy is blocked automatically.
Try this: Reorder the steps in your head: what happens if gate.py ran before evals.py? It would have no results.json to read — order matters, which is why produce-then-check is the pattern.
6 · Deployment strategies advanced
| Strategy | How | Why |
|---|---|---|
| Canary | route small % of traffic to the new version | catch problems on 5% before 100% |
| Blue-green | two full envs; flip traffic; keep old ready | instant rollback by flipping back |
| Shadow | send copies of live traffic to new version, don't serve its output | compare quality on real traffic, zero user risk |
| Feature flag | toggle new prompt/model per user/cohort | gradual rollout + instant kill switch |
7 · Monitoring & drift expert expert
Ship isn't done — you must watch the system in production. LLM apps need the usual service metrics plus quality/cost signals unique to AI.
| Signal | Watch for |
|---|---|
| Latency (p50/p95/p99) | slow tails, timeout spikes |
| Error & refusal rate | rising 429/5xx, model refusals |
| Token usage & cost | cost creep, prompt bloat, cache-hit drop |
| Quality proxies | escalation rate, thumbs-down, low-confidence rate |
| Drift | input distribution shift; a model/provider update changing behaviour |
pythonimport json, logging
log = logging.getLogger("agent.metrics")
def record(**fields):
# one structured JSON line per request -> ship to Grafana/Kibana/CloudWatch
log.info(json.dumps({"kind": "llm_call", **fields}))
record(model="claude-opus-4-8", prompt_version=2,
input_tokens=1200, output_tokens=300,
latency_ms=1180, cache_hit=True, escalated=False)
# Dashboards aggregate these; alerts fire on thresholds (p95 latency, cost/hr,
# escalation rate). This is the monitoring skill of the DevOps agent (Ch 8).
After you ship, you must watch the system. The cheapest, most powerful way is to emit one structured log line (JSON) per request, capturing the numbers you care about. Because it's JSON, dashboards and alerting tools can parse and aggregate it automatically.
log = logging.getLogger("agent.metrics")gets a named logger — a channel you can route to a file or a log shipper.record(**fields)accepts any keyword fields and writes them as one JSON object.**fieldsmeans 'collect all the named arguments into a dictionary', so you can log whatever matters without changing the function.log.info(json.dumps({"kind": "llm_call", **fields}))tags every line withkind: llm_calland merges your fields in — producing one tidy, machine-readable record.- The example call logs a real request's shape: model, prompt version, input/output tokens, latency, whether the prompt cache hit, and whether it escalated to a human.
What the output means: It writes a single JSON line to the log, e.g. {"kind": "llm_call", "model": "claude-opus-4-8", ..., "latency_ms": 1180, ...}. Tools like Grafana/Kibana/CloudWatch then aggregate thousands of these into charts and fire alerts on thresholds.
Try this: Add cost_usd=0.004 to the record(...) call. Because record takes **fields, it just appears in the JSON — no code change needed. That flexibility is why structured logging scales.
8 · Cost control & governance advanced
Cost is a first-class production concern for LLM apps. The levers: prompt caching (biggest — Ch 6), model routing (cheap model for easy steps, frontier for hard), token budgets per request, and batching. Governance adds auditability: who deployed which prompt/model version, and why.
claude-opus-4-8) for hard reasoning and final synthesis. Gate the routing decision itself with evals so a cheaper path can't silently drop quality below the bar. Track cost-per-outcome, not just cost-per-token.Exercises expert
- Extend the tracker to also record git commit + prompt version, and print a table sorted by pass_rate / cost.
- Add a
rollbackintegration test to thePromptRegistry: publish v1, v2, roll back, assertget()returns v1. - Write a
gate.pythat fails if pass_rate regresses OR cost_per_1k rises > 20% vs baseline. - Add a
/askendpoint variant that streams tokens (A3) viaStreamingResponse. - Design (in comments) a shadow-deploy plan for upgrading the model: what you log, how you compare, the promote/rollback criteria.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
The senior round — describe the loop, not a feature list.
python# SHIP behind an eval gate (golden set + judge; block deploy on regression)
# MONITOR latency / cost / quality / drift dashboards + alerts
# CONTROL cost: prompt caching, model routing, token budgets
# VERSION model + prompt pinned; canary / shadow deploy; instant rollback
# LOOP prod failures -> new golden cases -> prevent regression
This block is the model answer to a senior interview question: how do you productionize an LLM feature? Each comment line is one pillar of the loop this whole page teaches — say the loop, not a laundry list of tools.
- SHIP behind an eval gate — quality is checked before every deploy, and regressions block the release (section 5).
- MONITOR latency, cost, quality, and drift with dashboards and alerts (section 7).
- CONTROL cost with prompt caching, model routing, and token budgets (section 8).
- VERSION the model and prompt (pinned), and roll out safely with canary/shadow deploys and instant rollback (sections 3 and 6).
- LOOP — every production failure becomes a new golden-set case, so the same bug can never regress again. This closed loop is the heart of LLM MLOps.
Try this: In an interview, speak these five words in order — Ship, Monitor, Control, Version, Loop — and give one concrete example of each. Structure beats a feature list every time.
Non-zero exit fails the CI job, so quality can't silently regress.
pythonimport sys
BASELINE = 0.90
def gate(pass_rate, tol=0.02):
if pass_rate < BASELINE - tol:
print(f"regression {pass_rate:.3f} < {BASELINE}")
sys.exit(1) # fail CI -> block deploy
print("evals pass")
This is the interview-ready, whiteboard-sized version of the eval gate: the smallest code that shows you understand how to stop a bad deploy. It's the same idea as section 5, trimmed to the essentials.
BASELINE = 0.90is the minimum acceptable pass rate.gate(pass_rate, tol=0.02)compares the new score to the baseline, allowing a small tolerance so ordinary noise doesn't block releases.sys.exit(1)is the load-bearing line: a non-zero exit code tells the CI system the step failed, which blocks the deploy. Everything else is just the message.
Try this: Be ready to explain why sys.exit(1) matters: CI treats any non-zero exit as failure, so this one line is what turns a failing eval into a blocked deploy.
Checkpoint — you've completed the AI Engineering track when you can… expert
- Describe the LLM-app lifecycle and what MLOps means when the model is an API.
- Track experiments and version prompts/configs/model IDs with rollback.
- Package and serve an agent, and gate deploys on quality + safety + cost evals in CI.
- Choose canary/blue-green/shadow deploys and monitor latency, cost, quality, and drift.
- See how A1–A8 wrap the course: mechanics, memory, async, numerics, text, validation, retrieval, and operations — the full production stack around your agent.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: LLM apps aren't trained-then-served like classic ML — you usually don't train the model at all. Knowing what replaces each classic-ML stage tells you which artifacts you actually version and gate.
Your task: Map each classic-ML lifecycle stage to its LLM-app equivalent and name what replaces 'train a model'.
Requirements:
- Cover the stages: data collection, training, validation, deployment, monitoring
- Show 'train a model' becomes prompt/context engineering + model choice
- Show 'validate on a test set' becomes eval metrics + LLM-as-judge on an eval set
- Make clear the versioned artifacts are prompts and configs, not weights
- Print the mapping for a few stages to demonstrate
💡 Hint: A dict from classic-stage to LLM-equivalent is enough; the insight is that the weights belong to the vendor, so what you ship is prompts and context.
Show solution
The lifecycle shifts from weights to prompts/context (pure framing):
def llm_equivalent(classic_stage):
return {
"collect training data": "curate an eval set (question, answer, context)",
"train a model": "engineer prompts + choose/version a model + RAG context",
"validate on test set": "run eval metrics + LLM-as-judge on the eval set",
"deploy weights": "deploy prompts/config behind an API (weights are the vendor's)",
"monitor accuracy": "monitor quality, cost, latency, and prompt/data drift",
}[classic_stage]
for s in ("train a model", "validate on test set", "monitor accuracy"):
print(s, "->", llm_equivalent(s))
You usually don't train the model — you version prompts, context, and model choice, and validate with an eval set instead of a train/test split. "Train" is replaced by prompt/context engineering, so the artifacts you version and gate are prompts and configs, not weights.
Context: The prompt is the artifact you change most often, so it needs the same discipline as code: immutable versions and a movable tag. That's what makes 'the prompt change broke prod' instantly recoverable.
Your task: Model a tiny prompt registry that stores versioned prompt text, resolves a prompt by tag, and can roll back by moving the tag.
Requirements:
- Each
registerstores an immutable new version under a name - A movable tag (e.g.
prod) points at one version at a time resolve(name, tag)returns the text the tag currently points to- Promoting a version and rolling back are the same operation on the tag
- Demonstrate promote-then-rollback returning the earlier text
💡 Hint: Keep versions[name][v] = text and tags[name][tag] = v as two dicts; promotion and rollback both just reassign the tag.
Show solution
A minimal registry: immutable versions + movable tags (runnable):
class PromptRegistry:
def __init__(self):
self.versions = {} # name -> {version: text}
self.tags = {} # name -> {tag: version}
def register(self, name, text):
v = len(self.versions.setdefault(name, {})) + 1
self.versions[name][v] = text
self.tags.setdefault(name, {})["latest"] = v
return v
def promote(self, name, version, tag="prod"):
self.tags[name][tag] = version # move the tag (deploy / rollback)
def resolve(self, name, tag="prod"):
return self.versions[name][self.tags[name][tag]]
r = PromptRegistry()
r.register("triage", "Classify the ticket into billing/tech/other.")
v2 = r.register("triage", "Classify into billing/tech/other. Return one word.")
r.promote("triage", v2, "prod")
print(r.resolve("triage", "prod")) # v2 text
r.promote("triage", 1, "prod") # rollback to v1
print(r.resolve("triage", "prod")) # v1 text
Immutable versions plus a movable prod tag give you the two operations that matter: promote a new prompt and instantly roll back to a known-good one. Treating prompts as versioned artifacts is what makes "the prompt change broke prod" recoverable.
Context: Before a prompt change merges, an eval gate must pass — and it should tell you which quality axis regressed, not just that quality dropped. This is the CI check that keeps prompt edits from silently degrading production.
Your task: Implement eval_gate(scores) over per-metric thresholds and show it blocking a candidate whose faithfulness regressed below the bar.
Requirements:
- Keep per-metric thresholds (e.g. faithfulness, answer relevance, context recall)
- PASS only when every metric meets or beats its threshold
- On FAIL, report exactly which metric(s) fell short and by how much
- A baseline that passes and a candidate that regresses one metric both run
- The failing metric is named so the author fixes the right thing
💡 Hint: Collect the metrics below their threshold into a list; empty means PASS, non-empty means FAIL and doubles as the diagnostic message.
Show solution
Gate the merge on per-metric thresholds (runnable):
THRESH = {"faithfulness": 0.85, "answer_relevance": 0.75, "context_recall": 0.80}
def eval_gate(scores):
failed = [f"{k} {scores[k]:.2f}<{THRESH[k]}" for k in THRESH if scores[k] < THRESH[k]]
return ("PASS", []) if not failed else ("FAIL", failed)
baseline = {"faithfulness": 0.88, "answer_relevance": 0.79, "context_recall": 0.83}
candidate= {"faithfulness": 0.80, "answer_relevance": 0.81, "context_recall": 0.84} # faith regressed
print(eval_gate(baseline)) # ('PASS', [])
print(eval_gate(candidate)) # ('FAIL', ['faithfulness 0.80<0.85'])
A per-metric gate tells you not just that quality dropped but which axis regressed — here faithfulness fell below threshold, so the change is blocked and the author knows to fix grounding, not relevance. This is the CI check that keeps prompt edits from silently degrading production.
Context: Blue-green, canary, and shadow trade rollout risk against how much you can validate before real users see the change. Picking the right one is a judgment platform engineers make on every model or prompt change.
Your task: Write a rollout(...) selector that picks blue-green, canary, or shadow from the change's risk and how you're able to validate it.
Requirements:
- Choose SHADOW when you must judge on live traffic but can't compare offline
- Choose CANARY for high-risk changes (gradual ramp with auto-rollback on regression)
- Choose BLUE-GREEN for low-risk changes (fast full cutover, warm rollback env)
- Have a sensible default for the in-between case
- Show inputs that select each of the three strategies
💡 Hint: Order the checks by how conservative they need to be — the 'can't validate before serving' case forces shadow before you ever look at the risk level.
Show solution
Match the rollout to the risk and to how you validate (worked logic):
def rollout(risk, can_compare_offline, need_live_traffic_to_judge):
if need_live_traffic_to_judge and not can_compare_offline:
return "SHADOW — mirror real traffic to the new version, compare, serve old"
if risk == "high":
return "CANARY — 1%%->10%%->100%%, watch metrics, auto-rollback on regression"
if risk == "low":
return "BLUE-GREEN — cut over fully, keep old env warm for instant rollback"
return "CANARY — default cautious rollout"
print(rollout("high", True, False)) # CANARY
print(rollout("low", True, False)) # BLUE-GREEN
print(rollout("med", False, True)) # SHADOW
Shadow validates a change on real traffic without serving it (safest, needs comparison infra); canary limits blast radius with a gradual ramp and auto-rollback; blue-green is the fast full cutover for low-risk changes with a warm rollback env. Pick by how much you can validate before real users see it.
Context: Quality degrades silently as inputs shift over time. A rolling baseline plus a tolerance band turns 'it feels worse lately' into an alert the moment a metric leaves the band.
Your task: Implement a drift monitor that flags when an output-quality metric moves beyond a control band versus a rolling baseline.
Requirements:
- Maintain a rolling window of recent scores as the baseline
- Report a warm-up phase until the window is full
- Flag DRIFT when a new score deviates from the baseline by more than the tolerance
- Return an ok/warming/drift signal per observation, not just at the end
- Feed a series ending in a sharp drop and show the drift flag fire
💡 Hint: A collections.deque(maxlen=window) gives the rolling baseline for free; compare each new score to the window's mean against your tolerance.
Show solution
Flag drift when the metric leaves a band around the rolling baseline (runnable):
from collections import deque
class DriftMonitor:
def __init__(self, window=20, tol=0.05):
self.window, self.tol = window, tol
self.hist = deque(maxlen=window)
def observe(self, score):
if len(self.hist) < self.window:
self.hist.append(score); return "warming up"
baseline = sum(self.hist) / len(self.hist)
self.hist.append(score)
if abs(score - baseline) > self.tol:
return f"DRIFT: {score:.2f} vs baseline {baseline:.2f}"
return "ok"
m = DriftMonitor(window=5, tol=0.05)
for s in [0.90,0.91,0.89,0.90,0.90, 0.90, 0.72]: # last score drops sharply
print(m.observe(s))
A rolling baseline plus a tolerance band turns "quality feels worse lately" into an alert the moment a metric (faithfulness, thumbs-up rate, judge score) leaves the band. In production you'd also monitor input distribution and cost/latency, since drift often shows in inputs before it shows in outputs.
Context: A shared LLM layer needs a gate in front of every call: a spend cap so one tenant can't drain the budget, an allowlist so only vetted models run, and a kill switch for incidents. Owning cost and policy centrally is what keeps the platform safe to operate.
Your task: Write a governor that authorizes each LLM call against a per-tenant spend cap, a model allowlist, and a global kill switch.
Requirements:
- Deny any call once the kill switch is engaged
- Deny models that aren't on the allowlist
- Deny a call whose estimated cost would exceed the monthly cap
- On allow, accrue the estimated spend so the cap is enforced cumulatively
- Return a clear reason string for each decision and demo allow + each denial
💡 Hint: Order the checks cheapest/most-absolute first (kill switch, then allowlist, then budget) so a denied call never mutates the running spend total.
Show solution
Gate every call on budget, model policy, and a kill switch (runnable):
class Governor:
def __init__(self, monthly_cap, allowed_models):
self.cap = monthly_cap; self.allowed = set(allowed_models)
self.spent = 0.0; self.enabled = True
def authorize(self, model, est_cost):
if not self.enabled: return "DENY — kill switch engaged"
if model not in self.allowed: return f"DENY — {model} not on allowlist"
if self.spent + est_cost > self.cap:
return f"DENY — would exceed ${self.cap} monthly cap"
self.spent += est_cost
return f"ALLOW — spent ${self.spent:.2f}/{self.cap}"
g = Governor(monthly_cap=100.0, allowed_models={"claude-haiku-4-5","claude-opus-4-8"})
print(g.authorize("claude-opus-4-8", 5.0)) # ALLOW
print(g.authorize("some-other-model", 1.0)) # DENY — not on allowlist
g.enabled = False
print(g.authorize("claude-opus-4-8", 1.0)) # DENY — kill switch
Governance for an LLM platform is a gate in front of every call: enforce a spend cap so one tenant can't drain the budget, an allowlist so only vetted models run, and a kill switch to stop everything during an incident. Owning cost and policy centrally is what keeps a shared LLM layer safe to operate.
Knowledge check check yourself
The lesson says LLM-app MLOps versions different artifacts than classic MLOps. What are they, and why the shift?
Show answer
The eval gate calls sys.exit(1) when pass_rate drops below baseline minus tolerance. Why is a non-zero exit the "load-bearing" line, and why include a tolerance?