AI EngineeringZero to ProductionHome·About·Contact
Anthropic API in Practice · Part 2

Prompt caching, hands-on

A hands-on cookbook for prompt caching: put a cache_control breakpoint at the end of a stable prefix, reuse the computed state on later calls, and read the cache_creation / cache_read usage fields to prove it worked — for a large cost and latency win. Real Anthropic SDK code throughout, plus one offline calculator you can run right now.

⏱️ ~1.5 hours🧪 5 recipes🎯 Beginner→Tech-lead

Learning objectives

  • Explain what prompt caching is and why it cuts both cost and latency.
  • Place a cache_control breakpoint at the end of the stable prefix.
  • Read cache_creation_input_tokens and cache_read_input_tokens from usage.
  • Reason about the ~5-minute TTL and what invalidates a cache entry.
  • Use multiple breakpoints and do the cache write/read cost math.
  • Design a production caching strategy that actually gets hits.

1 · What prompt caching is essential

Every time you call the model, the whole prompt — your system prompt, tool definitions, and the conversation so far — is re-read and re-processed from scratch. If a big chunk of that prompt is identical from one call to the next (a long system prompt, a big document you keep asking about, a fixed list of tool definitions), you are paying to process the same tokens over and over.

Prompt caching lets the API remember the computed state of a stable prefix and reuse it on the next call. The first call writes the cache; later calls with the same prefix read it — skipping the re-computation. You mark where the stable part ends with a cache breakpoint (cache_control), and the API caches everything up to and including that point.

Stable prefix system, docs, tools [cache breakpoint] cache_control Variable suffix the question Model answers
🗺️ How to read this diagram

This picture is the whole idea of prompt caching in one row: split your prompt into a stable prefix and a variable suffix, and mark the boundary between them.

  • The green box (Stable prefix) is the part that's identical call after call — your system prompt, a big document, your tool definitions.
  • The amber box ([cache breakpoint]) is the cache_control marker. Everything up to and including it is what gets cached.
  • The blue box (Variable suffix) is what changes each call — this request's question. It sits after the breakpoint, so it never gets cached (and never invalidates the cache).
  • The caption's rule: the first call computes and stores the green part (a cache write); later calls with the same green bytes reuse it (a cache read) and only process the blue part fresh.

In short: Stable stuff first, changing stuff last, breakpoint on the boundary. Get the order wrong — a timestamp in the green box — and there's no reusable prefix to cache.

The rule that governs everything: caching is a prefix match. The cache key is the exact bytes up to the breakpoint. On the first call that prefix is computed and stored (a cache write). On later calls with a byte-identical prefix, the stored state is reused (a cache read) and only the variable suffix after the breakpoint is processed fresh.

2 · Why it saves cost and latency essential

Reusing the computed prefix is cheaper on both axes. On cost: a cache read is billed at roughly 0.1× the normal input price for those tokens, versus the full you'd otherwise pay every call. On latency: the model skips the prefill work for the cached tokens, so time-to-first-token drops — often dramatically when the prefix is large (tens of thousands of tokens of system prompt or document).

The catch is the write: the first call pays a small premium — about 1.25× for the default 5-minute cache (or 2× for the 1-hour TTL) — to store the prefix. So caching pays off once you reuse the prefix enough to amortize that write. With the 5-minute cache, that's just two calls.

Cache the big stable thing, vary the small thingThe ideal shape is a large, identical prefix (long system prompt, a big document, your tool definitions) followed by a short, changing suffix (this request's question). That's when the reused prefix dwarfs the re-processed suffix and the savings are largest.

3 · The request shape — cache_control on a content block essential

You cache by putting "cache_control": {"type": "ephemeral"} on the last content block of the stable prefix. Render order is toolssystemmessages, so a breakpoint on the last system block caches the tools and the system prompt together. The block below is real Anthropic SDK — it needs a key and a network connection to run.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · cache a large system prompt ▶ needs API key + network
cache_system.py# needs: pip install anthropic + ANTHROPIC_API_KEY
# ▶ needs API key + network — this really calls the API; it will NOT run offline.
import anthropic

client = anthropic.Anthropic()

LARGE_SYSTEM = open("company_handbook.txt").read()   # e.g. tens of thousands of tokens

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": LARGE_SYSTEM,
            # Breakpoint at the END of the stable prefix. Everything up to and
            # including this block is cached; the user message after it is not.
            "cache_control": {"type": "ephemeral"},
        }
    ],
    messages=[{"role": "user", "content": "Summarize the PTO policy in 3 bullets."}],
)

print(resp.content[0].text)
▶ How this works

This is the minimal caching recipe: take one big, unchanging system prompt and cache it so the next call doesn't re-process it. It's real Anthropic SDK — it needs a key and a network, so it will not run offline; read it for the shape.

  1. client = anthropic.Anthropic() builds the client; it picks up ANTHROPIC_API_KEY from the environment automatically.
  2. system is a list of content blocks (not a plain string) so a single block can carry "cache_control": {"type": "ephemeral"}. That marker is the cache breakpoint.
  3. Because render order is tools → system → messages, the breakpoint on this last system block caches the tools and the whole system prompt together — everything before the user message.
  4. The user message comes after the breakpoint, so it's the variable suffix: it changes per request and is never cached.

What the output means: Nothing prints about caching here — this call just answers. You confirm the cache worked by reading resp.usage, which the next recipe does.

Try this: Mentally move the cache_control onto the user message instead. Now the breakpoint is after the changing question, so every call is a distinct prefix and nothing is ever reused — the classic mistake.

There is also a top-level auto-cacheIf you don't need fine-grained placement, pass cache_control={"type": "ephemeral"} as a top-level argument to messages.create(...) and the API auto-places the breakpoint on the last cacheable block. Use the per-block form when you want to control exactly where the prefix ends.

4 · Reading the usage fields — proof of a hit intermediate

Never assume caching worked — measure it. Every response carries a usage object with three token fields that together tell the whole story:

FieldMeaningBilled at
cache_creation_input_tokenstokens written to cache this call~1.25× (write premium)
cache_read_input_tokenstokens served from cache this call~0.1× (cheap)
input_tokensuncached tokens processed fresh1× (full price)

On the first call you expect a big cache_creation_input_tokens and a zero cache_read_input_tokens. On the second identical-prefix call they flip: cache_creation drops to 0 and cache_read jumps up. The block below reads those fields — real SDK, needs a key and network.

Python · read the cache usage fields ▶ needs API key + network
read_usage.py# needs: pip install anthropic + ANTHROPIC_API_KEY
# ▶ needs API key + network — real API calls; will NOT run offline.
import anthropic

client = anthropic.Anthropic()

system = [{
    "type": "text",
    "text": open("company_handbook.txt").read(),
    "cache_control": {"type": "ephemeral"},
}]

def ask(question):
    resp = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=256,
        system=system,                                   # byte-identical prefix each call
        messages=[{"role": "user", "content": question}],
    )
    u = resp.usage
    print(f"created={u.cache_creation_input_tokens} "
          f"read={u.cache_read_input_tokens} "
          f"fresh={u.input_tokens}")
    return resp

ask("Summarize the PTO policy.")     # 1st: created=<big>  read=0
ask("What about parental leave?")    # 2nd: created=0      read=<big>
▶ How this works

Caching is invisible unless you measure it. This recipe calls twice with the same big prefix and prints the three usage fields so you can watch the cache go from a write to a read. Real SDK — needs a key and network.

  1. ask() sends the same cached system prefix every time; only the question after the breakpoint changes.
  2. u.cache_creation_input_tokens counts tokens written to the cache (you pay the ~1.25× premium on these). u.cache_read_input_tokens counts tokens served from cache (~0.1×). u.input_tokens is the uncached remainder at full price.
  3. The first ask() writes the prefix: created is large, read is 0. The second flips them: created=0, read is large — that's your proof of a cache hit.

What the output means: Two lines of created=… read=… fresh=…. The jump in read from 0 to a big number on the second call is the whole point — that's the cache being reused.

Try this: If read stays 0 on the second call, something in the prefix changed between calls. Diff the two system strings byte-for-byte — a stray timestamp or unsorted JSON is the usual cause.

If cache_read is always 0, a silent invalidator is at workThe usual culprits sit inside the prefix: a datetime.now() or a UUID in the system prompt, a json.dumps() without sort_keys=True, or a tool list that changes between calls. Any of these changes the prefix bytes, so every call writes a fresh entry and nothing is ever read.

5 · The cost math (runs offline) intermediate

