<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Cost on WebDevStation</title>
    <link>https://webdevstation.com/tags/cost/</link>
    <description>1 article tagged Cost — tutorials, code examples and notes from building real systems, newest first.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <managingEditor>Alex</managingEditor>
    <webMaster>Alex</webMaster>
    <copyright>© 2026 WebDevStation</copyright>
    <lastBuildDate>Mon, 31 Aug 2026 11:15:00 +0200</lastBuildDate>
    <atom:link href="https://webdevstation.com/tags/cost/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Prompt Caching: The Cheapest Win in Your LLM Bill</title>
      <link>https://webdevstation.com/posts/prompt-caching-llm-cost/</link>
      <pubDate>Mon, 31 Aug 2026 11:15:00 +0200</pubDate>
      <author>Alex</author>
      <guid isPermaLink="true">https://webdevstation.com/posts/prompt-caching-llm-cost/</guid>
      <description>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…</description>
      <content:encoded><![CDATA[<p>I have written on this blog about caching database reads with <a href="/posts/ristretto-the-most-performant-concurrent-cache-library-for-go/">Ristretto</a> and about teaching <a href="/posts/how-to-make-nginx-cookie-aware/">Nginx to cache by cookie</a>. 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.</p>
<h2 id="one-invariant-everything-follows-from-it">One Invariant, Everything Follows From It</h2>
<p><strong>Prompt caching is a prefix match. Any change anywhere in the prefix invalidates everything after it.</strong></p>
<p>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.</p>
<p>The render order matters and is fixed:</p>
<pre tabindex="0"><code>tools  →  system  →  messages
</code></pre><p>Tools render first, at position zero. That has a consequence people discover the expensive way: <strong>change the tool list and you have invalidated everything</strong>, system prompt and entire conversation included. More on that below.</p>
<h2 id="what-it-costs">What It Costs</h2>
<p>Two numbers govern whether caching pays:</p>
<ul>
<li>A cache <strong>read</strong> costs about <strong>0.1×</strong> the base input price.</li>
<li>A cache <strong>write</strong> costs <strong>1.25×</strong> for the 5-minute TTL, <strong>2×</strong> for the 1-hour TTL.</li>
</ul>
<p>So with the default 5-minute TTL, two requests already break even: <code>1.25 + 0.1 = 1.35</code> against <code>2.0</code> 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.</p>
<p>Which is why the TTL question is <em>not</em> &ldquo;how long do I want this cached&rdquo; but &ldquo;how far apart do requests sharing this prefix start&rdquo;:</p>
<table>
	<thead>
			<tr>
					<th>Start-to-start gap</th>
					<th>Use</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Under 5 minutes</td>
					<td>5-minute TTL. Every read refreshes the timer, so continuous traffic keeps it warm indefinitely and it is strictly cheaper.</td>
			</tr>
			<tr>
					<td>5–60 minutes</td>
					<td>1-hour TTL. This is the only window where the doubled write price earns its keep.</td>
			</tr>
			<tr>
					<td>Over an hour</td>
					<td>Neither, directly. Re-warm on a schedule or accept the cold miss.</td>
			</tr>
	</tbody>
</table>
<p>The subtlety in row one: a read refreshes the entry at no extra cost, and the lifetime is measured from the <em>start</em> 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.</p>
<h2 id="making-it-work-in-go">Making It Work in Go</h2>
<p>The syntax is a <code>CacheControl</code> on the last block of whatever you want cached. Because tools render before system, a marker on the final system block caches <strong>both</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">params</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">MessageNewParams</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Model</span>:     <span style="color:#e6db74">&#34;claude-opus-5&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">MaxTokens</span>: <span style="color:#ae81ff">16000</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Tools</span>:     <span style="color:#a6e22e">tools</span>, <span style="color:#75715e">// deterministic order — see below</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">System</span>: []<span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">TextBlockParam</span>{{
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">Text</span>:         <span style="color:#a6e22e">systemPrompt</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">CacheControl</span>: <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">NewCacheControlEphemeralParam</span>(), <span style="color:#75715e">// 5-minute default</span>
</span></span><span style="display:flex;"><span>    }},
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Messages</span>: <span style="color:#a6e22e">messages</span>,
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>For the 1-hour TTL:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">CacheControl</span>: <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">CacheControlEphemeralParam</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">TTL</span>: <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">CacheControlEphemeralTTLTTL1h</span>,
</span></span><span style="display:flex;"><span>},
</span></span></code></pre></div><p>There is also a top-level <code>CacheControl</code> on <code>MessageNewParams</code> 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.</p>
<p><strong>The robust combination for anything agentic:</strong> 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 <code>messages</code>, plus top-level automatic caching for the growing tail.</p>
<p>You get four breakpoints per request, so there is no need to be frugal — place them at genuine stability boundaries.</p>
<h2 id="where-automatic-caching-is-the-wrong-tool">Where Automatic Caching Is the Wrong Tool</h2>
<p>Automatic placement puts the breakpoint at the very end of your prompt. When the prompt <em>ends</em> with something unique per request — a retrieved document, the user&rsquo;s actual question — that is a pure surcharge: every request writes a new cache entry that nothing will ever read.</p>
<p>The signature is unmistakable once you know it: <code>cache_creation_input_tokens</code> is non-zero on every single request, while <code>cache_read_input_tokens</code> never covers the shared prefix.</p>
<p>The fix is an explicit marker at the end of the <strong>shared</strong> portion:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#a6e22e">Messages</span>: []<span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">MessageParam</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">NewUserMessage</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">TextBlockParam</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Text</span>:         <span style="color:#a6e22e">sharedContext</span>, <span style="color:#75715e">// few-shot examples, retrieved docs</span>
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">CacheControl</span>: <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">NewCacheControlEphemeralParam</span>(),
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">NewTextBlock</span>(<span style="color:#a6e22e">userQuestion</span>), <span style="color:#75715e">// no marker — differs every time</span>
</span></span><span style="display:flex;"><span>    ),
</span></span><span style="display:flex;"><span>},
</span></span></code></pre></div><p>Same rule, restated: put the breakpoint where the prompt <em>stops</em> being shared, not where the prompt ends.</p>
<h2 id="the-minimum-prefix-which-is-not-monotonic">The Minimum Prefix, Which Is Not Monotonic</h2>
<p>A prompt shorter than the model&rsquo;s minimum will not cache — no error, no warning, <code>cache_creation_input_tokens</code> simply comes back zero. And the minimum does not move in the direction you would guess as models get newer:</p>
<table>
	<thead>
			<tr>
					<th>Model</th>
					<th style="text-align: right">Minimum</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Claude Opus 5, Fable 5</td>
					<td style="text-align: right">512 tokens</td>
			</tr>
			<tr>
					<td>Opus 4.8, Sonnet 5, Sonnet 4.6</td>
					<td style="text-align: right">1024 tokens</td>
			</tr>
			<tr>
					<td>Opus 4.7</td>
					<td style="text-align: right">2048 tokens</td>
			</tr>
			<tr>
					<td>Opus 4.6, Haiku 4.5</td>
					<td style="text-align: right">4096 tokens</td>
			</tr>
	</tbody>
</table>
<p>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.</p>
<h2 id="silent-invalidators">Silent Invalidators</h2>
<p>This is the part worth committing to memory, because every one of these is code that looks perfectly reasonable in review.</p>
<table>
	<thead>
			<tr>
					<th>Pattern</th>
					<th>Why it kills the cache</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>time.Now()</code> in the system prompt</td>
					<td>The prefix differs on every single request</td>
			</tr>
			<tr>
					<td>A request ID or UUID early in the content</td>
					<td>Same — every request is unique</td>
			</tr>
			<tr>
					<td><code>json.Marshal</code> of a <code>map</code> in the prompt</td>
					<td>Go randomises map iteration order; the bytes differ run to run</td>
			</tr>
			<tr>
					<td>Ranging over a map to build tool definitions</td>
					<td>Same problem, at position zero, which is the worst place for it</td>
			</tr>
			<tr>
					<td>User or session ID interpolated into the system prompt</td>
					<td>A per-user prefix; nothing shares anything</td>
			</tr>
			<tr>
					<td><code>if flag { system += ... }</code></td>
					<td>Every flag combination is a distinct prefix</td>
			</tr>
			<tr>
					<td>A tool set that varies per user or per mode</td>
					<td>Tools render first — nothing caches across users</td>
			</tr>
	</tbody>
