AI agents

A Cache Breakpoint Lint Rule for Prompt Templates

Antonio Evans9 min read

A split scene: on the left, a prompt template shown as stacked blocks on a conveyor belt, with a marked breakpoint line and a hash stamp on the prefix; on the right.
Key points

Key points

  1. 1Prompt caching matches on an exact byte prefix, so any edit before the cache breakpoint silently turns every later request into a full-price cache write.
  2. 2You can guard this by declaring the breakpoint inside the template and hashing everything before it in a test.
  3. 3Storing that hash in the repo and failing CI when it changes turns prefix drift into a deliberate, reviewable decision.
  4. 4The failure message must name the template path and the block that moved, otherwise nobody knows which edit caused the miss.

Why a cache hit is a byte problem, not a prompt-engineering problem

Prompt caching stores the model's key and value tensors for a prefix and reuses them when the next request starts the same way. That reuse is computed from the front of the request: change one character before the breakpoint and everything after that point is recomputed at full price (a practical guide to prefix caching). This is not semantic matching. Two prompts that mean the same thing but start with different tokens cannot share a cached prefix (how prompt caching works).

The mechanism is a hash over the cumulative prefix. Anthropic's own documentation, as summarized in a walkthrough of breakpoints, states that cache hits require 100 percent identical prompt segments up to and including the marked block, and that a breakpoint belongs on the last block that stays identical across requests rather than on the varying block (breakpoint placement walkthrough). OpenAI's approach is automatic: it caches the longest matching prefix of prompts longer than 1,024 tokens, growing in 128-token increments (OpenAI prompt caching guide).

The financial consequence is what makes this a CI problem. A timestamp in a system prompt forces a fresh cache write on every call and never a read, with no error and no warning, which in one cost model multiplied an 80-turn session's input cost by 7.8 (five ways to invalidate your prompt cache). On the read side, cached tokens bill at a fraction of base input, and one published analysis puts reads at 0.1x base input with writes at 1.25x or 2x depending on TTL (Anthropic caching pricing mechanics). A silent miss therefore is not a small tax.

Teams already treat this as an operational metric. The Claude Code team alerts on cache hit rate and declares incidents when it drops (prompt caching field guide). CI is a cheaper place to catch the cause than a production dashboard is to catch the symptom.

Declare the breakpoint inside the template

The lint rule only works if the template itself knows where the cacheable region ends. Do not let the breakpoint live in application code, where a refactor can move it without touching the template. Put a marker in the file.

