AI Engineering

Prompt Caching: The Cheapest Win in Your LLM Bill

Prompt caching is a prefix match, and one stray timestamp can silently disable it. How cache breakpoints, TTLs and the usage fields actually work — and how to build Go prompt assembly that stays cacheable.

10 min read 2048 words
Prompt Caching: The Cheapest Win in Your LLM Bill

I have written on this blog about caching database reads with Ristretto and about teaching Nginx to cache by cookie. Prompt caching belongs in the same family, with one difference that makes it far more interesting: the thing you are caching costs real money per byte, and when the cache stops working, nothing breaks. No error, no alert, no failed request. Just a bigger invoice next month.

One Invariant, Everything Follows From It

Prompt caching is a prefix match. Any change anywhere in the prefix invalidates everything after it.

That is the whole model. The cache key is derived from the exact bytes of your rendered prompt up to each breakpoint. One byte different at position N — a timestamp, a reordered JSON key, an extra tool in the list — and every cached position at or after N is gone.

The render order matters and is fixed:

tools  →  system  →  messages

Tools render first, at position zero. That has a consequence people discover the expensive way: change the tool list and you have invalidated everything, system prompt and entire conversation included. More on that below.

What It Costs

Two numbers govern whether caching pays:

  • A cache read costs about 0.1× the base input price.
  • A cache write costs 1.25× for the 5-minute TTL, for the 1-hour TTL.

So with the default 5-minute TTL, two requests already break even: 1.25 + 0.1 = 1.35 against 2.0 uncached. By the third request you are well ahead. With the 1-hour TTL you need three requests to break even, because the write costs double.

Which is why the TTL question is not “how long do I want this cached” but “how far apart do requests sharing this prefix start”:

Start-to-start gap Use
Under 5 minutes 5-minute TTL. Every read refreshes the timer, so continuous traffic keeps it warm indefinitely and it is strictly cheaper.
5–60 minutes 1-hour TTL. This is the only window where the doubled write price earns its keep.
Over an hour Neither, directly. Re-warm on a schedule or accept the cold miss.

The subtlety in row one: a read refreshes the entry at no extra cost, and the lifetime is measured from the start of the request. A four-minute generation leaves about one minute for the next request to begin before a five-minute entry expires. For a chat endpoint under steady load, the 5-minute TTL is the right answer and the 1-hour TTL just doubles your write bill.

Making It Work in Go

The syntax is a CacheControl on the last block of whatever you want cached. Because tools render before system, a marker on the final system block caches both:

params := anthropic.MessageNewParams{
    Model:     "claude-opus-5",
    MaxTokens: 16000,
    Tools:     tools, // deterministic order — see below
    System: []anthropic.TextBlockParam{{
        Text:         systemPrompt,
        CacheControl: anthropic.NewCacheControlEphemeralParam(), // 5-minute default
    }},
    Messages: messages,
}

For the 1-hour TTL:

CacheControl: anthropic.CacheControlEphemeralParam{
    TTL: anthropic.CacheControlEphemeralTTLTTL1h,
},

There is also a top-level CacheControl on MessageNewParams that automatically places a breakpoint on the last cacheable block and moves it forward as the conversation grows. For multi-turn chat that is the right default — no marker bookkeeping, and the growing history caches incrementally.

The robust combination for anything agentic: one explicit breakpoint at the end of the static system prefix, so the expensive shared part has a guaranteed read point no matter what happens later in messages, plus top-level automatic caching for the growing tail.

You get four breakpoints per request, so there is no need to be frugal — place them at genuine stability boundaries.

Where Automatic Caching Is the Wrong Tool

Automatic placement puts the breakpoint at the very end of your prompt. When the prompt ends with something unique per request — a retrieved document, the user’s actual question — that is a pure surcharge: every request writes a new cache entry that nothing will ever read.

