AI EngineeringZero to ProductionHome·About·What’s new·Contact
MLOps & LLMOps · Chapter O3

Deployment, Containerization & Scaling of LLM Systems

Your app calls an API — so why is deployment hard? Because LLM apps are I/O-bound, slow-per-request, and metered by the token. Those three facts change how you containerize, scale, and stay up. This chapter is the deployment playbook built around them.

⏱️ ~55 min🚢 Hands-on🎯 Intermediate

Learning objectives

  • Explain why LLM apps scale differently from CPU-bound services.
  • Containerize an LLM app cleanly (config, secrets, health checks).
  • Choose a deployment target and a scaling strategy that fits token-metered, I/O-bound work.
  • Apply safe rollout patterns — blue/green, canary — with an eval gate.
  • Design for graceful degradation when the model is slow, rate-limited, or down.
Reuses your DevOps foundationsDocker, Kubernetes, and CI you may know already (and the repo's own k8s/terraform work). This chapter is what's different about deploying an LLM app on top of that. Async/streaming mechanics are in A3; caching and guardrails in Chapter 6.

Why LLM apps scale differently intermediate

Here's the mistake almost every team makes on their first LLM deploy. They reach for the exact playbook that has served them well for a decade of web services: containerize it, put it behind a load balancer, set up CPU-based autoscaling, ship it. Then traffic climbs, the dashboards look fine — CPU is at 15%, memory is flat — and yet users are staring at spinners and the bill is exploding. Nothing in the classic playbook is lying; it's just measuring the wrong thing. An LLM app doesn't fail the way a normal service fails, so it can't be scaled the way a normal service is scaled.

The reason is a single, simple difference in where the time goes. Picture a normal web service as a short-order cook: a request comes in, the CPU chops and fries and plates it in a few milliseconds, and moves to the next. The cook is busy — CPU is the bottleneck, and adding cooks (cores, replicas) directly buys you throughput. Now picture an LLM app as a waiter who takes your order and then walks it to a kitchen in another building, and stands there waiting for the meal. The waiter is barely working — they're mostly idle, blocked on a slow kitchen they don't control. Hiring more waiters doesn't make the kitchen cook faster; it just puts more people standing around waiting. That "standing around waiting" is the whole story of LLM deployment.

Concretely: a normal web service is CPU-bound and answers in milliseconds. An LLM app spends most of each request waiting for a slow, token-metered upstream — the model API — which it does not own and cannot speed up. That inversion (from CPU-bound-and-fast to I/O-bound-and-slow) is why the intuitions you built on classic services quietly betray you here, and why the levers that do work will feel backwards at first. Three facts flow from that difference, and every deployment decision in this chapter is downstream of one of them.

CPU-bound web service · fast request CPU busy (ms) the box does the work → add cores helps I/O-bound LLM app · slow request waiting onmodel API (s) the box is idle → more cores don't help same request budget: green = CPU · red = blocked upstream time → CPU blocked waiting Where the time goes decides how you scale. A CPU-bound service spends the request working (green), so cores and replicas buy throughput. An LLM app spends it waiting on the model API (red) — the CPU is idle, so replicas mostly add idle waiters.
🗺️ How to read this diagram

Compare the two halves left and right of the dashed line, then read the bar at the bottom as a single request's timeline.

  • The left half is a classic web service: a request arrives and the green box ("CPU busy") does the work in milliseconds — the machine is the bottleneck, so adding cores helps.
  • The right half is the LLM app: the request arrives, then sits in the red box ("waiting on model API") for seconds — the CPU is idle, blocked on an upstream you don't control.
  • The bottom bar lays one request's time budget flat: a sliver of green (your CPU) and a long red stretch (blocked waiting). That red dominance is why "add more cores" barely moves the needle.

In short: The LLM request is mostly red — idle, waiting on the API — so the levers that help are the ones that reduce or overlap that waiting, not the ones that add CPU.

FactConsequence for deployment
I/O-bound — mostly waiting on the modelOne process can handle many concurrent requests with async; you don't need a core per request (A3)
Slow per request — seconds, not msLong-lived connections; streaming; generous timeouts; graceful shutdown must drain in-flight calls
Metered per tokenScaling up multiplies cost linearly; caching & model-tiering matter more than raw replicas
The counter-intuitive partBecause the work is I/O-bound, throwing CPUs at it barely helps — your app is idle, waiting on the API. The levers that actually move the needle are concurrency (async), caching (don't pay for repeat calls), and model choice (route easy work to a cheaper tier). Adding replicas is the last lever, not the first.

Lab O3.1 · Containerize an LLM app intermediate

"It works on my machine" is the four most expensive words in software. You test locally, everything is green, you hand it off — and it dies in staging because the server has Python 3.10 and you built on 3.12, or a library version drifted, or an environment variable you set months ago and forgot is quietly missing. Every hour lost to that gap is an hour spent debugging the environment instead of the code. A container is the industry's answer: instead of shipping your app and hoping the destination matches your laptop, you ship the app and its entire environment as one sealed box.

The mental model that makes containers click is the shipping container they're named after. Before standardized steel boxes, loading a ship meant hand-stacking barrels, sacks, and crates of wildly different shapes — slow, and every port did it differently. The steel container changed everything: it doesn't matter what's inside, because the outside is always the same size and shape, so any crane, truck, or ship handles it identically. Your Docker image is that box. Kubernetes, your laptop, a CI runner, a teammate's Mac — they don't need to know or care what's inside; they just run the box, and it behaves the same everywhere. That's the reproducibility pillar from O1, made physical at the deployment layer.

So if containers are old news, what's new for an LLM app? Three things, and they're exactly where beginners get burned. First, secrets: your app needs an API key, and the single most common (and most dangerous) reflex is to bake it into the image — which, as you'll see below, ships your key to anyone who can pull the box. Second, config: the model ID, timeouts, and limits should come from the environment, not be hard-coded, so you can retune them without rebuilding. Third, and most subtly, a health check that means something. The naive instinct is to make /health call the model to "really" check it works — but that means every health probe (which fire constantly) costs you money and hits your rate limit, so your monitoring can knock over the very service it's watching. The rule is a cheap liveness check that proves the app is up, and probes the expensive dependency rarely and separately. Keep those three — secrets, config, honest health — in mind as you read the Dockerfile; the base recipe is ordinary Docker, and those three are the LLM-shaped edits on top.

the image · ordinary Docker + 3 LLM edits base: python-slim + your code config from env (model, timeouts) cheap /health (app up, not model) NO API key baked in — layers are forever API key (secret) docker run -e injected at run time only key lives in the running container, never the image One sealed box, three LLM-shaped edits. Config and the honest health check live inside the image (green); the API key stays outside and is injected at docker run (amber) — because image layers are forever, a baked key is a leaked key.
🗺️ How to read this diagram

Read the big left box as the image you build, then follow the amber arrow to see what deliberately stays out of it.

  • The large outer box is the Docker image. The top grey band is the ordinary base (python-slim + your code) — nothing LLM-specific.
  • The two green bands are two of the LLM edits that belong inside: config read from the environment, and a cheap /health that proves the app is up without calling the paid model.
  • The red band states the rule: no API key is baked in, because image layers are permanent. The amber key on the right is injected separately at docker run -e, so the secret lives only in the running container — never shipped in the box.

In short: Config and health ride inside the image; the secret rides in at run time. That separation is the whole LLM-container discipline.

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.
Lab O3.1
DockerfileFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# API keys come from the environment at RUN time — NEVER baked into the image
EXPOSE 8000
# stream-friendly server; graceful shutdown drains in-flight requests
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
▶ How this works

A Dockerfile is a recipe for building an image — a self-contained box holding your app plus everything it needs to run. Ship that box anywhere and it behaves the same. Read it top to bottom: each line is one build step, and the steps run in order.

  1. FROM python:3.12-slim picks the starting point: a small (slim) Linux image that already has Python 3.12. Everything you add layers on top of this. WORKDIR /app then says "do the rest of the work inside the /app folder."
  2. COPY requirements.txt . copies just the dependency list in, then RUN pip install ... -r requirements.txt installs those libraries while building the image. Copying the list first (before the rest of the code) lets Docker reuse the cached install step when only your code changes — a big speed-up.
  3. COPY . . copies the rest of your app into the image. The # comment lines aren't run — they're notes to humans. The key one says the API key comes from the environment at run time and is NEVER baked into the image (see the red box below for why that matters).
  4. EXPOSE 8000 documents that the app listens on port 8000. CMD ["uvicorn", "app:app", ...] is the command that runs when the container starts — it launches uvicorn, an async web server that fits the slow, streaming, I/O-bound work of an LLM app.

What the output means: This file doesn't print anything on its own — it's a build recipe. Running docker build against it produces an image; docker run then starts a container from that image, launching uvicorn on port 8000.

Try this: Build and run it, passing the key at run time: docker build -t myapp . then docker run -e ANTHROPIC_API_KEY=... -p 8000:8000 myapp. The -e injects the secret now, so it never lives inside the image — exactly what the comment insists on.

Container concernThe LLM-app rule
SecretsInject the API key at runtime (env / secrets manager). Never bake it into the image or commit it (C2, T1)
ConfigModel ID, timeouts, and limits from env — so you change them without rebuilding
Health checkA cheap /health that checks the app is up — don't call the paid model on every probe
Graceful shutdownOn SIGTERM, stop accepting new requests but let in-flight (slow!) calls finish
Image sizeSlim base; no model weights inside (you're calling an API) → small, fast-pulling images
The classic leakBaking ANTHROPIC_API_KEY into a Docker image (via ENV or a copied .env) ships your key to anyone who can pull the image — and image layers are forever. Keys are injected at run time, full stop. This is the deployment-layer version of the T1 secrets rule.

Scaling strategy intermediate

The reflex, when traffic spikes, is to add more servers. It's such a deeply worn groove that most engineers reach for it without thinking — the dashboard turns red, you bump the replica count, the red goes away. For a CPU-bound service that reflex is correct. For an LLM app it's the most expensive possible answer, and often it doesn't even work. This section is about un-learning that reflex and replacing it with an ordered checklist — because with LLM apps, the order you try the levers in matters as much as the levers themselves.

Think of it like a leaking water bill. If your bill triples, you don't first phone the utility to negotiate a bigger pipe — you first check for a running tap you forgot about, then a dripping faucet, then whether you're watering the lawn at noon when half evaporates. You exhaust the free and cheap fixes before you pay for more capacity. Scaling an LLM app is the same discipline: the cheap levers not only cost less, they frequently remove the need for the expensive one entirely. Add replicas first and you've paid the utility for a bigger pipe while the tap was still running.

Here's why each lever sits where it does. Caching is first because a cache hit is an answer you already paid for — serving it again is nearly free, and for repetitive workloads that alone can absorb most of your growth. Concurrency (async) is second because — remember the waiter — your process is mostly idle, blocked on the API, so a single replica can juggle dozens or hundreds of in-flight requests before it's truly full; you're using capacity you already own. Model tiering is third: much of your traffic is easy, and routing the easy work to a smaller, cheaper, faster model tier cuts both cost and latency without touching your infrastructure at all. Only when those three are exhausted do you reach for replicas — and the reason it's genuinely last, not just conventionally last, is the one that catches people: adding a replica is the only lever whose cost scales linearly with traffic (every replica multiplies your token bill), and it can't lift the real ceiling, which is usually the provider's rate limit. Ten replicas all hammering an API that 429s at a fixed rate don't serve more users — they just fail faster and in parallel. Read the ranked diagram below as a checklist to work down, and treat the urge to jump straight to the bottom as the exact instinct this section exists to break.

1 · Cache 2 · Concurrency (async) 3 · Route to cheaper model 4 · Add replicas (autoscale) — last cheapest,biggest win most cost,use last Try the levers in order. Cache repeat calls (free hits). Max out concurrency per replica with async (I/O-bound work loves it). Route easy requests to a cheaper model tier. Only then add replicas — each one multiplies your token bill, so it's the last resort, not the reflex.
🗺️ How to read this diagram

This picture ranks the four ways to handle more traffic, cheapest and most effective at the top, most expensive at the bottom. The whole point: try them in this order, top to bottom.

  • Bar 1 · Cache — reuse answers you've already paid for. Repeat calls become free hits, so this is the first and biggest win.
  • Bar 2 · Concurrency (async) — because LLM work is mostly waiting on the API, one server can juggle many requests at once. You squeeze far more out of each replica before adding hardware.
  • Bar 3 · Route to a cheaper model — send easy requests to a smaller, faster, cheaper model tier, saving both cost and time.
  • Bar 4 · Add replicas (autoscale) — last. Notice it's the widest bar and marked "use last": each extra copy of your app multiplies your token bill, so it's the last resort. The labels on the right ("cheapest, biggest win" up top; "most cost, use last" at the bottom) restate the ranking.

In short: Work down the list, not up. Cache and concurrency are nearly free and go first; spinning up more replicas is the reflex to resist, because it's the only lever that scales your cost linearly with traffic.

LeverWhy it's high on the listCourse link
CachingCached calls cost ~nothing; prompt caching cuts input cost ~90%Ch 6, O2 gateway
Concurrency (async)One replica serves many waiting requests; fits I/O-bound workA3
Model tieringHaiku/Sonnet for easy work slashes cost & latencyC1
Horizontal replicasReal capacity, but linear cost — autoscale on queue depth/latencyk8s HPA
Autoscale on the right signalCPU-based autoscaling misfires for LLM apps — your pods are near-idle while blocked on the API, so CPU stays low even when overloaded. Scale on request concurrency, queue depth, or p95 latency instead. And remember the real ceiling is often the provider rate limit, not your pod count — more replicas hitting a 429 wall just fail faster.

Lab O3.2 · Safe rollouts with an eval gate advanced

Imagine the most dangerous change you can ship — the one most likely to break production without a single failing test. In a normal app, that's a gnarly code change, and your instincts are good: a code bug usually throws an error, fails a test, or crashes a pod, and something screams. Now here's the twist that makes LLM apps different and unnerving: your scariest change isn't code at all. It's a one-line edit to a prompt, or a swap of the model version — and neither of those trips a single alarm. The app still returns 200 OK. The tests still pass. Nothing crashes. The quality of the answers just quietly gets worse, and you find out days later from user complaints or a churn report.

The reason is that LLM behavior is statistical, not logical. A code change is deterministic — given the same input it does the same thing, so a test that checks that thing catches the regression. A prompt change shifts a distribution: the model was getting 92% of cases right, now it gets 84%, but any single response still looks plausible, so no unit test flags it. Think of the difference between a bridge with a snapped cable (obvious, alarms everywhere) and a bridge whose concrete is slowly weakening (invisible until it fails catastrophically). Ordinary CI catches snapped cables. It is blind to weakening concrete. That blindness is exactly the gap safe-rollout patterns are built to close.

The defense is two gates, and the key idea is that they catch different failures, so you need both. The first is the eval gate (Ch 5, O1): before the change reaches anyone, you score it against a fixed "golden set" of known cases and block the deploy if quality drops — this catches the regressions you can anticipate. But a golden set can only test what you thought to put in it, so the second gate is a canary: you send the change to a small slice of real live traffic and watch quality, cost, and latency, catching the failures your test set never imagined. A common mistake is treating these as redundant and running only one — but the eval gate can't see novel real-world inputs, and the canary can't protect the users in its slice from a regression the golden set would have caught before launch. The single most important mindset shift in this section: treat a prompt edit with the same ceremony as a code deploy — version it, gate it, canary it. "It's just a prompt, ship it" is the sentence that precedes most silent LLM quality incidents.

eval gategolden set canary 5%watch metrics full rolloutor roll back a change ships only if it clears offline evals AND the live canary Two gates before everyone sees it. First the offline eval gate — the change must not regress the golden set. Then a canary: route a small traffic slice to the new version and watch quality, cost, and latency before going 100% (or rolling back).
🗺️ How to read this diagram

This is the safe path a prompt or model change travels before every user sees it. Read it left to right, following the arrows — the change only advances if each stage passes.

  • Left box · eval gate — before anything ships, the change is scored against a fixed golden set of test cases (offline, no real users). If quality drops, it's blocked here.
  • Arrow → means "only if it passed." Middle box · canary 5% — the change goes live for a small slice of real traffic (5%) while you watch quality, cost, and latency. A problem now hits only a few users, not everyone.
  • Arrow → then right box · full rollout (or roll back) — if the canary looks healthy, ramp to 100%; if not, roll back and no one else is affected.
  • The line across the top says it plainly: a change ships only if it clears the offline evals AND the live canary — two independent gates.

In short: Treat a prompt tweak like a code deploy. "It's just a prompt, ship it" skips both gates — and a bad prompt can silently wreck quality just like a code bug. Two gates catch it cheaply.

PatternWhat it doesGood for
Eval gateBlock deploy if the golden set regressesEvery prompt/model change (Ch 5)
Blue/greenFull new version alongside old; flip traffic instantlyFast rollback; infra changes
CanarySmall % to new version; ramp if healthyCatching live regressions cheaply
ShadowNew version runs on real traffic, output discarded & comparedValidating a change with zero user risk
Treat prompt changes like code deploysA prompt edit can regress quality as badly as a code bug — and silently. Version it (O1), run it through the eval gate, canary it. "It's just a prompt tweak, ship it" is how teams wake up to a quality incident with no idea what changed.

Designing for graceful degradation advanced

Accept one uncomfortable fact and this whole section falls into place: the model API will fail you, and not rarely. Not "might," not "in a bad month" — it will rate-limit you at your next traffic spike, it will slow to a crawl under its own load, it will have an outage you read about on someone else's status page, and it will occasionally refuse or return garbage. The junior instinct is to treat these as freak events to be surprised by. The senior instinct is to treat them as the weather: entirely predictable in aggregate, so you build for them up front. The question is never "what if the model fails?" It's "when the model fails — and it will — what does my user see?"

The idea has a name from engineering: graceful degradation, and the everyday example is your car. When a bulb burns out, the car doesn't refuse to start; a warning light comes on and you keep driving. When the anti-lock brakes fail, you still have ordinary brakes. A well-built system sheds capability in layers under stress instead of dropping dead all at once. The opposite — the thing you're designing against — is the brittle failure: one dependency hiccups and the whole app throws a 500 or spins forever. For an LLM app the model isn't a nice-to-have on the side; it's on the critical path of nearly every request, which makes it the single most likely thing to take you down. So resiliency here isn't optional polish you add if there's time — it's core architecture.

The practical move is to enumerate the failure modes before the incident and pre-decide the degraded response for each, because 3 a.m. during an outage is the worst possible time to be inventing one. A 429 rate limit means back off and retry, queue the work, shed load, or fall back to a smaller model — never just error. High latency means stream the partial output you have and show real progress, not a spinner that lies about being close. A provider outage is what the circuit breaker is for: stop hammering a service that's down, fail fast, and fail over to a secondary provider through your gateway. A refusal or bad output means checking stop_reason and returning a safe fallback message instead of crashing on an empty response. And a cost spike means budget caps that throttle and alert before the bill runs away. Notice the through-line: every row in the table below maps a specific, foreseeable failure to a specific, pre-built response — and the resiliency machinery (retry with backoff, the circuit breaker) is exactly what you built in A6, now earning its keep on the critical path.

brittle: one hiccup, whole app dies model fails 500 / spinner user sees a hard error graceful: shed capability in layers model fails 429 → back off · queue · fallback slow → stream partial · progress outage → circuit-break · fail over user gets a usable answer Brittle vs graceful. Left: the model hiccups and the whole app throws a 500 or hangs. Right: each foreseeable failure has a pre-decided fallback, so the app sheds capability in layers and the user still gets something usable.
🗺️ How to read this diagram

Contrast the two halves across the dashed line: both start with "model fails," but they end very differently.

  • The left half is the brittle path — one dependency fails and it goes straight to the red box: a 500 error or an endless spinner. The user is stuck.
  • The right half is graceful degradation. The same failure branches by type through the amber layers — a 429 backs off and queues or drops to a smaller model, high latency streams partial output, a full outage trips the circuit breaker and fails over.
  • The green box at the bottom is the payoff: whichever layer catches it, the user still gets a usable answer instead of a crash.

In short: Pre-decide a fallback for each failure mode and the app degrades in layers (amber → green) instead of dropping dead (red). The table below is that map, row by row.

FailureGraceful response
429 rate limitRetry with backoff (SDK does this); queue; shed load or fall back to a smaller model (C2, A6)
High latencyStream partial output; set a timeout; show progress rather than a spinner that never ends
Provider outageCircuit breaker; fail over to a secondary model/provider via the gateway (O2, A6)
Refusal / bad outputDetect stop_reason; return a safe fallback message, don't crash (C2)
Cost spikeBudget caps in the gateway; alert & throttle before the bill runs away (O4)
The circuit breaker earns its keep hereThe circuit breaker and retry/backoff you built in A6 exist precisely for this: when the model is failing, stop hammering it, fail fast, and recover automatically. In an LLM app that external dependency is your critical path — resiliency isn't optional polish, it's core.

Common pitfalls expert

PitfallFix
Baking the API key into the imageInject at runtime; never commit or ENV it
Autoscaling on CPUScale on concurrency / queue depth / p95 latency
Adding replicas as the first scaling moveCache → concurrency → model tier → replicas last
Health check that calls the paid modelCheap liveness check; probe the dependency separately & sparingly
Shipping a prompt change with no gateVersion it; run the eval gate; canary it
No plan for provider 429/outageBackoff, circuit breaker, fallback, budget caps
Killing pods without drainingGraceful shutdown lets slow in-flight calls finish

Exercises expert

Exercise O3.1 — Containerize & run

Context: Shipping an LLM app means containerising it without baking the API key into the image — a secret in a layer is a secret leaked to anyone who pulls the image.

Your task: Wrap a small FastAPI-plus-Claude app in a Dockerfile, pass the API key at run time, and prove the key is not baked into the image.

Requirements:

  • Build the app into an image with a Dockerfile
  • Pass ANTHROPIC_API_KEY via -e at run time, not at build
  • Confirm the running container answers requests
  • Inspect docker history and confirm the key is absent from every layer

💡 Hint: Anything on a build-time ARG or ENV lands in the image history — inject secrets only at docker run.

Exercise O3.2 — Scaling plan

Context: A sudden 10× traffic spike is where the lever ordering pays off — and where you discover the provider rate limit is the real ceiling that no number of replicas can raise.

Your task: Write the order in which you would apply the four scaling levers under 10× traffic, what you would measure after each, and when the provider rate limit becomes the binding constraint.

Requirements:

  • Order the levers from cheapest to most expensive
  • State the metric you would watch after each lever (hit rate, p95, cost/req, 429s)
  • Identify the point where 429s mean you have hit the provider limit
  • Say what you do then (higher tier, fallback provider, queue/shed load)

💡 Hint: Replicas past the provider limit just fail faster — the fix at that point is capacity or a second provider, not more pods.

Show a sample answer

Cache (measure hit rate) → raise per-pod concurrency (measure p95, error rate) → route easy calls to Haiku (measure cost/req) → add replicas (watch for 429s). When 429s appear, you've hit the provider limit — request a higher tier, add a fallback provider, or queue/shed load. Replicas past that point just fail faster.

Exercise O3.3 — Degradation drill

Context: A graceful-degradation spec is what stands between a bad minute and an incident. None of your failure modes should surface as a stack trace or an infinite spinner.

Your task: For your own app, write exactly what the user sees in each failure mode: rate-limited, model slow, provider down, and refusal.

Requirements:

  • Cover all four failure modes
  • Specify a concrete user-visible response for each
  • Ensure none is a stack trace or an endless spinner
  • Frame the result as a reusable degradation spec

💡 Hint: Write it from the user's seat — every mode should map to a message or a fallback, not a raw error.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Rank the four scaling levers by costBeginner

Context: LLM apps scale differently from classic services. The levers, cheapest-win first, are cache, concurrency (async), model tier, and replicas — and replicas are the last resort because they multiply cost and still hit the provider rate limit.

Your task: Given a jumbled list of the four scaling levers, return them in the order you should reach for them.

Requirements:

  • Encode the canonical lever order (cache, concurrency, model tier, replicas)
  • Sort an arbitrary input list into that order
  • Keep it pure-stdlib and deterministic
  • Demonstrate on a shuffled list

💡 Hint: Caching reuses answers you already paid for, so it sits first; replicas are last because they scale cost linearly and don't lift the provider ceiling.

Show solution

Encode the ranked levers (pure stdlib):

ORDER = ["cache", "concurrency", "model tier", "replicas"]

def rank_levers(levers):
    return sorted(levers, key=lambda x: ORDER.index(x))

print(rank_levers(["replicas", "cache", "model tier", "concurrency"]))
# ['cache', 'concurrency', 'model tier', 'replicas']

Reach for the cheapest lever first: caching reuses answers you already paid for, async concurrency exploits the I/O-bound wait, model-tiering routes easy work to a cheap model, and only then do you add replicas — which multiply cost linearly and hit the provider rate limit anyway.

Exercise 2 · Cache hit-rate turns into dollars savedIntermediate

Context: Caching is the top lever because it reuses paid answers. Because LLM cost is metered per token, a good hit rate cuts spend before you touch tiers or replicas.

Your task: Given request volume, per-request cost, and a cache hit rate, compute the monthly spend with and without the cache and the percent saved.

Requirements:

  • Model cache hits as costing ~0
  • Compute monthly spend at a given hit rate
  • Compare against a zero-hit-rate baseline
  • Report the percent saved
  • Show that a 60% hit rate cuts spend ~60%

💡 Hint: Only the miss fraction incurs model cost — the saving tracks the hit rate almost directly.

Show solution

The cache savings model (pure arithmetic, runnable):

def monthly_cost(requests, cost_per_req, hit_rate):
    paid = requests * (1 - hit_rate)      # cache hits cost ~0
    return round(paid * cost_per_req, 2)

reqs, cost = 1_000_000, 0.01
no_cache = monthly_cost(reqs, cost, hit_rate=0.0)
cached   = monthly_cost(reqs, cost, hit_rate=0.6)
saved = round(100 * (no_cache - cached) / no_cache)
print(f"no cache: ${no_cache:,.2f}   60% hit: ${cached:,.2f}   saved: {saved}%")

A 60% hit rate is a 60% cut in model spend before you touch replicas or tiers. Because LLM cost is metered per token, reusing a paid answer is the single biggest scaling win — which is why it sits at the top of the lever list.

Exercise 3 · Size replicas with Little's Law (not CPU)Advanced

Context: Autoscaling on CPU is wrong for I/O-bound LLM apps — pods sit idle while blocked on the API. The right signal is request concurrency, and Little's Law converts arrival rate and latency into the concurrency you must serve.

Your task: Use Little's Law to size in-flight concurrency from arrival rate × latency, then compute the replicas needed given a per-pod concurrency.

Requirements:

  • Compute in-flight requests as arrival rate × average latency
  • Divide by per-pod concurrency and round up to whole replicas
  • Use seconds consistently for latency
  • Demonstrate with a concrete load (e.g. 50 req/s, 2s latency, 20 slots/pod)
  • Note that the provider rate limit is often the real ceiling above this number

💡 Hint: Little's Law is L = λ × W — arrival rate times time in system gives the concurrency; replicas follow by division.

Show solution

Little's Law sizes the fleet from load, not CPU (pure stdlib):

import math

def replicas_needed(req_per_sec, avg_latency_s, concurrency_per_pod):
    # Little's Law: in-flight requests L = arrival rate * time in system
    in_flight = req_per_sec * avg_latency_s
    return math.ceil(in_flight / concurrency_per_pod)

# 50 req/s, each request holds a connection ~2s (seconds, not ms), 20 async slots/pod
print("in-flight:", 50 * 2, "-> replicas:", replicas_needed(50, 2.0, 20))
# in-flight: 100 -> replicas: 5

The right autoscaling signal is request concurrency / queue depth / p95 latency — never CPU, which stays low while a pod waits on the model. Little's Law converts arrival rate and latency into the concurrency you must serve, and that divides into replicas. The provider rate limit is often the real ceiling above this number.

Exercise 4 · The p99 tail, not the mean, sets your SLOExpert

Context: Users feel the tail, not the mean. Chaining sequential model calls compounds each hop's tail, so a multi-hop agent's p99 balloons far past its average — and the SLO you promise is a p99.

Your task: Compute p50/p95/p99 over a latency sample and show why a 4-hop sequential path inflates the tail versus a 1-hop path.

Requirements:

  • Implement a percentile function over a latency list
  • Model one hop as mostly fast with an occasional slow tail
  • Model four sequential hops as the sum of four independent draws
  • Report p50/p95/p99 for both the 1-hop and 4-hop paths
  • Make it reproducible with a fixed seed

💡 Hint: Summing independent per-hop latencies is what compounds the tails — design against p99 by cutting hops, parallelising, or streaming partial output.

Show solution

Percentiles and tail compounding (pure stdlib, runnable):

def pctl(latencies, p):
    s = sorted(latencies)
    k = max(0, math.ceil(p/100 * len(s)) - 1)
    return s[k]

import math, random
random.seed(7)
# one model hop: mostly ~700ms, occasional 2200ms tail
one_hop = [random.choice([700]*9 + [2200]) for _ in range(1000)]
# four sequential hops: tails compound (sum of four independent draws)
four_hop = [sum(random.choice([700]*9 + [2200]) for _ in range(4)) for _ in range(1000)]

for name, data in [("1-hop", one_hop), ("4-hop", four_hop)]:
    print(f"{name}: p50={pctl(data,50)}  p95={pctl(data,95)}  p99={pctl(data,99)}")

Averages hide the pain: a 4-hop agent compounds each hop's tail, so the p99 balloons far past 4x the mean. Design against the tail — plan once and parallelize, stream partial output, or set per-hop timeouts — because the SLO you promise is a p99, not an average.

Exercise 5 · Gate a rollout: eval gate then canaryProfessional

Context: Safe rollout is two independent gates before 100%: an eval gate on the golden set that stops known regressions, then a canary on a slice of live traffic that catches the failures the golden set could not.

Your task: Implement the two-gate rollout: block on a golden-set regression, then require the canary metrics to hold within tolerance before ramping.

Requirements:

  • Block at the eval gate when the golden pass rate is below the gate
  • Compare canary quality and error rate against a live baseline
  • Allow a small tolerance on the canary metrics
  • Return RAMP / ROLLBACK / BLOCK as distinct outcomes
  • Demonstrate all three outcomes

💡 Hint: The two gates are independent — the eval gate runs before any user sees the change, the canary runs on real traffic; both must pass to ramp.

Show solution

The two-gate rollout as a decision function (pure stdlib):

def rollout_decision(golden_pass, golden_gate, canary, baseline, tol=0.02):
    if golden_pass < golden_gate:
        return "BLOCK at eval gate -- golden set regressed, do not deploy"
    # canary: quality must not drop, error rate must not rise beyond tolerance
    q_ok = canary["quality"] >= baseline["quality"] - tol
    e_ok = canary["error_rate"] <= baseline["error_rate"] + tol
    if q_ok and e_ok:
        return "RAMP -- canary healthy, proceed to full rollout"
    return "ROLLBACK -- canary regressed on live traffic"

base = {"quality": 0.90, "error_rate": 0.01}
good = {"quality": 0.90, "error_rate": 0.012}
bad  = {"quality": 0.83, "error_rate": 0.05}
print(rollout_decision(0.94, 0.90, good, base))   # RAMP
print(rollout_decision(0.94, 0.90, bad,  base))   # ROLLBACK
print(rollout_decision(0.85, 0.90, good, base))   # BLOCK at eval gate

The eval gate stops known regressions before any user sees them; the canary catches the live-only failures the golden set could not. Two cheap, independent gates in front of every deploy is what makes shipping LLM changes routine instead of scary.

Exercise 6 · Author the graceful-degradation playbook as the on-call leadIndustry scenario

Context: Production will hit rate limits, latency spikes, provider outages, refusals, and cost spikes. As on-call lead your job is to write, before the incident, the map from each failure to a user-visible degradation — never a hard error.

Your task: Author the graceful-degradation playbook: map each failure mode to the degraded response the system should show, routed through the gateway.

Requirements:

  • Cover rate limits, high latency, provider outage, refusals, and cost spikes
  • Map each to a concrete degradation (backoff/queue, stream partial, circuit breaker/failover, safe fallback, budget cap)
  • Route responses through the gateway (fallback, circuit breaker, budget cap)
  • Handle an unknown failure explicitly
  • Print the full playbook

💡 Hint: Every foreseeable failure should degrade to something a user can live with — pair the map with a pre-built kill switch so an outage is a downgrade, not a 500.

Show solution

The failure-to-response map from the lesson (pure logic):

PLAYBOOK = {
    "rate_limit_429":  "retry w/ backoff; queue; shed load; fall back to cheaper model",
    "high_latency":    "stream partial output; set timeout; show progress",
    "provider_outage": "circuit breaker; fail over to secondary provider via gateway",
    "refusal_bad_out": "detect stop_reason; return safe fallback message",
    "cost_spike":      "budget cap in gateway; alert & throttle",
}

def degrade(failure):
    return PLAYBOOK.get(failure, "unknown failure -- add it to the playbook after")

for f in PLAYBOOK:
    print(f"{f:16s}: {degrade(f)}")

Every foreseeable failure should degrade to something a user can live with, routed through the gateway (fallback, circuit breaker, budget cap). The lead's job is to write this map before the incident and wire a kill switch, so an outage is a graceful downgrade, not a 500.

✓ Checkpoint — you can move on when you can…

  • Explain why LLM apps are I/O-bound and what that changes.
  • Containerize an app with runtime secrets and a real health check.
  • Order the scaling levers and justify replicas being last.
  • Roll out a prompt/model change through an eval gate and canary.
  • Specify graceful behavior for rate limits, latency, and outages.
🏗️ Toward the capstoneThe AI DevOps Engineer ships as a container with its key injected at runtime, autoscales on concurrency (not CPU), and every change to its prompt or safety policy clears the eval gate (Chapter 8d) and a canary before full rollout. When Claude is rate-limited, it degrades — queues work, falls back — rather than dropping incidents. That's this chapter, applied to the capstone. See the evals + go-real build →

Knowledge check check yourself

✓ Knowledge check

LLM apps are I/O-bound, so what does the lesson say is the correct order of scaling levers, and why are replicas last?

Show answer
Cache repeat calls, then max out concurrency with async, then route easy work to a cheaper model tier, and only then add replicas. Replicas are last because each one multiplies the per-token bill linearly, whereas caching/concurrency are nearly free.
✓ Knowledge check

Why is CPU-based autoscaling the wrong signal for an LLM app, and what should you scale on instead?

Show answer
The pods are near-idle while blocked waiting on the model API, so CPU stays low even when the app is overloaded — CPU autoscaling misfires. Scale on request concurrency, queue depth, or p95 latency; and note the real ceiling is often the provider rate limit, not pod count.
© 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