You can decide whether caching is worth it with arithmetic, no API needed. Without caching, N calls each pay full price (1×) for the prefix. With caching, the first call pays the write premium (~1.25×) and the other N−1 calls pay the read rate (~0.1×). The helper below is pure stdlib — it runs with a plain python file.py — and reports the tokens saved as a percentage.

Python · cache-savings calculator (runs offline)
cache_savings.pydef cache_savings(prefix_tokens, calls, write_mult=1.25, read_mult=0.1):
    """Compare paying full price N times vs. write-once + read (N-1) times.
    Returns (no_cache_units, with_cache_units, percent_saved) in token-equivalents.
    """
    no_cache = prefix_tokens * calls
    with_cache = prefix_tokens * write_mult + prefix_tokens * read_mult * (calls - 1)
    saved_pct = round(100 * (no_cache - with_cache) / no_cache)
    return round(no_cache), round(with_cache), saved_pct

no_cache, with_cache, saved = cache_savings(prefix_tokens=10_000, calls=100)
print(f"no cache:   {no_cache} token-units")
print(f"with cache: {with_cache} token-units")
print(f"saved:      {saved}% on the cached prefix")

# Break-even: at how few calls does caching already win?
one, two = cache_savings(10_000, 1)[1], cache_savings(10_000, 2)[1]
print(f"1 call costs {one} (write premium, a loss); 2 calls cost {two} (already a win)")
no cache:   1000000 token-units
with cache: 111500 token-units
saved:      89% on the cached prefix
1 call costs 12500 (write premium, a loss); 2 calls cost 13500 (already a win)
▶ How this works

Before you wire up caching, this offline calculator tells you whether it's even worth it. It's pure stdlib — it runs with a plain python cache_savings.py, no key needed — and turns the pricing multipliers into a savings percentage.

  1. no_cache = prefix_tokens * calls is the naive cost: every one of calls requests pays full price (1×) for the whole prefix.
  2. with_cache pays the write premium once (prefix_tokens * 1.25) and the cheap read rate on the other N−1 calls (prefix_tokens * 0.1 * (calls - 1)).
  3. saved_pct is how much of the prefix cost caching removes. At 100 reuses of a 10K-token prefix, that's ~89%.
  4. The last line probes the break-even: 1 call is a small loss (you paid 1.25× for a prefix you used once); by 2 calls caching is already cheaper than not caching.

What the output means: no cache: 1000000 vs with cache: 111500 token-units — a saved: 89%. The final line shows the 1-call loss (12500) turning into a 2-call win (13500 < the 20000 two full calls would cost).

Try this: Call cache_savings(20_000, 5) and cache_savings(20_000, 100) and compare the percentages — the savings climb fast with reuse, then flatten as the one-time write premium gets amortized away.

Two lessons from the numbers. First, at 100 reuses of a 10K-token prefix you save ~89% of the prefix cost. Second, a single call costs 1.25× — caching a prefix you'll use only once is a small loss. It turns a profit at the second call and climbs from there.

6 · TTL and what invalidates the cache advanced

A cache entry is ephemeral: by default it lives about 5 minutes, and the clock resets on every hit. So a steady stream of requests keeps the prefix warm indefinitely; a 6-minute gap lets it expire and the next call pays the write again. For bursty traffic with long gaps you can request a 1-hour TTL with {"type": "ephemeral", "ttl": "1h"} — but the write premium doubles to ~2×, so it needs more reads to pay off.

Invalidation follows straight from the prefix-match rule: any byte change anywhere before the breakpoint invalidates everything after it. That includes changes you might not think of as "the prompt":

What invalidates a cache entry

  1. Editing the prefix text — a changed system prompt, a re-ordered document, a stray whitespace edit.
  2. Changing the tool set — adding, removing, or re-ordering tools. Tools render at position 0, so this invalidates everything.
  3. Switching models — caches are per-model; the same prefix on a different model is a cold write.
  4. Non-deterministic serializationjson.dumps() without sorted keys, iterating a set, or an interpolated timestamp/UUID.
  5. Letting it expire — no hit within the TTL window and the entry is gone.
The system prompt must be frozenThe most common self-inflicted cache miss is interpolating "current date: …", "user: …", or a request ID into the system prompt. That sits at the front of the prefix and invalidates the whole cache on every call. Put anything that changes per-request after the last breakpoint — in the user message — not in the cached prefix.

