AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Advanced AI Engineering · Part 8

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.

⏱️ ~2 hours🎯 Advanced → Expert🚢 ship & operaterunnable

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

Here's the moment this chapter exists to prevent. Your agent works beautifully in the notebook. You paste it into production, it helps real users for two weeks, and then someone quietly edits a prompt to "make answers friendlier." Overnight the escalation rate doubles and nobody notices for three days because there was no eval gate, no version history, and no dashboard. When you finally trace it, you can't even reproduce the old behaviour — the good prompt is gone. That whole disaster is an MLOps failure, and every section here is a piece of the machinery that would have caught it on day one.

Think of it like the difference between cooking dinner and running a restaurant kitchen. Anyone can make one good meal when they have all afternoon and only themselves to feed. A restaurant has to serve the same dish, at quality, hundreds of times a night, when the head chef is off sick and a supplier swapped the tomatoes — so it needs written recipes (versioned prompts), a pass at the line before food leaves (eval gates), a way to yank a bad dish (rollback), and a manager watching the tickets (monitoring). MLOps is the kitchen discipline that turns "I made a good agent once" into "this agent is reliably good for everyone, every day."

The common mistake — and it catches strong engineers — is assuming that because you're not training a model, the ops rigor doesn't apply to you. It absolutely does; it just points at different objects. 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. A prompt string is production logic every bit as load-bearing as a function — treat it casually and it will page you at 2am just like any other untested code path. This page is the operational wrapper around everything in Ch 5–6 and the capstone: it's what lets a working prototype become a system you'd stake a customer relationship on.

same rigor (version · test · monitor · roll back) — different artifacts classic MLOps training pipeline → weights version: model weights you train the model LLM-app MLOps call a vendor model API version: prompts · configs tool defs · retrieval · eval suites you don't train — you configure Same discipline, different objects. Classic MLOps versions and gates model weights from a training pipeline; for LLM apps the model is a vendor API, so the load-bearing artifacts you version, test, monitor, and roll back become prompts, tool definitions, retrieval configs, and eval suites.
🗺️ How to read this diagram

Compare the two columns across the dashed divider — left is the world you may have expected, right is the one this page is about.

  • The left column is classic MLOps: a training pipeline produces model weights, and the weights are the blue "version this" artifact. You own the model.
  • The right column is LLM-app MLOps: you call a vendor model API instead of training, so the green "version this" artifact shifts to prompts, tool definitions, retrieval configs, and eval suites.
  • The top line is the payoff: the four disciplines — version, test, monitor, roll back — are identical across both columns. Only the objects those disciplines act on change.

In short: not training a model doesn't exempt you from ops rigor — it just repoints that rigor at prompts and configs instead of weights.

1 · The LLM-app lifecycle intermediate

Every serious AI system is a loop, not a line — and the teams that forget that ship once and then slowly rot. The tempting mental model is a straight arrow: build the thing, launch it, done. But an LLM app that isn't fed by what happens after launch is like a shop that never reads its own complaints box — it keeps making the same mistakes forever, confidently.

Picture instead the way a good hospital handles a near-miss. Something goes wrong, it gets logged, the case is reviewed, and a new checklist item is added so that exact failure can't recur. The lifecycle below is the same idea applied to software: you develop, evaluate, package, deploy, monitor — and then the interesting part, the monitor stage feeds back into evaluate. Every real production failure and every thumbs-down becomes a new test case, so your safety net grows tighter with every incident instead of staying frozen at launch.

The table maps each stage to the concrete artifact you produce and where in this course you learned to build it — read it as a checklist you'll return to on every project. The mistake to avoid is treating the stages as one-and-done departments handed off over the wall. A prompt tweak (Develop) with no new eval (Evaluate) is how quality silently drifts; a deploy (Deploy) with no monitoring (Monitor) is how you find out about outages from angry users instead of your own alerts. The stages are cheap when they're a habit and brutally expensive when they're skipped, because a gap anywhere breaks the feedback loop that keeps the whole system honest.