</table>
<p>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 <strong>every request</strong>, and it is invisible because the rendered prompt is semantically identical each time:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Bad: iteration order is randomised, so the bytes differ every run.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> <span style="color:#a6e22e">name</span>, <span style="color:#a6e22e">def</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">tools</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tools</span> = append(<span style="color:#a6e22e">tools</span>, <span style="color:#a6e22e">def</span>)
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Good: deterministic.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">names</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">slices</span>.<span style="color:#a6e22e">Sorted</span>(<span style="color:#a6e22e">maps</span>.<span style="color:#a6e22e">Keys</span>(<span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">tools</span>))
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> <span style="color:#a6e22e">_</span>, <span style="color:#a6e22e">name</span> <span style="color:#f92672">:=</span> <span style="color:#66d9ef">range</span> <span style="color:#a6e22e">names</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tools</span> = append(<span style="color:#a6e22e">tools</span>, <span style="color:#a6e22e">h</span>.<span style="color:#a6e22e">tools</span>[<span style="color:#a6e22e">name</span>])
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Sorting keys is a one-line fix that most Go LLM code needs and almost none has.</p>
<h2 id="injecting-dynamic-context-without-breaking-everything">Injecting Dynamic Context Without Breaking Everything</h2>
<p>The usual reason a system prompt has a timestamp in it is that the model genuinely needs to know the date, or the user&rsquo;s plan tier, or the current mode. The instinct is to template it into the system prompt. Don&rsquo;t — that is the front of the prefix, and it invalidates everything behind it.</p>
<p>Put dynamic context <strong>after</strong> the cached history instead. On the newest models there is a first-class channel for this: a <code>system</code>-role message appended to <code>messages</code>, rather than an edit to the top-level <code>system</code> field.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// The top-level system prompt stays byte-identical and stays cached.</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The operator instruction goes after the history, invalidating nothing before it.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">messages</span> = append(<span style="color:#a6e22e">messages</span>, <span style="color:#a6e22e">userTurn</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">messages</span> = append(<span style="color:#a6e22e">messages</span>, <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">MessageParam</span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Role</span>: <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">MessageParamRoleSystem</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">Content</span>: []<span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">ContentBlockParamUnion</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">anthropic</span>.<span style="color:#a6e22e">NewTextBlock</span>(<span style="color:#e6db74">&#34;Terse mode enabled — keep responses under 40 words.&#34;</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><p>A message at turn five invalidates nothing before turn five. That is the whole trick.</p>
<p>Two constraints: it must follow a user message and be either the last entry or followed by an assistant turn — it cannot be <code>messages[0]</code>, so use the top-level <code>system</code> for the initial prompt. And support is model-dependent; unsupported models return a 400 saying the <code>system</code> role is not supported, so catch that and fall back to putting the instruction in a user turn.</p>
<h2 id="three-rules-that-beat-marker-placement">Three Rules That Beat Marker Placement</h2>
<p>Fix these before you fiddle with breakpoints.</p>
<p><strong>Freeze the system prompt.</strong> No dates, no user names, no modes. It is the front of the prefix and everything downstream depends on it not moving.</p>
<p><strong>Never change tools or model mid-conversation.</strong> 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 &ldquo;modes&rdquo;, do not swap the tool set — pass the mode as message content.</p>
<p><strong>Forked calls must reuse the parent&rsquo;s exact prefix.</strong> Summarisation passes, sub-agents and side computations usually build their own request. If that fork rebuilds <code>system</code>, <code>tools</code> or <code>model</code> with any difference at all, it misses the parent&rsquo;s cache completely. Copy them verbatim and append the fork-specific content at the end.</p>
<p>That last one is also the argument against a &ldquo;cheap model for the easy stuff&rdquo; 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 <em>and</em> simpler, and it keeps one cache namespace.</p>
<h2 id="verifying-it-forever">Verifying It, Forever</h2>
<p>Every response carries the accounting (the same <code>Usage</code> struct I said to log from day one in <a href="/posts/calling-claude-from-go/">calling an LLM from Go</a>):</p>
<table>
	<thead>
			<tr>
					<th>Field</th>
					<th>Meaning</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>CacheCreationInputTokens</code></td>
					<td>Written to cache this request (you paid ~1.25×)</td>
			</tr>
			<tr>
					<td><code>CacheReadInputTokens</code></td>
					<td>Served from cache (you paid ~0.1×)</td>
			</tr>
			<tr>
					<td><code>InputTokens</code></td>
					<td>Full price, uncached</td>
			</tr>
	</tbody>
</table>
<p><code>InputTokens</code> is the <strong>uncached remainder only</strong> — not the prompt size. Total prompt = all three added together. If an agent ran for an hour and <code>InputTokens</code> reads 4K, the rest came from cache; check the sum, not the one field.</p>
<p>In a healthy multi-turn loop you should see, on each request:</p>
<ul>
<li><code>CacheReadInputTokens</code> — the whole prior prefix, growing turn over turn.</li>
<li><code>CacheCreationInputTokens</code> — roughly the last assistant turn plus the new input. Small.</li>
<li><code>InputTokens</code> — just the tail past the last breakpoint.</li>
</ul>
<p>If <code>CacheCreationInputTokens</code> is instead close to the full conversation size every time, the prefix is being rewritten upstream of your breakpoint. Go find the timestamp.</p>
<p><strong>And then keep checking.</strong> 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.</p>
<p>So make it a standing assertion, not a one-time look:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">TestSystemPromptStaysCacheable</span>(<span style="color:#a6e22e">t</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">testing</span>.<span style="color:#a6e22e">T</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Two identical requests: the second must read from cache.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">first</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">callModel</span>(<span style="color:#a6e22e">t</span>, <span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">fixture</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">first</span>.<span style="color:#a6e22e">Usage</span>.<span style="color:#a6e22e">CacheCreationInputTokens</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">t</span>.<span style="color:#a6e22e">Fatalf</span>(<span style="color:#e6db74">&#34;nothing was cached: prompt may be under the model minimum&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">second</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">callModel</span>(<span style="color:#a6e22e">t</span>, <span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">fixture</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">second</span>.<span style="color:#a6e22e">Usage</span>.<span style="color:#a6e22e">CacheReadInputTokens</span> <span style="color:#f92672">==</span> <span style="color:#ae81ff">0</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">t</span>.<span style="color:#a6e22e">Errorf</span>(<span style="color:#e6db74">&#34;cache miss on an identical prompt — a silent invalidator crept in&#34;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>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 <a href="/posts/how-to-test-database-interactions-go/">how to test database interactions in Golang</a>. Better still, put a monitor on the ratio of <code>CacheReadInputTokens</code> to total input tokens and alert when it drops.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Frozen system prompt: no dates, IDs, names or conditional sections.</li>
<li>Deterministic tool list, sorted by name, identical across users.</li>
<li>One explicit breakpoint at the end of the static prefix; automatic caching for the tail.</li>
<li>Breakpoint at the end of the <em>shared</em> portion, not the end of the prompt.</li>
<li>5-minute TTL under continuous traffic; 1-hour only for 5–60 minute gaps.</li>
<li>Prompt above the model&rsquo;s minimum, which changes between models.</li>
<li>Dynamic context appended after the history, never templated into the system prompt.</li>
<li>Forks copy the parent&rsquo;s <code>system</code>, <code>tools</code> and <code>model</code> verbatim.</li>
<li>A test or monitor on the usage fields, checked on every change to prompt assembly.</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>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.</p>]]></content:encoded>
      <category>AI Engineering</category>
      <category>Performance Optimization</category>
      <category>Backend Development</category>
    </item>
  </channel>
</rss>
