In the demo, nobody counts tokens or times the call. In production, both have ceilings, and the model will fail. Tonight: budgets, the five levers that hold them, and how to design for failure on purpose, with the code.
Before you optimize anything, write the ceiling: this request must cost under $X and answer within Y ms at p95. Without the number you can't tell a win from a waste, and you'll optimize the wrong thing.
The demo made one small call. Production multiplies it: agent loops, retries, big retrieved context, and long outputs. The bill lives in the multipliers, not the unit price.
| Input tokens | system + retrieved context + history · retrieval bloat lands here |
| Output tokens | usually 5× the input price per token · long answers cost most |
| × calls | an agent loop of 8 steps is 8× a single call · retries multiply again |
| − cache reads | a cached input token bills at roughly 0.1× · the only line that goes down |
| The tell | price a typical and a worst-case request, not just the happy one |
A typical RAG turn. The question is a rounding error. The system prompt, the tool schemas and the retrieved chunks are the bill, and they're the same on every single request.
Real list prices, July 2026. Prices move, so re-check them, but the shape of the argument is what you carry.
| Frontier | claude-opus-5 · $5 in / $25 out per million tokens |
| Mid | claude-sonnet-5 · $3 in / $15 out |
| Small | claude-haiku-4-5 · $1 in / $5 out · 5× cheaper than frontier |
| One request | 3k in + 500 out → frontier $0.0275 · small $0.0055 |
| At 1M req / month | frontier $27.5k · small $5.5k |
| Now add caching | 2.6k of that input is frozen → bills at 0.1× · small + cached ≈ $3.2k, an 8.7× swing |
If cost only exists in a spreadsheet, nobody sees it move. Every provider returns a usage object; turn it into dollars at the call site and log it with the request.
Bill the four token buckets separately. Cached reads and cache writes are not the same price as fresh input, and the whole point of caching is that you can prove the difference.
// $ per 1M tokens. cachedIn = 0.1x in, write = 1.25x in.
const PRICE = {
"claude-opus-5": { in: 5, out: 25, cachedIn: 0.50, write: 6.25 },
"claude-sonnet-5": { in: 3, out: 15, cachedIn: 0.30, write: 3.75 },
"claude-haiku-4-5":{ in: 1, out: 5, cachedIn: 0.10, write: 1.25 },
} as const;
export type Model = keyof typeof PRICE;
export function costOf(model: Model, u: Anthropic.Usage): number {
const p = PRICE[model];
return (
u.input_tokens * p.in
+ (u.cache_read_input_tokens ?? 0) * p.cachedIn
+ (u.cache_creation_input_tokens ?? 0) * p.write
+ u.output_tokens * p.out
) / 1_000_000;
}
// the two numbers that go in the runbook
const typical = costOf("claude-opus-5", res.usage);
const worstCase = typical * MAX_AGENT_STEPS * (1 + MAX_RETRIES);
Latency is dominated by the number of sequential model calls and the output length. A five-step chain feels broken even if each step is quick. And you're graded at p95/p99, not the average, the slow tail is the experience users remember.
The vector search and the profile lookup have no dependency on each other. Run them serially and you've spent 800ms before the model has seen a single token.
Draw the dependency graph, not the call order. Anything without an edge between it and the previous step belongs in the same Promise.all.
Percentiles do not add. If each of five sequential calls independently exceeds 1s five percent of the time, the chance the whole chain stays under 5s is 0.95⁵, and one slow hop is enough to blow it.
| What this means | every hop you add makes the tail worse faster than it makes the mean worse |
| Fix 1 | fewer sequential hops · the cheapest latency win there is |
| Fix 2 | a per-hop deadline, not a per-hop average · slow hop is cut, not waited on |
| Fix 3 | measure the chain end to end · per-service dashboards will all look green |
Every shared thing queues: your concurrency limit, the connection pool, the provider's rate limit. Waiting time scales with 1/(1−ρ), so the curve is flat right up until it isn't. This is why a load test at 60% tells you nothing about Monday morning.
An unbounded queue is not backpressure, it's a slower outage: requests pile up, every one of them times out, and you pay the provider for answers nobody is still waiting for. Cap the queue and reject fast.
Little's Law gives you the number: maxConcurrent = throughput × p95. 20 req/s at 1.5s means ~30 calls in flight. Set the cap there, not at "whatever the provider allows".
// One gate per dependency, sized from Little's Law.
export class Admission {
private active = 0;
private waiters: Array<() => void> = [];
constructor(
private maxConcurrent = 30, // throughput x p95
private maxQueued = 60, // past this: shed, don't stack
) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.maxConcurrent) {
if (this.waiters.length >= this.maxQueued) {
metrics.inc("admission.shed");
throw new TooBusy({ retryAfterSec: 2 }); // a fast 429
}
metrics.gauge("admission.queue_depth", this.waiters.length);
await new Promise<void>(r => this.waiters.push(r));
}
this.active++;
try { return await fn(); }
finally {
this.active--;
this.waiters.shift()?.(); // wake exactly one
}
}
}
One structured log line per model call, with the same fields every time. That single line is what later becomes your p95 dashboard, your cost-per-tenant report, and your cache hit rate.
Wrap the call once, at the boundary. Log on the failure path too, with the error class, not the message, so 429s and timeouts are countable.
export async function measured<T extends { usage: Anthropic.Usage }>(
route: string,
model: Model,
call: () => Promise<T>,
): Promise<T> {
const t0 = performance.now();
try {
const res = await call();
const u = res.usage;
log.info({
route, model, ok: true,
ms: performance.now() - t0,
costUsd: costOf(model, u),
tokensIn: u.input_tokens,
tokensOut: u.output_tokens,
cacheRead: u.cache_read_input_tokens ?? 0, // 0 forever = broken cache
});
return res;
} catch (err) {
log.error({ route, model, ok: false,
ms: performance.now() - t0,
kind: errorKind(err) }); // class, never message
throw err;
}
}
Prompt / prefix caching reuses the tokens of a stable system prompt and context, cheaper and faster on every call. A semantic cache short-circuits the whole model call when a near-identical question was already answered.
flowchart LR
Q["Request"] --> SC{"Seen a
near-match?"}
SC -- yes --> HIT["Return cached answer
no model call"]
SC -- no --> PC["Model call
reuse cached prefix"]
PC --> STORE["Store answer"]
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
class HIT,STORE ok;
class PC m;
Semantic caches need a similarity floor and a freshness rule, a stale cached answer is worse than a slow correct one.
The cache key is the exact bytes up to the breakpoint. One changed byte anywhere before it invalidates everything after it. So the prompt has to be sorted by stability: frozen first, volatile last.
Providers render tools → system → messages. A tool added mid-conversation sits at position zero, so it invalidates the entire conversation's cache.
flowchart LR
subgraph cacheable["Frozen prefix · bills at 0.1x"]
direction TB
T["1 · tool schemas"]:::froz --> S["2 · system prompt"]:::froz --> F["3 · few-shot examples"]:::froz
end
F --> BP{{"cache
breakpoint"}}:::bp
BP --> V
subgraph volatile["Volatile suffix · full price"]
direction TB
V["4 · retrieved chunks"]:::vol --> Q["5 · this turn's question"]:::vol
end
classDef froz fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef vol fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef bp fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
new Date() in the system prompt and you have a cache that never hits, silently, forever.Turning prefix caching on is one field. Keeping it on is the discipline: a per-user string, an unsorted JSON.stringify, or a tool list built from a Set will quietly kill it.
cache_read_input_tokens being zero across two identical-prefix requests is a test failure, not a mystery. Write that test.
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
system: [
{ type: "text", text: POLICY_DOC }, // ~40k, frozen
{ type: "text", text: FEW_SHOTS,
cache_control: { type: "ephemeral", ttl: "1h" } }, // breakpoint
],
messages: [{ role: "user", content: question }], // volatile, after it
});
// prove it, on every deploy
const { cache_read_input_tokens: read = 0,
cache_creation_input_tokens: wrote = 0 } = res.usage;
metrics.gauge("llm.cache.hit_ratio", read / (read + wrote + res.usage.input_tokens));
// silent invalidators, all of them in the prefix:
// new Date() · crypto.randomUUID() · `user:${id}`
// JSON.stringify(obj) with unordered keys · a per-request tool list
sequenceDiagram
autonumber
participant C as Client
participant K as Cache
participant M as Model
C->>K: request (miss)
K->>M: call model
M-->>K: answer
K-->>C: answer + store
Note over C,K: later, an identical request
C->>K: request (hit)
K-->>C: cached answer, no model call
The hit path skips the slowest, priciest hop entirely. That's why caching is the first lever you reach for.
A semantic cache is three decisions, and every one of them is a way to ship a bug. Too low a floor answers the wrong question. No TTL answers last month's price. No tenant key leaks one customer's answer to another.
Anything personalised, anything time-sensitive, anything with a side effect. Cache the expensive, stable, repeated reads.
const FLOOR = 0.93; // below this it's a different question
const TTL_MS = 15 * 60_000; // "stale but fast" is not a feature
type Entry = { vec: Float32Array; answer: string; at: number };
export async function answerCached(
tenant: string, // never share across tenants
question: string,
miss: () => Promise<string>,
) {
const vec = await embed(question);
const now = Date.now();
for (const e of store.entries(tenant)) {
if (now - e.at > TTL_MS) continue;
if (cosine(vec, e.vec) >= FLOOR) {
metrics.inc("cache.semantic.hit");
return { answer: e.answer, hit: true };
}
}
const answer = await miss();
store.put(tenant, { vec, answer, at: now });
return { answer, hit: false };
}
| h = 0.3 | cost −29% · p95 basically unchanged · the tail is still a miss |
| h = 0.7 | cost −68% · median collapses, p95 still the miss path |
| The trap | a cache improves the average and leaves your p95 exactly where it was |
| So | cache for cost · stream and shorten the chain for latency · don't confuse them |
People argue past each other on this one. Micro-batching trades a little latency for throughput. The batch API trades a lot of latency for half the price. Prompt packing trades accuracy for token count. Pick deliberately.
If the user is waiting, you can only afford micro-batching, and only tens of milliseconds of it. Everything else belongs on a queue.
flowchart TB
subgraph micro["1 · Micro-batch"]
direction TB
m0["+20ms, user still waiting"]:::hot --> m1["group 3 in-flight reqs"]:::hot --> m2["one embeddings call"]:::ok
end
subgraph api["2 · Batch API"]
direction TB
a0["async, ~50% off"]:::cold --> a1["submit 10k jobs"]:::cold --> a2["poll: minutes to hours"]:::ok
end
subgraph pack["3 · Prompt packing"]
direction TB
p0["cheapest, riskiest"]:::warn --> p1["10 items in one prompt"]:::cold --> p2["one shared context"]:::warn
end
classDef hot fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef cold fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef warn fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
The whole pattern is two knobs. maxSize caps the batch, maxWaitMs caps the latency you're willing to add. Under load the size trips first and you pay nothing; when it's quiet the timer trips and one user waits 20ms.
A rejected batch must reject every caller, not hang them. That catch is the line people forget, and it produces requests that never resolve.
export class MicroBatcher<In, Out> {
private q: { item: In; ok: (o: Out) => void; err: (e: unknown) => void }[] = [];
private timer?: ReturnType<typeof setTimeout>;
constructor(
private run: (items: In[]) => Promise<Out[]>,
private maxSize = 32,
private maxWaitMs = 20, // the latency you agree to add
) {}
submit(item: In): Promise<Out> {
return new Promise((ok, err) => {
this.q.push({ item, ok, err });
if (this.q.length >= this.maxSize) return this.flush();
this.timer ??= setTimeout(() => this.flush(), this.maxWaitMs);
});
}
private async flush() {
clearTimeout(this.timer); this.timer = undefined;
const batch = this.q.splice(0, this.maxSize);
if (!batch.length) return;
try {
const out = await this.run(batch.map(b => b.item));
batch.forEach((b, i) => b.ok(out[i]));
} catch (e) {
batch.forEach(b => b.err(e)); // never leave a caller hanging
}
}
}
Streaming does not make the request faster and does not make it cheaper. It changes when the user sees the first word, and that is the number they call "fast". Same 2.1 seconds, completely different product.
You cannot stream a payload you have to validate as a whole. If the client needs a checked JSON object, stream the prose and compute the structure on a second, non-streamed path.
sequenceDiagram
autonumber
participant U as User
participant A as Your API
participant M as Model
U->>A: ask
A->>M: stream: true
M-->>A: first token (700ms)
A-->>U: first word appears
Note over U,A: user is now reading
M-->>A: ...tokens...
M-->>A: done (2100ms)
A-->>U: complete
If you only record total duration you cannot tell whether streaming is working. TTFT is a separate metric with a separate budget, and it's the one that correlates with people not leaving.
A proxy, a compression middleware or a framework that collects the response before sending it will undo all of this and the metric will look fine. Test through the real edge.
const t0 = performance.now();
let ttft: number | undefined;
const stream = client.messages.stream({
model: "claude-sonnet-5",
max_tokens: 1024,
messages,
});
stream.on("text", (delta) => {
ttft ??= performance.now() - t0; // the number users feel
res.write(delta); // straight out, no buffering
res.flush?.();
});
const final = await stream.finalMessage();
if (final.stop_reason === "max_tokens") {
log.warn({ msg: "truncated", route }); // half an answer shipped
}
log.info({
ttftMs: ttft,
totalMs: performance.now() - t0,
out: final.usage.output_tokens,
stop: final.stop_reason,
});
The moment the first byte leaves, the status code is spent. A provider error 40 tokens in cannot be a 500, because the client already committed to success and is rendering. The error has to travel in band.
A dropped socket and a finished answer look identical unless you send a terminator. No done event means failed, not complete, and the UI should say so rather than leaving half a sentence on screen.
res.writeHead(200, { "Content-Type": "text/event-stream",
"Cache-Control": "no-cache" });
let sent = 0;
try {
const stream = client.messages.stream({
model: "claude-sonnet-5", max_tokens: 1024, messages,
});
stream.on("text", (delta) => {
sent += delta.length;
res.write(`data: ${JSON.stringify({ delta })}\n\n`);
});
const final = await stream.finalMessage();
res.write(`event: done\ndata: ${
JSON.stringify({ stop: final.stop_reason })}\n\n`);
} catch (err) {
// The status line left at byte 0. A 500 is no longer available.
log.error({ msg: "mid-stream failure", sent, kind: errorKind(err) });
res.write(`event: error\ndata: ${
JSON.stringify({ retryable: sent === 0 })}\n\n`);
} finally {
res.end();
}
sent is what makes it.Most requests are easy. Send them to a cheap, fast model; escalate only the hard ones to the expensive model. Right-sizing is choosing the smallest model that still passes your evals, not the most capable one you can afford.
flowchart LR
Q["Request"] --> R{"Classify
hard?"}
R -- no --> S["Small model
cheap, fast"]
R -- yes --> B["Big model
slow, capable"]
S --> CK{"Confident?"}
CK -- no --> B
CK -- yes --> OUT["Answer"]
B --> OUT
classDef s fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef b fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
class S s;
class B b;
The cheapest classifier is an if. Use length, route, plan tier, whether tools are needed. Only when the rules can't decide do you spend a model call deciding which model call to make.
Self-reported confidence is weakly calibrated. Treat it as one signal, alongside stop_reason, schema validity, and whether the answer cites anything. Then watch the escalation rate.
const Draft = z.object({
answer: z.string(),
confidence: z.number().min(0).max(1), // ask, then act on it
});
async function cheap(q: string) {
const r = await client.messages.parse({
model: "claude-haiku-4-5",
max_tokens: 800,
output_config: { format: zodOutputFormat(Draft) },
messages: [{ role: "user", content: q }],
});
return { ...r.parsed_output, stop: r.stop_reason };
}
export async function answer(q: string, plan: Plan) {
if (plan === "enterprise" || needsTools(q)) return strong(q);
const d = await cheap(q);
const good = d.confidence >= 0.8
&& d.stop !== "max_tokens"
&& d.answer.length > 0;
if (good) return d;
metrics.inc("router.escalated"); // alert above ~30%:
return strong(q); // you now pay for BOTH calls
}
Right-sizing is not a guess, it's a loop with a gate. The gate is your eval suite from Week 4. Without it you are choosing a model by vibes and calling it engineering.
flowchart LR A["Current model"] --> B["Drop one tier
or one effort level"] B --> C{"Evals still pass?"} C -- yes --> D["Ship it
bank the saving"] D --> B C -- no --> E["Step back up
this is your floor"] E --> F["Re-run when the
next model ships"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef stop fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class D,F ok; class E stop;
Output is the priciest token you buy and the slowest, because it comes out serially at a fixed rate. Halve the answer and you've halved a bill and a latency at the same time. No other lever on this deck does both.
On claude-opus-5 thinking is on by default: a request that never mentioned it is paying for it. effort is the dial. Disabling thinking entirely is only accepted at high effort or below.
const res = await client.messages.parse({
model: "claude-haiku-4-5",
max_tokens: 400, // a ceiling the model cannot cross
system: "Answer in at most three sentences. No preamble, "
+ "no restating the question, no closing summary.",
output_config: {
format: zodOutputFormat(Verdict), // structure can't sprawl
effort: "low", // thinking is a token bill too
},
stop_sequences: ["\n\nSources:"], // stop at the useful part
messages: [{ role: "user", content: q }],
});
// The tell: max_tokens means you ASKED for too much, not that
// the cap is too low. Shorten the ask before you raise the cap.
if (res.stop_reason === "max_tokens") {
metrics.inc("answer.truncated");
}
// 3k in + 500 out on frontier = $0.0275.
// 3k in + 150 out = $0.0188. A 32% cut, no eval risk.
Right-sizing picks the smallest model that passes. The next question is who runs it. Per-token pricing is rent; a GPU is a mortgage. The crossover is a volume number you can calculate, not a matter of taste.
The honest number includes the ops. A GPU you keep 12% busy costs more than the API it replaced, and residency rules can decide this before cost ever does.
| Hosted small | $0.0055 / request · scales to zero when nobody shows up |
| One A100-class GPU | ~$1.60 / hour → $1,150 / month, idle or not |
| Plus the ops | 0.25 of an engineer ≈ $4,000 / month · the term people leave out |
| True cost | $5,150 / month · breakeven at ~940k requests |
| At 100k req/mo | hosted $550 · self-hosted $5,150 · GPU is 9× worse |
| At 5M req/mo | hosted $27,500 · self-hosted $5,150 · now it's obvious |
It will time out, rate-limit, return malformed output, and occasionally go down. That's not an edge case, it's Tuesday. You wrap every model call the way you'd wrap a call to a third-party API you don't control.
stop_reason before you read the content · validate the schema · on malformed, repair once then degrade, never crash| Failure | Looks like | Retry? | What you do |
|---|---|---|---|
| Rate limited | 429 + retry-after | yes | backoff with jitter, honour the header, shed load |
| Provider 5xx | 500 / 529 overloaded | yes | backoff, then trip the breaker and fall down a rung |
| Network / timeout | connection error, deadline hit | yes | one retry inside the remaining budget, then degrade |
| Bad request | 400, context too long | no | trim the context and change the request, or fail fast |
| Truncated | stop_reason: max_tokens | no | raise max_tokens or shorten the ask · never ship half |
| Refusal | stop_reason: refusal | no | route to the fallback model or the human path |
| Confidently wrong | HTTP 200, plausible, false | no | the one your evals and guardrails exist for |
Every serious SDK already retries 429s and 5xx with backoff. Rewriting that is how you end up with two retry loops multiplying. What the SDK cannot know is your request's deadline, and that is the part you must own.
Wall clock can reach timeout × (maxRetries + 1). A 20s timeout with 3 retries is a request that can legally take 80 seconds. Budget the whole thing, not one attempt.
// The SDK retries 408/409/429/5xx + connection errors, with backoff.
// Configure it; do not reimplement it. (timeout is ms in TS.)
const client = new Anthropic({ maxRetries: 2, timeout: 20_000 });
// What you own: one deadline for the whole request, all attempts included.
export async function withDeadline<T>(
ms: number,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), ms);
try { return await fn(ac.signal); }
finally { clearTimeout(t); }
}
// And what counts as retryable, by class, never by message.
export function retryable(err: unknown): boolean {
if (err instanceof Anthropic.RateLimitError) return true; // 429
if (err instanceof Anthropic.APIConnectionError) return true; // net
if (err instanceof Anthropic.APIError) return (err.status ?? 0) >= 500;
return false; // 400 / 404: retrying just spends money slower
}
A retry reacts to failure. A hedge reacts to slowness: at your p95, fire a second identical request, take whichever answers first, cancel the loser. Your tail collapses because you now only need one of two draws to be fast.
A hedge duplicates the call. Never hedge anything with a side effect until it's behind the idempotency key we build in a few slides, or you've just booked the room twice on purpose.
export async function hedged<T>(
hedgeAfterMs: number, // your p95, not your mean
call: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const acs: AbortController[] = [];
const attempt = () => {
const ac = new AbortController(); acs.push(ac);
return call(ac.signal);
};
const first = attempt();
const hedge = new Promise<T>((resolve, reject) => {
const t = setTimeout(() => {
metrics.inc("hedge.fired"); // above ~10%? p95 moved
attempt().then(resolve, reject);
}, hedgeAfterMs);
first.finally(() => clearTimeout(t));
});
try {
return await Promise.race([first, hedge]);
} finally {
acs.forEach(ac => ac.abort()); // always cancel the loser
}
}
When the provider is down, retrying makes it worse: you queue requests, exhaust connections and turn a degraded feature into a degraded site. A breaker fails fast after N failures, then sends one probe to check.
A failure in 5ms lets you serve the fallback inside budget. A failure after three retries and a 20-second timeout has already broken the request that depended on it.
stateDiagram-v2
direction LR
state "Half-open" as HalfOpen
[*] --> Closed
Closed --> Open: N failures in a row
Open --> HalfOpen: cooldown elapsed
HalfOpen --> Closed: probe succeeds
HalfOpen --> Open: probe fails
This is not a library-sized problem. Three states, a counter and a timestamp. What makes it valuable is that the fallback is a required argument, so you cannot write a breaker that has nothing to fall back to.
One breaker per dependency, not per process and not per request. Model provider, vector store and rerank endpoint each get their own; one being down should not trip the others.
type State = "closed" | "open" | "half-open";
export class Breaker {
private state: State = "closed";
private fails = 0;
private openedAt = 0;
constructor(
private dep: string, // one breaker per dependency
private threshold = 5,
private cooldownMs = 30_000,
) {}
async run<T>(fn: () => Promise<T>, fallback: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.cooldownMs) return fallback();
this.state = "half-open"; // cooldown up: one probe
}
try {
const out = await fn();
this.fails = 0; this.state = "closed";
return out;
} catch {
if (++this.fails >= this.threshold || this.state === "half-open") {
this.state = "open"; this.openedAt = Date.now();
log.error({ msg: "breaker opened", dep: this.dep });
}
return fallback(); // fail fast, then degrade
}
}
}
When the primary path fails, step down a rung instead of throwing an error. Every rung is a worse-but-working answer. Users forgive "slightly less smart." They don't forgive a spinner that never ends.
flowchart LR A["Big model
4000ms budget"] -- "timeout
/ error" --> B["Small model
1500ms"] B -- "still
failing" --> C["Cached answer
50ms"] C -- "no
cache" --> D["Deterministic default
rules, canned"] D -- "nothing
works" --> E["Honest 'try again'
+ log + alert"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef warn fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class A,B ok; class D,E warn;
This is the detail that makes a ladder work in production. If rung one is allowed to burn the whole 2 seconds, there is no time left to fall to rung two, and your "graceful degradation" is just a slow error.
4000 + 1500 + 50 + 10 is over budget on paper, but the rungs only run in sequence when the one above fails. Size the ladder so the common failure path still lands inside p95.
type Rung = { name: string; ms: number; run: (q: string) => Promise<Answer> };
const LADDER: Rung[] = [
{ name: "opus", ms: 4000, run: q => call("claude-opus-5", q) },
{ name: "haiku", ms: 1500, run: q => call("claude-haiku-4-5", q) },
{ name: "cache", ms: 50, run: q => nearestCachedAnswer(q) },
{ name: "rules", ms: 10, run: q => cannedAnswer(q) },
];
export async function answer(q: string): Promise<Answer> {
for (const rung of LADDER) {
try {
const a = await withDeadline(rung.ms, () => rung.run(q));
metrics.inc(`ladder.served.${rung.name}`);
return { ...a, servedBy: rung.name };
} catch (err) {
metrics.inc(`ladder.fell.${rung.name}`); // every fall is a metric
log.warn({ rung: rung.name, kind: errorKind(err) });
}
}
// the bottom rung is honest, not a crash
return { text: "We couldn't answer that just now. Try again.",
degraded: true, servedBy: "none" };
}
ladder.served.opus dropping, not on errors. Silent degradation is the real outage.A 200 response is not a valid response. It can be truncated mid-object, it can be a refusal with empty content, and it can be well-formed JSON that doesn't match your schema. All three are ordinary, and all three crash naive code.
One reprompt with the validation error attached fixes most schema misses. A second one almost never does, so don't loop; fall down the ladder instead.
const Ticket = z.object({
category: z.enum(["billing", "bug", "feature", "other"]),
urgency: z.number().int().min(1).max(5),
});
export async function triage(text: string, attempt = 0) {
const r = await client.messages.parse({
model: "claude-haiku-4-5",
max_tokens: 256,
output_config: { format: zodOutputFormat(Ticket) },
messages: buildMessages(text, attempt),
});
// 1. why did it stop? check BEFORE touching content
if (r.stop_reason === "max_tokens") return FALLBACK; // half JSON
if (r.stop_reason === "refusal") return FALLBACK; // empty
// 2. does the shape hold?
const parsed = Ticket.safeParse(r.parsed_output);
if (parsed.success) return parsed.data;
// 3. repair exactly once, then degrade. Never crash the request.
if (attempt === 0) return triage(text, 1);
metrics.inc("triage.unparseable");
return FALLBACK;
}
A retry that re-runs a tool can charge the card twice, send the email twice, book the room twice. Non-determinism makes it worse: the retry may take a different action. Make side-effecting tools idempotent, and only retry the model, not the effect.
If the key includes a timestamp, a UUID or the attempt number, it's a different key every time and you've built nothing. Derive it from what the user asked for: this conversation, this order, this action.
Your own claim table stops your retry re-running the effect. The provider's idempotency key stops a duplicate that slipped past you. Use both; they fail differently.
// The model may retry. The refund must not.
export async function once<T>(key: string, effect: () => Promise<T>): Promise<T> {
const claimed = await db.insertIfAbsent("effects", { key, state: "running" });
if (!claimed) return db.awaitResult<T>(key); // someone already did this
try {
const out = await effect();
await db.complete(key, out);
return out;
} catch (err) {
await db.fail(key); // releases the claim
throw err;
}
}
// derive the key from the INTENT: same ask = same key, forever
const key = hash(`${conversationId}:refund:${orderId}`);
await once(key, () =>
stripe.refunds.create({ payment_intent }, { idempotencyKey: key }),
);
// NOT: hash(`refund:${Date.now()}`) <- a new key on every attempt
An agent loop with a bug doesn't page you. It invoices you, quietly, for three weeks. The worst case you priced on slide 8 is a wish until something in the request path enforces it.
Per request stops the runaway loop. Per tenant per day stops the customer whose integration retries forever. The second one is the bug that arrives shaped like an invoice.
export class Budget {
private spentUsd = 0;
private steps = 0;
constructor(
private capUsd = 0.10, // the worst case from slide 8
private maxSteps = 8,
) {}
/** Before every model call. Throws instead of quietly spending. */
check() {
if (this.steps >= this.maxSteps) throw new OverBudget("steps");
if (this.spentUsd >= this.capUsd) throw new OverBudget("usd");
}
record(model: Model, u: Anthropic.Usage) {
this.steps++;
this.spentUsd += costOf(model, u); // from slide 8
metrics.gauge("budget.spent_usd", this.spentUsd);
}
}
const budget = new Budget();
for (const step of agentLoop) {
budget.check(); // fail INTO the ladder, not the bill
const res = await measured(route, model, step);
budget.record(model, res.usage);
}
flowchart LR
ADM{"Request
admitted?"} -- full --> SHED(["429"]):::w
ADM -- ok --> BUD["Deadline
+ budget"]:::g
BUD -. "over budget" .-> LAD
BUD --> SC{"Semantic
cache?"}
SC -- hit --> OUT(["Stream answer"]):::ok
SC -- miss --> PAR["Parallel
retrieval"]
PAR --> RTR{"Route"}
RTR -- easy --> SM["Small model"]:::ok
RTR -- hard --> BG["Big model"]:::m
SM --> CK{"Valid +
confident?"}
CK -- no --> BG
CK -- yes --> OUT
BG --> VAL{"Shape
valid?"}
VAL -- yes --> OUT
VAL -- no --> LAD["Fallback ladder"]:::w
BR["Breaker open?"]:::w -. short-circuit .-> LAD
LAD --> OUT
OUT --> LOG["Log cost, TTFT,
cache, served-by"]:::g
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
classDef w fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
classDef g fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
| Cost ceiling | < $0.02 typical · < $0.10 worst case (loop + retries) |
| Latency ceiling | < 2s p95 total · first token < 500ms via streaming |
| Deadline split | retrieval 400ms · model 1200ms · ladder headroom 400ms |
| Levers on | prefix cache (1h) · small-model default · parallel retrieval · stream output · 400 max_tokens |
| Under load | 30 in flight · 60 queued · then shed with a 429, never queue forever |
| On failure | SDK retry ×2 → hedge at p95 → small model → cached → rules → honest error |
| Effects | every side-effecting tool behind an idempotency key derived from intent |
| Proof | one log line per call: cost, TTFT, cache read, served-by rung |
| Metric | Why it's on the wall | Alert when |
|---|---|---|
| cost per request (p50, p95) | the budget, measured | p95 > ceiling for 15 min |
| TTFT p95 | what "fast" means to a user | > 500ms |
| total latency p95 | the SLO you wrote down | > 2s |
| cache read ratio | the cheapest lever, silently breakable | drops > 20% day over day |
| escalation rate | your router's classifier, grading itself | watch, don't page |
| queue depth + shed rate | load, before it becomes latency | any shedding at all |
| budget burn per tenant | the bug that arrives as an invoice | watch daily, page on 10× normal |
| served-by rung | silent degradation, made visible | top rung < 95% of traffic |
| Switch | What it does | Why a flag, not a deploy |
|---|---|---|
| Force small model | pins every request to the cheap tier | spend and latency drop in seconds · quality dips, and you chose that |
| Degraded mode | serve the ladder's lower rungs only | a provider outage becomes a worse feature, not a dead one |
| Per-tenant throttle | sheds one noisy customer | one broken integration stops being everyone's outage |
| Feature kill | turns the LLM path off entirely | you built the deterministic rung for exactly this moment |
Take the busiest request in your system. Put a ceiling on it, find where the cost hides, and name the first lever you'd pull. Drop your cost ceiling in the chat.
Write the ceiling and instrument it. Pull the right lever: cache, batch, stream, route, right-size (including the output). Then assume failure and design the fallback ladder so it degrades, with idempotent effects, a budget guard, a breaker in front, and switches you can flip at 3am.