a loop, not a line — Monitor feeds new cases back into Evaluate Develop prompts, tools Evaluate golden set Package versioned app Deploy canary, rollback Monitor traces, cost, drift failures & feedback →new golden cases The lifecycle is a closed loop. Develop → Evaluate → Package → Deploy → Monitor runs left to right, but the amber arrow is the part teams forget: Monitor feeds real failures back into Evaluate as new golden cases, so the safety net tightens with every incident.
🗺️ How to read this diagram

Read the five boxes left to right — that's the order a change moves through — then follow the amber arrow underneath back to the start.

  • The blue boxes joined by blue arrows are the forward path: you Develop prompts/tools, Evaluate them against a golden set, Package a versioned app, Deploy it with a rollback plan, and Monitor it in production.
  • The amber box and arrow at the bottom is the feedback loop: production failures and user feedback become new golden-set cases, which flow back into Evaluate. That is what turns a straight line into a loop.
  • The payoff is the green box: because every real bug becomes a permanent test, the same failure can't silently return — the eval suite grows tighter with each incident instead of freezing at launch.

In short: ship → observe → add eval → prevent regression. The loop, not the launch, is the heart of LLM MLOps.

StageArtifactCourse link
Developprompts, tools, schemas, retrieval configCh 2, Ch 4
Evaluategolden set, judges, metricsCh 5
Packageversioned app + depsP6
Deployservice, canary, rollback planCh 6
Monitortraces, metrics, cost, driftCh 6, Ch 8
Iteratefeedback → new golden casesback to Evaluate
The loop that mattersProduction failures and user feedback become new golden-set cases (Ch 5), so the eval suite grows to cover every real bug. That closed loop — ship → observe → add eval → prevent regression — is the heart of LLM MLOps.

2 · Experiment tracking advanced

Ask yourself an honest question: three prompt tweaks ago, what was your pass rate? If the answer is "I think it was better?" you've just met the problem this section solves. Tuning an LLM app means running dozens of variations — this model or that one, temperature 0.2 or 0.7, retrieve 5 chunks or 8 — and human memory is spectacularly bad at holding a scoreboard of that many runs. You end up going in circles, re-testing things you already tried, and shipping a version because it "felt good" rather than because it won.

Experiment tracking is nothing more exotic than a lab notebook for your prompts. A scientist doesn't trust their memory of which reagent concentration gave the best yield — they write down every condition and every result so the comparison is undeniable and repeatable. You do the same: for each run you record the inputs (prompt version, model, temperature, retrieval settings) alongside the outputs (eval scores, cost, latency). Now "which was better?" stops being an argument and becomes a sort on a column.

Why the discipline matters so much here specifically: an LLM app's quality is multi-dimensional, so a change almost never makes everything better at once. Prompt v4 lifts your pass rate but also costs 30% more and adds 400ms of latency — is that a win? You genuinely cannot answer without the numbers side by side, because the tradeoff depends on your product. The classic mistake is tracking only the metric you're chasing and discovering, weeks later in the billing console, that your "better" prompt quietly tripled the bill. Log cost and latency from the very first run, even while you're only trying to improve quality — they're free to capture now and impossible to reconstruct later. Without tracking, you can't say which run was better or reproduce a win; with it, every experiment compounds into knowledge instead of evaporating.

a lab notebook for prompts: log inputs + outputs per run, then sort INPUTS (what you tried) OUTPUTS (how it did) v3 · opus · k=5 pass 0.92 · $1.80 · 1.2s v4 · opus · k=8 pass 0.95 · $2.40 · 1.6s ↑ highest pass_rate = the winner but costs 33% more — only visible if logged "which was better?" becomes a sort on a column, not an argument Tracking turns memory into a scoreboard. Each run records its inputs (prompt version, model, k) beside its outputs (pass rate, cost, latency). Sorting by pass rate names the winner (v4) — and because cost was logged too, its 33% higher bill is right there instead of surfacing weeks later in billing.
🗺️ How to read this diagram

Read it as two-column rows: the left half of each row is what you fed the run, the right half is what came out. The two data rows are two experiments you can compare directly.

  • The grey header cells split every run into INPUTS (prompt version, model, k) and OUTPUTS (pass rate, cost, latency) — the config-plus-metrics pair you log per run.
  • The white row is prompt v3; the green row is v4. The green highlight and the arrow mark v4 as the winner because its pass rate (0.95) is highest.
  • The amber note is the aha: v4 also costs 33% more. You can only see that tradeoff because cost was captured from the first run — the mistake is logging only the metric you're chasing.

In short: record inputs beside outputs for every run, and picking a winner (and spotting its hidden cost) becomes a column sort instead of a guess.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — a minimal tracker (the idea behind MLflow/W&B)
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.
▶ How this works

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.

  1. MLflow and Weights & Biases (W&B) are popular experiment-tracking products. You call mlflow.log_params/log_metrics or wandb.log instead of your own log_run.
  2. 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.

▶ How this works

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.

  1. 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.
  2. 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).
  3. The two log_run(...) calls record two experiments: prompt v3 vs v4. Each stores real-world numbers you'd care about — how often it passed, dollars per 1000 calls, and typical latency.
  4. 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

Picture the 2am page. Quality has cratered, users are complaining, and you trace it to a prompt edit someone made this afternoon. The fix is obvious: put the old prompt back. So — what was the old prompt? If it lived as a string pasted into code and edited in place, the previous wording is gone, and now you're reconstructing production logic from memory under pressure. That is the nightmare a registry exists to abolish.

The core realization is that a prompt is code — it's the instruction that decides what your system does — so it deserves everything code gets: a history of every version, the ability to review a change before it ships, and a one-move undo. Think of it like the "published version" of a document versus the live draft. You keep every published edition immutably, and you keep a separate pointer that says "this is the one customers see right now." Fixing a bad release isn't re-typing the old text; it's just moving the pointer back to a known-good edition. That move is instant and can't introduce a new typo, because you're re-selecting text that already worked, not recreating it.

That separation — immutable versions plus a movable "active" tag — is what makes rollback a seconds-long, deterministic operation instead of an anxious rewrite. A deploy pins an exact version (diagnose@v2), so the behaviour in production is reproducible and a rollback lands somewhere you've actually tested. The mistake that bites teams is letting production point at "latest" or at a mutable string: now every prompt edit is an instant, unreviewed, unversioned production change, and you've lost the very history you need the day something breaks. The same discipline extends to the model ID and retrieval config — pin everything that affects the output, or your "reproducible" system quietly shifts under you when a vendor updates an alias.

immutable versions + one movable ACTIVE pointer — rollback just moves the pointer diagnose@v1 "You are an SRE…" diagnose@v2 "senior SRE…" diagnose@v3 "new wording" frozen — never edited ACTIVE → v2 was here (v3) rollback = move the pointer, not a rewrite Rollback is a pointer move, not a rewrite. Every published version stays frozen and addressable (diagnose@v1/v2/v3); a single "active" tag decides what prod uses. Reverting a bad v3 just re-points active at the known-good v2 — instant and typo-proof.
🗺️ How to read this diagram

Look at the stacked white boxes as a version history growing downward, then follow the green "active" tag and the dashed arrow to see what rollback does.

  • The three white boxes are immutable published versions — v1, v2, v3 — each holding a frozen prompt template. Once published, a version is never edited (the note on the left), so its exact wording is always recoverable.
  • The green "ACTIVE → v2" box is the single movable pointer that decides which version production serves. Its solid arrow shows it currently points at v2.
  • The dashed arrow is the rollback: the pointer used to sit on v3 and was moved back to v2. The red bar states the payoff — reverting is just re-pointing the tag, with no redeploy and no chance of re-introducing a typo.

In short: keep every version frozen, keep one movable "active" tag, and rollback becomes a deterministic seconds-long move instead of an anxious rewrite.

Try it — a versioned prompt registry
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
▶ How this works

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.

  1. @dataclass(frozen=True) class PromptVersion defines one immutable record: a prompt's name, its version number, and its template text. frozen means once made it can't be changed — a safe, fixed snapshot.
  2. 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.
  3. 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.
  4. 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.

Pin versions in productionNever let prod point at "latest." A deploy should pin 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

"It works on my machine" is where a thousand production incidents are born. Your agent runs perfectly in your notebook, with your Python version, your installed libraries, and your API key sitting in a shell variable. None of that travels. The moment someone else — a teammate, a CI runner, a Kubernetes pod — needs to run it, all those invisible assumptions surface as errors. Packaging and serving is the craft of turning "runs where I built it" into "runs anywhere, the same way, on demand."

