AI Engineering No. 255

Prompt Caching and Costs skill for Claude Code

Cut the LLM bill with caching before downgrades. Claude profiles where tokens actually go before changing anything, then applies the no-quality-loss levers in order: stable-prefix prompt caching with correct breakpoints, the batch API for offline work, and trimmed history and tool results. A bundled cost model shows break-even hit rates and tier what-ifs, and cheaper models or lower effort ship only after passing the eval.

  • nameprompt-caching-costs
  • size189 lines · ≈2,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Cuts LLM API spend without losing quality by profiling token usage, then applying prompt caching with stable prefixes and cache_control breakpoints, batch processing, model and effort routing, context trimming, and output-length control, verified against an eval. Use when the LLM bill is too high, cache hit rates are low, cache_read_input_tokens stays at zero, when planning cost for a new AI feature, or when deciding whether to route traffic to a cheaper model.

Try asking

  • Our Claude API bill doubled last month. Find out why and cut it
  • cache_read_input_tokens is always zero even though the system prompt never changes
  • Estimate monthly cost for this feature and show what caching and batching would save

Use it when

  • LLM API spend is high or growing faster than traffic
  • Prompt cache hit rate is low or cache reads stay at zero
  • You are budgeting a new LLM feature before launch
  • You are considering routing some traffic to a cheaper model

Skip it when

  • You need tracing and dashboards before you can see spend — set up llm-observability first
  • The cost problem is cloud infrastructure, not tokens — use cloud-cost-audit

How to check the result

A repeated request within the TTL shows nonzero cache_read_input_tokens, and a week of production usage shows lower cost per completed task with the eval score unchanged.

The common mistake it prevents

Putting a timestamp or request ID at the top of the system prompt, which changes the prefix on every call so nothing is ever read from cache.

~/.claude/skills/prompt-caching-costs/SKILL.md

--- name: prompt-caching-costs description: Cuts LLM API spend without losing quality by profiling token usage, then applying prompt caching with stable prefixes and cache_control… license: MIT ---

Cost work is done when you have a measured token profile (where spend goes by feature, input vs. output, cached vs. uncached), the free wins are applied (caching, batching, trimming), any quality-trading change (cheaper model, lower effort) passed the eval, and the savings show up in logged usage, not in a spreadsheet estimate.

Ground rules

  • Measure before optimizing. Pull usage from logs or the provider’s usage reports and rank features by spend.
  • Free wins first, trade-offs second: caching, batching, and trimming do not change output quality; model downgrades and effort cuts might.
  • Judge cost per completed task, not per request. A cheaper call that needs retries or extra agent turns is not cheaper.
  • Every experiment that calls the model costs money. Estimate it and get the user’s approval before large eval runs.

Workflow

- [ ] 1. Build the token profile
- [ ] 2. Fix prompt caching (structure, breakpoints, invalidators)
- [ ] 3. Move non-urgent work to the batch API
- [ ] 4. Trim input: history, retrieved context, tool results, tool definitions
- [ ] 5. Control output: max_tokens, verbosity, reasoning effort
- [ ] 6. Route by difficulty (only with eval evidence)
- [ ] 7. Verify savings in production usage

1. Token profile

For each feature, collect over a representative window: request count, mean and p95 input tokens, cache_read_input_tokens, cache_creation_input_tokens, output tokens, model. Then model the options:

python3 scripts/cost_model.py --model claude-sonnet-5-5 --requests 200000 \
  --prefix-tokens 6000 --variable-tokens 800 --output-tokens 400 --hit-rate 0.9 --batch-share 0.3

The script prints spend with and without caching and batching, the output share of spend, the break-even cache hit rate for the chosen TTL, and a what-if across model tiers. Its built-in prices are a snapshot; confirm them on the provider’s pricing page or pass --input-price/--output-price/--cache-read-mult.

Read the profile:

FindingFirst lever
Large repeated prefix (system prompt, tools, documents), low cache readsCaching (step 2)
Offline/background volumeBatch API (step 3)
Input grows with conversation length or agent turnsTrimming (step 4)
Output > 50% of spendOutput control and effort (step 5)
Simple, high-volume requests on a top-tier modelRouting (step 6)

2. Prompt caching

Caching is a prefix match: the provider reuses computation for an identical leading sequence of tokens. Render order on the Anthropic Messages API is tools -> system -> messages. Any byte change in the prefix invalidates everything after it.

Structure every request as stable first, volatile last:

resp = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=1024,
    tools=TOOLS,                                   # fixed list, fixed order
    system=[
        {"type": "text", "text": STABLE_INSTRUCTIONS},
        {"type": "text", "text": REFERENCE_DOCS,
         "cache_control": {"type": "ephemeral"}},  # breakpoint: caches tools + system
    ],
    messages=history + [{"role": "user", "content": user_msg}],
)
u = resp.usage
log.info("cache", read=u.cache_read_input_tokens, write=u.cache_creation_input_tokens, uncached=u.input_tokens)

Placement rules (Anthropic specifics; other providers cache prefixes automatically or with their own controls, but the same ordering principle applies):

  • Up to 4 cache_control breakpoints per request. Typical: end of system/tools, and the last message of the conversation so each turn reads the previous turn’s prefix.
  • A top-level cache_control on the request auto-places one breakpoint on the last cacheable block; use it when you do not need fine-grained placement.
  • Prefixes below the model’s minimum length (from 512 tokens on the newest Claude models up to 4,096 on some older ones) silently do not cache.
  • Default TTL is 5 minutes, refreshed on every read. {"type": "ephemeral", "ttl": "1h"} costs more to write (2x vs. 1.25x base input) and only pays off when requests sharing the prefix arrive 5-60 minutes apart.
  • Cache reads are billed at a small fraction of base input price (about 0.1x, lower on some current models) and writes at a premium, so a prefix pays off after roughly two requests at the 5-minute TTL.
  • Caches are scoped per model and per workspace/organization. Splitting traffic across models or workspaces splits the cache.

Silent invalidators to grep for:

InvalidatorFix
Timestamp, date, request ID, or user name in the system promptMove to the final user message
Non-deterministic JSON (unsorted keys) in the prefixjson.dumps(obj, sort_keys=True)
Tool list built from a set or filtered per requestFixed, sorted tool list per session
Retrieved documents reordered between turnsAppend new context; do not reshuffle earlier blocks
Editing earlier messages (summaries rewritten each turn)Append-only history; summarize in larger, rarer steps
Switching model, reasoning effort, or tool_choice mid-sessionKeep per-session settings fixed

Verify: send the same request twice within the TTL. The second response must show cache_read_input_tokens > 0. If it is zero, diff the two rendered request bodies byte for byte.

3. Batch API

Anthropic’s Message Batches API processes requests asynchronously (most finish within an hour, up to 24 hours) at about half the price, and stacks with caching. Use it for evals, backfills, nightly summarization, classification of stored data, and embedding-adjacent enrichment. Key results by custom_id; they return in any order.

batch = client.messages.batches.create(requests=[
    {"custom_id": f"doc-{d.id}", "params": {"model": "claude-haiku-5-5", "max_tokens": 512,
     "system": SYSTEM, "messages": [{"role": "user", "content": d.text}]}}
    for d in docs
])
# poll client.messages.batches.retrieve(batch.id).processing_status until "ended",
# then iterate client.messages.batches.results(batch.id)

4. Trim input

  • Chat history: keep the last N turns verbatim plus a summary of older turns; or use the provider’s server-side compaction/context-editing features for long agent sessions.
  • RAG: send the top 5-8 reranked chunks, not 30.
  • Agent tool results: cap result size; clear or summarize old tool results.
  • Tool definitions count as input on every call. Remove unused tools; load domain subsets.
  • Count tokens with the provider’s counting endpoint (client.messages.count_tokens); tokenizers differ across vendors and model generations.

5. Control output

  • Output tokens cost several times more than input. Set max_tokens to the real need and ask for concise formats (“3 bullets”, “label only”).
  • Reasoning tokens bill as output. On current Claude models tune output_config: {"effort": ...} per route: low for classification and chat, higher for coding and agentic work. Measure; do not guess.
  • For classification, return a label from a structured output, not an explanation.

6. Route by difficulty

  • Route cheap/easy traffic to a smaller model (claude-haiku-5-5) and hard traffic to a larger one, via a rule (feature, input length) or a cheap classifier.
  • First test the simpler alternative: the stronger model at lower effort may match a cascade with one cache namespace and less code.
  • Every route needs its own eval pass on real traffic samples (see llm-evals).

7. Verify in production

Compare a week of usage before and after: spend per feature, cache hit ratio (cache_read / (cache_read + cache_creation + input) on cacheable traffic), output tokens per request, and cost per completed task. Confirm the eval score held.

Gotchas

  • A 1-hour TTL on continuous traffic just doubles the write cost; reads already keep the 5-minute entry warm.
  • Caching a prefix that changes every request (hit rate below break-even) costs more than not caching. The script’s break-even line flags this.
  • Generation time counts against the TTL; a 4-minute response leaves 1 minute for the next request to hit a 5-minute cache.
  • Parallel first requests all miss: warm the cache with one request before fanning out.
  • Downgrading a model to save money without an eval is a quality regression waiting for a customer to report it.

Output contract

**Profile:** <period> spend <$>; top features <name: $ and %>; output share <x%>
**Applied (free):** caching (hit ratio <a> -> <b>), batch share <c>, trimming <what>
**Applied (trade-offs):** <model/effort changes> — eval <score before -> after>
**Measured savings:** <$ per period, %> from production usage, cost per task <x -> y>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/cost_model.py 105 lines · 5.3 KB
#!/usr/bin/env python3
"""Model LLM API spend with and without prompt caching, batching, and model routing.

Usage examples:
    python3 cost_model.py --model claude-sonnet-5-5 --requests 200000 \
        --prefix-tokens 6000 --variable-tokens 800 --output-tokens 400 --hit-rate 0.9
    python3 cost_model.py --model claude-opus-5-5 --requests 5000 --prefix-tokens 20000 \
        --variable-tokens 1000 --output-tokens 1500 --hit-rate 0.7 --ttl 1h --batch-share 0.5
    python3 cost_model.py --input-price 3 --output-price 15 --cache-read-mult 0.1 ...   # any provider

All prices are USD per million tokens. Built-in prices are a snapshot (late 2026) and
MUST be checked against the provider's current pricing page; override with
--input-price/--output-price/--cache-read-mult for anything else.

Exit code 0 on success, 2 on bad arguments.
"""
import argparse
import sys

# model -> (input $/MTok, output $/MTok, cache-read multiplier of input price)
PRICES = {
    "claude-fable-5-1": (10.00, 50.00, 0.025),
    "claude-opus-5-5": (4.00, 20.00, 0.05),
    "claude-sonnet-5-5": (2.00, 10.00, 0.10),
    "claude-haiku-5-5": (0.10, 0.50, 0.10),  # prompts <= 100K tokens; higher tier above
}
WRITE_MULT = {"5m": 1.25, "1h": 2.0}
BATCH_DISCOUNT = 0.5