7 · Multiple breakpoints & multi-turn caching professional

You get up to 4 breakpoints per request, which lets you cache at multiple stability boundaries. A common layout: one breakpoint after the tool definitions + frozen system prompt (never changes), and a second on the last block of the most recent conversation turn (grows as the chat continues). Each turn re-reads the entire prior prefix cheaply and only writes the newest slice. The block below shows both — real SDK, needs a key and network.

Python · two breakpoints: frozen prefix + growing conversation ▶ needs API key + network
multi_breakpoint.py# needs: pip install anthropic + ANTHROPIC_API_KEY
# ▶ needs API key + network — real API call; will NOT run offline.
import anthropic

client = anthropic.Anthropic()

# Breakpoint #1: the frozen system prompt (+ any tools) — cached once, reused forever.
system = [{
    "type": "text",
    "text": open("company_handbook.txt").read(),
    "cache_control": {"type": "ephemeral"},
}]

# Breakpoint #2: the last block of the newest turn — the conversation prefix grows,
# so each turn reads everything before it cheaply and writes only the new slice.
messages = [
    {"role": "user", "content": "What's our refund window?"},
    {"role": "assistant", "content": "30 days from delivery."},
    {
        "role": "user",
        "content": [
            {
                "type": "text",
                "text": "And for digital goods?",
                "cache_control": {"type": "ephemeral"},
            }
        ],
    },
]

resp = client.messages.create(
    model="claude-opus-4-8", max_tokens=256, system=system, messages=messages,
)
u = resp.usage
print(f"read={u.cache_read_input_tokens} created={u.cache_creation_input_tokens}")
▶ How this works

This recipe caches at two stability boundaries at once — a frozen prefix that never changes and a conversation that grows each turn. Real SDK; needs a key and network.

  1. Breakpoint #1 sits on the system block: the handbook + any tools. It's written once and read on every future turn, forever.
  2. Breakpoint #2 sits on the last block of the newest user turn. As the chat grows, each turn reads the entire prior conversation cheaply and writes only the newest slice — caching accrues incrementally.
  3. Note the newest user turn is a list of content blocks (not a bare string) so it can carry its own cache_control. The earlier turns are plain strings — they don't need a marker, they're read via breakpoint #2's prefix.
  4. You get up to 4 breakpoints per request; here two is enough — one for the never-changes prefix, one for the grows-each-turn conversation.

What the output means: read=… created=…: after the first turn, later turns show a large read (the frozen system + prior turns) and a small created (just the newest turn).

Try this: Add another user/assistant turn and move breakpoint #2 onto the new last block. The previous turn's tokens shift from created into read — the conversation prefix is now cached up to the new boundary.

A subtle production detail: caches are scoped by TTL, model, and prefix bytes. If a background job (summarization, a sub-agent) rebuilds system/tools/model even slightly differently from the main loop, it misses the main cache entirely. Copy the parent's stable fields verbatim and append fork-specific content after the breakpoint.

8 · Tech-lead — a caching strategy that gets hits tech-lead

A lead owns the cache hit rate as a real, monitored metric — not a hopeful cache_control sprinkled on and forgotten. That means designing the prompt assembly so the stable content is genuinely stable, and instrumenting cache_read_input_tokens in production to catch regressions the moment someone interpolates a timestamp into the system prompt.

The contract to own: a frozen prefix (system + deterministic, sorted tool definitions) with dynamic context pushed after the last breakpoint; a breakpoint plan (which of the 4 slots caches what, and why); a TTL choice matched to traffic shape (5-minute for steady load, 1-hour for bursty); and a hit-rate dashboard that alerts when reads fall toward zero. Get those right and a large, repetitive prompt surface stays both fast and affordable at scale.

Verify the hit, then trust the mathConfirm caching is actually happening — cache_read_input_tokens > 0 across repeated requests — before you rely on the cache_savings.py numbers. A cache you think is working but isn't is worse than none: you pay the 1.25× write on every single call and never collect the read discount.

Exercise AP2.1 — Prove a hit, then a miss

Context: Seeing a cache hit collapse to a miss the instant you inject volatile content is the fastest way to internalize the prefix-match rule. Proving it on a real call beats reading about it.