The signature is unmistakable once you know it: cache_creation_input_tokens is non-zero on every single request, while cache_read_input_tokens never covers the shared prefix.

The fix is an explicit marker at the end of the shared portion:

Messages: []anthropic.MessageParam{
    anthropic.NewUserMessage(
        anthropic.TextBlockParam{
            Text:         sharedContext, // few-shot examples, retrieved docs
            CacheControl: anthropic.NewCacheControlEphemeralParam(),
        },
        anthropic.NewTextBlock(userQuestion), // no marker — differs every time
    ),
},

Same rule, restated: put the breakpoint where the prompt stops being shared, not where the prompt ends.

The Minimum Prefix, Which Is Not Monotonic

A prompt shorter than the model’s minimum will not cache — no error, no warning, cache_creation_input_tokens simply comes back zero. And the minimum does not move in the direction you would guess as models get newer:

Model Minimum
Claude Opus 5, Fable 5 512 tokens
Opus 4.8, Sonnet 5, Sonnet 4.6 1024 tokens
Opus 4.7 2048 tokens
Opus 4.6, Haiku 4.5 4096 tokens

A 3,000-token system prompt caches on Opus 5 and Opus 4.8, and silently does not on Opus 4.6 or Haiku 4.5. If you switched models and your cache hit rate fell off a cliff, this is the first thing to check — and it cuts the other way too: moving to Opus 5 halves the Opus 4.8 minimum, so prompts that were previously too short start caching with no code change at all.

Silent Invalidators

This is the part worth committing to memory, because every one of these is code that looks perfectly reasonable in review.

Pattern Why it kills the cache
time.Now() in the system prompt The prefix differs on every single request
A request ID or UUID early in the content Same — every request is unique
json.Marshal of a map in the prompt Go randomises map iteration order; the bytes differ run to run
Ranging over a map to build tool definitions Same problem, at position zero, which is the worst place for it
User or session ID interpolated into the system prompt A per-user prefix; nothing shares anything
if flag { system += ... } Every flag combination is a distinct prefix
A tool set that varies per user or per mode Tools render first — nothing caches across users

The Go-specific ones deserve emphasis. Map iteration order in Go is deliberately randomised, so this is not a cache bug that appears under load — it appears on every request, and it is invisible because the rendered prompt is semantically identical each time:

// Bad: iteration order is randomised, so the bytes differ every run.
for name, def := range h.tools {
    tools = append(tools, def)
}

// Good: deterministic.
names := slices.Sorted(maps.Keys(h.tools))
for _, name := range names {
    tools = append(tools, h.tools[name])
}

Sorting keys is a one-line fix that most Go LLM code needs and almost none has.

Injecting Dynamic Context Without Breaking Everything

The usual reason a system prompt has a timestamp in it is that the model genuinely needs to know the date, or the user’s plan tier, or the current mode. The instinct is to template it into the system prompt. Don’t — that is the front of the prefix, and it invalidates everything behind it.

Put dynamic context after the cached history instead. On the newest models there is a first-class channel for this: a system-role message appended to messages, rather than an edit to the top-level system field.

// The top-level system prompt stays byte-identical and stays cached.
// The operator instruction goes after the history, invalidating nothing before it.
messages = append(messages, userTurn)
messages = append(messages, anthropic.MessageParam{
    Role: anthropic.MessageParamRoleSystem,
    Content: []anthropic.ContentBlockParamUnion{
        anthropic.NewTextBlock("Terse mode enabled — keep responses under 40 words."),
    },
})

A message at turn five invalidates nothing before turn five. That is the whole trick.

Two constraints: it must follow a user message and be either the last entry or followed by an assistant turn — it cannot be messages[0], so use the top-level system for the initial prompt. And support is model-dependent; unsupported models return a 400 saying the system role is not supported, so catch that and fall back to putting the instruction in a user turn.

Three Rules That Beat Marker Placement

Fix these before you fiddle with breakpoints.

Freeze the system prompt. No dates, no user names, no modes. It is the front of the prefix and everything downstream depends on it not moving.

Never change tools or model mid-conversation. Tools render at position zero, so adding, removing or reordering one invalidates the entire cache. Caches are also model-scoped, so switching models mid-conversation starts from cold. If you need “modes”, do not swap the tool set — pass the mode as message content.

Forked calls must reuse the parent’s exact prefix. Summarisation passes, sub-agents and side computations usually build their own request. If that fork rebuilds system, tools or model with any difference at all, it misses the parent’s cache completely. Copy them verbatim and append the fork-specific content at the end.

That last one is also the argument against a “cheap model for the easy stuff” cascade, at least as a first move. Caches are per model, so routing between two models forfeits cache reuse across them. Measure the capable model at lower effort before you build the cascade — it is often cheaper and simpler, and it keeps one cache namespace.

Verifying It, Forever

Every response carries the accounting (the same Usage struct I said to log from day one in calling an LLM from Go):

Field Meaning
CacheCreationInputTokens Written to cache this request (you paid ~1.25×)
CacheReadInputTokens Served from cache (you paid ~0.1×)
InputTokens Full price, uncached

InputTokens is the uncached remainder only — not the prompt size. Total prompt = all three added together. If an agent ran for an hour and InputTokens reads 4K, the rest came from cache; check the sum, not the one field.

In a healthy multi-turn loop you should see, on each request:

  • CacheReadInputTokens — the whole prior prefix, growing turn over turn.
  • CacheCreationInputTokens — roughly the last assistant turn plus the new input. Small.
  • InputTokens — just the tail past the last breakpoint.

If CacheCreationInputTokens is instead close to the full conversation size every time, the prefix is being rewritten upstream of your breakpoint. Go find the timestamp.

And then keep checking. The expensive failure here is never the bad first implementation — it is the regression. Caching works the day you write it, then six weeks later somebody adds a dynamic field to the system prompt or a tool list that stopped being sorted, and every request misses. Nothing fails. Nothing pages. You find out from finance.

So make it a standing assertion, not a one-time look:

func TestSystemPromptStaysCacheable(t *testing.T) {
    // Two identical requests: the second must read from cache.
    first := callModel(t, ctx, fixture)
    if first.Usage.CacheCreationInputTokens == 0 {
        t.Fatalf("nothing was cached: prompt may be under the model minimum")
    }

    second := callModel(t, ctx, fixture)
    if second.Usage.CacheReadInputTokens == 0 {
        t.Errorf("cache miss on an identical prompt — a silent invalidator crept in")
    }
}

That test costs a few cents to run and catches a regression that otherwise runs for months. Put it behind a build tag so it only runs when you mean it — the same treatment I gave integration tests in how to test database interactions in Golang. Better still, put a monitor on the ratio of CacheReadInputTokens to total input tokens and alert when it drops.

Checklist

  • Frozen system prompt: no dates, IDs, names or conditional sections.
  • Deterministic tool list, sorted by name, identical across users.
  • One explicit breakpoint at the end of the static prefix; automatic caching for the tail.
  • Breakpoint at the end of the shared portion, not the end of the prompt.
  • 5-minute TTL under continuous traffic; 1-hour only for 5–60 minute gaps.
  • Prompt above the model’s minimum, which changes between models.
  • Dynamic context appended after the history, never templated into the system prompt.
  • Forks copy the parent’s system, tools and model verbatim.
  • A test or monitor on the usage fields, checked on every change to prompt assembly.

Conclusion

Prompt caching is the rare optimisation with no quality tradeoff: identical output, roughly a tenth of the input cost, and lower latency as a bonus. It is also unusually fragile, because it hinges on byte-exact prefixes and fails completely silently. Treat the prompt-building path the way you would treat a cache key anywhere else in your system — deterministic, stable, and covered by a test — and it mostly takes care of itself.