Session 5 · Week 3 · Tue 28 Jul 2026 · 90 min

Make it cheap, fast, and hard to kill.

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.

Ehsan Gazar
Production-Ready Systems with LLMs and Agents · session 5 of 12
Where we're going · the densest 90 min

The run sheet.

0–16Put a number on itcost, latency, load, instrumenting
16–46The five levers, then buy vs hostcache, batch, stream, route, right-size
46–76Designing for failuredeadlines, hedges, breakers, ladders, budgets
76–86Live: budget one requestyour hot path
86–90Recap + Project 2Thursday's workshop
You’ll leave able to state a per-request cost and latency ceiling, instrument one call, reach for the right lever, design for a model that fails, build a fallback ladder with idempotent effects, and enforce the ceiling at runtime.
The reframe

"Fast enough" and "cheap enough" are numbers.

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.

"It feels slow / pricey"
a vibe you can't act on
no target
"< $0.02 and < 2s p95"
a ceiling you can defend and test against
a budget
The cost model

Cost = (tokens in + tokens out) × price × calls.

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 tokenssystem + retrieved context + history · retrieval bloat lands here
Output tokensusually the input price per token · long answers cost most
× callsan agent loop of 8 steps is a single call · retries multiply again
− cache readsa cached input token bills at roughly 0.1× · the only line that goes down
The tellprice a typical and a worst-case request, not just the happy one
Anatomy of one request

Most of what you pay for, you didn't write.

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.

system + tools 26%
retrieved chunks 41%
history 15%
output 15%
system + tool schemas · frozen, so cacheable retrieved chunks · trim or rerank history the user's question · 3% output · priciest per token
85% of this bar is input. Two thirds of it never changes between requests.
The math that decides your margin

Same feature, a 5× swing before you've cached anything.

Real list prices, July 2026. Prices move, so re-check them, but the shape of the argument is what you carry.

Frontierclaude-opus-5 · $5 in / $25 out per million tokens
Midclaude-sonnet-5 · $3 in / $15 out
Smallclaude-haiku-4-5 · $1 in / $5 out · 5× cheaper than frontier
One request3k in + 500 out → frontier $0.0275 · small $0.0055
At 1M req / monthfrontier $27.5k · small $5.5k
Now add caching2.6k of that input is frozen → bills at 0.1× · small + cached ≈ $3.2k, an 8.7× swing
The levers compound. That's the whole reason to pull more than one.
Instrument it · pricing

Put the price table
in the code.

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.

Do this

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);
The latency model

Sequential steps add up. The tail is what hurts.

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.

Time to first token
what "feels fast" depends on; streaming attacks this and nothing else does.
Sequential depth
each hop waits on the last; parallelize what has no dependency.
Output length
tokens stream at a fixed rate; a 2× longer answer takes 2× longer.
The p99 tail
timeouts and retries live here; budget for the slow case, not the median.
The same work, two shapes

Parallelize
what doesn't depend.

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.

The rule

Draw the dependency graph, not the call order. Anything without an edge between it and the previous step belongs in the same Promise.all.

serial
embed
vector 300
profile
rerank
model 1400
total
2200 ms
parallel
embed
vector 300
rerank
model 1400
first token
 
profile
total
2020 ms · first token at 1020 ms
Parallelising saved 180ms. Streaming halved the felt latency, 2020ms → 1020ms. Know which one you're buying.
The arithmetic nobody does

Five calls at "p95 = 1s" is not a 1-second p95.

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.

P(all fast) = 0.955 = 0.774 your chain misses its budget 23% of the time
What this meansevery hop you add makes the tail worse faster than it makes the mean worse
Fix 1fewer sequential hops · the cheapest latency win there is
Fix 2a per-hop deadline, not a per-hop average · slow hop is cut, not waited on
Fix 3measure the chain end to end · per-service dashboards will all look green
The third model · load

At 90% busy you wait 10× longer, and your code didn't change.

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.

queue wait ≈ service_time × ρ / (1 − ρ) · in flight = arrival rate × latency (Little's Law)
ρ = 50%
ρ = 70%
2.3×
ρ = 90%
ρ = 95%
19× the wait, for 5% more traffic
Same model, same prompt, same code. The only thing that changed is how many other people showed up.
Load · in code

Queue a little.
Shed the rest.

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.

Sizing it

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
    }
  }
}
A shed request costs you nothing and tells the client the truth. A queued one costs you a timeout and a token bill.
Instrument it · one line per request

You cannot budget
what you don't emit.

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.

Do this

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;
  }
}
Ship this before you ship a single optimisation. It's how you'll know one worked.
The toolbox

Five levers for cost and latency.

1
Caching
don't pay twice for the same work
2
Batching
group work; throughput up, cost down
3
Streaming
answer feels fast; TTFT, not total
4
Routing
send each request to the right model
5
Right-sizing
smallest model that passes evals
Only streaming changes nothing about your bill. Only right-sizing risks your quality. Reach in that order.
Lever 1 · caching

Two caches: the prefix and the answer.

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.

Prefix caching · the one rule

It's a prefix match.
Order is the whole game.

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.

The rendering order

Providers render toolssystemmessages. 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;
Put new Date() in the system prompt and you have a cache that never hits, silently, forever.
Lever 1 · in code

Mark the breakpoint.
Then prove the hit.

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.

Assert it in CI

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
Writes cost ~1.25×, reads ~0.1×. Two hits and you're ahead; a hundred and it's free.
Caching · miss then hit

The second identical request never touches the model.

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.

Lever 1 · the answer cache

A similarity floor,
a TTL, and a tenant.

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.

Don't cache

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 };
}
Log every hit with the similarity score. You'll want to raise the floor once, after reading them.
Is the cache actually paying?

Hit rate turns into money linearly. Latency doesn't.

effective cost = (1 − h) × miss_cost + h × hit_cost  ·  effective p95 ≈ miss p95
h = 0.3cost −29% · p95 basically unchanged · the tail is still a miss
h = 0.7cost −68% · median collapses, p95 still the miss path
The trapa cache improves the average and leaves your p95 exactly where it was
Socache for cost · stream and shorten the chain for latency · don't confuse them
Lever 2 · batching

Three different things
called "batching".

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.

The tell

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;
          
Prompt packing is where accuracy quietly drops: item 9 gets contaminated by item 3.
Lever 2 · in code

Size or time,
whichever comes first.

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.

Watch for

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
    }
  }
}
Lever 3 · streaming

The only lever that
changes nothing real.

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.

Where it fails

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
          
Perceived latency 700ms. Actual latency 2100ms. Both are true; only one is on the pager.
Lever 3 · in code

Measure the first token,
not just the last.

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.

Don't buffer

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,
});
Two budgets, not one: TTFT < 500ms and total < 2s p95.
Streaming · the failure nobody handles

You already sent 200 OK.
Now it fails.

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.

The client's half

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();
}
Nothing sent yet? Retry silently. Half an answer on screen? Retrying gives them two. That's the whole decision, and sent is what makes it.
Levers 4 & 5 · routing + right-sizing

Small model by default. Escalate on need.

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;
This one change routinely halves spend, because the expensive model stops answering easy questions.
Lever 4 · in code

Rules first.
The model is the fallback.

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.

Be honest about this

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
}
Lever 5 · right-sizing

Step down until the evals break. Then step back up once.

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;
Effort and thinking settings are a tier too. Dropping effort is often the saving you get without changing models at all.
Lever 5, continued · the output

The cheapest token
is the one you never ask for.

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.

The one people miss

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.
This is the only lever you can pull this afternoon, on one endpoint, without changing your architecture.
The lever nobody prices honestly

Buy the model, or host it?

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.

1
Hosted API
pay per token, zero ops, elastic. Wins on low or spiky volume, and on frontier quality you can't reproduce.
2
Self-hosted
pay per GPU-hour, busy or idle. Wins on steady high volume, or when the data legally cannot leave.
3
The crossover
hosted cost/req × req/month, versus GPU/month plus the engineer who babysits it.

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.

Do the arithmetic on the slide

Utilisation is the whole argument.

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 ops0.25 of an engineer ≈ $4,000 / month · the term people leave out
True cost$5,150 / month · breakeven at ~940k requests
At 100k req/mohosted $550 · self-hosted $5,150 · GPU is 9× worse
At 5M req/mohosted $27,500 · self-hosted $5,150 · now it's obvious
Two orders of magnitude either side of breakeven the answer is obvious. Near it, pick the boring one.
The other half of tonight

The model is a flaky dependency. Treat it like one.

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.

Assume it fails
a deadline on every call · retries with backoff + jitter · a cap on attempts · a breaker when it's clearly down
Never trust the shape
check stop_reason before you read the content · validate the schema · on malformed, repair once then degrade, never crash
Name them, then handle them

Seven ways a model call fails. Only three are retryable.

FailureLooks likeRetry?What you do
Rate limited429 + retry-afteryesbackoff with jitter, honour the header, shed load
Provider 5xx500 / 529 overloadedyesbackoff, then trip the breaker and fall down a rung
Network / timeoutconnection error, deadline hityesone retry inside the remaining budget, then degrade
Bad request400, context too longnotrim the context and change the request, or fail fast
Truncatedstop_reason: max_tokensnoraise max_tokens or shorten the ask · never ship half
Refusalstop_reason: refusalnoroute to the fallback model or the human path
Confidently wrongHTTP 200, plausible, falsenothe one your evals and guardrails exist for
The last row is the only one your retry logic can't see. It returns 200.
Designing for failure · in code

Configure the SDK's retries.
Own the deadline.

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.

The multiplication trap

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
}
Jitter matters: without it, every client retries at the same millisecond and you DDoS your own recovery.
The retry you send before anything fails

Hedge the tail.
Pay 5% more, delete p99.

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.

Reads only

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
  }
}
Fire at p95 and ~5% of requests get a twin: a 5% bill for a p99 that stops existing. Fire at the median and you've doubled your spend.
Stop retrying a corpse

The breaker turns
a slow outage into a fast one.

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.

Why "fast" is better

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
          
Closed: calls pass through.  Open: fail fast, serve the fallback, zero load on the provider.  Half-open: exactly one probe.
The breaker · in code

Forty lines that
save the incident.

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.

Per what?

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
    }
  }
}
Log the open and the close. A breaker that opens silently is an outage a customer tells you about.
Graceful degradation

The fallback ladder: degrade, don't break.

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;
The ladder · in code

Every rung gets
its own budget.

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.

The arithmetic

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" };
}
Alert on ladder.served.opus dropping, not on errors. Silent degradation is the real outage.
Designing for failure · the output

Check why it stopped
before you read what it said.

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.

Repair once, then degrade

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;
}
The subtle trap

Retries + side effects = double trouble.

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.

Naive retry
timeout → retry the whole step → the refund fires twice
→ fix →
Idempotency key
the effect runs once regardless of how many times you retry the call
The timeout is the dangerous case: you don't know whether it succeeded, so "retry" and "don't retry" are both wrong. Only a key makes it safe.
Idempotency · in code

Key on the intent,
not the attempt.

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.

Two layers

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
Designing for failure · the wallet

A breaker
for your bill.

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.

Two ceilings, not one

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);
}
Going over budget is a ladder event, not a 500. The user still gets rung four; you just stop paying for rung one.
All of it, on one request

Every lever and every guard, in order.

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;
Nothing here is exotic. It's a cache, a router, a validator, a breaker and a log, in the right order.
Put it together

One request, fully budgeted.

Cost ceiling< $0.02 typical · < $0.10 worst case (loop + retries)
Latency ceiling< 2s p95 total · first token < 500ms via streaming
Deadline splitretrieval 400ms · model 1200ms · ladder headroom 400ms
Levers onprefix cache (1h) · small-model default · parallel retrieval · stream output · 400 max_tokens
Under load30 in flight · 60 queued · then shed with a 429, never queue forever
On failureSDK retry ×2 → hedge at p95 → small model → cached → rules → honest error
Effectsevery side-effecting tool behind an idempotency key derived from intent
Proofone log line per call: cost, TTFT, cache read, served-by rung
How you'll know it's working

Eight numbers. Five of them page you.

MetricWhy it's on the wallAlert when
cost per request (p50, p95)the budget, measuredp95 > ceiling for 15 min
TTFT p95what "fast" means to a user> 500ms
total latency p95the SLO you wrote down> 2s
cache read ratiothe cheapest lever, silently breakabledrops > 20% day over day
escalation rateyour router's classifier, grading itselfwatch, don't page
queue depth + shed rateload, before it becomes latencyany shedding at all
budget burn per tenantthe bug that arrives as an invoicewatch daily, page on 10× normal
served-by rungsilent degradation, made visibletop rung < 95% of traffic
The last one is the whole point of the ladder. Without it, degrading and working look identical.
Build these before the incident, not during

Four switches you want at 3am.

SwitchWhat it doesWhy a flag, not a deploy
Force small modelpins every request to the cheap tierspend and latency drop in seconds · quality dips, and you chose that
Degraded modeserve the ladder's lower rungs onlya provider outage becomes a worse feature, not a dead one
Per-tenant throttlesheds one noisy customerone broken integration stops being everyone's outage
Feature killturns the LLM path off entirelyyou built the deterministic rung for exactly this moment
Each one is a boolean read at the top of the request. Ship them the week you launch, and flip each one once in staging, because a switch you've never flipped isn't a switch.
The runbook page you'll actually open

The first five minutes.

1Name the symptom.Cost, latency, errors, or quality? Four different dashboards, four different switches. Guessing here costs you the other four minutes.
2Read served-by first.If the top rung is at 40%, the ladder is already absorbing it: you are degraded, not down, and you have more time than the pager implies.
3Theirs or yours?Provider status page and your breaker state. An open breaker plus a green status page means the problem is on your side of the wire.
4Flip the cheapest switch that stops the bleeding.Force-small, degraded mode, tenant throttle. Mitigate first, diagnose after: nobody has ever thanked you for a correct root cause found during the outage.
5Write down the timestamps as you go.You will not reconstruct the sequence afterwards, and the sequence is the whole write-up.
This page is the deliverable of Project 2. Write it now; you will not compose it under pressure.
Your turn · ~10 min

Budget your hot path.

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.

1
Set the ceiling
$ and ms, typical + worst
2
Find the spend
which multiplier dominates?
3
Pick a lever
the one with the best payoff
4
Name the bottom rung
what ships when everything fails?
Recap · then Thursday

A number, five levers, a ladder, and a switch.

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.

Thursday · S6 workshop
Build your budget + failure-mode map. Output: Project 2 (runbook started, finished Week 5).
due Sun Aug 2
Take-home
price your worst-case request, and add the one log line from slide 14 to one real endpoint.
bring the number