Your task: Using read_usage.py, call twice with a byte-identical large system prefix to confirm a cache read, then deliberately break it by interpolating datetime.now() into the system text and re-run.

Requirements:

  • Needs a real API key
  • The second identical call shows cache_read_input_tokens > 0
  • After injecting datetime.now() into the system text, cache_read collapses to 0
  • Explain which caching rule the timestamp violated

💡 Hint: The break should live in the system prefix, not the user message — that is what makes the prefix differ and kills the read.

Exercise AP2.2 — Do the break-even by hand, then check it

Context: Caching pays off only past a reuse threshold, so a prefix touched once per session may not be worth it. Working the numbers by hand and checking them against the script builds the intuition.

Your task: Use cache_savings.py to find the percentage saved at 5, 20, and 100 calls for a 20,000-token prefix, then decide whether caching a once-per-session prefix is worth it.

Requirements:

  • Runs offline — no API key needed
  • Report the percentage saved at 5, 20, and 100 calls
  • Decide whether a prefix used exactly once per session is worth caching
  • Back the decision with the 1-call versus 2-call numbers the script prints

💡 Hint: Compare the single-use write cost (1.25x) against paying full price once — one read is never enough to recover a write.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Put cache_control on the stable prefixBeginner

Context: Prompt caching lets a large, unchanging prefix be reused across requests instead of re-billed in full every time. The whole mechanism turns on placing one breakpoint correctly and reading two usage fields to confirm it worked.

Your task: Cache a large shared system prompt with cache_control on the last system block, and name the two usage fields that distinguish a write from a hit.

Requirements:

  • Needs a real API key to run
  • The breakpoint is cache_control: {"type": "ephemeral"} on the last block of the stable prefix
  • The first call shows cache_creation_input_tokens > 0 (write)
  • A later byte-identical call shows cache_read_input_tokens > 0 (read)
  • A read stuck at 0 means the prefix is not actually identical

💡 Hint: Put the big shared context in a system block, mark that block ephemeral, and vary only the user message between the two calls.

Show solution

The breakpoint goes on the last block of the stable prefix:

import anthropic
client = anthropic.Anthropic()

resp = client.messages.create(
    model="claude-opus-4-8", max_tokens=512,
    system=[{
        "type": "text",
        "text": LARGE_SHARED_CONTEXT,          # e.g. a 20k-token manual
        "cache_control": {"type": "ephemeral"},  # 5-minute TTL by default
    }],
    messages=[{"role": "user", "content": "Summarize section 3."}],
)
print(resp.usage.cache_creation_input_tokens)  # >0 on the FIRST call (cache write)
print(resp.usage.cache_read_input_tokens)      # >0 on LATER calls  (cache read)

First call writes the cache (cache_creation_input_tokens); subsequent calls with a byte-identical prefix read it (cache_read_input_tokens). If the read stays 0, something in the prefix is changing.

Exercise 2 · Compute the break-even for caching (OFFLINE)Intermediate

Context: Caching is not free: the write costs more than a normal read, so it only pays off past a break-even number of reuses. Modeling that economics keeps you from caching a prefix that is used only once.

Your task: Write cache_savings(prefix_tokens, n_requests) comparing cached versus uncached total input cost, and show it for a 20k-token prefix over 10 requests.

Requirements:

  • Pure Python — runs offline, no API key
  • Uncached bills the full prefix at base input price on every request
  • Cached bills the first request at 1.25x (5-minute TTL) and each later one at 0.1x
  • Report the percentage saved for the 20k/10 case
  • Make clear that two requests already beat uncached (1.25 + 0.1 < 2)

💡 Hint: Split the cached cost into a one-time write term and a per-remaining-request read term; the more reads per write, the closer you get to the ~90% ceiling.

Show solution

Model the economics directly — pure Python:

IN = 5.0 / 1e6   # $ per token, Opus 4.8 input

def uncached(prefix, n):
    return prefix * IN * n                       # full price every time

def cached(prefix, n):
    write = prefix * IN * 1.25                   # first request: 1.25x
    reads = prefix * IN * 0.10 * (n - 1)         # rest: 0.1x
    return write + reads

p, n = 20_000, 10
print(f"uncached: ${uncached(p, n):.4f}")   # $1.0000
print(f"cached  : ${cached(p, n):.4f}")     # $0.2150
print(f"saved   : {100*(1-cached(p,n)/uncached(p,n)):.0f}%")  # 78%

