Skip to main content
Listen to this lab

Lab 5 — Prompt caching and cost

Time: 35 minutes · Prerequisites: Labs 1–2

Why this matters

This lab is the difference between the project shipping and not shipping.

The arithmetic is not subtle. Priya's budget is roughly $4,000 a month. Peak week is 11,300 tickets, about 45,000 in a peak month. That is just under nine cents per ticket for everything — triage, resolution, and a drafted reply.

The cached prefix — role instructions plus the full handbook — measures about 3,400 tokens, and it goes out on every request, because legal changes it weekly and it cannot be baked into a prompt. Priced at full input rate on a peak month that is about $765 in handbook tokens alone, before a single word of output. Caching drops the same line to roughly $75.

Be honest about what that does and does not prove. $765 is not the whole $4,000, and Lab 7 will measure the entire flagship pipeline at about $137 a month — this system is not close to its budget ceiling, and anyone who tells you caching is what makes it ship is selling something. What caching buys here is a fivefold cost headroom on the largest single line item, which is the difference between "we can afford to send the handbook on every request" and "we start trimming the handbook to save money" — and a trimmed handbook is an accuracy problem, not a cost one.

The failure mode is the real reason this deserves a whole lab: every way of breaking the cache succeeds silently. HTTP 200, correct answer, and a prefix line item that jumps 10×. Nobody notices until finance does.


Objectives

  • Get a cache hit, and prove it with cache_read_input_tokens
  • Break the cache four different ways and recognize each signature
  • Decide where a breakpoint belongs
  • Reason about when caching loses money

Silent failure

A broken cache does not look broken. The tell is a single usage field on the second identical call — not the status code, not the answer text.


Step 1 — a cold call and a warm one

npm run smoke 2>&1 | grep -A12 "call 2"

The second call should report cache_hit: true with several thousand cache_read_input_tokens.

Q1. The first call shows cache_creation_input_tokens and the second shows cache_read_input_tokens, both large, while input_tokens stays small on both. Explain what each field is counting, and compute the cost difference.

Step 2 — break it (four ways)

Read src/prompts.ts. Then break the cache deliberately, one change at a time, running npm run smoke after each and recording whether cache_hit survives.

Break A — a timestamp in the prefix. In buildSystem, change the frozen block to:

text: `Generated at ${new Date().toISOString()}\n${roleText}\n\n---\n\n${POLICY_HANDBOOK}`,

Break B — move the breakpoint. Put cache_control on the volatile block instead of the frozen one.

Break C — drop under the floor. Replace the whole frozen block's text with POLICY_HANDBOOK.slice(0, 400) — roughly 110 tokens, and note that you have to drop the role text too. The role instructions alone measure ~554 tokens, which already clears Opus 5's 512-token minimum on their own; trimming only the handbook would leave the prefix cacheable and this break would quietly demonstrate nothing. Getting a "below the minimum" repro is fiddlier than it looks, which is itself the point.

Break D — reorder tools. In src/tools/index.ts, return the tools array reversed, then hit /v1/resolve twice.

Q2. For each break, record: does cache_hit go false? Is there an error? Which is the most dangerous in production, and why?

Restore everything.

The signature of a cache bug is silence. Every break above succeeds with HTTP 200 and a correct answer. The only symptom is money: the handbook line goes to 10× (a cache read is 90% off, so losing it multiplies that line by ten), and the request as a whole to roughly 5×, because output tokens are never cached and they dominate a small request. Run the numbers on the receipt above before you quote either figure — "10× the bill" is the version of this that gets repeated and it is wrong. cache_read_input_tokens is your only detector — alert on it.

Check1 of 2

Someone adds `Today is ${new Date().toISOString()}` to the top of the cached system block. What breaks?

Check2 of 2

You cache a 3,400-token prefix for a tenant that sends one ticket a day. What happens to cost?

Step 3 — the floor, and why it is not a number you can memorize

curl -s localhost:8787/v1/estimate -H 'content-type: application/json' \
-d '{"message":"test","role":"triage"}' | jq .tokens

Note cache_minimum_tokens in that output. It is read from MODEL_CATALOG in src/config.ts for the configured model, not written as a literal, because the minimum is a per-model property:

modelshortest cacheable prefix
claude-opus-5512
claude-sonnet-51024
claude-haiku-4-54096

Two things about that table. First, it is not monotonic — the cheap tier requires the longest prefix, eight times the flagship's. Any intuition of the form "smaller model, smaller everything" gets this exactly backwards. Second, this service's prefix is ~3,400 tokens, which sits between the two: it caches on Opus 5 and Sonnet 5 and does not cache on Haiku 4.5. You will meet the consequence of that in Lab 7, where it is hiding inside a cost table.

Q3. prefix_meets_cache_minimum is computed against the configured model. What happens if you set a breakpoint on a 400-token prefix — error, warning, or silence? Now the harder version: what happens to a prefix that has always been comfortably over the line when someone changes TRIAGE_MODEL? What does that imply about how you validate a caching change before shipping it?

Step 4 — where does the breakpoint go?

Render order is tools → system → messages. You have four content categories:

  1. A 40K-token product catalog, identical for all users
  2. A 3K-token per-tenant policy override, stable within a tenant
  3. Conversation history, growing each turn
  4. The current user message

Q4. Sketch the ordering and place up to 4 breakpoints. Which category must come last, and why does putting the growing history before the per-tenant block cost you money?

Step 5 — when caching loses

curl -s localhost:8787/v1/estimate -H 'content-type: application/json' -d '{
"message":"Where is my order?",
"monthly_volume":10000
}' | jq .monthly_projection_usd

Cache writes cost ~1.25× fresh tokens; reads cost ~0.1×. The default TTL is 5 minutes.

Q5. At what request rate does caching start losing money? Derive the break-even in terms of requests-per-TTL-window, then name a real traffic pattern in this support domain where you would deliberately not cache.

Step 6 — spend the savings

src/config.ts sets EFFORT.triage = "low". Change it to "high" and run:

npm run eval 2>&1 | tail -12

Q6. Compare accuracy and total cost against the low baseline. If accuracy is unchanged, what have you learned about this task — and what would you need to see before spending the extra tokens?

Step 7 — re-run the scoreboard

npm run eval:quick

This one matters. If you left Break C in place, the cached prefix is broken and you may see cost climb without accuracy moving at all — which is exactly the production failure the lab is about. git diff before you conclude anything.


Checkpoint

  • What is the one field that proves caching is working?
  • Name three silent invalidators.
  • What is the minimum cacheable prefix for the model you configured, and what happens below it?
  • When is caching a net loss?
  • Scoreboard re-run; you can say why it did or did not move

Extension

Add a cache_hit_rate counter to the service and expose it at GET /metrics. Then write the alert rule you would page on. (Hint: the rule is not "hit rate < 100%" — cold starts are legitimate. What is the actual signal?)

Answers: ../solutions/lab-5.md