Caching LLM Responses: Cutting Cost Without Cutting Quality
A practitioner’s guide to LLM caching: prompt caching mechanics, exact-match cache keys, why semantic caching fails on negation, and how to prove quality
A support assistant we inherited last year was burning roughly $840 a day in model spend on 38,000 requests. Nobody had looked at the prompt in four months. When we logged the actual traffic, 31% of requests were character-for-character duplicates of something answered in the previous hour, and every single request was re-sending the same 4,200 token system prompt and product summary at full price.
Three days of work took that to about $290 a day. No model downgrade, no shorter answers, no measurable change in the eval suite. Most of the win came from two mechanisms that get lumped together under llm caching but behave nothing alike: provider-side prompt caching, and an exact-match response cache in front of the API.
The third mechanism, semantic caching, is the one every listicle leads with. It’s also the one most likely to quietly hand a customer the wrong answer. We’ll get to why.
- Prompt caching and response caching solve different problems. Prompt caching cuts input token cost (typically 50% to 90% off the cached portion) and time to first token, but you still pay full price for output. Response caching makes a hit cost literally nothing.
- Cache hit rates live or die on prompt ordering. Anything that varies, timestamps, user names, retrieved chunks, must sit after the stable prefix, or you invalidate the whole thing on every call.
- Your cache key needs the model name, temperature, top_p, response format, system prompt hash, tool schema hash and tenant ID. Leave out tenant ID and you will serve one customer’s answer to another.
- Semantic caching fails hardest on negation, numbers and dates. “Can I cancel after 30 days” and “Can I cancel before 30 days” sit above 0.95 cosine similarity on most embedding models. Gate hits with explicit guardrails, not just a threshold.
- Measure quality before and after with a fixed eval set, and keep a 1% to 5% shadow sample that bypasses the cache so you can detect drift instead of guessing.
Three caches, and only one of them is risky
Conflating these three is the root of most bad advice on the topic. They sit at different layers, have different failure modes and different cost curves.
- Provider prompt caching. The provider keeps the attention KV state for a prompt prefix warm on their side and charges you a fraction of the normal input rate to reuse it. The model still runs. Output is freshly generated every time. Quality risk: zero. There is no scenario where prompt caching changes the answer you’d otherwise get.
- Exact-match response caching. You hash the full request and store the completion in Redis or similar. A hit skips the API entirely. Cost on hit: zero plus a sub-millisecond Redis round trip. Quality risk: staleness only, which you control with TTLs and invalidation.
- Semantic caching. You embed the incoming query, search a vector index of previous queries, and if something is “close enough” you return its stored answer. Cost on hit: one embedding call. Quality risk: substantial and non-obvious.
Deploy them in that order. We’ve never seen a project where skipping straight to semantic caching was the right call. Layers one and two typically get you 60% to 70% of the available savings with essentially no downside.