There are two separate ideas here, and it helps to name them. Packaging is bundling your agent into a self-describing, installable unit — code plus its exact dependencies (P6's pyproject.toml) — so any machine can install and import it without guesswork. Think of it as vacuum-sealing a meal with the recipe taped to the lid: whoever opens it gets the same thing you made. Serving is the next step — wrapping that unit in a web API so other programs can call it over HTTP instead of importing your Python. That's the difference between a script you run and a service that's always listening; a front-end, a mobile app, or another microservice can all hit the same endpoint.

FastAPI is the standard tool for the serving half in Python, and it's standard for good reasons that all trace back to earlier chapters: it's async (A3), so one process can juggle many slow LLM calls at once instead of blocking on each; it's typed with Pydantic (A6), so malformed requests are rejected at the door with a clear error before your code ever runs; and it's streaming-friendly, so you can flush tokens to the user as they generate. The beginner mistake is stopping at a bare /ask route and calling it production. A real service also needs a /health endpoint so orchestrators know whether to route traffic to it or restart it — without one, Kubernetes has no way to tell a wedged pod from a healthy one, and users get served by a corpse. Serving is as much about the boring operational surface as it is about the answer.

package once (installable unit) → serve it as an HTTP API callers can reach Package code + pinned deps (pyproject.toml) wrap FastAPI service (serve) POST /ask GET /health async · Pydantic-validated front-end / app k8s / orchestrator HTTP probes Two steps: package, then serve. Packaging makes an installable unit (code + pinned deps); serving wraps it in a FastAPI app with a /ask route for callers and a /health route so an orchestrator can tell a live pod from a wedged one.
🗺️ How to read this diagram

Read left to right: an installable unit on the left becomes a running service in the middle, which the callers on the right reach over HTTP.

  • The left blue box is packaging — your code plus its exact pinned dependencies (P6's pyproject.toml) — a self-describing unit any machine can install the same way.
  • The center box is serving: that unit wrapped in a FastAPI app. Inside it, the green POST /ask is the real work endpoint and the amber GET /health is the liveness probe; the service is async and Pydantic-validated.
  • The purple boxes on the right are the two kinds of caller: a front-end/app hits /ask over HTTP (solid arrow), while the orchestrator (Kubernetes) pings /health (dashed arrow) to decide whether to route traffic or restart the pod.

In short: package = vacuum-seal the meal with its recipe; serve = put it behind a counter with an "open?" sign the kitchen manager can check.

Try it — serve an agent with FastAPI
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.
▶ How this works

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.

  1. app = FastAPI() creates the web application object you attach routes to.
  2. class Query(BaseModel) declares the expected request body: a required question string and an optional prompt_version. Pydantic (A6) auto-validates incoming JSON against this — bad input is rejected before your code runs.
  3. @app.post("/ask") registers the ask function to handle POST requests at /ask. async def lets 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.
  4. @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.

🔗 Used in the courseThis is how the projects graduate from scripts to services. The DevOps capstone (Ch 8) containerizes exactly this shape (Dockerfile + health checks), and the deployment/rollback lab (Ch 6) puts it behind a real rollout.

5 · CI/CD with eval gates expert expert

If you remember one practice from this entire page, make it this one. The single thing that most separates a hobby LLM project from a production one is whether a quality regression can reach users without anyone deciding it should. On a mature system, it can't — because every change automatically runs the eval suite, and a deploy is blocked the moment quality drops. That gate is the difference between "we move fast and occasionally torch the customer experience" and "we move fast and the customer experience only ever gets better."

Here's the analogy that makes it click. An eval gate is the airport metal detector for your prompts. Everyone walks through the same scanner, every time, no exceptions — the CEO and the intern alike. It doesn't judge intent or trust anyone's assurance that "this change is fine"; it just measures, and if something trips the threshold, that passenger doesn't board. Wire that scanner into CI (the automation that runs on every push) and you've made high quality the default state of your main branch, enforced by a machine that never gets tired, rushed, or talked out of it.

The mechanism is beautifully simple and rests on one humble fact: a program that exits with a non-zero code is telling the operating system "I failed," and CI systems treat any failed step as a reason to halt the pipeline. So an eval script that computes your pass rate and calls sys.exit(1) on a regression will physically stop the deploy — no human in the loop required. Two design choices make it usable in practice. First, a small tolerance: LLM evals wobble run-to-run, so you allow a couple of points of noise or you'll block every release for nothing. Second — and this is the mistake that undermines otherwise-careful teams — gate on more than pass rate. A prompt that's 2% more accurate but 3x more expensive, or that quietly starts taking irreversible actions without approval, is not an improvement. Quality, cost, and safety are all release criteria; a gate that watches only one of them is a scanner that checks for metal but waves through the explosives.

every push runs the same scanner — the gate decides deploy vs block push pytest unit tests evals.py golden set gate.py pass ≥ base−tol? deploy ✓ BLOCKED ✗ pass exit 1 produce (tests, evals) → then check (gate): a non-zero exit halts the pipeline The gate is the metal detector for prompts. On every push, CI runs unit tests then the golden-set evals, then gate.py compares the pass rate to a committed baseline. Pass → deploy; regress → a non-zero exit physically blocks the deploy, no human needed.
🗺️ How to read this diagram

Trace one push left to right through the pipeline, then notice the two possible endings on the far right.

  • The blue "push" box starts the pipeline. The blue arrows run the steps in order: pytest (ordinary code correctness), then evals.py (the golden-set quality suite), then gate.py.
  • The purple gate box is the decision: it checks whether the new pass rate is at least the committed baseline minus a small tolerance (which absorbs run-to-run eval noise).
  • The two outcomes: a green "deploy" box if it passes, or a red "BLOCKED" box reached by the dashed exit 1 arrow. That non-zero exit code is the load-bearing part — CI treats any failed step as a reason to stop, so quality can't silently regress.
  • The bottom line names the ordering rule: produce then check — tests and evals must run before the gate, because the gate reads their results.

In short: the same scanner runs on every push; a regression trips sys.exit(1) and the deploy never boards.

Try it — an eval gate (fails CI on regression)
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
▶ How this works

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.

  1. BASELINE = {"pass_rate": 0.90} is the quality bar, committed next to your code so everyone agrees on 'good enough'.
  2. gate(results, tolerance=0.02) reads the new run's pass_rate and compares it to the baseline. The tolerance allows tiny, harmless wobble (here 2%) so normal noise doesn't block every deploy.
  3. If the score falls below baseline − tolerance, it prints a regression message and calls sys.exit(1). A non-zero exit code is how a program tells the CI system 'I failed' — which stops the deploy.
  4. 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
Also gate on safety & costA pass-rate gate isn't enough. Add hard-fail safety evals (never take an irreversible action without approval — Ch 8) that block deploy on any failure, and watch cost per case so a "better" prompt doesn't quietly triple the bill. Quality, safety, and cost are all release criteria.
▶ How this works

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.

  1. jobs: and steps: 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.
  2. run: pytest -q runs your unit tests (P6) — ordinary code correctness.
  3. run: python evals.py --json runs the golden-set quality evals (Ch 5) and writes the scores to a file.
  4. run: python gate.py results.json feeds those scores to the gate from the previous block. If quality regressed, gate.py exits 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

The riskiest word in operations is "just." "Just push the new version to everyone" feels efficient right up until the new version has a subtle flaw — and now 100% of your users are hitting it simultaneously, your on-call is scrambling, and the blast radius is the entire customer base. Deployment strategies are the accumulated wisdom of everyone who's been burned by that flip and decided there had to be a safer way to introduce change.

The unifying idea across all of them is controlling exposure: instead of a binary old-then-new switch, you find a way to let a new version prove itself while limiting who can be hurt if it's wrong. A canary sends a small slice of traffic (5%) to the new version first — named after the caged birds miners carried underground, which showed distress before the humans did. Blue-green keeps two complete environments and flips traffic between them, so rollback is as fast as flipping back. Shadow is the cleverest: it mirrors real traffic to the new version but throws its answers away, letting you measure it against live load with zero user risk. And a feature flag toggles the new behaviour per user or cohort, giving you a gradual rollout with an instant kill switch. Read the table below as a menu, not a ranking — each buys safety in a different currency.

Which one you reach for is a judgment about how much you can validate before real users see the change, and that's where LLM apps have a genuine superpower worth internalizing. Because you have evals, shadow mode is far more powerful for you than for a typical web service: you can run a candidate prompt or a new model against a day of real traffic, score both offline with your golden set, and compare quality, cost, and latency before a single user is affected. That's how you de-risk something as scary as a model-version upgrade. The mistake here is defaulting to one strategy for everything — big-bang cutovers for high-risk model swaps (reckless), or elaborate shadow pipelines for a one-word prompt fix (wasteful). Match the ceremony to the risk.

big-bang: 100% hits the new version all traffic NEW · 100% blast radius = everyone canary: 5% new, 95% stays on old all traffic → split OLD · 95% NEW · 5% only 5% exposed if wrong healthy → widen; bad → roll back the slice Controlling exposure is the whole idea. A big-bang cutover puts 100% of users on an unproven version at once; a canary sends only ~5% to the new version and keeps 95% on the known-good old one — so a flaw hurts a sliver, not the whole base, and you widen only once it looks healthy.
🗺️ How to read this diagram

Compare the two sides across the dashed divider: the left is the reckless default, the right is the controlled-exposure alternative.

  • On the left, all traffic flows into a single red "NEW · 100%" box. If the new version has a subtle flaw, the blast radius is everyone at once — the risk this section exists to avoid.
  • On the right, the same traffic is split: the blue arrows send 95% to the OLD version (still the known-good one) and only 5% to the amber "NEW · 5%" box. A flaw now touches only that 5% slice.
  • The bottom line is the decision rule the split enables: if the canary looks healthy you widen its share; if it misbehaves you roll the slice back — long before most users ever saw it.

In short: don't flip everyone at once; let a new version prove itself on a small slice, then grow it. Blue-green, shadow, and feature flags are other ways to buy that same safety.

StrategyHowWhy
Canaryroute small % of traffic to the new versioncatch problems on 5% before 100%
Blue-greentwo full envs; flip traffic; keep old readyinstant rollback by flipping back
Shadowsend copies of live traffic to new version, don't serve its outputcompare quality on real traffic, zero user risk
Feature flagtoggle new prompt/model per user/cohortgradual rollout + instant kill switch
Shadow mode is the LLM superpowerBecause you have evals, you can shadow a new prompt/model against live traffic and score both offline before it ever affects a user. This is how you de-risk a model upgrade (e.g. moving to a new Claude version): shadow, compare eval + cost + latency, then canary, then full. It maps directly onto the autonomy ladder in the capstone.

7 · Monitoring & drift expert expert

The worst way to learn your AI system has degraded is from a user telling you. By then it's been broken for who-knows-how-long, the damage is done, and you're reacting instead of preventing. Shipping is not the finish line — it's the moment the system starts living in the wild, exposed to real inputs you never tested and a model provider who can change behaviour under you without warning. Monitoring is how you keep your eyes open after launch instead of shipping and hoping.

Monitoring an LLM app means watching two layers at once. The first is the ordinary service dashboard any backend needs — latency percentiles, error rates, throughput — the vital signs on the hospital monitor. But AI systems have a second, sneakier layer: quality and cost signals that a normal service simply doesn't have. Your app can be returning HTTP 200s in 200 milliseconds and still be quietly getting worse — refusing more often, escalating more, giving answers a judge would score lower. The service looks healthy while the product rots. That's why you track quality proxies (escalation rate, thumbs-down, low-confidence rate) and cost (token usage, cache-hit rate) right alongside the classic metrics.

The signal that trips people up most is drift — the slow, silent kind of failure with no error message attached. Nothing crashes; the world just shifts out from under your assumptions. Users start asking about a new product you have no context for, so retrieval quality quietly falls. Or the model provider ships an update and your carefully-tuned prompt now behaves subtly differently. Drift is like a photograph left in sunlight: no single day looks different from the last, but compare against last month and the colours have visibly faded. You catch it by tracking a rolling baseline and alerting when a metric leaves its expected band. The mistake is monitoring only outputs. Drift usually shows up in your inputs first — the distribution of questions shifting — so watching input patterns gives you an early warning before quality visibly cracks. The practical way to feed all of this is boring and powerful: emit one structured (JSON) log line per request, and let dashboards aggregate thousands of them into charts and threshold alerts.

drift = a slow slide out of the expected band — no error, just decay normal band (rolling baseline ± tolerance) lower threshold crosses band 🔔 alert fires quality healthy time → Drift is the failure with no error message. A quality metric can sit inside its normal band for weeks, then slowly slide — inputs shift, or a provider update nudges behaviour — until it crosses the threshold and an alert fires. You catch it with a rolling baseline, not a crash.
🗺️ How to read this diagram

Read the blue line left to right as time passing; watch where it sits relative to the shaded "normal" band and the dashed threshold underneath it.

  • The shaded blue band is the expected range for a metric — a rolling baseline plus a tolerance. While the line stays inside it (the green "quality healthy" stretch), nothing is wrong.
  • The blue line doesn't crash — it slowly slides down. The amber dot marks where it leaves the band; the dashed lower threshold is the line you alert on.
  • When the line crosses below the threshold (the red dot), the red "alert fires" box triggers. That gradual slide — not a spike — is drift: caused by input distribution shifting or a model/provider update changing behaviour under you.

In short: track a rolling baseline and alert when a metric leaves its band. Watching inputs too (question mix) warns you before the output quality visibly cracks.

SignalWatch for
Latency (p50/p95/p99)slow tails, timeout spikes
Error & refusal raterising 429/5xx, model refusals
Token usage & costcost creep, prompt bloat, cache-hit drop
Quality proxiesescalation rate, thumbs-down, low-confidence rate
Driftinput distribution shift; a model/provider update changing behaviour
Try it — structured, queryable metrics
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).
▶ How this works

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.

  1. log = logging.getLogger("agent.metrics") gets a named logger — a channel you can route to a file or a log shipper.
  2. record(**fields) accepts any keyword fields and writes them as one JSON object. **fields means 'collect all the named arguments into a dictionary', so you can log whatever matters without changing the function.
  3. log.info(json.dumps({"kind": "llm_call", **fields})) tags every line with kind: llm_call and merges your fields in — producing one tidy, machine-readable record.
  4. 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.

🔗 Used in the courseStructured JSON logging (P6) + the observability lab (Ch 6) produce exactly these signals; the DevOps agent's monitoring skill (Ch 8) reads Grafana/Kibana/CloudWatch to diagnose incidents from them. Drift detection turns "the model update changed our outputs" into an alert, not a surprise.

8 · Cost control & governance advanced

Every token you send and receive is metered, and the meter never sleeps. That's the fact that makes LLM apps unlike almost anything else you've deployed. A traditional service costs roughly the same whether it handles a clever request or a dumb one; an LLM app can bill you ten times more for one answer than another, and a single sloppy prompt shipped to production can turn a comfortable budget into a runaway invoice while you sleep. Cost isn't an afterthought you optimize later — for LLM apps it's a first-class production concern that belongs in the same conversation as latency and correctness.

The good news is that cost is controllable with a handful of well-understood levers, and it pays to know their rough order of impact. Prompt caching is usually the biggest single win (Ch 6): reused context — a long system prompt, a fixed set of instructions — gets charged at a fraction of the price on repeat calls. Model routing is the next lever and the one people most often skip: not every step deserves your most expensive model. Classification, routing, and easy lookups can go to a small cheap model (or the cache), while you reserve the frontier model (claude-opus-4-8) for the genuinely hard reasoning. Then token budgets per request cap the worst case, and batching amortizes overhead across many calls. The instinct to reach for is "cheapest tool that clears the quality bar," not "best tool for everything."

But there's a trap in cheapness, and it's why routing decisions must themselves be gated by evals: a cheaper path is only a saving if quality holds. Route a hard question to a weak model and you don't save money — you burn it on an answer that fails, gets escalated, and costs a human's time on top. That's why the right metric is cost-per-outcome, not cost-per-token; a "cheaper" prompt that halves your success rate is the most expensive change you could make. The other half of this section is governance — the auditability layer that answers "who deployed which prompt and model version, and why," and that puts a gate in front of every call for spend caps, model allowlists, and an incident kill switch. On a shared platform, owning cost and policy centrally is what keeps one team's experiment from draining everyone's budget or shipping an unvetted model into production.

route by difficulty — cheapest tool that clears the quality bar request how hard? router classify difficulty small / cheap classify · cache frontier model hard reasoning easy hard eval gate quality ok? cost-per-outcome, not cost-per-token — a wrong cheap answer costs more Model routing sends each task to the cheapest tool that still passes. Easy work (classification, routing, cache hits) goes to a small cheap model; only genuinely hard reasoning reaches the frontier model — and the cheap path is itself eval-gated so "cheaper" never quietly means "worse."
🗺️ How to read this diagram

Follow a request left to right: it arrives, a router judges its difficulty, and the two branches send it to different models.

  • The blue "request" box enters the purple "router", whose only job is to classify how hard the task is.
  • The upper branch ("easy") goes to the green "small / cheap" box — classification, routing, and easy lookups, or a cache hit. The lower branch ("hard") goes to the amber "frontier model" box, reserved for genuine reasoning.
  • The red "eval gate" box guards the cheap path: the dashed arrow means the cheap route is only allowed if quality still clears the bar. The bottom line states why — the right metric is cost-per-outcome, because a wrong cheap answer gets escalated and costs more than the frontier call would have.

In short: route easy → cheap, hard → frontier, and eval-gate the cheap path so savings never come at the cost of a failed answer.

The routing patternNot every step needs the top model. Route: a small/cheap model (or cache) for classification, routing, and easy answers; the frontier model (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

Practice
  1. Extend the tracker to also record git commit + prompt version, and print a table sorted by pass_rate / cost.
  2. Add a rollback integration test to the PromptRegistry: publish v1, v2, roll back, assert get() returns v1.
  3. Write a gate.py that fails if pass_rate regresses OR cost_per_1k rises > 20% vs baseline.
  4. Add a /ask endpoint variant that streams tokens (A3) via StreamingResponse.
  5. 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.

System design: productionize an LLM feature

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
▶ How this works

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.

  1. SHIP behind an eval gate — quality is checked before every deploy, and regressions block the release (section 5).
  2. MONITOR latency, cost, quality, and drift with dashboards and alerts (section 7).
  3. CONTROL cost with prompt caching, model routing, and token budgets (section 8).
  4. VERSION the model and prompt (pinned), and roll out safely with canary/shadow deploys and instant rollback (sections 3 and 6).
  5. 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.

An eval gate that blocks a bad deploy (CI)

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")
▶ How this works

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.

  1. BASELINE = 0.90 is the minimum acceptable pass rate.
  2. gate(pass_rate, tol=0.02) compares the new score to the baseline, allowing a small tolerance so ordinary noise doesn't block releases.
  3. 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.
🏗️ Where this landsYou now have the production layer the capstone assumes. Head to the AI DevOps Engineer (Ch 8) — its CI/CD, Docker, monitoring, evals, and phased rollout are this track applied end-to-end — or revisit Ch 6 to see caching, observability, and deployment with this depth behind them.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · What's different about the LLM-app lifecycleBeginner

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.

Exercise 2 · Version prompts like code (prompt registry)Intermediate

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 register stores 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.

Exercise 3 · CI eval gate — block a regressing promptAdvanced

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.

Exercise 4 · Choose a deployment strategy for a prompt/model changeExpert

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.

Exercise 5 · Detect prompt/output drift in productionProfessional

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.

Exercise 6 · Cost & governance guardrails as the platform ownerIndustry scenario

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

✓ Knowledge check

The lesson says LLM-app MLOps versions different artifacts than classic MLOps. What are they, and why the shift?

Show answer
Because you usually call a model API rather than train weights, your versioned/tested/monitored artifacts become prompts, tool definitions, retrieval configs, and eval suites — the rigor (reproducibility, testing, monitoring, rollback) is the same but the objects differ.
✓ Knowledge check

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?

Show answer
A non-zero exit code tells CI the step failed, which blocks the deploy — that's how quality can't silently regress; the small tolerance absorbs ordinary run-to-run noise so normal wobble doesn't block every release.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in