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
Picture the moment this skill is for. It's 9:40 on a Tuesday, a dashboard is red, Slack has three threads, and someone from finance is asking why the API bill tripled overnight. Your instinct — everyone's instinct — is to open the codebase and start changing things. That instinct is the trap. Under pressure the fastest engineers do the opposite of what feels productive: they spend the first five minutes naming the problem before touching a single line, because the wrong fix applied confidently makes an outage longer, not shorter.
The reason a name helps so much is that LLM scaling failures aren't infinitely varied. Think of them like the four ways a car fails on the highway: it's guzzling fuel, it's crawling, the engine is cutting out, or it's steering wrong. You don't rebuild the engine when the tank is empty. Almost every production LLM incident sorts cleanly into one of four classes — cost (the bill is too high), latency (responses are too slow), reliability (the API is erroring under load), or quality (the answers got worse) — and each class has a tiny, well-known set of usual root causes and one canonical fix. Classification isn't bureaucracy; it's what turns an unbounded search over your whole system into a lookup in a table of four rows.
Here is the distinction that separates a patch from a fix, and it's the whole spine of this lesson. A symptom is what the dashboard shows ("cost is 3× up"). A root cause is the systemic gap that let it happen ("we re-send a 50k-token prefix uncached on every call") — and crucially, a systemic gap is never "someone should have known better." People forgetting is a constant of the universe; a resilient system assumes it. So the last and most important step in the tree below isn't the hot-fix — it's the platform default (a token ceiling, a shared client with backoff baked in, an eval that runs on every change) that makes the entire class of failure impossible next quarter. The common mistake is to stop one box too early: you add a cache to the one endpoint that broke, close the ticket, and the sequel ships from a different team three months later. As you read the tree, watch for that final column — it's where real engineering happens.
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
You have almost certainly written the code that causes this one. It's the most natural thing in the world: your feature needs the model to answer questions about a document — a policy manual, an API spec, a knowledge base — so you paste the document into the prompt as context and append the user's question. It works beautifully in the demo. Then it ships, usage grows, and the bill grows with it in a way nobody flagged, because the code never changed and no single request looks expensive. The blowout is invisible precisely because it's spread across thousands of individually-reasonable calls.
To feel why this is wasteful, use an analogy. Imagine a librarian who, every time you ask a question about a 300-page report, re-reads the entire report from page one before answering — even though it's the same report and you're the tenth person to ask today. That re-reading is the "input token" cost, and the model pays it in full on every single call. The document is the shared prefix: a large chunk of the prompt that is byte-for-byte identical across many requests, sitting in front of a small, unique tail (the question). The precise definition matters: it's not "big prompt" that's the problem, it's "big and repeated and stable." A prompt that's huge but different every time can't be cached; a prompt that's tiny but repeated isn't worth caching. The sweet spot — and it's an enormous one in practice — is a large stable block reused across calls.
The fix is prompt caching: you mark the stable prefix once, the provider stores its processed form, and subsequent calls read it back at a small fraction of the price instead of reprocessing it from scratch. Why is that allowed to be so much cheaper? Because the expensive work — turning those 50k tokens into the model's internal representation — was already done on the first call; the rest are just reusing it. The mistake this section is built to prevent isn't failing to cache so much as caching fragilely: because a cache hit requires the prefix to match exactly and sit at the very start of the prompt, dropping one volatile token into that block (a timestamp, a request id, the user's name) silently turns every call back into a full-price write — and because nothing errors, you won't notice for a week. Keep that fragility in mind as you read the money math below; the number it produces is real, but it evaporates the day someone interpolates a variable in the wrong place.
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):
Read it left panel then right panel; each horizontal bar is one API call, top-to-bottom in time.
- The left red bars are the naive world: every call re-sends the full 50k-token prefix at full price — the cost repeats on every row.
- The right side is caching: the amber bar is the first call paying a one-time cache write (~1.25×); the green bars below are every later call reading the same prefix back at ~0.1×.
- The small blue box after each prefix is the short unique question — it is full price in both worlds, so it is not where the money is.
- The payoff arrow: identical answers, but the repeated expensive part is now nearly free — about an 89% cut in this scenario.
In short: Pay for the big prefix once (write), then reuse it cheaply (reads); the saving grows with how large and how repeated the prefix is.
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
Here's the failure you'll defend against most often, because it hides so well. Everything is fast in staging. You test with a handful of requests, the p95 is comfortably under target, you ship. Then real traffic arrives — not more complexity, just more concurrency — and the tail latency balloons past your SLO while the median still looks fine. What makes this maddening is that no request got slower on its own; they started queueing behind each other, and the 95th-percentile user is the one waiting at the back of the line.
Two design choices almost always set the trap, and they're worth naming precisely because they feel like "keeping it simple" at the time. The first is one model for everything: you pick the most capable model so you never have to think about routing, and now a one-line "is this spam?" classification pays the same latency as a hard multi-step analysis. That's like sending every letter — a postcard and a legal contract alike — by armored courier. The second is no streaming: the client waits for the entire response to finish generating before showing the user anything, so even a fast answer feels slow because the screen sits blank behind a spinner. Note these are two different quantities — total generation time versus time-to-first-token — and confusing them is the common mistake. Streaming does nothing for total time; it transforms perceived latency, which is often the number users actually judge you on.
The fix has two independent levers. Model tiering routes each request to the cheapest model that can clear its complexity bar — a small fast model (Haiku) for the simple bulk, a mid model (Sonnet) for the middle, the heavy model (Opus) only for the genuinely hard tail — so 95% of traffic stops waiting behind the worst case's cost and latency. Streaming is orthogonal and nearly free: flip it on and time-to-first-token drops to near-instant regardless of which tier answered. The subtle thing the simulation below will show is that tiering helps the p95 specifically because real traffic is mostly-simple with a thin hard tail — when the tail is thin, moving it off the common path is exactly what rescues the percentile the SLO measures. If your traffic were uniformly hard, tiering would buy you far less, which is itself a useful thing to know before you build the router.
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):
Compare the two panels top to bottom; each panel shows the same traffic mix — a wide "simple bulk" band, a thinner "middle" band, and a thin "hard tail" — flowing down into a model.
- The left panel funnels every band into one red Opus box, so the simple bulk pays the same latency and cost as the hardest request — a p95 of 1500ms.
- The right panel is the router: the wide simple band goes green to Haiku, the middle to amber Sonnet, and only the thin band goes red to Opus.
- The band widths are the point — because the simple band is by far the widest, moving it off Opus is what pulls the 95th-percentile latency down and cuts cost ~86%.
- Streaming is orthogonal (not drawn): it improves time-to-first-token for whichever tier answers.
In short: Route the wide simple bulk to a cheap fast model and reserve the heavy model for the thin hard tail — the SLO is defended by where the mass of traffic goes.
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
This is the one where you cause your own outage — twice — with code that looks defensive. A traffic spike pushes you past your rate limit, the API returns 429 Too Many Requests, and your client does the "obvious" thing: it retries the failed request. Immediately. On every failure. What feels like resilience is actually pouring fuel on the fire, and the sickening part is that the second dip is usually caused by the fix a well-meaning engineer applies to the first ("let's just bump the retry count").
The mechanism has a name — a thundering herd — and an analogy makes it visceral. Picture a doorway that's briefly blocked. A calm crowd waits, then filters through. But if everyone shoves forward the instant they're refused, and shoves again the instant they're refused again, the doorway stays jammed by the very people trying to get through it. That's positive feedback: more failures trigger more retries, which add more load, which cause more failures. A rate limit is supposed to push back when you exceed it; the outage isn't the limit malfunctioning, it's your retry loop amplifying the limit's correct signal into a self-inflicted denial of service. Naming it as feedback (not "the API is flaky") is what points you at the real fix.
Retrying isn't wrong — retrying rudely is. The fix is to retry politely, and it stacks three ideas. Exponential backoff waits longer after each failure (0.5s, 1s, 2s, 4s…) so a struggling dependency gets breathing room instead of a fresh wave. Jitter — a random offset added to each delay — is the small detail that does the heavy lifting: without it, a thousand clients that failed at the same instant all back off by the same amount and retry in perfect synchrony, recreating the herd one step later; jitter smears them across the window so the load flattens. And a circuit breaker is the meta-move: after N consecutive failures it "trips," fast-failing every request for a cool-down period so the client stops hammering a service that's already down and lets it recover. The mistake to avoid is treating these as optional polish — under stress a client's job is to shed load, not add it, and a naive retry loop does the exact opposite. The simulation below makes the difference concrete and deterministic.
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.
Follow the arrows left to right — this is a chain of cause and effect, and every box makes the next one worse.
- The first red box, Spike → 429s, is the trigger: a traffic burst exceeds the rate limit and the API starts returning
429s. - The amber Retry, no backoff box is the fatal reflex — the client re-fires every failure instantly, so its "adds load" subtitle is the whole problem: the response to overload is more load.
- The middle boxes show the feedback tightening: a red Thundering herd (self-DoS) drives a Throttled harder box where the provider clamps the limit down further — each arrow feeds the loop rather than relieving it.
- The final red Outage box is the payoff the caption warns about: the arrows never bend back to recovery, so the system takes itself fully down.
In short: Immediate retries turn a rate-limit signal into a self-inflicted outage — the cascade only breaks if a box sheds load instead of adding it.
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
Some work is answered "right now" and some is answered "by morning" — and shipping the second kind through the first kind's door is one of the most common and most fixable money leaks in production. You have a nightly job: re-classify a few hundred thousand documents, backfill embeddings, re-run evals. Someone wires it up the way they already know how — loop over the items, fire each one at the realtime API, collect the answers. It works. It's also slow, expensive, and quietly sabotaging your daytime traffic, and none of that is obvious until you look.
The insight is a mismatch of shapes. Think of shipping options: overnight courier versus a standing freight contract. When a customer is waiting on the other end of a chat, you're paying for a courier — speed at a premium, and worth it. But a nightly batch has slack on latency: no human is staring at a spinner, and "done within 24 hours" is a perfectly good deadline. Forcing that job through the realtime path pays courier prices for freight you had all night to move. Worse, it competes with your interactive users for the same rate-limit headroom, so your throughput job actively degrades the latency-critical path it's running alongside — a throughput-shaped workload jammed into a latency-shaped channel.
The fix is the Message Batches API, which exists precisely for this shape: submit all the items at once, let them process asynchronously within a window (up to 24 hours), and collect the results matched back by an id you assign. Because the provider can schedule this work on its own terms across separate capacity, it's priced at roughly half the standard per-token cost — so you're paid to give up latency you never needed. Just as importantly, it runs off your interactive rate-limit budget, so the nightly job stops stealing headroom from daytime chat. The mistake to avoid is the mirror image: don't batch a user-facing call to save money — you'll trade a 50% discount for a response that arrives hours late to someone waiting now. The rule that kills the whole class is a design-review reflex: if nobody is waiting on the response this second, it probably doesn't belong on the realtime path. The lab does the money math.
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):
Follow the arrows out of the "Nightly job" box on the left into the two possible channels — the wrong one (middle) and the right one (right).
- The middle red path is today: the bulk job goes through the same Realtime API as interactive chat, sharing one rate-limit budget — so it pays full price and starves the chat path of headroom.
- The blue box above it is the interactive chat that suffers; note both arrows converge on the same realtime box — that convergence is the contention.
- The right green path is the fix: the job goes to Message Batches on separate capacity within a ≤24h window, at ~50% cost.
- The payoff line: because no user waits on the job, the latency it gives up becomes a discount — $2,520 realtime drops to $1,260 batched.
In short: If nobody is waiting on the answer right now, route it off the realtime channel to Batches — you trade unneeded latency for half the cost and stop fighting chat for rate limits.
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
The scariest incidents are the ones with no alarm. A crash pages you; a 500 shows up on a dashboard; a cost spike hits a budget alert. But this failure is a slow leak with no siren: the bot still answers, still sounds fluent, still returns something — and yet, over a week, a downstream parser starts dropping records and the "wrong/mis-formatted answer" tickets tick upward. Nobody deployed anything on your side. There was no error, no page, no obvious moment to point at. That silence is exactly what makes it a professional-tier story: catching it requires machinery you have to build before you need it.
The root of it is treating the model as if it were a constant when it's actually a dependency. Contrast two ways to reference a model. A mutable alias — asking for "the latest" version — is like pinning your build to latest on a package: convenient until the thing underneath changes without a version bump and your code inherits behavior you never reviewed. A pinned version is the opposite: you name the exact model, and any upgrade becomes a deliberate change you opt into. The trap with the alias isn't that models get worse — they usually get better on average — it's that "better on average" can still mean "different in the specific shape your strict consumer depended on." A newer model that's more conversational might wrap its JSON in a sentence of prose, and a parser expecting bare JSON silently discards it. The change is real; your ability to see it is what's missing.
So the fix is two parts, and the ordering of blame matters. First, pin the model, so an upgrade is a reviewed pull request rather than a surprise that arrives while you sleep. Second — and this is the part that actually defends you — build a small eval gate: a set of golden inputs paired with the output shape or quality you require, run automatically on every model or prompt change, blocking rollout if the pass rate drops below a threshold. Resist the tempting conclusion "someone should have pinned it." That's the symptom talking. The systemic cause is a release process that let a core dependency change with no review and no automated quality check — and "someone should remember" is not a control. The lab below is a miniature of exactly this gate: it diffs a pinned model against a candidate and refuses the rollout when quality regresses, turning a week-long silent bleed into a red build in CI.
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:
Read left to right: a proposed model change flows into the gate, gets scored, then branches on the threshold.
- The amber "Model change" box is a candidate version — the thing that used to slip in silently under a mutable alias.
- The blue "Eval gate" box runs it against a fixed set of golden inputs and computes a pass rate (here, how many outputs keep the required JSON shape).
- The diamond-style "score ≥ 0.95?" box is the decision: the upper green branch ships the change, the lower red branch blocks the rollout.
- The bottom line is the payoff: the pinned model scores 1.00 and ships; the candidate that regresses to prose on 2 of 4 goldens scores 0.50 and is blocked automatically — caught in CI, not by users.
In short: Score every model change against goldens and gate on a threshold — a quality drop becomes a blocked build instead of a week-long silent bleed.
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
This is the failure that punishes your best users, which is why it's the tech-lead story. A chat assistant, built the natural way, appends the whole conversation to every request so the model "remembers" what was said. Short sessions are flawless. But the users who love your product — the ones who chat the longest — are the ones whose transcript quietly grows until a request hits the context limit and throws, and every call before that gets steadily slower and more expensive as the history bloats. A bug that scales with engagement, striking your most engaged users first, is about the cruelest place for a defect to live.
The naive framing is "we ran out of context," but the real problem is a missing decision. Sending the entire transcript every turn means per-request cost and latency grow linearly with session length, and eventually the assembled prompt simply exceeds the window. The systemic gap is that there was no token budget on the assembled prompt and no policy for what to do when the history exceeds it. This is where the tech-lead judgment comes in, because the obvious fixes each have a sharp edge. Just truncate to the last N turns? You'll amputate the crucial fact the user gave you in turn two. Summarize everything? You lose the exact wording that recent turns depend on and you pay to summarize on every request. The skill isn't picking one lever; it's composing them so their weaknesses cancel.
The durable answer is to bound the assembled context to a token budget with a keep-newest-plus-summarize policy: keep the most recent turns verbatim, because recency carries the most conversational weight, and fold the older overflow into a single rolling summary so nothing important is silently dropped. Then cache that stable summarized head (story 1 again — the levers compose) so the retained context is not just small but nearly free to re-send each turn. The mental model to carry away: you are packing a fixed-size suitcase, not filling an infinite one — the newest items go in whole, the rest get compressed to fit, and the total never exceeds what the window allows. The mistake this prevents is the one that feels safe today and pages you at 2am when your happiest customer's session finally tips over the limit. The lab implements the packing policy against a hard budget so you can watch a 39k-token transcript fold down to something that fits.
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:
Read left to right: the ever-growing raw history on the left is transformed into the fixed-budget prompt on the right.
- The left red block is the naive prompt — all 60 turns appended every request, ~39,000 tokens, which eventually overflows the window.
- The arrow's label is the policy: keep the newest turns verbatim, summarize the rest.
- On the right, the amber bar is the single rolling summary that stands in for the 54 older turns, and the green bars are the 6 newest turns kept word-for-word.
- The payoff: the packed total is ~4,020 tokens — at the budget regardless of how long the raw chat grows, so the context-limit error becomes structurally impossible.
In short: Keep recent turns whole, fold the overflow into one summary, and the prompt stays inside a fixed budget forever — bound the unbounded thing before it errors.
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.