Prompt Engineering

Prompt Caching: Reducing Latency and Cost

KV cache reuse across Anthropic, OpenAI, and self-hosted engines — mechanics, pricing models, and strategies for maximizing cache hit rates in production.

Why Cache Prompts

Modern LLM applications repeatedly send near-identical prompts. A customer-support chatbot prepends the same 4,000-token system prompt to every user query. A RAG pipeline prefixes the same retrieval context across a batch of follow-up questions. A coding assistant re-sends the same repository context on each edit. Without caching, the model recomputes the KV projections for these shared tokens on every single request — wasting both compute and money.

System prompt (4,000 tokens) User Q₁ → prefill 4,100 tok System prompt (4,000 tokens) User Q₂ → prefill 4,150 tok System prompt (4,000 tokens) User Q₃ → prefill 4,200 tok 12,000+ tokens recomputed across 3 requests

The three main motivations for prompt caching:

When caching matters most: The benefit scales with the ratio of cached-to-total tokens. A 4,000-token system prompt with a 100-token user query gives a 97% cache ratio — extremely effective. A 100-token system prompt with a 2,000-token document gives only 5% — barely worth optimizing.

KV Cache Reuse Mechanics

Prompt caching fundamentally reuses the Key-Value projections computed during the prefill phase. In a transformer decoder, each layer produces K and V matrices for every input token. These matrices are stored in GPU memory and reused during autoregressive generation. Prompt caching extends this principle across requests — if two requests share an identical token prefix, the KV entries for that prefix are computed once and shared.

Request A: prefill KV₁ KV₂ … KVₙ stored Prefix Cache hash(tokens) → KV pages Request B: same prefix lookup ✓ Cache HIT — skip prefill Only prefill unique suffix Request C: different prefix ✗ Cache MISS — full prefill Compute all KV from scratch

Prefix Matching Rules

Caching works via exact prefix matching. The token sequence from position 0 up to some boundary must be byte-identical between requests. A single different token at position k invalidates the cache for all positions ≥ k. This has practical implications:

Cache Invalidation

Providers use time-based TTLs. Anthropic caches last 5 minutes (extended on each hit). OpenAI caches persist 5–60 minutes depending on traffic. Self-hosted engines evict via LRU when GPU memory pressure rises. There is no explicit invalidation API — you rely on TTL expiry and natural eviction.

Token-level identity: Caching operates on token IDs, not on text strings. Two semantically identical prompts that tokenize differently (due to Unicode normalization, encoding, or model-specific tokenizer behavior) will NOT share a cache entry.

Anthropic Prompt Caching

Anthropic provides explicit, developer-controlled prompt caching via the cache_control parameter. You mark specific message blocks as cache breakpoints, and the API caches KV state up to those breakpoints. This gives fine-grained control over what gets cached and when.

Setting Cache Breakpoints

# Anthropic prompt caching — explicit breakpoints
import anthropic

client = anthropic.Anthropic()

# The system prompt is cached at the breakpoint
response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": LARGE_SYSTEM_PROMPT,   # 3,000+ tokens
            "cache_control": {"type": "ephemeral"}
        }
    ],
    messages=[
        {"role": "user", "content": user_query}
    ]
)

# Check cache performance in response headers
usage = response.usage
print(f"Cache read tokens:  {usage.cache_read_input_tokens}")
print(f"Cache write tokens: {usage.cache_creation_input_tokens}")
print(f"Uncached tokens:    {usage.input_tokens}")

Multi-Turn Caching with Multiple Breakpoints

# Cache system prompt + few-shot examples + conversation history
response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": SYSTEM_PROMPT,
            "cache_control": {"type": "ephemeral"}  # Breakpoint 1
        }
    ],
    messages=[
        {"role": "user", "content": [
            {"type": "text", "text": FEW_SHOT_EXAMPLES,
             "cache_control": {"type": "ephemeral"}}  # Breakpoint 2
        ]},
        {"role": "assistant", "content": assistant_reply_1},
        {"role": "user", "content": [
            {"type": "text", "text": conversation_history,
             "cache_control": {"type": "ephemeral"}}  # Breakpoint 3
        ]},
        {"role": "user", "content": current_question}
    ]
)

Anthropic Pricing & TTL

Pricing Model

Cache write: 25% more than base input price (one-time cost to store KV state).

Cache read: 90% discount on base input price (subsequent hits).

Example (Claude Sonnet):

Base input: $3/M tokens
Cache write: $3.75/M tokens
Cache read: $0.30/M tokens

Break-even: cache hit on 2nd request.

TTL & Constraints

TTL: 5 minutes, refreshed on each cache hit.

Min cacheable tokens: 1,024 for Claude Sonnet/Opus, 2,048 for Claude Haiku.

Max breakpoints: 4 per request.

Scope: Per-organization — different API keys in the same org share cache.

Eviction: LRU after TTL expires; no manual flush.

Cost calculation: For a workload sending 10,000 requests/hour with a 4,000-token system prompt (Claude Sonnet): Without caching: 40M input tokens × $3/M = $120/hr. With caching (95% hit rate): write cost $0.15 + read cost 38M × $0.30/M + miss cost 2M × $3/M = $17.55/hr. Savings: 85%.

OpenAI Prompt Caching

OpenAI implements automatic prompt caching — no code changes required. The API detects matching prefixes and serves cached KV state transparently. This simplifies adoption but gives less control over caching behavior.

How Automatic Caching Works

# OpenAI automatic caching — no special parameters needed
from openai import OpenAI

client = OpenAI()

# First request — computes and caches KV
response_1 = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": LARGE_SYSTEM_PROMPT},
        {"role": "user", "content": "What is prompt caching?"}
    ]
)

# Second request — same prefix triggers cache hit
response_2 = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": LARGE_SYSTEM_PROMPT},  # identical
        {"role": "user", "content": "How does it reduce costs?"}
    ]
)

# Check cache usage in response
usage = response_2.usage
print(f"Total tokens:         {usage.total_tokens}")
print(f"Cached input tokens:  {usage.prompt_tokens_details.cached_tokens}")

OpenAI Caching Rules

Prefix Matching

Granularity: 128-token blocks. The longest matching prefix (rounded down to 128-token boundary) is cached.

Minimum: 1,024 tokens for cache eligibility.

Matching scope: Messages are concatenated in order. Matching starts from the beginning and extends as far as tokens are identical.

Cross-request: Caching works across different API calls sharing the same prefix.

Pricing & TTL

Cache read discount: 50% off input token price.

Cache write: No additional charge (included in normal input pricing).

Example (GPT-4o):

Base input: $2.50/M tokens
Cached read: $1.25/M tokens

TTL: 5–60 minutes, depending on traffic volume. High-traffic prompts stay cached longer.

Scope: Per-organization, same model only.

Anthropic vs OpenAI Comparison

Feature Anthropic OpenAI Control Explicit (cache_control) Automatic Write cost +25% surcharge Free (normal pricing) Read discount 90% 50% Min tokens 1,024 (Sonnet/Opus) 1,024 TTL 5 min (refreshes on hit) 5–60 min (traffic-based) Breakpoints Up to 4 explicit N/A (auto at 128-tok blocks) Best for High-volume, cost-sensitive Simplicity, drop-in savings
Break-even analysis: Anthropic's 90% read discount makes it cheaper at scale despite the 25% write surcharge. At ≥2 requests sharing the same prefix, Anthropic caching is cost-positive. OpenAI's free writes and 50% discount make it simpler — every request benefits without any code changes, but the per-token savings are lower.

Self-Hosted Caching

When running models on your own infrastructure, you control the caching layer directly. Two major engines support prefix caching: vLLM (automatic prefix caching) and SGLang (RadixAttention). Both eliminate redundant prefill computation for shared prefixes.

vLLM Prefix Caching

# Enable prefix caching in vLLM
# Command-line flag — no code changes needed
#   python -m vllm.entrypoints.openai.api_server \
#       --model meta-llama/Llama-3-70B \
#       --enable-prefix-caching \
#       --gpu-memory-utilization 0.90

# Programmatic usage with the LLM engine
from vllm import LLM, SamplingParams

llm = LLM(
    model="meta-llama/Llama-3-70B",
    enable_prefix_caching=True,
    gpu_memory_utilization=0.90
)

system_prompt = "You are a helpful coding assistant..."  # 3,000 tokens

# All requests sharing this prefix reuse cached KV blocks
prompts = [
    f"{system_prompt}\nUser: Explain decorators\nAssistant:",
    f"{system_prompt}\nUser: What is asyncio?\nAssistant:",
    f"{system_prompt}\nUser: Explain generators\nAssistant:",
]

params = SamplingParams(temperature=0.7, max_tokens=512)
outputs = llm.generate(prompts, params)

# First request computes prefix KV (cache miss)
# Subsequent requests skip prefix prefill (cache hit)

SGLang RadixAttention

# SGLang uses RadixAttention for prefix caching
# It stores prefixes in a radix tree for efficient matching

# Launch with prefix caching enabled (default in SGLang)
#   python -m sglang.launch_server \
#       --model meta-llama/Llama-3-70B \
#       --port 30000

import sglang as sgl

@sgl.function
def cached_qa(s, system_prompt, question):
    s += sgl.system(system_prompt)     # Cached via radix tree
    s += sgl.user(question)
    s += sgl.assistant(sgl.gen("answer", max_tokens=512))

# RadixAttention automatically detects shared prefixes
# across requests and reuses KV cache entries
# Key difference from vLLM: uses radix tree (trie) data structure
# — supports partial prefix matching, not just full prefix

questions = ["What is RLHF?", "Explain DPO", "Define PPO"]
states = cached_qa.run_batch(
    [{"system_prompt": SYSTEM, "question": q} for q in questions],
    num_threads=3
)

vLLM Automatic Prefix Caching

Data structure: Hash table mapping token-block hashes to KV pages.

Granularity: Block-level (default 16 tokens). Prefix matching is quantized to block boundaries.

Eviction: LRU. Cached blocks are evicted when GPU memory is under pressure.

Overhead: Near-zero — hash computation is negligible relative to attention FLOPs.

Enable: --enable-prefix-caching flag.

SGLang RadixAttention

Data structure: Radix tree (compressed trie) mapping token sequences to KV cache entries.

Granularity: Token-level. Matches the longest common prefix in the radix tree.

Advantage: Handles tree-structured sharing (e.g., multi-turn conversations branching from the same root).

Eviction: LRU on tree nodes. Leaf nodes evicted first.

Enable: On by default in SGLang.

Monitoring Cache Performance

# Monitor vLLM prefix cache hit rates via metrics endpoint
import requests

metrics = requests.get("http://localhost:8000/metrics").text

# Key metrics to track
# vllm:prefix_cache_hit_rate        — fraction of blocks served from cache
# vllm:prefix_cache_total_blocks     — total blocks in cache
# vllm:prefix_cache_used_blocks      — blocks currently referenced
# vllm:prefix_cache_evictions_total  — cumulative evictions

def parse_cache_metrics(metrics_text):
    hit_rate = None
    for line in metrics_text.splitlines():
        if "prefix_cache_hit_rate" in line and not line.startswith("#"):
            hit_rate = float(line.split()[-1])
    return hit_rate

hit_rate = parse_cache_metrics(metrics)
print(f"Cache hit rate: {hit_rate:.1%}")

# Alert if hit rate drops below threshold
if hit_rate is not None and hit_rate < 0.70:
    print("⚠ Low cache hit rate — check prompt ordering")
GPU memory tradeoff: Prefix caching reserves GPU memory for cached KV blocks that could otherwise serve additional concurrent requests. If your workload has low prefix overlap (diverse RAG contexts), the memory reserved for caching may reduce throughput. Profile with --enable-prefix-caching on and off to measure net impact.

Cost Optimization Strategies

Maximizing the return from prompt caching requires deliberate prompt design. The goal is to maximize the ratio of cached-to-total tokens while maintaining prompt quality.

Strategy 1: Prompt Ordering

Place static, shared content at the beginning of the prompt. Variable, per-request content goes at the end. This maximizes the cacheable prefix length.

✗ Bad ordering User query System prompt Few-shot examples ← Variable data first = 0% cache ✓ Good ordering System prompt Few-shot examples User query ← Static prefix = 85%+ cache

Strategy 2: Maximize Cache Hit Rates

# Build prompts with a stable, cacheable prefix
class CacheOptimizedPromptBuilder:
    def __init__(self, system_prompt, few_shot_examples):
        # Static components — deterministic order, no timestamps
        self.system_prompt = system_prompt
        self.few_shot_examples = sorted(few_shot_examples, key=lambda x: x["id"])

    def build_messages(self, user_query, conversation_history=None):
        messages = []

        # Layer 1: System prompt (always first, always identical)
        messages.append({
            "role": "system",
            "content": self.system_prompt
        })

        # Layer 2: Few-shot examples (sorted, deterministic)
        for ex in self.few_shot_examples:
            messages.append({"role": "user", "content": ex["input"]})
            messages.append({"role": "assistant", "content": ex["output"]})

        # Layer 3: Conversation history (grows per turn)
        if conversation_history:
            messages.extend(conversation_history)

        # Layer 4: Current query (variable, uncached)
        messages.append({"role": "user", "content": user_query})

        return messages

Strategy 3: Monitor and Tune

# Track caching efficiency across requests
class CacheMonitor:
    def __init__(self):
        self.total_input_tokens = 0
        self.cached_tokens = 0
        self.cache_write_tokens = 0
        self.request_count = 0

    def record_anthropic(self, usage):
        self.request_count += 1
        self.total_input_tokens += usage.input_tokens
        self.cached_tokens += getattr(usage, "cache_read_input_tokens", 0)
        self.cache_write_tokens += getattr(usage, "cache_creation_input_tokens", 0)

    def record_openai(self, usage):
        self.request_count += 1
        self.total_input_tokens += usage.prompt_tokens
        details = getattr(usage, "prompt_tokens_details", None)
        if details:
            self.cached_tokens += getattr(details, "cached_tokens", 0)

    @property
    def hit_rate(self):
        if self.total_input_tokens == 0:
            return 0.0
        return self.cached_tokens / self.total_input_tokens

    def report(self):
        print(f"Requests:       {self.request_count}")
        print(f"Cache hit rate: {self.hit_rate:.1%}")
        print(f"Cached tokens:  {self.cached_tokens:,}")
        print(f"Total input:    {self.total_input_tokens:,}")
        # Estimate savings (Anthropic Sonnet pricing)
        base_cost = self.total_input_tokens * 3.0 / 1_000_000
        actual_cost = (
            (self.total_input_tokens - self.cached_tokens) * 3.0 / 1_000_000
            + self.cached_tokens * 0.30 / 1_000_000
            + self.cache_write_tokens * 3.75 / 1_000_000
        )
        print(f"Est. savings:   ${base_cost - actual_cost:.2f}")

Common Anti-Patterns

❌ Anti-Patterns

  • Injecting timestamps: Adding Current time: 2025-07-27T14:30:00Z into the system prompt destroys cache hits every second.
  • Random few-shot ordering: Shuffling examples per request means every request has a unique prefix.
  • Per-request UUIDs: Request-tracing IDs in the prompt break prefix matching.
  • Long variable preambles: Putting user-specific data before the system prompt.

✅ Best Practices

  • Fixed system prompt: Keep the system prompt identical across all requests in a use case.
  • Sorted examples: Sort few-shot examples by a stable key (ID, alphabetical).
  • Metadata at the end: Move request IDs, timestamps, and user context to the final user message.
  • Batch similar requests: Group requests sharing a prefix within the same time window to maximize cache retention.
Key Takeaway: Prompt caching is one of the highest-leverage optimizations for production LLM applications. Anthropic's explicit cache_control delivers up to 90% cost savings for high-volume workloads. OpenAI's automatic caching requires zero code changes for a 50% discount. Self-hosted engines like vLLM and SGLang provide the same benefit at the inference layer. The critical pattern is consistent: put static content first, keep prefixes deterministic, monitor cache hit rates, and let the math compound across millions of requests.