A minimal format: a line starting with @@CACHE_BREAKPOINT: name. Everything above the marker is the cacheable prefix. Everything below is the volatile suffix (the user's message, retrieved chunks, per-request timestamps). The renderer reads the marker, splits the file, and emits the API call with a cache_control block on the last prefix block.

@@BLOCK: tools
You have three tools: search, fetch_url, run_sql.
@@BLOCK: policy
You are a support analyst. Cite every claim. Refuse to speculate.
@@CACHE_BREAKPOINT: support_v3
@@BLOCK: request
User question: {{question}}

Three properties fall out of this format. The breakpoint has a name, so the test can report it. Blocks are named, so the test can say which one changed. The marker is plain text, so any editor, diff view and CI runner can read it without a parser dependency beyond your own code.

Name the breakpoint with a version or a purpose, not a number that changes when someone reorders lines. If you version it, the test's failure message will say "breakpoint support_v3 moved from line 12 to line 9", which is exactly the sentence a reviewer needs.

There is a second reason to make block boundaries explicit. The invalidation hierarchy is tools, then system, then messages, and a change at one level invalidates that level and everything after it (breakpoint placement walkthrough). If your template mixes a tool definition into a late block, one edit to the tool definition resets far more than the author expected. The block structure makes that blast radius visible before the edit lands.

The test: hash the prefix, compare, fail with the block name

The test is short. Parse each template, find the marker, hash the text before it, and compare against a stored value keyed by template path and breakpoint name. A mismatch fails and prints the diff location.

import hashlib, pathlib, json, re

MARKER = re.compile(r"^@@CACHE_BREAKPOINT:\s*(?P<name>[\w.-]+)\s*$", re.MULTILINE)
BLOCK = re.compile(r"^@@BLOCK:\s*(?P<name>[\w.-]+)\s*$", re.MULTILINE)
SNAPSHOT = pathlib.Path("tests/cache_prefix_snapshot.json")

def split_prefix(text):
    m = MARKER.search(text)
    assert m, "template is missing a CACHE_BREAKPOINT marker"
    return m.group("name"), text[: m.start()]

def digest(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def blocks_in(prefix):
    return [m.group("name") for m in BLOCK.finditer(prefix)]

def test_cache_prefix_unchanged():
    stored = json.loads(SNAPSHOT.read_text())
    failures = []
    for path in sorted(pathlib.Path("prompts").glob("*.tmpl")):
        text = path.read_text(encoding="utf-8")
        name, prefix = split_prefix(text)
        key = f"{path}::{name}"
        actual = {"sha256": digest(prefix), "blocks": blocks_in(prefix)}
        expected = stored.get(key)
        if expected is None:
            failures.append(f"{path}: breakpoint '{name}' has no stored hash")
        elif expected != actual:
            failures.append(describe_drift(key, expected, actual))
    assert not failures, "\n".join(failures)

The test uses pytest's ordinary discovery conventions, so it runs wherever the rest of the suite runs (pytest good practices). The same command works in a GitLab or GitHub pipeline as a stage step (running pytest in CI).

The interesting part is describe_drift. Comparing the block name lists tells you whether the breakpoint moved or the content changed.

def describe_drift(key, expected, actual):
    exp, act = expected["blocks"], actual["blocks"]
    if exp != act:
        added = [b for b in act if b not in exp]
        removed = [b for b in exp if b not in act]
        return f"{key}: cache prefix block order changed. added={added} removed={removed}"
    return f"{key}: content changed inside cached prefix, blocks {exp} unchanged in order"

The first message says a block entered or left the cacheable region, which almost always means the breakpoint marker moved by accident. The second says someone edited a cached block in place, which is often intentional but needs review. Neither message requires you to reason about token counts or hashes.

Failure messageLikely causeTypical fix
block order changed. added=['request']Breakpoint marker moved above the user messageMove the marker back below the volatile block
block order changed. removed=['policy']A cached block was deleted or renamedConfirm the removal is intended, then update the snapshot
content changed inside cached prefix, blocks [...] unchangedText edited inside a cached blockLand the edit with the snapshot update in the same commit
breakpoint 'x' has no stored hashNew template addedGenerate its entry and commit both files
template is missing a CACHE_BREAKPOINT markerTemplate authored without a breakpointAdd the marker at the end of the stable region

The snapshot file is a normal JSON file in the repo. Reviewers see the hash change in the diff, next to the template change, in the same commit. That is the whole design: the hash does not prevent edits, it forces the edit and the acknowledgement to travel together.

Choosing what belongs above the breakpoint

The lint rule cannot tell you whether a given piece of text is stable. That decision is yours, and it is where most of the value sits. Use a stability test rather than an intuition test: if two consecutive requests in the same session would render different text for a block, it does not belong above the marker.

ContentStable across requests?Above the breakpoint?
Tool definitions and namesYes, until a deployYes
System persona and policyYes, until a deployYes
Static reference corpusYes, if the corpus version is pinnedYes
Current date or request timestampNoNo
Retrieved chunksNo, varies by queryNo
Conversation historyGrows every turnOnly via a trailing breakpoint
User messageNoNo

The ordering rule from the same body of work: stable content first, volatile content last, never change what came before (five ways to invalidate your prompt cache). A team that moved dynamic working memory out of the system prompt and into a trailing user message raised its cache hit rate from 7 percent to 84 percent and cut overall LLM cost by 59 percent (prompt caching guide). That is a single reordering, not a model change, and the lint rule would have caught the original layout on the first push.

For retrieval pipelines, chunk-level caching research makes the same point structurally: prefix reuse is safe by construction when prefixes match, but everything downstream of an unstable insert becomes fresh work (Grounded Cache Routing). RAGCache builds a knowledge tree of cached states and reports up to 4x lower time to first token than a baseline without it (RAGCache paper). Those gains evaporate if the injected document order changes per request, which is precisely what a per-template prefix hash will show you at review time.

Worked example: what one moved line costs

Consider a support agent with a 12,000-token cacheable prefix and 500-token requests, so the prefix dominates. Reads bill at 0.1x base input and writes at 1.25x on the 5-minute tier, per the published multipliers (Anthropic caching pricing mechanics). Track a burst of 20 requests in five minutes, all sharing the prefix.

With a stable prefix: one write (12,000 tokens at 1.25x) plus 19 reads (12,000 tokens at 0.1x each). With a broken prefix: 20 writes and zero reads, because each request differs before the breakpoint.

ScenarioWritesReadsCost in units of base input per token
Stable prefix11912,000 x 1.25 + 19 x 12,000 x 0.1 = 37,800
Broken prefix20020 x 12,000 x 1.25 = 300,000

That is roughly an 8x difference on input cost for the same 20 requests. At higher volume the ratio moves toward 12.5x, and the break-even is low: the 5-minute tier pays for itself after a single read, so almost any reuse makes caching correct and almost any silent miss makes it wrong (Anthropic caching pricing mechanics). Also note that a 12,000-token prefix clears the minimum cacheable size for every tier described there, while a shorter prefix may silently never cache at all. The lint rule protects you from the first failure mode; a token-count assertion against the documented minimum protects you from the second.

The latency half matters too. Caching reduces time to first token sharply while leaving generation speed unchanged, so a miss is a visible stall for a user waiting on a response (prompt caching explained). A cost-only framing understates the damage.

Adding it to CI without slowing the pipeline

Wire the test as a normal unit test job. It reads files and hashes strings, so it runs in well under a second per template and needs no network, no API key and no model call. That means it can run on every pull request, including from forks where secrets are unavailable.

Two pieces of enforcement make the rule stick beyond CI. A pre-commit hook that runs the same test locally catches drift before a push, so the developer who knows why the prefix changed is the person who updates the snapshot. A CODEOWNERS rule on the snapshot file forces a prompt owner to approve hash changes, which turns the diff into a review checkpoint rather than a rubber stamp.

Keep the snapshot generation in the same script as the check. One command with a --update flag rewrites the hashes and prints a summary of what changed; the CI job calls it without the flag. Resist the temptation to auto-update on failure. The entire point is that a hash change is a decision someone made, not a side effect of a passing build.

Scope matters as the number of templates grows. Hash per template and per breakpoint, not one hash for the whole directory, so a failure names one file. If you support several providers, the same template can carry multiple breakpoints (one for an explicit cache_control provider, one for a automatic-prefix provider), and the snapshot key is the pair. If your prompt library starts to span many models with different caching rules, routing them through one interface keeps the test surface manageable: Quanta Inference exposes text, image, video, audio and embedding models through a single OpenAI-compatible key, which keeps the template and its breakpoint deterministic across providers (https://quantaintelligence.ai/inference).

Separate concerns: prefix caching and answer caching

A cache breakpoint test protects prefix reuse. It says nothing about correctness of reused answers, and confusing the two produces both false confidence and unnecessary fear.

Prefix and KV reuse preserves token outputs exactly when prefixes match, while output-level semantic caches do not carry that guarantee: a similar query can map to a cached answer whose evidence is stale or absent, and naive semantic caches have been shown to be adversarially exploitable (Grounded Cache Routing). The same paper reports an evidence-validated router driving the wrong-answer rate to zero on one benchmark family versus 15 to 35 percent under naive semantic caching, and to 1.5 percent versus 51.5 percent on a document-drift dataset. Those numbers are about answer reuse, not prefix reuse, and they are a reason to gate semantic caching behind evidence checks, not a reason to distrust your breakpoint.

Practically: your lint rule answers "did the cacheable prefix change?" Answer caching needs a different gate, keyed on source version and evidence overlap. Keep them in separate test files with separate failure messages so nobody tries to fix one by weakening the other.

FAQ

FAQ

Doesn't the provider's automatic caching make this test unnecessary?

Automatic prefix caching removes the need to place a breakpoint by hand, but it does not remove the need for a stable prefix. OpenAI caches the longest previously computed prefix of prompts above 1,024 tokens, so a timestamp near the front still destroys the hit (OpenAI prompt caching guide). The test guards the ordering rule, which is independent of who manages the breakpoint.

What exactly should the hash cover: the raw template or the rendered prompt?

Hash the rendered cacheable region with dynamic slots left as literal placeholders, not with real values substituted. If you hash rendered prompts, a changing user message or retrieved chunk will fail the test constantly, and the failure will be noise rather than signal. Substitute values only for genuinely static slots such as a pinned corpus version.

How do I update the snapshot without bypassing review?

Regenerate it in the same commit as the template edit, and add a one-line note in the commit message naming the block and the reason. A reviewer sees a template diff and a hash diff together. If your team wants a stronger gate, require approval from a code owner on the snapshot path so that prompt changes cannot land unread.

Does a hash change always mean a cache miss in production?

No. A hash change means the prefix differs from the one recorded in the repo, which is almost always a real change but may be a whitespace-only edit that you intend. What it does guarantee is that the difference shows up in review rather than in the invoice. Confirm against the usage fields: a healthy cached request reports read tokens, and a broken one reports write tokens on every call (Anthropic caching pricing mechanics).

What else silently breaks the cache besides template edits?

Tool definition changes, model switches, unstable JSON serialization and inserting images all invalidate parts of the prefix, and the invalidation follows the tools, system, messages hierarchy (breakpoint placement walkthrough). Extend the same snapshot approach to tool schemas by hashing their serialized form alongside the template, so a schema edit and a prompt edit fail the same check.

Sources

Sources

  1. Prefix caching - LLM Inference Handbook - Modular
  2. How prompt caching works - Paged Attention and ...
  3. What Invalidates a Prompt Cache, Where the Breakpoint Goes ...
  4. Prompt caching | OpenAI API
  5. Five ways to invalidate your prompt cache | by Minh Le Duc
  6. Anthropic Claude prompt caching pricing: write, read, TTL ...
  7. Prompt Caching: How It Works, What It Costs ...
  8. Good Integration Practices
  9. Using pytest in GitLab pipelines
  10. Prompt Caching in 2026: Cut LLM Costs, Keep Quality
  11. Grounded Cache Routing for Retrieval-Augmented ...
  12. RAGCache: Efficient Knowledge Caching for Retrieval ...
  13. Prompt Caching: What It Is, What It Is Not
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles