Skip to content

Prompt Caching — Measured Behaviour

Prompt caching in GenAIIDP — what actually happens, measured

Section titled “Prompt caching in GenAIIDP — what actually happens, measured”

Why this page exists. Several cost claims in this repo rest on one sentence: the duplicated schema copies “sit inside the prompt-cache prefix, so they are cache reads at roughly a tenth of input price”. That was never measured. It turns out to be true for most classes and false for some, and the exception is invisible — no error, no warning, no metric. This page measures the mechanism directly.

Method: direct bedrock-runtime Converse calls, outside idp_common, so the measurement does not inherit the pipeline’s prompt assembly or model routing. Every number below is read back from usage.cacheWriteInputTokens / cacheReadInputTokens / inputTokens. Reproduce with benchmarks/harness/cache_threshold_probe.py. Raw data: benchmarks/results/v0.6.7/prompt-cache/.


1. There is a minimum cacheable prefix, and below it caching silently does nothing

Section titled “1. There is a minimum cacheable prefix, and below it caching silently does nothing”

Anthropic publishes a per-model minimum. It is not monotonic across generations, which is the trap:

ModelMinimum cacheable prefix
Claude Opus 5, Fable 5512 tokens
Claude Sonnet 5, Sonnet 4.6, Opus 4.81,024 tokens
Claude Opus 4.72,048 tokens
Claude Opus 4.6, Opus 4.5, Haiku 4.54,096 tokens

Measured on Bedrock, us.anthropic.claude-sonnet-4-6, sweeping prefix size:

prefix (total input tok)uncachedcacheWritecacheReadcached?
58558500no
84784700no
98598500no
1,0091,00900no
1,0321,03200no
1,059131,0460yes
1,114131,1010yes

The effective boundary lies in (1,032, 1,059] total input tokens — consistent with the published 1,024-token minimum plus the ~13-token user turn. Below it, cacheWrite and cacheRead are both 0 and no error is raised: the request is billed entirely at full input price, and nothing in the response distinguishes that from a cache that simply had not warmed yet.

⚠️ idp_common.bedrock.client inserts a cachePoint wherever <<CACHEPOINT>> appears in a prompt template, with no length check. That is correct behaviour — there is nothing better to do at that layer — but it means a document class whose prompt prefix is short simply never caches, permanently and silently.

Nova is different, and much more forgiving

Section titled “Nova is different, and much more forgiving”

us.amazon.nova-2-lite-v1:0, same sweep: every prefix tested cached, including the smallest (a 355-token prefix produced cacheWrite: 355). A write only happens when the minimum is met, so Nova’s minimum is ≤355 tokens — well below anything these presets produce. Combined with Nova’s price multipliers in pricing.yaml (read 0.25×, write 1.0×, versus Claude’s 0.1× / 1.25×), Nova has neither failure mode: it always caches, and a write that is never read costs exactly what an uncached read would have cost. Both mechanisms in this page are Claude-specific.

This matters for scoping: ocr-benchmark as shipped pins extraction.model: us.amazon.nova-2-lite-v1:0 and therefore does not hit the cliff. The per-class measurements in §2 pin Sonnet 4.6 instead, because the cliff is a Claude question and Claude is what the docs recommend for extraction. A config using a Nova extraction model is unaffected by §1–§3.

A note on our own instrument. An earlier version of the sweep appeared to show Nova failing to serve reads on 5 of 8 repeat calls. That was an artifact of the probe, not of Nova: filler() builds text by appending, so filler(400) is a literal prefix of filler(700) and consecutive sweep points share cache state. Re-measured with six identical calls, both Nova and Claude write once and then read on every subsequent call. The flakiness was ours. It is recorded here because the same overlap would mislead anyone re-running the sweep.

2. Two of the nine classes in the shipped ocr-benchmark preset never cache

Section titled “2. Two of the nine classes in the shipped ocr-benchmark preset never cache”

Measured per class, using each class’s real prompt prefix (system prompt + the task prompt up to <<CACHEPOINT>> with the actual schema substituted), with the cachePoint placed in the user message exactly as the pipeline places it:

classprefix tokenscaches?
DELIVERY_NOTE1,710yes
BANK_CHECK1,383yes
EQUIPMENT_INSPECTION1,305yes
REAL_ESTATE1,246yes
PETITION_FORM1,187yes
CREDIT_CARD_STATEMENT1,178yes
COMMERCIAL_LEASE_AGREEMENT1,335yes
SHIFT_SCHEDULE1,000NO
GLOSSARY949NO

For comparison, realkie-fcc-verified → Invoice is 1,687 tokens and caches, and lending-package-sample → Bank-checks estimates at ~944 and would not.

Five of the seven caching classes sit between 1,178 and 1,383 — within ~35% of the cliff. Adding a few fields to a class, or shortening a task prompt, can move a class across it in either direction with no visible signal. This is not a corner case.

3. A one-off document pays +24.9% on the prefix for caching it will never reuse

Section titled “3. A one-off document pays +24.9% on the prefix for caching it will never reuse”

A cache write costs 1.25× base input; a read costs 0.1×. So a prefix that is written and never read inside the TTL is a net loss. Measured against the exact counterfactual — identical prefix, cachePoint versus no cachePoint:

scenariouncachedcacheWritecacheReadbilled token-equivalents
no cachePoint, 1 call1,637001,637
cachePoint, 1st call (write)91,62802,044
cachePoint, 2nd call (read)901,628172

A single document costs 1.249× what it would with no cachePoint at all. Break-even is exactly the second request within the TTL:

requests in TTLwith cachewithout cachewinner
12,0441,637cache loses (+24.9%)
22,2163,274cache wins
32,3884,911cache wins
52,7318,185cache wins

The default TTL is 5 minutes, measured from the start of the writing request, and a read refreshes the timer for free. So batch workloads and busy stacks collect; a stack processing one document every few minutes pays the 25% surcharge on every one of them.

4. What this changes about the schema-duplication claim

Section titled “4. What this changes about the schema-duplication claim”

The claim “the duplicated copies are cache reads at ~a tenth of input price” is:

  • true for a class above the minimum on a busy stack — the schema is in the cached span and reads at 0.1×, so a token saving there is worth only ~10% of its face value;
  • false for a class below the minimum — the schema is billed at full price on every request, so a token saving is worth its full face value, ~10× more than we have been telling people;
  • false in the other direction for a one-off document — the schema is billed at 1.25×, so removing it saves more than face value.

Any statement of the form “the schema is cheap because it’s cached” needs the class’s prefix length and the workload’s arrival rate attached, or it is unfalsifiable.

5. Consequences for extraction.forced_tool.enabled (#744)

Section titled “5. Consequences for extraction.forced_tool.enabled (#744)”

A forced toolSpec renders at position 0 — before the system prompt — so it lengthens the cached prefix. Measured per class:

classprefix, forcing offcaches?prefix, forcing oncaches?
GLOSSARY949no1,769yes
SHIFT_SCHEDULE1,000no1,831yes
CREDIT_CARD_STATEMENT1,178yes2,178yes
BANK_CHECK1,383yes2,492yes
DELIVERY_NOTE1,710yes2,980yes

So forcing has a cost effect that flips sign by class, in steady state (all prefix tokens, billed token-equivalents per request):

classoffoneffect
GLOSSARY949 × 1.0 = 9491,769 × 0.1 = 177−81%
SHIFT_SCHEDULE1,000 × 1.0 = 1,0001,831 × 0.1 = 183−82%
CREDIT_CARD_STATEMENT1,178 × 0.1 = 1182,178 × 0.1 = 218+85%
BANK_CHECK1,383 × 0.1 = 1382,492 × 0.1 = 249+80%

This is a mechanism the existing guidance for #744 does not mention, and it is the opposite of the intuition that “forcing adds a toolSpec, so it costs more”. On a sub-minimum class forcing is a large prefix saving; on a comfortably-caching class it is a moderate prefix increase.

⚠️ The prefix is only part of a request. On ocr-benchmark a document carries ~7,800 input tokens in total, of which the prefix is ~1,000–2,900 — the rest is document text and page images, after the cachePoint and therefore never cached. So these percentages apply to a fraction of the bill, not to the bill. The end-to-end magnitude is measured separately (§6).

6. End-to-end magnitude — measured, and smaller than the mechanism suggests

Section titled “6. End-to-end magnitude — measured, and smaller than the mechanism suggests”

The §2 and §5 predictions were registered before the run and then confirmed on 293 real documents (stack IDPBench at the v0.6.7 tag, ocr-benchmark, Sonnet 4.6):

classforcing off: cacheRead/docverdictforcing on: cacheRead/docverdict
GLOSSARY (23 docs)0never cached1,839caching active
SHIFT_SCHEDULE (18 docs)0never cached1,901caching active
7 other classes1,000–1,706caching1,839–2,670caching

Extraction-phase read share 28.4% → 48.1%. So the cliff is real in production, not just in a probe: 41 of 293 documents (14%) were paying full price on their entire prompt prefix, on every request, with nothing reporting it.

But the dollar effect is small, and honesty requires saying so. Decomposing the −2.3% cost delta that forcing produced on this corpus:

token classΔ/doc$/MTokΔ$share
outputTokens−3416.50−0.00056187%
inputTokens−1133.30−0.00037358%
cacheReadInputTokens+1,2210.33+0.000403−63%
cacheWriteInputTokens−274.12−0.00011117%

The whole input-plus-cache shift nets 13% of the saving; 87% is fewer output tokens. The reason is arithmetic: moving a token from uncached input (3.30) to cache read (0.33) saves 2.97/MTok, but forcing also adds ~1,000 prefix tokens that are then read at 0.33 — and those two nearly cancel.

So: the cliff is worth fixing for its own sake, not for its bill. A class that never caches is paying ~10× per prefix token what a caching class pays, which matters much more on a workload whose prefix is a large fraction of the request. On ocr-benchmark the prefix is ~1,000–2,900 of ~7,800 input tokens; on a corpus of short documents with a big schema it could be most of the request, and there the same mechanism would dominate.

Full A/B: config-guidance.md §7. Data: benchmarks/results/v0.6.7/forcing-real-corpus/.

7. Instrumentation note — why nobody noticed

Section titled “7. Instrumentation note — why nobody noticed”

benchmarks/harness/detection_ab_teststudio.py (the previous real-corpus A/B tool) computes input tokens as every metering key whose name contains both “token” and “input” — which sums inputTokens, cacheReadInputTokens and cacheWriteInputTokens into one number. A change that moves tokens between those classes therefore reports a delta of ≈0, and a 12.5× price difference (1.25× write vs 0.1× read) is invisible. Every prior real-corpus A/B in this repo was run with that instrument.

benchmarks/harness/real_corpus_ab.py keeps the four classes separate and reports a per-class cache verdict, and benchmarks/harness/cache_audit.py can be pointed at any existing run to classify it as caching normally / write-only (paying 1.25× for nothing) / never cached.

The product now applies the same classification itself: per phase on the document cost table and per class in the section’s Processing Report tab and result.json (metadata.prompt_cache), queryable in Athena through the document_sections_* tables — see configuration.md.

Terminal window
source .venv/bin/activate && export PYTHONPATH=$PWD/lib/idp_common_pkg
# §1 the threshold itself
AWS_PROFILE=default python3 benchmarks/harness/cache_threshold_probe.py \
--model us.anthropic.claude-sonnet-4-6 --targets 600 650 700 720 740 760 1100 2000
# §3 the write-without-read penalty (no-cachePoint counterfactual)
# see the probe embedded in this page's commit message for the exact call shape
# audit any existing run for its cache verdict, per phase and per class
AWS_PROFILE=default python3 benchmarks/harness/cache_audit.py \
--stack <STACK> --run <runId> --label "as-shipped"

See Configuration Guidance for which settings to pick, and the Benchmarking Guide for how the suite is designed.