Prompt caching cuts the input bill, not the output bill
The mechanics differ by provider but the shape is the same. Anthropic charges 1.25x base input rate to write a cache entry with a 5 minute TTL (2x for the 1 hour option) and 0.1x to read it, with a minimum cacheable prefix of 1,024 tokens on most models and up to four breakpoints. OpenAI does automatic prefix caching above 1,024 tokens with no write premium, and the read discount varies by model generation, roughly 50% on the GPT-4o era and up to 90% on newer ones. Gemini runs implicit caching by default and offers explicit context caching with a storage charge per token-hour, which only makes sense for genuinely large contexts you query repeatedly.
The hard part isn’t enabling it. It’s prompt architecture. Caching works on exact prefix matches, so one variable token near the top destroys everything below it.
The pattern that keeps failing: someone puts Current date: 2026-02-14T09:31:02Z in the first line of the system prompt. Every request now has a unique prefix. Cache hit rate: 0%. The fix is to round that timestamp to the day, or better, move it to the end of the user turn where it belongs.
const res = await anthropic.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 1024,
system: [
// Stable blocks first, longest and least volatile at the top.
{ type: 'text', text: TOOL_INSTRUCTIONS }, // ~900 tokens, changes on deploy
{ type: 'text', text: PRODUCT_CATALOGUE, // ~3,300 tokens, changes weekly
cache_control: { type: 'ephemeral' } } // breakpoint AFTER everything stable
],
messages: [
// Volatile content lives here, below the breakpoint.
{ role: 'user', content: Today is ${today}.\n\n${retrievedChunks}\n\n${question} }
]
});
// Log these on every call. If cachereadinput_tokens is 0, your prefix is broken.
log.info({
read: res.usage.cachereadinput_tokens,
written: res.usage.cachecreationinput_tokens
});
Run the arithmetic on your own traffic before you celebrate. At Sonnet-class list pricing ($3 per million input, $15 per million output), that client’s 4,200 token static prefix across 38,000 daily requests cost $479 a day uncached. Served from cache at 0.1x, plus cache writes refreshed every five minutes, it came to about $52. The remaining 1,600 variable tokens and all 320 output tokens per request were untouched. That’s exactly why prompt caching alone only halved the bill.
When prompt caching does not pay
Short prompts under the minimum threshold get nothing. High-cardinality prefixes (per-user system prompts, personalised instructions injected at the top) get nothing. On Anthropic’s write premium, a prefix you touch once every twenty minutes with a 5 minute TTL costs you 1.25x repeatedly and returns nothing. Traffic has to be dense enough to keep entries warm.
Exact-match response caching: the key is the whole problem
This is the layer that actually eliminates cost, and it’s also where we see the ugliest bugs. Not because the caching is hard, but because people build the key carelessly.
import { createHash } from 'node:crypto';
const CACHE_VERSION = 'v4'; // bump on prompt change to invalidate every entry at once
const sha = (s) => createHash('sha256').update(s).digest('hex');
export function cacheKey({ model, system, question, params, tenantId, toolSchemaHash }) {
const normalised = question
.replace(/\r\n/g, '\n')
.replace(/[ \t]+/g, ' ')
.trim(); // whitespace only. Lowercasing breaks code, SKUs and proper nouns.
return llm:${CACHE_VERSION}: + sha(JSON.stringify({
model, // claude-sonnet-4-5 and claude-haiku-4-5 must never share an entry
temperature: params.temperature,
topp: params.topp,
maxtokens: params.maxtokens,
responseformat: params.responseformat ?? null,
system: sha(system),
toolSchemaHash, // changing a tool's JSON schema changes the answer
tenantId, // omit this and you leak answers across customers
normalised
}));
}
Two opinions worth arguing with. First: aggressive normalisation is a trap. Stripping punctuation turns “Let’s eat, Grandma” into something else, and lowercasing a prompt containing a case-sensitive API key or a SKU gives you a hit on a genuinely different question. Normalise whitespace and line endings. Stop there.
Second: cache the structured result, not the prose. If the model returns JSON you then render, store the parsed and validated object, keyed by schema version. You get free re-rendering when the template changes, and you can inspect what you cached. The same argument we make about versioning webhook payloads applies here: a cached artefact is a contract, and contracts need version numbers.
On hit rates: anything over 25% on conversational traffic is good. Single-turn classification, moderation, tagging and extraction jobs routinely hit 60% to 85% because the input distribution is narrow. Long multi-turn chat hits almost nothing at exact match, because the conversation history makes every request unique. Accept that and spend your effort on prompt caching for those paths instead.
Semantic caching is where quality quietly dies
The pitch is seductive: embed the query, find a similar past query, return its answer. In practice, cosine similarity measures topical overlap, not semantic equivalence, and the gap between those two concepts is exactly where your support team starts getting angry tickets.
Try these pairs against any current embedding model:
- “Can I cancel after 30 days?” versus “Can I cancel before 30 days?”
- “Is the Pro plan included in the trial?” versus “Is the Pro plan excluded from the trial?”
- “Ship to Ireland” versus “Ship to Iceland”
- “What’s my invoice total?” versus “What was my invoice total last year?”
Every pair lands high, often above 0.94. Negation barely moves an embedding. Numbers and dates move it less than you’d expect. A threshold alone cannot save you, so the gate needs explicit rules on top:
const POLARITY = /\b(not|never|no longer|except|without|cancel|refund|exclude|instead)\b/i;
const nums = (s) => (s.match(/\d+(?:[.,]\d+)?/g) ?? []).join('|');
function eligible(query, hit) {
if (hit.score 6 60 60 * 1000) return false; // short TTL: policy text changes
if (hit.confidence === 'low') return false; // never promote a hedged answer
return true;
}
Our position: semantic caching belongs on FAQ-shaped, read-only, non-personalised, non-transactional queries. Documentation lookups. Product questions. “How do I reset my password.” It does not belong anywhere near account data, pricing quotes, order status, medical or legal content, or anything that writes to a database. On client work we gate it behind an intent classifier and whitelist the intents, rather than letting it see all traffic.
TTLs, invalidation and a staleness budget
Pick a staleness budget per content class and write it down, because “how long is too old” is a product decision, not an engineering one. The split that has held up across projects:
- Immutable derivations (summarise this uploaded PDF, extract entities from this fixed string): cache indefinitely, keyed by content hash. There is no staleness.
- Documentation and policy answers: 6 to 24 hours, with event-based purge wired into your CMS publish hook.
- Anything touching live data: don’t cache the answer. Cache the retrieval, or the embedding, or nothing.
Event-based invalidation beats short TTLs. Tag entries by the source documents that fed them, then purge by tag when a document changes. If you’ve built tag-based purging for a custom REST endpoint with proper cache headers, it’s the same mechanism, just with a vector or hash key instead of a URL.
One operational detail people miss: cache the fact that a request failed validation, but never cache a truncated response. If stop_reason is a length cap, or a tool call came back malformed, or your JSON schema validation failed, write nothing. We’ve debugged a production cache serving a half-finished sentence to thousands of users for two days because the write happened before validation.
Proving you did not cut quality
Cost savings are trivially measurable. Quality regressions are not, which is why most teams ship a cache and then have no idea whether it hurt. Three things make this tractable.
A frozen eval set. Between 100 and 300 real queries with graded reference answers, run through the full stack with the cache enabled and disabled. If the cached path scores within noise of the uncached path, you’re fine. If it doesn’t, your similarity threshold or TTL is wrong.
Shadow sampling. Route 1% to 5% of cache-eligible traffic past the cache, call the model anyway, and diff the two answers asynchronously. A rising divergence rate is your early warning that the underlying data moved and your TTLs are too long. This costs you a few percent of the savings, and it’s the best money in the whole system.
Per-layer attribution. Log which layer served every response: exact, semantic, promptcachehit, cold. Then track thumbs-down rate, escalation rate and follow-up rate per layer. On one deployment, semantic hits showed a 2.4x higher follow-up question rate than cold responses, which told us the answers were technically related but not actually responsive. We cut the eligible intents from nine to three.
This kind of instrumented, measure-then-decide discipline is the same reason we’re picky about which AI tools earn a place in the workflow. Anything that can’t be measured gets removed eventually.
The default stack we ship
For a typical client assistant or content pipeline, here’s the configuration we start from and tune down:
- Restructure the prompt so the stable prefix is first and over 1,024 tokens, with volatile content below the breakpoint. Verify with
cachereadinput_tokensin production logs, not locally. - Redis exact-match cache with a fully specified key, 1 hour TTL for conversational paths, content-hash keys with no expiry for deterministic transforms. Writes gated behind schema validation.
- Cache the embedding calls too. They’re cheap individually and enormous in aggregate on any RAG pipeline, and embeddings of fixed text never go stale.
- Semantic cache off by default. Enabled per intent, threshold 0.95 plus polarity and numeric guardrails, 6 hour TTL, logged separately.
- 5% shadow sample, weekly eval run in CI against the frozen set.
That combination is what took the $840 a day case to roughly $290 without touching the model or the output length. Roughly half from prompt caching, most of the rest from exact-match hits, and almost nothing from semantic, which we eventually limited to three intents.
Frequently Asked Questions
Does prompt caching change the model’s output?
No. Prompt caching reuses the key-value attention state for an identical prefix, so the model computation that follows is the same as it would have been. You get identical sampling behaviour and the same variability you’d see without caching. It only affects price and time to first token.
Do I need temperature 0 for a response cache to work?
No, but you should understand what you’re freezing. At temperature 0.8, the first answer you cache is one sample from a distribution, and you’ll now serve that exact sample to everyone who asks the same question. For factual and support use cases that’s usually an improvement because it makes answers consistent. For creative generation it’s wrong, so either skip the cache or include a nonce in the key.
What similarity threshold should I use for a semantic cache?
Start at 0.95 cosine and measure, rather than trusting any number from a blog post including this one. Thresholds are model-specific and domain-specific; the same 0.92 that’s safe for product documentation is dangerous for billing questions. The threshold matters less than the guardrails you layer on top for negation, numbers and dates.
How do I handle streaming responses with a cache?
Store the fully assembled text after the stream completes and validation passes, then replay cached hits as a synthetic stream so the client code path stays identical. Chunk the replay at roughly 20 to 40 tokens with a small delay if the UI expects progressive rendering. Never write partial content mid-stream: an aborted request will poison the entry.
Where should the cache live, in my app or at the edge?
Start in your application layer with Redis, because that’s where you have the tenant context, the schema validation and the invalidation hooks. Edge caching LLM responses only works for genuinely public, non-personalised content, and the moment you add an auth header to the key you’ve lost most of the hit rate. Application layer first, edge only for a narrow public subset.
Where to start tomorrow
Pull a day of production prompts and run two numbers: the percentage of requests that are byte-identical duplicates, and the token length of your longest stable prefix. If duplicates are above 15%, build the exact-match cache first. If the stable prefix is over 1,024 tokens and you’re not seeing cache reads in your usage logs, fix the prompt ordering first. It’s an afternoon of work for a 40% to 50% cut in input cost with zero quality risk.
Leave semantic caching until last, and only turn it on for intents you can name. The teams that reduce AI cost without regressions are the ones that treat prompt caching as an architecture problem and response caching as a correctness problem. The ones that get burned treat both as a library you install.