Two requests already beat uncached at 5-minute TTL (1.25 + 0.1 = 1.35 < 2). The more reads per write, the closer you get to the ~90% ceiling.

Exercise 3 · Find the silent cache invalidatorAdvanced

Context: Caching is a strict prefix match, so a single volatile byte near the front silently defeats it and every request quietly pays full price. Spotting this class of bug is a core caching skill.

Your task: Explain why a system prompt beginning with f"Today is {datetime.now()}. " never caches, and give the fix.

Requirements:

  • Diagnose that a changing byte at position N invalidates everything at positions ≥ N
  • Identify the leading timestamp as the reason the prefix is never repeated
  • Fix by freezing the cached prefix and moving volatile content after the last breakpoint
  • Generalize the rule to other volatile content: UUIDs, per-request ids, unsorted json.dumps

💡 Hint: Anything that differs per request must live after the last cache_control block — move the date into the user message.

Show solution

Caching is a prefix match: one byte change at position N invalidates everything at positions ≥ N.

# BROKEN — the timestamp changes every request, so the prefix never repeats:
system = [{"type": "text",
           "text": f"Today is {datetime.now()}. " + MANUAL,
           "cache_control": {"type": "ephemeral"}}]

# FIXED — freeze the prefix; move volatile content AFTER the last breakpoint:
system = [{"type": "text", "text": MANUAL,
           "cache_control": {"type": "ephemeral"}}]
messages = [{"role": "user",
             "content": f"Today is {datetime.now()}. Summarize section 3."}]

The date sat at the front of the cached prefix, so every request had a unique prefix and paid full price. Anything volatile — timestamps, UUIDs, per-request ids, unsorted json.dumps — must live after the last cache_control breakpoint (or be deleted).

Exercise 4 · Multi-turn caching that accrues hits as the conversation growsExpert

Context: In a growing agent conversation, you want each new turn to read the entire prior history from cache instead of re-billing it. Moving the breakpoint correctly each turn is what makes the read total grow over time.

Your task: In a multi-turn loop, place a breakpoint so every new turn reuses the whole prior conversation prefix, and explain why earlier breakpoints still count as reads.

Requirements:

  • Needs a real API key to run
  • Mark the last content block of the most recent message as the breakpoint each turn
  • The reported cache_read_input_tokens grows as the conversation lengthens
  • Respect the max of 4 breakpoints per request; earlier ones remain valid read points
  • Note the 20-block lookback limit for very long tool-heavy turns

💡 Hint: Set cache_control on the newest turn's last block each step; the system prompt keeps its own breakpoint so both are read.

Show solution

Cache the last content block of the most recent turn each request:

def step(messages):
    # mark the last block of the last message as the cache breakpoint
    last = messages[-1]
    if isinstance(last["content"], list):
        last["content"][-1]["cache_control"] = {"type": "ephemeral"}
    resp = client.messages.create(
        model="claude-opus-4-8", max_tokens=1024,
        system=[{"type": "text", "text": SYSTEM,
                 "cache_control": {"type": "ephemeral"}}],
        messages=messages,
    )
    print("read:", resp.usage.cache_read_input_tokens)  # grows each turn
    return resp

Max 4 breakpoints per request, but earlier ones stay valid read points — so as the conversation grows, each turn reads the whole prior prefix from cache and only the newest turn is full price. Watch the 20-block lookback: very long tool-heavy turns may need an intermediate breakpoint.

Exercise 5 · Audit a prompt-builder for cache-safetyProfessional

Context: The only reliable guarantee of cache reuse is a byte-identical prefix, so cache-safety reduces to one testable invariant. An automated audit catches the usual invalidators before they cost money in production.

Your task: Write audit(build_prompt) that renders two requests and flags a broken cache by diffing their stable prefixes, plus a checklist of usual suspects.

Requirements:

  • Pure offline check — no API key required
  • Render two requests where only the question differs
  • Diff everything before the last breakpoint and require it to be identical
  • Return a clear BROKEN vs OK verdict
  • Call out the common invalidators: datetime.now()/UUID in system, unsorted json.dumps, per-user interpolation, tools rebuilt per request

💡 Hint: Serialize the stable prefix deterministically (e.g. json.dumps(..., sort_keys=True)) and compare the two renders.

Show solution