def money(x):
    return f"${x:,.2f}"


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--model", default="claude-sonnet-5-5")
    ap.add_argument("--requests", type=float, required=True, help="requests per period (e.g. per month)")
    ap.add_argument("--prefix-tokens", type=float, default=0, help="stable, cacheable prefix tokens per request")
    ap.add_argument("--variable-tokens", type=float, required=True, help="uncacheable input tokens per request")
    ap.add_argument("--output-tokens", type=float, required=True, help="output tokens per request (incl. thinking)")
    ap.add_argument("--hit-rate", type=float, default=0.0, help="fraction of requests whose prefix is a cache read")
    ap.add_argument("--ttl", choices=["5m", "1h"], default="5m")
    ap.add_argument("--batch-share", type=float, default=0.0, help="fraction of requests sent via batch API")
    ap.add_argument("--input-price", type=float)
    ap.add_argument("--output-price", type=float)
    ap.add_argument("--cache-read-mult", type=float)
    args = ap.parse_args()

    if not 0 <= args.hit_rate <= 1 or not 0 <= args.batch_share <= 1:
        print("error: --hit-rate and --batch-share must be between 0 and 1", file=sys.stderr)
        sys.exit(2)
    base = PRICES.get(args.model)
    if base is None and (args.input_price is None or args.output_price is None):
        print(f"error: unknown model {args.model}; pass --input-price and --output-price", file=sys.stderr)
        sys.exit(2)
    pin = args.input_price if args.input_price is not None else base[0]
    pout = args.output_price if args.output_price is not None else base[1]
    rmult = args.cache_read_mult if args.cache_read_mult is not None else (base[2] if base else 0.1)
    wmult = WRITE_MULT[args.ttl]

    n, P, V, O, h = args.requests, args.prefix_tokens, args.variable_tokens, args.output_tokens, args.hit_rate
    per_m = 1_000_000

    no_cache_in = n * (P + V) * pin / per_m
    out_cost = n * O * pout / per_m
    baseline = no_cache_in + out_cost

    cached_in = (n * h * P * pin * rmult + n * (1 - h) * P * pin * wmult + n * V * pin) / per_m
    with_cache = cached_in + out_cost
    batch_factor = 1 - args.batch_share * BATCH_DISCOUNT
    with_cache_batch = with_cache * batch_factor

    print(f"model: {args.model}  input ${pin}/MTok  output ${pout}/MTok  cache read x{rmult}  write x{wmult} ({args.ttl})")
    print(f"requests: {n:,.0f}  prefix {P:,.0f}  variable {V:,.0f}  output {O:,.0f} tokens  hit rate {h:.0%}")
    print()
    print(f"{'scenario':<34}{'input':>14}{'output':>14}{'total':>14}")
    print(f"{'no caching':<34}{money(no_cache_in):>14}{money(out_cost):>14}{money(baseline):>14}")
    print(f"{'with caching':<34}{money(cached_in):>14}{money(out_cost):>14}{money(with_cache):>14}")
    if args.batch_share:
        print(f"{'with caching + ' + format(args.batch_share, '.0%') + ' batched':<34}"
              f"{'':>14}{'':>14}{money(with_cache_batch):>14}")
    final = with_cache_batch
    print()
    saved = baseline - final
    print(f"savings vs. no caching: {money(saved)} ({saved / baseline:.1%})" if baseline else "savings: n/a")
    share_out = out_cost / baseline if baseline else 0
    print(f"output share of baseline spend: {share_out:.0%}"
          + ("  -> output dominates: trim verbosity, lower effort, or route to a cheaper model" if share_out > 0.5 else ""))

    # Break-even hit rate: caching beats no caching when h*r + (1-h)*w < 1  ->  h > (w-1)/(w-r)
    be = (wmult - 1) / (wmult - rmult)
    print(f"break-even hit rate for the prefix at {args.ttl} TTL: {be:.0%}"
          + ("  WARNING: current hit rate is below break-even; caching costs more" if P and h < be else ""))

    print("\nrouting what-if (same token profile, caching and batching applied):")
    for m, (i, o, r) in PRICES.items():
        ci = (n * h * P * i * r + n * (1 - h) * P * i * wmult + n * V * i) / per_m
        total = (ci + n * O * o / per_m) * batch_factor
        print(f"  {m:<20} {money(total):>14}")
    print("Routing only counts if the cheaper model passes your eval on this traffic.")


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc