Prompt caching, section 2 of 2, 4h

The cost maths, and caching whole responses

By the end: You can work out whether caching pays for a given traffic pattern, and cache responses without serving wrong answers.

  • Cache writes, cache reads and expiry
  • Working out whether it pays
  • Exact response caching
  • Semantic caching and its risks
  • What must be in a cache key

New to prompt caching? Most calls to a model repeat a great deal of text: the same instructions, the same examples, the same reference documents, followed by a small changing question. Prompt caching lets the provider keep the processed form of that unchanging opening stretch, so it does not have to be worked through again. Send the same opening twice and the second call reuses it, at a fraction of the price and noticeably faster. Why it matters

Prefix caching is not free, and whether it saves you anything depends on your traffic rather than your prompt. Writing a prefix into the cache costs more than plain input, reading it back costs much less, and the entry expires after minutes without use. With steady traffic that is a large saving; with one request an hour it is a loss. This section is that arithmetic, and then caching at the other end — storing whole responses, where an exact key is safe and a semantic one can serve a confidently wrong answer to a question that merely resembled an earlier one.

Writes, reads and expiry#

Prefix caching is not free on every provider. On some, writing a prefix into the cache costs a little more than normal input, and reading it back costs much less. Cached prefixes also expire after a period without use, often minutes, and providers set a minimum prefix length. Look up the current terms for your provider; they change.

Does it pay? Work it out#

Illustrative prices per million input tokens (check your provider):
  normal input   $3.00
  cache write    $3.75
  cache read     $0.30

Prefix of 8,000 tokens, 1,000 requests in the cache lifetime:
  without cache  1,000 x 8,000 x $3.00 / 1M          = $24.00
  with cache     1 write:  8,000 x $3.75 / 1M        = $0.03
                 999 reads: 999 x 8,000 x $0.30 / 1M = $2.40
                                                total = about $2.43

With steady traffic it is a large saving. With one request every hour and a cache that expires in minutes, every request pays the write price and caching costs more than not caching. Traffic pattern decides.

The variable is time, not the request count#

The prompt-caching section works out break-even in requests and finds it at the second one. That answer is only right if the second request arrives before the entry expires, and whether it does is a property of your traffic rather than your prompt. Model the arrivals as a Poisson process with a mean gap and the chance that the previous request is still cached is one minus the exponential of minus the lifetime over the gap. That gives an expected cost per request you can put a number on.

import math

BASE, WRITE, READ = 1.0, 1.25, 0.10      # multipliers on a normal input token
TTL = 300                                # seconds an unused entry survives

def effective(gap, ttl=TTL):
    """Poisson arrivals: the chance the previous request is still cached."""
    p_hit = 1 - math.exp(-ttl / gap)
    return p_hit, p_hit * READ + (1 - p_hit) * WRITE

print(f"{'mean gap':>10}{'hit rate':>10}{'cost/req':>10}{'vs no cache':>13}")
for gap in (5, 30, 120, 300, 900, 1800, 3600):
    p, c = effective(gap)
    print(f"{gap:>9}s{p:>10.0%}{c:>10.2f}{1 - c / BASE:>12.0%}")

p_break = (WRITE - BASE) / (WRITE - READ)
gap_break = -TTL / math.log(1 - p_break)
print(f"break-even hit rate {p_break:.1%}, reached at a mean gap of {gap_break:.0f}s")

#   mean gap  hit rate  cost/req  vs no cache
#         5s      100%      0.10         90%
#        30s      100%      0.10         90%
#       120s       92%      0.19         81%
#       300s       63%      0.52         48%
#       900s       28%      0.92          8%
#      1800s       15%      1.07         -7%
#      3600s        8%      1.16        -16%
# break-even hit rate 21.7%, reached at a mean gap of 1224s
Read the last two rows: at a request every half hour against a five-minute lifetime, caching costs 7% more than not caching, and at one an hour it costs 16% more. The break-even gap works out at almost exactly four times the lifetime, which is the rule of thumb worth keeping.

That break-even hit rate falls straight out of the multipliers rather than out of the traffic: a write costs a quarter more than plain input and a read costs a tenth of it, so a hit has to happen often enough to repay the extra quarter, which is 0.25 divided by 1.15, or about 22%. Substitute your provider's numbers and the shape holds. It also tells you what to do about a prefix that is used too rarely — batch the work so requests arrive together, or pay for a longer lifetime where the provider sells one, and compare that price against the 25% write premium you are paying repeatedly instead.

Caching the whole response#

If the exact same request arrives again, you can return the stored answer and skip the model entirely. This suits repeated, non-personal questions and deterministic pipelines. The key must include everything that affects the answer.

import hashlib, json

def cache_key(model, prompt_version, messages, settings, tenant_id):
    raw = json.dumps([model, prompt_version, messages, settings, tenant_id], sort_keys=True)
    return hashlib.sha256(raw.encode()).hexdigest()

a = {"temperature": 0.0, "max_tokens": 512}
b = {"max_tokens": 512, "temperature": 0.0}      # the same settings, built elsewhere
msgs = [{"role": "user", "content": "What is the refund window?"}]

print("same key with sort_keys:  ",
      cache_key("m-1", "v3", msgs, a, "acme")[:12],
      cache_key("m-1", "v3", msgs, b, "acme")[:12])
print("without sort_keys:        ",
      hashlib.sha256(json.dumps(a).encode()).hexdigest()[:12],
      hashlib.sha256(json.dumps(b).encode()).hexdigest()[:12])
print("different tenant:         ",
      cache_key("m-1", "v3", msgs, a, "acme")[:12],
      cache_key("m-1", "v3", msgs, a, "globex")[:12])

# same key with sort_keys:   5d721c1d80c1 5d721c1d80c1
# without sort_keys:         0f6fb34da972 a78cfe8d527c
# different tenant:          5d721c1d80c1 b19d2c710f32
The middle line is a whole afternoon of confusion: two dictionaries that compare equal in Python serialise to different bytes and hash to different keys, so the cache has a hit rate of zero and no error anywhere. sort_keys is the fix, and a hit-rate metric is how you notice.
  • The model id, including the dated version. A new model returns a different answer to the same prompt.
  • The prompt version, bumped on every deploy that touches the template. Otherwise yesterday's wording is served all week.
  • Every message, in full, and the sampling settings. Temperature above zero means the stored answer is one sample of many, which is a decision to make deliberately.
  • The tenant or user, whenever the answer could differ between them. This is the field whose absence becomes an incident rather than a bug.
  • A version of the underlying data, if the answer depends on retrieved documents. Changing a document has to invalidate the answers built from it.

An exact cache is safe when the same key genuinely implies the same correct answer, and that is a claim about your application rather than about caching. It holds for a documentation assistant answering the same five hundred questions all day. It does not hold for anything that reads live state, anything personalised, or anything where yesterday's answer is stale rather than merely old, and for those cases a short time to live is not a fix but a smaller version of the same bug.

Semantic caching is riskier than it looks#

A semantic cache returns a stored answer when a new question is similar enough to an old one, judged by embeddings. The danger is that similar is not the same. 'Cancel my order' and 'do not cancel my order' are close in embedding space. Answers that depend on who is asking can leak between users.

That is easy to say and easy to dismiss, so measure it. Take five cached questions, five genuine paraphrases that should hit them, and six near-misses that must not — the annual plan rather than the monthly one, the paid tier rather than the free one, a negated version of the same sentence. Then sweep the threshold and count, at each one, how many paraphrases hit the right entry and how many near-misses hit anything at all.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

CACHED = {
    "How do I cancel my subscription?": "Open Billing and choose Cancel plan.",
    "What is the refund window on the monthly plan?": "Fourteen days, in full.",
    "How do I reset my password?": "Use Forgot password on the sign-in page.",
    "Is there a discount for annual billing?": "Yes, two months free.",
    "What is the rate limit on the free tier?": "Sixty requests per minute.",
}
PARAPHRASE = {
    "How can I cancel my subscription?": "How do I cancel my subscription?",
    "Where do I reset my password?": "How do I reset my password?",
    "What is the refund period for monthly plans?": "What is the refund window on the monthly plan?",
    "Do you give a discount if I pay annually?": "Is there a discount for annual billing?",
    "How many requests per minute does the free tier allow?": "What is the rate limit on the free tier?",
}
NEAR_MISS = [
    "What is the refund window on the annual plan?",
    "What is the rate limit on the paid tier?",
    "I do not want to cancel my subscription, how do I keep it?",
    "How do I reset another user's password?",
    "Is there a discount for annual billing for non-profits?",
    "How do I cancel my free trial before it becomes a subscription?",
]

keys = list(CACHED)
vec = TfidfVectorizer().fit(keys + list(PARAPHRASE) + NEAR_MISS)   # stands in for embeddings
K = vec.transform(keys)

def nearest(question):
    sims = cosine_similarity(vec.transform([question]), K)[0]
    i = int(sims.argmax())
    return keys[i], float(sims[i])

print(f"{'threshold':>10}{'true hits':>11}{'wrong hits':>12}{'misses':>8}")
for t in [0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90]:
    hits = sum(nearest(q)[1] >= t and nearest(q)[0] == want for q, want in PARAPHRASE.items())
    wrong = sum(nearest(q)[1] >= t for q in NEAR_MISS)
    print(f"{t:>10.2f}{hits:>11}{wrong:>12}{len(PARAPHRASE) - hits:>8}")

right = [nearest(q)[1] for q, want in PARAPHRASE.items() if nearest(q)[0] == want]
wrong = [nearest(q)[1] for q in NEAR_MISS]
print(f"lowest correct paraphrase {min(right):.2f}, highest near-miss {max(wrong):.2f}")

for q in sorted(NEAR_MISS, key=lambda q: -nearest(q)[1])[:3]:
    match, sim = nearest(q)
    print(f"{sim:.2f}  {q}")
    print(f"      served: {CACHED[match]}")

#  threshold  true hits  wrong hits  misses
#       0.30          3           6       2
#       0.40          3           6       2
#       0.50          3           6       2
#       0.60          2           4       3
#       0.70          2           3       3
#       0.80          0           3       5
#       0.90          0           0       5
# lowest correct paraphrase 0.13, highest near-miss 0.88
# 0.88  What is the refund window on the annual plan?
#       served: Fourteen days, in full.
# 0.86  What is the rate limit on the paid tier?
#       served: Sixty requests per minute.
# 0.82  Is there a discount for annual billing for non-profits?
#       served: Yes, two months free.
TF-IDF stands in for an embedding model so the run is reproducible without a network call, and it flatters nobody: the lowest true paraphrase scores 0.13 while the highest near-miss scores 0.88, so the two populations do not separate at any threshold. A real embedding model lifts the paraphrase column and leaves the near-miss column where it is, because a plan name and a tier name are exactly the kind of one-word difference embeddings are built to ignore.

Read the three worst rows as answers rather than as numbers. A customer on the annual plan is told the refund window is fourteen days. A customer on the paid tier is told their rate limit is sixty requests a minute. Both answers are fluent, confident, cached and wrong, and neither raises an error anywhere in your system — which is the difference between a semantic cache and every other cache you have ever run.

  • Use it only for questions whose answer does not depend on the user.
  • Set the similarity threshold from an eval of near-miss pairs, not from intuition.
  • Scope the cache by tenant, and expire entries when the underlying data changes.
  • Build the near-miss set out of the entities your answers turn on — plan names, tiers, regions, dates — because those are the words a single embedding will not keep apart.
  • Log every semantic hit with both questions and the similarity. It is the only record that lets you find out afterwards what was served and to whom.

The cheap version of a semantic cache

Most of the saving people want from a semantic cache is available from an exact cache over a normalised question: lowercase, strip punctuation and whitespace, and hash that. It catches the repeats that actually dominate support traffic, which are the same question typed again rather than a clever paraphrase, and it cannot serve the annual-plan customer the monthly-plan answer. Try that first, measure the hit rate, and only reach for embeddings if the number is disappointing and you are prepared to run the threshold sweep above every time the corpus changes.

Where people go wrong

Adding a semantic cache to a support bot and discovering it answered one customer's question with another customer's account details.

If this comes up in an interview

When does prompt caching not save money?

When requests are too infrequent for the cache to stay warm, so most requests pay the write price, or when the prompt changes near the start so the prefix rarely matches. Work out the numbers from your real traffic before assuming a saving.

Then they push further

How would you confirm the saving in production?

Read the cached and uncached token counts the API reports for each request, compute the hit rate and the effective price per request, and compare with the month before.

What gets marked up. Strong answers reason from traffic pattern and prices, not from 'caching is always cheaper'.

What are the risks of semantic caching?

Returning an answer to a question that is similar but means something different, and leaking answers between users when responses are personal. Limit it to non-personal questions, tune the threshold on near-miss examples, and scope it by tenant.

Then they push further

What would be in your cache key for an exact response cache?

The model and version, the prompt version, the full messages, the sampling settings, and the tenant or user when answers can differ between them. Leaving one out means serving an answer produced under different conditions.

Your cached prefix has a five-minute lifetime. How often do requests have to arrive before caching pays?

Break-even is a hit rate, and it comes from the multipliers: a write costs about 25% more than plain input and a read a tenth of it, so a hit has to repay the extra quarter, which is 0.25 over 1.15, or roughly 22%. Treating arrivals as Poisson, that hit rate is reached when the mean gap is about four times the lifetime, so with five minutes you need a request roughly every twenty minutes. Rarer than that and caching costs more than not caching.

Then they push further

It is rarer than that and you cannot change the traffic. What now?

Make the requests arrive together rather than spread out, by batching the work, so one write serves several reads. Or buy a longer lifetime if the provider sells one and compare its price against the write premium you are paying repeatedly. Or accept that this prefix should not be cached and spend the effort on a prefix that is.

What gets marked up. Strong answers reason in hit rate against the lifetime rather than in total request count, and know that a cache can be a net loss rather than merely ineffective.

Build this: Take your own traffic, or simulate a day of it, and compute cost with and without prefix caching using your provider's current prices. Then add an exact response cache to one endpoint and measure the hit rate over a week.

This is one section of Prompt caching, 9 hours across 2 sections. Read it in full to get the whole topic in order.

Stop 14 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeTool callingThe model asks; your code acts.7h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.