Prove cache-safety by rendering twice and diffing the prefix bytes:

import json

def render_prefix(req):
    # tools -> system -> messages is the render order; hash the stable prefix
    return json.dumps(req.get("system"), sort_keys=True)

def audit(build_prompt):
    a = build_prompt(question="Q1")
    b = build_prompt(question="Q2")   # only the question should differ
    if render_prefix(a) != render_prefix(b):
        return "BROKEN: system prefix differs between requests -> no cache reads"
    return "OK: prefix stable; volatile content is after the breakpoint"

# Usual suspects it catches: datetime.now()/uuid in system, unsorted json.dumps,
# per-user string interpolation in the prefix, tools reordered/rebuilt per user.

If the stable prefix is not byte-identical across requests, no marker helps. The audit reduces caching to one testable invariant: render two requests, diff everything before the last breakpoint, and demand they match.

Exercise 6 · Design a caching strategy for a multi-tenant RAG serviceIndustry scenario

Context: A multi-tenant RAG service has content at different stability tiers: a frozen global prompt, per-tenant docs, and per-request questions. Layering breakpoints by stability lets each tier cache at its own cadence.

Your task: Design what to cache across tenants — global system prompt, per-tenant docs, per-request question — choosing TTL and breakpoint placement, and defend the ordering.

Requirements:

  • Order blocks stable→volatile: global first, tenant docs next, question last
  • The frozen global prompt is a shared prefix reusable across all tenants
  • Per-tenant docs get their own breakpoint for reuse within a tenant session
  • The per-request question has no breakpoint and is always full price
  • Justify a 1h TTL for bursty traffic (doubles write cost, survives idle gaps, needs ~3 reads to pay off)
  • Keep both shared tiers before the question or cross-tenant sharing collapses

💡 Hint: Think of the prompt as concentric stability rings; a breakpoint after each shared ring lets the global tier be reused even across different tenants.

Show solution

Layer the prompt by stability so each tier caches at its own cadence (design + code):

def build(tenant_docs, question, tenant_id):
    return dict(
        model="claude-opus-4-8", max_tokens=1024,
        system=[
            # Tier 1: frozen, shared across ALL tenants -> global cache
            {"type": "text", "text": GLOBAL_SYSTEM,
             "cache_control": {"type": "ephemeral", "ttl": "1h"}},
            # Tier 2: per-tenant retrieved docs -> per-tenant cache, bursty traffic
            {"type": "text", "text": tenant_docs,
             "cache_control": {"type": "ephemeral", "ttl": "1h"}},
        ],
        # Tier 3: volatile per-request question -> NO breakpoint, always full price
        messages=[{"role": "user", "content": question}],
    )

Order stable→volatile: global first (shared prefix reused across tenants), tenant docs next (reused within a tenant session), question last (unique, uncached). Use 1h TTL for bursty tenant traffic — it doubles the write cost but survives idle gaps, so it needs ~3 reads to pay off; keep the two shared tiers before the per-request question or cross-tenant sharing collapses.

✓ Checkpoint — you can move on when you can…

  • Explain prompt caching as a prefix match and where the breakpoint goes.
  • Say why a cache read is cheaper (~0.1×) and faster than a fresh prefix.
  • Read cache_creation_input_tokens / cache_read_input_tokens to prove a hit.
  • List what invalidates a cache entry and why the system prompt must be frozen.
  • Do the write (~1.25×) vs read (~0.1×) cost math and find the break-even.
  • Design a multi-breakpoint, monitored caching strategy for production.

Knowledge check check yourself

✓ Knowledge check

How does the prefix-match rule determine a prompt-cache hit, and what happens if a single byte before the breakpoint changes?

Show answer
The cache key is the exact bytes up to the cache_control breakpoint (rendered as tools then system then messages). Any byte change anywhere before the breakpoint invalidates everything after it, forcing a cold write instead of a read, which is why the cached prefix must be frozen.
✓ Knowledge check

Given a cache read costs ~0.1x and a write ~1.25x of normal input price, why does a single cached call lose money, and where is break-even?

Show answer
The first call pays the ~1.25x write premium to populate the cache, so one call alone costs more than not caching. Break-even is the second call: from then on each hit reads at ~0.1x, so caching only pays off when the same prefix is reused, ideally many times within the ~5-minute TTL (which resets on each hit).
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in