Scale & cost war-stories
CS1–CS4 designed LLM systems that work. This one is about what breaks when they meet real traffic, real bills, and real rate limits — and how the hard-won fixes lean on Anthropic’s actual features. You will triage six representative production war-stories — an uncached shared prefix melting the budget, a latency SLO blown under load, a rate-limit thundering-herd outage, a nightly job too slow and too dear, a silent quality regression after a model change, and a context-window blowup — and for each: read the symptom, find the systemic root cause, ship the durable fix (prompt caching, model tiering + streaming, backoff + circuit breaker, Message Batches, pinned model + eval gate, context trimming), and do the money and latency math yourself in a small offline lab. These are realistic composites, not specific customers; the fixes are real.
Learning objectives
- Read a production scaling problem the way an on-call tech lead does: sort it into cost, latency, reliability, or quality, then reach for the fix that class demands.
- Turn each war-story's symptom into a systemic root cause — a missing cap, cache, gate, backoff, or budget — never "someone should have known."
- Apply Anthropic's real public levers in the right place: prompt caching on shared prefixes, model tiering (Haiku/Sonnet/Opus) + streaming for latency, backoff+jitter for rate limits, Message Batches for bulk, pinned models + eval gates for quality, and history trimming/summarization for context.
- Do the money and latency math yourself with small offline models, so you can estimate a fix's payoff before you ship it.
- Grade your own scale readiness against a staff-level rubric.
How to triage a scale problem essential
Every incident in this lesson starts the same way: something is on fire and you have minutes, not hours. The fastest way to the right fix is to classify the problem before you touch anything. Almost every LLM scaling failure is one of four shapes — the bill is too high, responses are too slow, the API is throwing errors under load, or the answers got worse — and each shape has a small set of usual root causes and a canonical fix. The triage tree below is the map; the six sections after it walk one representative story per branch (with cost and quality getting the extra depth they earn in practice).
This is the decision the first ten minutes of any scaling incident should follow. The mistake under pressure is to start editing code before you know which of four problems you actually have.
- Symptom → Which class? — sort what you're seeing into exactly one of four shapes: the bill spiked (cost), responses are slow (latency), the API is throwing errors under load (reliability), or answers got worse (quality). The class picks the toolbox.
- Usual root cause — each class has a small set of repeat offenders: an uncached shared prefix, one heavy model for everything, retries with no backoff, an unbounded history. Name the systemic gap, not a person.
- Canonical fix — reach for the real feature that class demands: prompt caching, model tiering + streaming, backoff + circuit breaker, Message Batches, pin + eval gate.
- Kill the class — the last box is the one that matters: a platform default (token ceiling, shared client with backoff, eval-on-change) so the same story can't recur next quarter.
In short: A fix touches the last box; a patch stops at the second-to-last. If your write-up ends at "we added a cache here," you patched the instance — keep going to the default that kills the class.
| If the symptom is… | The class | Usual root cause | Canonical fix (real feature) |
|---|---|---|---|
| Bill spiked, no traffic change | Cost | Re-sending a huge shared prefix uncached; blind retries | Prompt caching on the prefix; conditional retries |
| p95 latency blew the SLO under load | Latency | One heavy model for every request; no early bytes | Model tiering + streaming; batch the async work |
| 429s, then a cascading outage | Reliability | Immediate retries → thundering herd; no breaker | Backoff+jitter + circuit breaker + a queue |
| Nightly job too slow / too dear | Cost/throughput | Running bulk work through the realtime API | Message Batches (~50% off, 24h window) |
| Answers silently got worse | Quality | Mutable model alias; no eval on change | Pin the model + an eval gate on every change |
| Context-length errors mid-conversation | Cost/reliability | Unbounded history appended every turn | Trim / summarize / cache the history to a budget |
Notice the last column of the tree: the real work is not the hot-fix, it's the platform default that makes the class impossible next quarter. A cache you added to one endpoint is a fix; a default token ceiling and a shared API client with backoff baked in are what stop the sequel. Each story below ends on that class-killing move.
1 · Cost blowout — a huge shared prefix, uncached essential
What finance saw: a document-QA feature answered every user question by stuffing the same ~50k-token policy manual into the prompt as context, then adding the user's short question. Traffic was flat, but as usage grew to ~1,000 calls a day the input-token bill dominated everything else. Nobody had changed the code; the cost simply scaled linearly with a prefix that never changed. (All figures illustrative.)
Root cause. The expensive part of each request — the 50k-token manual — was identical across every call and was being re-sent and re-processed from scratch each time. That is exactly the situation prompt caching exists for: mark the stable prefix with cache_control, and after the first call the model reads it from cache at a fraction of the price.
Fix. Add a cache breakpoint on the shared prefix. Anthropic bills a cache write at roughly 1.25× the base input price (5-minute TTL) and a cache read at about 0.1× — so a prefix reused across many calls is close to a tenth of its uncached cost. The lab does the money math for the exact scenario above (verify the multipliers in current docs):
cache_savings.pydef cache_savings(prefix_tokens, unique_tokens, calls,
in_price=3.0, write_mult=1.25, read_mult=0.10):
"""Cost of a big shared prefix WITHOUT vs WITH prompt caching.
Prices are per-million input tokens; write_mult/read_mult are the
published cache multipliers (5-min write ~1.25x, read ~0.1x).
Verify current multipliers in Anthropic's docs."""
M = 1_000_000
per_uncached = (prefix_tokens + unique_tokens) * in_price / M
no_cache = per_uncached * calls
# cached: first call writes the prefix, the rest read it cheaply
first = (prefix_tokens * write_mult + unique_tokens) * in_price / M
rest = (prefix_tokens * read_mult + unique_tokens) * in_price / M * (calls - 1)
with_cache = first + rest
saved = no_cache - with_cache
pct = saved / no_cache * 100
return no_cache, with_cache, saved, pct
no_cache, with_cache, saved, pct = cache_savings(
prefix_tokens=50_000, unique_tokens=500, calls=1_000)
print(f"no cache : ${no_cache:8.2f}")
print(f"cached : ${with_cache:8.2f}")
print(f"saved : ${saved:8.2f} ({pct:.1f}%)")
no cache : $ 151.50
cached : $ 16.67
saved : $ 134.83 (89.0%)
This puts a dollar figure on the biggest cost lever in the lesson: caching a large, unchanging prefix instead of re-sending it full-price on every call.
- no_cache is the naive bill: every one of
callsrequests pays full input price for the wholeprefix + uniquetokens. - with_cache splits into two parts — the first call pays a cache write (the prefix billed at ~
1.25×), and the rest pay a cache read (the prefix at ~0.10×). The short unique question is full-price every time either way. - The multipliers are Anthropic's published cache pricing (5-minute TTL) — the code leaves them as parameters so you can drop in the current numbers from the docs.
What the output means: Re-sending a 50k-token manual 1,000 times costs ~$151; caching it drops that to ~$17 — about an 89% saving, because the expensive part is now read cheaply.
Try this: Shrink prefix_tokens to 500 and watch the saving nearly vanish — caching only pays when the shared prefix is large relative to the unique part.
Lesson. When a large, stable chunk of the prompt is reused across requests, caching it is usually the single biggest cost lever you have — here it erases ~89% of spend. The class-killing move is to make caching the default for any endpoint with a shared system prompt or knowledge prefix, and to alarm on the cache-hit rate so a refactor that accidentally breaks the prefix (and the cache) shows up as a cost regression, not a silent one.
2 · Latency SLO missed under load intermediate
What users saw: a p95 latency SLO of "first token in under 1s, done in under 3s" held fine in staging and blew up under real load. Every request — a one-line classification, a short rewrite, a hard multi-step analysis — was routed to the largest model, and the client waited for the entire response before showing anything. Under a traffic peak the tail latency ballooned and the SLO went red. (Latency numbers illustrative.)
Root cause. Two mismatches. First, one heavy model for everything: most traffic was simple and did not need Opus-class reasoning, but paid Opus-class latency anyway. Second, no streaming: users stared at a spinner for the full generation instead of seeing tokens as they arrived.
Fix. Route by task complexity across the Haiku / Sonnet / Opus tiers — cheap-and-fast Haiku for the simple bulk, Sonnet for the middle, Opus only for the genuinely hard tail — and turn on streaming so time-to-first-token drops to near-instant regardless of tier. The offline simulation compares everything-on-Opus against tiered routing on a realistic traffic mix (mostly simple with a thin hard tail):
tiering_sim.pyTIERS = {"haiku": 0.25, "sonnet": 3.0, "opus": 15.0} # illustrative $/Mtok output
def route(task_complexity):
"""Cheapest tier that clears the task's complexity bar (0..1)."""
if task_complexity < 0.34:
return "haiku"
if task_complexity < 0.75:
return "sonnet"
return "opus"
def simulate(tasks, out_tokens=800):
"""Compare 'everything on Opus' vs tiered routing + a p95 latency proxy."""
M = 1_000_000
all_opus = flat = 0.0
lat_flat, lat_tier = [], []
base = {"haiku": 300, "sonnet": 700, "opus": 1500} # ms, illustrative
for c in tasks:
all_opus += out_tokens * TIERS["opus"] / M
tier = route(c)
flat += out_tokens * TIERS[tier] / M
lat_flat.append(base["opus"])
lat_tier.append(base[tier])
p95 = lambda xs: sorted(xs)[max(0, int(len(xs) * 0.95) - 1)]
return all_opus, flat, p95(lat_flat), p95(lat_tier)
# mostly-simple traffic with a thin tail of hard tasks (typical of real workloads)
tasks = ([0.1, 0.2, 0.3] * 40 + [0.5, 0.6] * 45 + [0.9] * 8 + [0.95] * 2)
all_opus, tiered, p95_flat, p95_tier = simulate(tasks)
print(f"all-opus cost : ${all_opus:7.2f} p95 latency {p95_flat} ms")
print(f"tiered cost : ${tiered:7.2f} p95 latency {p95_tier} ms")
print(f"cost cut : {(1 - tiered / all_opus) * 100:.0f}%")
all-opus cost : $ 2.64 p95 latency 1500 ms
tiered cost : $ 0.36 p95 latency 700 ms
cost cut : 86%
This models the latency-and-cost fix from story 2: route each request to the cheapest model that can handle it, instead of sending everything to Opus.
route(complexity)is the policy — simple tasks go to Haiku, mid tasks to Sonnet, only the genuinely hard tail to Opus. In real systems the complexity signal comes from a classifier or heuristics.simulatetallies two costs (all-Opus vs tiered) and collects a crude per-request latency for each, then takes the p95 — the tail latency your SLO actually cares about.- The task mix is deliberately realistic: mostly simple, with a thin hard tail. That's why p95 improves — 95% of traffic no longer waits behind the biggest model.
What the output means: Tiering cuts cost ~86% AND drops p95 latency from 1500ms to 700ms, because the common case stops paying the worst case's price and time.
Try this: Push the hard-task fraction up (add more 0.9s) until the p95 latency climbs back to 1500ms — that's the point where the tail is thick enough to dominate the SLO again.
Lesson. Tiering cuts both the bill (here ~86%) and the p95 latency (1500ms → 700ms), because the common case stops paying for the worst case. Streaming is orthogonal and free: it fixes perceived latency (time-to-first-token) even when total generation time is unchanged. The class-killing move is a small routing layer with an explicit complexity signal, plus streaming on by default — so the SLO is defended by architecture, not by hoping traffic stays light. Genuinely async work (no user waiting) should leave the realtime path entirely — that is story 4.
3 · Rate-limit thundering-herd outage advanced
What users saw: the assistant went from slow to fully down for minutes, twice, in a self-inflicted cycle. A traffic spike pushed request volume past the account's rate limit, the API began returning 429 Too Many Requests, and the client retried every failure immediately. The retries stacked onto new requests — a thundering herd — so the system generated more load exactly when it needed to generate less, and the provider throttled harder.
Root cause. The client had no load-shedding behavior under failure. Retries were unconditional and instantaneous, making the failure mode positive feedback: more failures → more retries → more load → more failures. The rate limit did precisely what a rate limit is for; the outage was amplified by our own retry loop. The naive fix — "just retry the 429s" — is what caused the second dip.
Fix. Retry politely. Anthropic's guidance is exponential backoff on 429/5xx, and adding jitter spreads retries out so they don't synchronize into a fresh herd. Pair it with a circuit breaker that trips after N consecutive failures and fast-fails during a cool-down (so the client stops hammering a dependency that is already down and lets it recover), and a queue in front of the API to smooth spikes into a steady drain. The lab simulates the difference deterministically:
backoff_sim.pyimport random
def backoff_delays(attempts, base=0.5, cap=30.0, jitter=True, seed=0):
"""Exponential backoff with FULL JITTER, as recommended for 429/5xx:
delay = random(0, min(cap, base * 2**attempt)). Deterministic here via seed."""
rng = random.Random(seed)
delays = []
for a in range(attempts):
window = min(cap, base * (2 ** a))
delays.append(round(rng.uniform(0, window), 3) if jitter else round(window, 3))
return delays
class CircuitBreaker:
"""Trip after `threshold` consecutive failures; fast-fail during cool-down
so the client stops hammering a dependency that is already down."""
def __init__(self, threshold=3):
self.threshold = threshold
self.fails = 0
self.open = False
def record(self, ok):
if ok:
self.fails = 0
self.open = False
else:
self.fails += 1
if self.fails >= self.threshold:
self.open = True
return self.open
print("no-backoff retries (thundering herd): all fire at t=0 ->", [0.0] * 5)
print("full-jitter delays (spread out) :", backoff_delays(5))
cb = CircuitBreaker(threshold=3)
seq = [False, False, False, False, True]
states = ["OPEN" if cb.record(ok) else "closed" for ok in seq]
print("breaker states over", seq, "->", states)
no-backoff retries (thundering herd): all fire at t=0 -> [0.0, 0.0, 0.0, 0.0, 0.0]
full-jitter delays (spread out) : [0.422, 0.758, 0.841, 1.036, 4.09]
breaker states over [False, False, False, False, True] -> ['closed', 'closed', 'OPEN', 'OPEN', 'closed']
This is the reliability fix from story 3, made concrete: retry politely so a spike doesn't turn into a self-inflicted outage.
backoff_delayscomputes exponentially growing windows (base·2^attempt, capped) and picks a random delay inside each window — that's full jitter. The seed makes it deterministic here so the output is stable.- Contrast the two printed lines: no-backoff retries all fire at
t=0together (the thundering herd); jittered delays fan out across time so the dependency can breathe. CircuitBreakercounts consecutive failures and flips to OPEN after the threshold, fast-failing during cool-down so the client stops hammering a service that's already down. A success resets it.
What the output means: The breaker trips OPEN on the 3rd consecutive failure and stays open through the 4th, then a success closes it — exactly the load-shedding the naive retry loop lacked.
Try this: Also honor a retry-after header when the API sends one — it tells you the polite delay directly, overriding your computed backoff.
Lesson. Under stress a client must shed load, not add it. Immediate retries fire at t=0 together; full-jitter delays fan out across the window so the dependency can recover, and the breaker cuts the feedback loop entirely once failures cluster. Also respect the retry-after header when the API sends one. The class-killing move: make backoff+jitter and a breaker the default in the shared API client, so no service can ship a naive retry loop again (verify your account's current rate limits in Anthropic's docs).
4 · Bulk nightly job too slow and too expensive advanced
What the platform team saw: a nightly job re-classified and summarized ~200k documents by firing them one at a time through the realtime API. It competed with daytime interactive traffic for rate-limit headroom, took hours, and paid full realtime token prices for work that no user was waiting on. (Volume and cost illustrative.)
Root cause. A throughput-shaped job was forced through a latency-shaped channel. The realtime API is optimized for "answer now"; a bulk offline pass wants "answer all of these within a window, as cheaply as possible." That is exactly what the Message Batches API is for.
Fix. Move the job to Message Batches: submit all items as one batch, let it process asynchronously within its window (up to 24 hours), and retrieve results matched by custom_id. Anthropic prices batch token usage at about 50% of the standard per-token cost, and it runs on separate capacity so it stops fighting interactive traffic for rate limits. The lab does the cost comparison (verify the discount and window in current docs):
batch_cost.pydef nightly_job_cost(n_items, in_tok, out_tok,
in_price=3.0, out_price=15.0, batch_discount=0.50):
"""Realtime vs Message Batches for a bulk nightly job.
Batches run within a 24h window at ~50% of standard token cost.
Verify the current discount/window in Anthropic's docs."""
M = 1_000_000
per_item = (in_tok * in_price + out_tok * out_price) / M
realtime = per_item * n_items
batched = realtime * (1 - batch_discount)
return realtime, batched
realtime, batched = nightly_job_cost(n_items=200_000, in_tok=1_200, out_tok=600)
print(f"realtime : ${realtime:9.2f}")
print(f"batched : ${batched:9.2f}")
print(f"saved : ${realtime - batched:9.2f} (50% off)")
realtime : $ 2520.00
batched : $ 1260.00
saved : $ 1260.00 (50% off)
This prices the story-4 fix: move a bulk, no-one-is-waiting job off the realtime API and onto Message Batches.
per_itemis the standard realtime cost of one document (input + output tokens at their respective prices).realtimeis that times the whole 200k-item job;batchedapplies the ~50% batch discount Anthropic publishes for the Message Batches API.- The discount is a parameter (
batch_discount=0.50) so you can confirm the current figure and 24-hour window in the docs and re-run.
What the output means: The nightly job drops from ~$2,520 to ~$1,260 — half price — and, not shown here, it also stops competing with interactive traffic for rate-limit headroom.
Try this: Stack story 1 on top: if the 200k items share a big instruction prefix, cache it inside the batch and the two savings multiply.
Lesson. If nobody is waiting on the response right now, it probably does not belong on the realtime path. Batching halves the token bill here and removes the job from your interactive rate-limit budget entirely. The class-killing move is a rule of thumb baked into design review: any bulk, deadline-not-latency job goes through Batches — nightly re-indexing, backfills, evals, bulk classification. Combine it with story 1 (cache the shared prefix inside the batch) and the savings stack.
5 · Silent quality regression after a model change professional
What users saw: nothing dramatic — the bot still answered fluently — but over a week, a downstream parser that expected strict JSON began silently dropping records, and "the bot gave a wrong/mis-formatted answer" tickets climbed. The app referenced its model by a mutable alias rather than a pinned version, and when the underlying model rolled, its output shape drifted just enough to break the strict consumer. No deploy on our side, no error, no page — a slow bleed.
Root cause. The application depended on a mutable model reference and had no eval gate to detect a quality change. "Someone should have pinned it" is the symptom, not the cause; the real cause is a release process that let a core dependency change with no review and no automated quality check.
Fix. Pin the exact model version so any upgrade is a deliberate, reviewed change — a PR, not a surprise — and build a small eval suite of golden inputs with an expected output shape/quality, run on every model change and gating rollout on it. A regression is then caught in CI, not by users a week later. The lab is a miniature eval gate: it diffs a pinned model against a candidate and blocks the rollout when the pass rate drops below threshold:
eval_gate.pyGOLDEN = [
("refund order 88", "json"),
("cancel my plan", "json"),
("what's my balance", "json"),
("update address", "json"),
]
def run_model(prompt, model):
"""Stand-in for a real API call: returns the output SHAPE the model produced.
The 'candidate' model regresses to prose on 2 of 4 goldens."""
if model == "pinned":
return "json"
return "prose" if prompt in ("cancel my plan", "update address") else "json"
def eval_gate(model, threshold=0.95):
"""Fraction of goldens whose shape still matches. Block rollout below threshold."""
passed = sum(run_model(p, model) == want for p, want in GOLDEN)
score = passed / len(GOLDEN)
verdict = "PASS -> rollout" if score >= threshold else "FAIL -> block rollout"
return score, verdict
for model in ("pinned", "candidate"):
score, verdict = eval_gate(model)
print(f"{model:10} score={score:.2f} {verdict}")
pinned score=1.00 PASS -> rollout
candidate score=0.50 FAIL -> block rollout
This is a miniature version of the story-5 fix: an automated eval gate that blocks a model change when quality drops, instead of finding out from users a week later.
GOLDENis a tiny eval set — inputs paired with the output shape you require (here, strictjson). Real suites check correctness and format on many more cases.run_modelstands in for a real API call. The pinned model returns JSON for everything; the candidate regresses to prose on two cases — the silent formatting drift that broke the downstream parser.eval_gatescores the pass rate and compares it to athreshold. Below it, the verdict is block rollout — the gate you run in CI on every model change.
What the output means: The pinned model scores 1.00 and passes; the candidate scores 0.50 and is blocked automatically — a caught regression instead of a week-long bleed.
Try this: Add more goldens or raise the threshold toward 1.0 to make the gate stricter — the tradeoff is more false blocks, so tune it to how much drift you can tolerate.
| Before (aliased model) | After (pinned + eval gate) | |
|---|---|---|
| Model selection | "latest" alias — can change under us | pinned version, changes on a PR |
| Upgrade trigger | silent, provider-driven | explicit, reviewed by us |
| Change detection | support tickets, days later | eval suite on every change |
| Blast radius | all traffic, immediately | caught in CI before rollout |
Lesson. Treat the model like any other versioned dependency: pinned, changelogged, and eval-gated. The candidate above passes only 50% of the goldens and is blocked automatically — the difference between a caught regression and a week-long silent one. The class-killing move is making "pin + eval-on-change" a platform rule, so no service can consume a mutable model reference. See CH6 and the evals material for building the golden set.
6 · Context-window blowup from unbounded history tech-lead
What users saw: a long-running chat assistant appended the entire conversation to every request. Short sessions were fine; long ones grew until requests hit the context limit and threw — and every call before that got steadily slower and more expensive as the history bloated. The failure was correlated with your best users (the ones who chat the longest), which is the cruelest place for a bug to live. This is the tech-lead story because the naive fixes each have a sharp edge.
Root cause. Unbounded history with no packing strategy. Sending the whole transcript every turn makes per-request cost and latency grow with session length and eventually exceeds the window entirely. The systemic gap: there was no token budget on the assembled prompt and no policy for what to do when the history exceeds it.
Fix. Bound the assembled context to a token budget: keep the newest turns verbatim (recency matters most), and replace the older overflow with a single rolling summary so nothing important is simply forgotten. Then cache the stable head of the prompt (story 1) so the retained context is cheap to re-send. The offline lab implements the packing policy against a hard budget:
pack_history.pydef pack_history(turns, budget, summary_cost=120):
"""Bound an unbounded chat history to a token BUDGET.
Keep newest turns verbatim; replace the older overflow with one summary.
turns: list of (role, tokens), oldest first. Returns
(kept, n_summarized, total_tokens)."""
kept, running = [], 0
for role, tok in reversed(turns): # newest first
if running + tok <= budget:
kept.append((role, tok))
running += tok
else:
break
kept.reverse()
dropped = turns[:len(turns) - len(kept)]
if dropped:
running += summary_cost # one rolling summary replaces them
return kept, len(dropped), running
turns = [("user", 400), ("assistant", 900)] * 30 # 60 turns, ~39k tokens
raw = sum(t for _, t in turns)
kept, summarized, total = pack_history(turns, budget=4_000)
print(f"raw history : {raw:,} tokens over {len(turns)} turns")
print(f"kept verbatim : {len(kept)} turns")
print(f"summarized away : {summarized} older turns -> 1 summary")
print(f"packed total : {total:,} tokens (fits budget 4,000)")
raw history : 39,000 tokens over 60 turns
kept verbatim : 6 turns
summarized away : 54 older turns -> 1 summary
packed total : 4,020 tokens (fits budget 4,000)
This implements the story-6 fix: bound an ever-growing conversation to a token budget so it can never blow the context window.
- It walks the turns newest-first and keeps them verbatim until adding the next one would exceed
budget— recency is what matters most for coherence. - Everything older than the kept window is the overflow. Rather than drop it silently, one rolling summary (costed at
summary_costtokens) stands in for all of it, so context isn't simply forgotten. - The returned total is guaranteed to sit at or near the budget regardless of how long the raw conversation grows — the context-limit error becomes structurally impossible.
What the output means: A 39,000-token, 60-turn transcript packs down to ~4,020 tokens: 6 recent turns kept verbatim, 54 older ones folded into a single summary.
Try this: Combine with caching (story 1): cache the stable summarized head so the retained context is not just small but nearly free to re-send each turn.
Lesson. Never send an unbounded prompt. A budget with a keep-newest + summarize-the-rest policy turns a 39k-token transcript into ~4k tokens that fits, stays cheap, and keeps the thread coherent. Which levers combine: trim to a budget, summarize the overflow so context isn't lost, and cache the stable prefix so the retained history is nearly free to re-send. The class-killing move is a shared context-assembly component with a mandatory budget — so no feature can ship an unbounded prompt, and the context-limit error becomes structurally impossible.
| Dimension | Meets bar | Above bar |
|---|---|---|
| Triage speed | You can name the class (cost/latency/reliability/quality) of an incident. | You reach for the class's canonical fix reflexively and can estimate its payoff with a back-of-envelope model before shipping. |
| Root cause vs symptom | You find a cause deeper than "it broke." | You land on a systemic gap (missing cap/cache/gate/backoff/budget), never "someone forgot." |
| Right tool for the class | You know caching, tiering, backoff, batches, pinning, trimming exist. | You apply each to the class it fits — caching a stable prefix, batches for async bulk, tiering for latency+cost — and know when NOT to (don't batch a user-facing call). |
| Cost/latency numeracy | You can read a bill and a p95 chart. | You model savings/latency offline (the labs here) and set alarms on cache-hit rate, spend, and p95 so regressions page you, not surprise you. |
| Kills the class, not the instance | You fix the incident. | You add a platform default (token ceiling, shared client with backoff, eval-on-change, mandatory context budget) so the whole class cannot recur. |
Score each dimension Meets or Above. All five at least Meets = you can run an LLM system at scale without it running you. If "kills the class" is only at "I fixed the incident," you shipped a patch, not a fix — go back to the triage tree and ask what platform default would have made the story impossible.
A team caches a 40k-token system prompt and sees a big saving for a week, then cost quietly climbs back to the uncached level even though traffic is flat. What most likely happened, and how would the labs here have caught it sooner?
Show answer
Your nightly evaluation job and your interactive chat both call the API, and during peak hours the chat starts getting rate-limited. A junior engineer proposes raising the retry count on the chat client. Why is that the wrong lever, and what actually fixes it?
Show answer
Exercise CS5.1 — Triage and cost a real (or composite) incident
Context: The real work in a scaling incident is not the hot-fix — it's the platform default that makes the class impossible next quarter. Working one incident end to end, with your own numbers, is how you prove the fix.
Your task: Take one scaling problem and work it end to end: classify it with the triage tree, write the one-line systemic root cause, pick the canonical fix and the real Anthropic feature it maps to, adapt the matching offline lab with your numbers to compute the payoff, and name the platform default that kills the class.
Requirements:
- Classify the incident as cost, latency, reliability, or quality
- Write a one-line systemic root cause — a missing cap/cache/gate/backoff/budget, never "someone forgot"
- Pick the canonical fix and the real Anthropic feature it maps to (caching, tiering, backoff, Batches, pin+eval)
- Adapt the matching offline lab with your own token counts, call volume, and prices (verify current prices in Anthropic's docs) and compute the payoff before shipping
- Name the platform default (token ceiling, shared client with backoff, eval-on-change, context budget) that kills the whole class
- Grade yourself against the rubric; if "kills the class" isn't at least Meets, you designed a patch — keep going
💡 Hint: A fix touches the last box of the triage tree; if your write-up ends at "we added a cache here," you only patched the instance.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every scaling incident starts the same way: something is on fire and you have minutes, not hours. The mistake under pressure is to start editing code before you know which of four problems you actually have.
Your task: For "our LLM feature suddenly costs 3× as much," write the triage steps you'd follow to find the cause before changing anything.
Requirements:
- Measure, don't guess: pull per-request token counts (input vs output), call volume, and model mix to find where the 3× is
- Segment cost by feature/endpoint/customer to localize the spike
- Form one hypothesis from the data (e.g. "input tokens per call doubled") and confirm it before acting
- Change one lever, then re-measure
- State the discipline: observe → localize → hypothesize → change one thing → re-measure
💡 Hint: Diagnose from the token and latency data, not intuition — the class of the problem picks the toolbox.
Show solution
- Measure, don’t guess: pull per-request token counts (input vs output), call volume, and model mix. Find where the 3× is — more calls, bigger prompts, or a pricier model?
- Segment: break cost by feature/endpoint/customer to localize the spike.
- Form one hypothesis from the data (e.g. “input tokens per call doubled”), then confirm it before acting.
- Change one lever, re-measure.
The discipline is observe → localize → hypothesize → change one thing → re-measure. Most scale regressions are diagnosed by looking at the token/latency data, not by intuition.
Context: The classic cost blowout: every request re-sends a large, identical system prompt plus reference docs, so cost scales linearly with a prefix that never changes. This is exactly what prompt caching exists for.
Your task: Diagnose a cost blowout where every request re-sends a large identical prefix: explain the fix, the expected effect, and the one thing that would defeat the cache.
Requirements:
- Fix: enable prompt caching (public Anthropic feature) on the stable prefix (system prompt + reference docs)
- Effect: repeated input tokens are billed at the reduced cached rate instead of full price on every call
- Quantify the effect as proportional to how much of each prompt is the shared, unchanging prefix
- Name what defeats the cache: anything that changes the prefix per request — a timestamp or per-user text injected before the stable block
- State the fix for that: keep the cached prefix byte-identical and put variable content after it
💡 Hint: Caching only pays when the shared prefix is large relative to the unique part — and one volatile token silently breaks it.
Show solution
Fix: enable prompt caching (public Anthropic feature) on the stable prefix (system prompt + reference docs). The repeated input tokens are then billed at the reduced cached rate instead of full price on every call.
Expected effect: large input-token cost reduction proportional to how much of each prompt is the shared, unchanging prefix.
What defeats it: anything that changes the prefix per request — e.g. injecting a timestamp or per-user text before the stable block. Keep the cached prefix byte-identical and put the variable content after it.
Context: A p95 latency SLO that held in staging blows up under real load because every request — trivial or hard — goes to the largest model and the client waits for the whole response. The fixes are layered and ordered.
Your task: Give the layered fixes, in priority order, for a p95 latency SLO missed under load, naming the metric each targets.
Requirements:
- Stream the response — improves time-to-first-token and perceived latency even if total time is unchanged
- Parallelize independent work (e.g. retrieval + other lookups) instead of serial awaits — cuts wall-clock latency
- Right-size the model per step — a smaller model on a cheap sub-step drops both latency and cost
- Add a concurrency limiter + queue so load sheds gracefully — protects p95 under spikes
- Cap output length where long generations aren't needed — output tokens dominate generation time
- State the order: streaming + parallelism first (cheap, big perceived win), then model sizing, then load control — re-measure p95 after each
💡 Hint: Fix perceived latency first with streaming, then attack the tail that the SLO actually measures.
Show solution
- Stream the response — improves time-to-first-token and perceived latency even if total time is unchanged.
- Parallelize independent work (e.g. retrieval + other lookups) instead of serial awaits — cuts wall-clock latency.
- Right-size the model per step — a smaller model on a cheap sub-step drops both latency and cost.
- Add a concurrency limiter + queue so load sheds gracefully rather than everything slowing at once (protects p95 under spikes).
- Cap output length where long generations aren’t needed — output tokens dominate generation time.
Order: streaming + parallelism first (cheap, big perceived win), then model sizing, then load control. Re-measure p95 after each; stop when you’re under SLO.
Context: A traffic burst hits the rate limit, the client retries every failure immediately, and the retries stack onto new requests — a self-inflicted thundering herd. The naive fix, "just retry the 429s," is what caused it.
Your task: Design the fix for a rate-limit thundering-herd outage where naive retries make it worse.
Requirements:
- Explain why naive retry worsens it: everyone retries immediately and in sync, keeping you pinned at the limit
- Add exponential backoff with jitter on 429s so retries don't resynchronize into a fresh herd
- Add a client-side concurrency limiter / token bucket sized under the provider limit — shape your own outbound rate
- Queue and shed load beyond capacity: enqueue or return a graceful "busy, try shortly" rather than hammering
- Add a circuit breaker that trips after sustained failures, cools down, then probes — stops the herd entirely
- Note that jitter is the small detail that actually breaks the synchronization
💡 Hint: Under stress the client must shed load, not add it — backoff + jitter + a breaker turn a cliff into graceful degradation.
Show solution
Why naive retry worsens it: everyone retries immediately and in sync, so the retry storm keeps you pinned at the limit — a self-inflicted thundering herd.
- Exponential backoff with jitter on 429s — spreads retries out in time so they don’t resynchronize.
- Client-side concurrency limiter / token bucket sized under the provider limit — shape your own outbound rate instead of discovering the ceiling by hitting it.
- Queue + shed load: beyond capacity, enqueue or return a graceful “busy, try shortly” rather than hammering.
- Circuit breaker: trip after sustained failures, cool down, then probe — stops the herd entirely.
The combination (backoff+jitter, self-limiting, breaker) turns a cliff into a graceful degradation. Jitter is the small detail that actually breaks the synchronization.
Context: A nightly job fires ~200k documents one at a time through the realtime API, competes with daytime traffic for rate-limit headroom, and pays full price for work no user is waiting on — a throughput job on a latency channel.
Your task: Redesign a bulk nightly job that runs for hours and costs a fortune as live real-time calls.
Requirements:
- Use batch processing for non-urgent work — async/batch APIs (public Anthropic feature) trade latency you don't need for a lower price
- Cache the shared prefix across the many similar records (same instructions each time)
- Right-size the model for the bulk classification/extraction step; reserve the big model for the hard minority
- Deduplicate and pre-filter — don't send records a cheap rule can resolve or that are duplicates
- Parallelize with a bounded worker pool respecting rate limits, and checkpoint so a failure resumes rather than restarts
- State the insight: a nightly job has slack on latency — spend it; batch + caching + right-sizing attack cost, parallelism + checkpointing attack wall-clock
💡 Hint: If nobody is waiting on the response right now, it probably doesn't belong on the realtime path.
Show solution
- Use batch processing for non-urgent work — asynchronous/batch APIs (public Anthropic feature) trade latency you don’t need for a lower price than real-time calls.
- Cache the shared prefix across the many similar records (same instructions each time).
- Right-size the model for a bulk classification/extraction step; reserve the big model for the hard minority.
- Deduplicate & pre-filter — don’t send records that a cheap rule can resolve or that are duplicates.
- Parallelize with a bounded worker pool respecting rate limits, and checkpoint so a failure resumes rather than restarts.
Key insight: a nightly job has slack on latency — spend it. Batch + caching + right-sizing attack cost; parallelism + checkpointing attack wall-clock time.
Context: Two linked incidents test whether you fix the class or just the instance: quality quietly drops after a model version switch, and an agent's unbounded chat history eventually overflows the context window.
Your task: For both — (a) a silent quality regression after a model change, and (b) a context-window blowup from unbounded history — give the fix and the prevention.
Requirements:
- (a) Root cause: the change shipped with no offline eval, so a regression went unnoticed until users complained
- (a) Fix now: roll back / pin the prior model, then build an eval set from recent real cases
- (a) Prevent: gate every model/prompt change in CI on that eval; no change ships without clearing the baseline — follow public migration guidance and re-test prompts
- (b) Root cause: every turn appends full history; long sessions eventually exceed the window
- (b) Fix: bound the history — a rolling window of recent turns plus a running summary of older ones; externalize the rest to retrieval
- (b) Prevent: track token count per session, enforce a budget, and summarize-and-compact before the limit, not after it errors
💡 Hint: Both share one lesson: put a bound on the unbounded thing — quality via an eval gate, context via a token budget — before it fails in prod.
Show solution
(a) Silent quality regression:
- Root cause: the change shipped with no offline eval, so a regression went unnoticed until users complained.
- Fix now: roll back / pin the prior model, then build an eval set from recent real cases.
- Prevent: gate every model/prompt change in CI on that eval (faithfulness, task metrics); no change ships without clearing the baseline. Follow public migration guidance — re-test prompts against the new model rather than assuming parity.
(b) Context-window blowup from unbounded history:
- Root cause: every turn appends full history; long sessions eventually exceed the window (errors or truncated, degraded answers).
- Fix: bound the history — keep a rolling window of recent turns plus a running summary of older ones; drop or externalize the rest to retrieval.
- Prevent: track token count per session and enforce a budget; summarize-and-compact before the limit, not after it errors.
Both share one lesson: put a bound on the unbounded thing (quality via an eval gate; context via a token budget) before it fails in production.
✓ Checkpoint — you can move on when you can…
- Sort any scaling incident into cost / latency / reliability / quality and name its canonical fix without looking it up.
- Explain why prompt caching helps a large stable prefix, and what silently breaks a cache hit.
- Justify model tiering + streaming as the latency fix, and know which async work belongs on Message Batches instead of the realtime path.
- Describe why immediate retries cause a thundering-herd cascade and how backoff+jitter + a circuit breaker stop it.
- Explain why a mutable model alias is a quality risk and how a pinned model + eval gate turns a silent regression into a blocked rollout.
- Bound an unbounded history to a token budget (keep-newest + summarize + cache) and state the platform default that makes context-limit errors impossible.
- For any story, name the class-killing platform default, not just the one-off patch.