Free lesson · GenAI Application Engineering

Implement Anthropic prompt caching with cache_control markers

You will build a CachedContextManager class in context/cached_context.py structuring Anthropic API calls for prompt caching. The manager implements prepare_cached_messages(context: ComposedContext) adding cache_control={'type': 'ephemeral'} markers to stable prefixes: system prompt, tool definitions, and few-shot examples. A CacheStrategy Pydantic model defines cacheable sections: system_prompt (always), tool_definitions (cached, refreshed on changes), few_shot_examples (cached), mem0_memories (not cached), conversation_history (not cached). The CacheSavingsEstimator compares cached_tokens at $0.30/M vs uncached at $3/M for Claude Sonnet, projecting 90% cost reduction. A FastAPI endpoint GET /api/v1/cache/stats returns hit rates and cumulative savings.

Course: Full-Stack GenAI Applications · Chapter 3 · Context Engineering & Conversation Memory

Free to read — no subscription required.

Introduction

When you ship a chat application that re-sends the same 40,000-token system prompt, tool schemas, and memories on every turn, your Anthropic bill scales linearly with traffic and your token budget burns out long before the conversation does. If you skip prompt caching here, you pay full input rates on content the model has already ingested — and a single misplaced cache_control marker can quietly drop your hit rate to zero while still charging the 25% write surcharge. By the end of this lesson you will be able to mark stable prefix segments with cache_control: {"type": "ephemeral"}, place breakpoints in stability order, and verify cache hits via the API's usage response so cached tokens cost 90% less.

Key Terminology

  • cache_control: An Anthropic API marker ({"type": "ephemeral"}) attached to a content block, declaring that everything up to and including that block forms a cacheable prefix with a five-minute TTL.
  • CacheStats: A dataclass that tracks the three token counters returned in the API response's usage field — cache_creation_input_tokens, cache_read_input_tokens, and input_tokens — and exposes hit_rate and estimated_savings_pct properties used to monitor cache effectiveness.
  • ComposedContext: The dataclass produced by the context engineering pipeline that bundles the system prompt, tool definitions, Mem0 memories, conversation history, current message, and per-session identifiers consumed by CachedContextManager.prepare_cached_messages.

Concepts

Cache Placement Strategy and Cost Mathematics

Effective caching depends on understanding the cost arithmetic. Anthropic charges different rates per token depending on cache status:

  • Uncached input tokens: 1.0× base rate (standard pricing)
  • Cache write tokens: 1.25× base rate (25% surcharge on first request)
  • Cache read tokens: 0.1× base rate (90% discount on subsequent requests)

For a prefix of P tokens that gets N cache hits within the five-minute TTL window, the break-even point is straightforward. Without caching, you pay P × N at standard rate. With caching, you pay P × 1.25 for the write plus P × 0.1 × (N - 1) for subsequent reads. Caching becomes cheaper when N ≥ 2 — meaning you need just two requests within five minutes to profit. In a typical conversational application where users send messages every 30-60 seconds, you achieve 5-10 cache hits per write cycle, yielding 70-85% net savings on the cached prefix.

The critical rule for placement is: never place a cache_control marker after content that changes every request. If your Mem0 memories update on every turn (because the extraction pipeline runs synchronously), move the second breakpoint to before the memories instead of after. Monitor CacheStats.hit_rate — if it drops below 0.5, your breakpoints are positioned incorrectly, and cache writes are costing more than they save. Your Redis session state should store the running CacheStats alongside the token budget counters, enabling real-time alerts when cache efficiency degrades.

Integration with the Session Pipeline

When wiring the CachedContextManager into your FastAPI endpoint, the flow follows a clear sequence. Your endpoint loads conversation history from PostgreSQL using the SQLAlchemy async session, retrieves relevant memories from MemoryClient.search(), assembles the ComposedContext, calls prepare_cached_messages, sends the payload to Anthropic, updates cache stats, and persists the new stats to Redis with a TTL matching your session expiration. The Redis-backed session state serves double duty: it tracks both the per-section token counts (for context window budget enforcement) and the cache performance metrics (for cost optimization monitoring). When the context window approaches the token budget limit, your eviction logic trims the oldest conversation history messages — the uncached tail — preserving the cached prefix intact and maintaining cache hit rates even as conversations grow long.

  • Breakpoint placement is prefix-sensitive: A single byte change in any content block before a cache_control marker invalidates the cache for that marker and all subsequent markers. Always place the most stable content first.
  • Monitor the five-minute TTL: If your application has bursty traffic patterns with gaps longer than five minutes, cache writes may not amortize. Consider adding a keep-alive mechanism that sends lightweight requests to refresh the cache during idle periods.
  • Token counting matters: The minimum cacheable prefix is 1,024 tokens for Claude Sonnet and 2,048 for Claude Opus. If your system prompt is shorter than these thresholds, combine it with tool definitions to meet the minimum. The CachedContextManager._build_system_blocks method already handles this by merging both into a single block.

Code Walkthrough

How Prompt Prefix Caching Works

Anthropic's caching operates on contiguous prefixes of the message array. When you set cache_control: {"type": "ephemeral"} on a content block, you tell the API: "everything up to and including this block is a cacheable prefix." On the first request, the API writes this prefix to a server-side cache (the cache write), charging 1.25× the normal input rate for those tokens. On subsequent requests within the five-minute TTL that share an identical prefix byte-for-byte, the API reads from cache (a cache hit), charging only 0.1× the normal input rate. If the prefix diverges at any point — even a single character difference — the cache misses from that divergence point onward.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares a Mermaid flowchart with left-to-right (LR) directional layout.
  • Lines 2-7: Defines a subgraph labeled "Context Window (Stability Order)" containing five nodes (A through E) connected by arrows, representing the sequential components of an LLM context window — from System Prompt (~2K tokens, most stable) → Tool Definitions (~5K tokens, stable) → Mem0 Memories (~1K tokens, session-stable) → Conversation History (~30K tokens, growing) → Current User Message (~200 tokens, volatile).
  • Line 9: Draws a dotted arrow labeled "cache_control ①" from node A to node B, indicating the first cache breakpoint is placed after the system prompt and tool definitions block.
  • Line 10: Draws a dotted arrow labeled "cache_control ②" from node C to node D, indicating the second cache breakpoint is placed between Mem0 memories and the conversation history.
  • Lines 12-13: Styles nodes A (System Prompt) and B (Tool Definitions) with a dark green background (#2d5016) and white text, visually grouping them as the most stable, cached portion of the context.
  • Line 14: Styles node C (Mem0 Memories) with a dark goldenrod background (#8b6914) and white text, indicating moderate stability between the cached and volatile sections.
  • Line 15: Styles node D (Conversation History) with a saddle brown background (#8b4513) and white text, signaling this is a growing, less stable region of the context.
  • Line 16: Styles node E (Current User Message) with a dark red background (#8b0000) and white text, marking it as the most volatile, frequently changing part of the context window.

The diagram shows two cache_control breakpoints. Breakpoint ① marks the end of the system prompt and tool definitions — this prefix is identical across all users and all sessions, making it the highest-value cache target. Breakpoint ② marks the end of the Mem0 memories section, which remains stable within a single conversation turn. Everything after breakpoint ② — the conversation history tail and the current user message — is uncached and priced at standard input rates.

The CachedContextManager Class

The core implementation lives in a CachedContextManager class within context/cached_context.py. This class accepts a ComposedContext object — the output of your context engineering pipeline that contains the system prompt, tool schemas, Mem0 memories retrieved via MemoryClient.search(), and the conversation history loaded from PostgreSQL via SQLAlchemy async sessions. The prepare_cached_messages method transforms this composed context into the Anthropic API's expected message format with cache_control markers injected at optimal positions. The class also tracks cache performance metrics through a CacheStats dataclass, recording hit rates that feed into your Redis-backed session state for monitoring.

Code snippet python
1from dataclasses import dataclass, field 2from typing import Any 3 4@dataclass 5class CacheStats: 6 cache_creation_input_tokens: int = 0 7 cache_read_input_tokens: int = 0 8 input_tokens: int = 0 9 10 @property 11 def hit_rate(self) -> float: 12 total = self.cache_read_input_tokens + self.cache_creation_input_tokens + self.input_tokens 13 return self.cache_read_input_tokens / total if total > 0 else 0.0 14 15 @property 16 def estimated_savings_pct(self) -> float: 17 if self.cache_read_input_tokens == 0: 18 return 0.0 19 read_savings = self.cache_read_input_tokens * 0.9 20 write_penalty = self.cache_creation_input_tokens * 0.25 21 total = self.cache_read_input_tokens + self.cache_creation_input_tokens + self.input_tokens 22 return ((read_savings - write_penalty) / total) * 100 if total > 0 else 0.0 23 24@dataclass 25class ComposedContext: 26 system_prompt: str 27 tool_definitions: list[dict[str, Any]] 28 mem0_memories: list[str] 29 conversation_history: list[dict[str, Any]] 30 current_message: str 31 user_id: str 32 session_id: str 33 token_budget: int = 100_000 34 35class CachedContextManager: 36 CACHE_MARKER = {"type": "ephemeral"} 37 38 def __init__(self, model: str = "claude-sonnet-4-20250514"): 39 self.model = model 40 self.stats = CacheStats() 41 42 def prepare_cached_messages( 43 self, context: ComposedContext 44 ) -> dict[str, Any]: 45 system_blocks = self._build_system_blocks( 46 context.system_prompt, context.tool_definitions 47 ) 48 messages = self._build_message_list( 49 context.mem0_memories, 50 context.conversation_history, 51 context.current_message, 52 ) 53 return { 54 "model": self.model, 55 "max_tokens": 4096, 56 "system": system_blocks, 57 "messages": messages, 58 }
  • Lines 1-2: Imports the dataclass decorator and field factory for defining structured data containers, plus Any for flexible type annotations across the composed context fields.
  • Lines 4-8: The CacheStats dataclass tracks three token counters returned by the Anthropic API response's usage object — creation tokens (cache writes), read tokens (cache hits), and standard uncached input tokens.
  • Lines 10-12: The hit_rate property computes what fraction of total input tokens were served from cache. A value above 0.7 indicates effective cache placement.
  • Lines 14-20: The estimated_savings_pct property calculates net cost savings accounting for the 0.9× discount on cache reads minus the 0.25× surcharge on cache writes. A sustained rate above 60% confirms your prefix ordering is correct.
  • Lines 23-30: ComposedContext holds all sections produced by the context engineering pipeline. The mem0_memories field contains strings returned by MemoryClient.search(), while conversation_history holds message dicts loaded from PostgreSQL.
  • Lines 31-32: The token_budget field defaults to 100,000 tokens, matching Claude Sonnet's practical context limit. Your Redis session cache uses this budget to decide when to evict older messages.
  • Lines 35-36: The class-level CACHE_MARKER constant defines the ephemeral cache control block. Anthropic currently supports only the "ephemeral" type, which provides a five-minute TTL.
  • Lines 38-40: The constructor accepts a model identifier and initializes a fresh CacheStats instance that accumulates metrics across multiple calls within a session.
  • Lines 42-55: The prepare_cached_messages method orchestrates the full payload construction. It delegates system block assembly and message list construction to private methods, returning a dictionary ready to pass directly to anthropic.AsyncAnthropic().messages.create(**payload).

Building Cache-Optimized System Blocks and Message Lists

The two private methods _build_system_blocks and _build_message_list handle the precise placement of cache_control markers. The system blocks method serializes tool definitions as a JSON string appended to the system prompt, then marks the entire combined block with a cache breakpoint. This ensures that the system prompt plus tools — typically 5,000-8,000 tokens that never change during a session — are cached as a single contiguous prefix. The message list method injects a second breakpoint after the Mem0 memories, which are injected as an assistant-primed context block. Conversation history loaded from PostgreSQL via your SQLAlchemy async session follows without cache markers, since it changes on every turn.

Code snippet python
1import json 2 3class CachedContextManager: 4 # ... (continued from above) 5 6 def _build_system_blocks( 7 self, system_prompt: str, tools: list[dict] 8 ) -> list[dict]: 9 tool_text = json.dumps(tools, indent=2) if tools else "" 10 combined = f"{system_prompt}\n\n## Available Tools\n{tool_text}" if tool_text else system_prompt 11 12 return [ 13 { 14 "type": "text", 15 "text": combined, 16 "cache_control": self.CACHE_MARKER, 17 } 18 ] 19 20 def _build_message_list( 21 self, memories: list[str], history: list[dict], current_msg: str 22 ) -> list[dict]: 23 messages = [] 24 25 if memories: 26 memory_text = "## What I Remember About You\n" + "\n".join( 27 f"- {m}" for m in memories 28 ) 29 messages.append({ 30 "role": "user", 31 "content": [ 32 { 33 "type": "text", 34 "text": memory_text, 35 "cache_control": self.CACHE_MARKER, 36 } 37 ], 38 }) 39 messages.append({ 40 "role": "assistant", 41 "content": "I'll keep these memories in mind throughout our conversation.", 42 }) 43 44 for msg in history: 45 messages.append({ 46 "role": msg["role"], 47 "content": msg["content"], 48 }) 49 50 messages.append({ 51 "role": "user", 52 "content": current_msg, 53 }) 54 55 return messages 56 57 def update_stats(self, usage: dict) -> None: 58 self.stats.cache_creation_input_tokens += usage.get( 59 "cache_creation_input_tokens", 0 60 ) 61 self.stats.cache_read_input_tokens += usage.get( 62 "cache_read_input_tokens", 0 63 ) 64 self.stats.input_tokens += usage.get("input_tokens", 0)
  • Lines 1-1: Imports json for serializing tool definitions into the system prompt block. Tool schemas from your context pipeline are Python dicts that must become text for the system content block.
  • Lines 6-11: The _build_system_blocks method combines the system prompt and tool definitions into a single text block. Merging them ensures they share one cache entry rather than requiring two separate cache writes.
  • Lines 12-19: Returns a list containing one system content block with cache_control applied. This is breakpoint ① from the architecture diagram — the most stable prefix segment.
  • Lines 21-24: The _build_message_list method initializes an empty message list that will be built up section by section in stability order.
  • Lines 26-29: When Mem0 memories exist, they are formatted as a bulleted list under a clear header. Each memory string comes from MemoryClient.search(query=current_msg, user_id=user_id), which performs semantic retrieval against the user's stored memory graph.
  • Lines 30-38: The memories are wrapped in a user message content block with cache_control applied — this is breakpoint ②. The content block format (a list containing a dict with type, text, and cache_control) is required when mixing cached and uncached content within a single message role.
  • Lines 39-42: An assistant acknowledgment message follows the memories injection. This priming pattern ensures the model treats the memories as established context rather than a new user query requiring a response.
  • Lines 44-48: Conversation history messages are appended without any cache markers. Since history grows on every turn, caching it would cause constant cache misses and waste the 25% write surcharge. The history is loaded from PostgreSQL using your SQLAlchemy async session with cursor-based pagination to avoid loading unbounded message counts.
  • Lines 50-53: The current user message is appended last as a plain user message — no cache marker, since it is unique to every request.
  • Lines 55-62: The update_stats method extracts cache metrics from the Anthropic API response's usage field. Call this after every API response to maintain running statistics. These stats can be persisted to your Redis session cache alongside per-section token counts for observability dashboards.

Do's and Don'ts

Do's

  1. Do place cache_control: {"type": "ephemeral"} breakpoints in strict stability order — system prompt and tool definitions first (breakpoint ①), then Mem0 memories (breakpoint ②) — so the longest, most reused prefix remains byte-for-byte identical across all users and sessions and consistently hits the 0.1× cache-read rate.
  2. Do read cache_creation_input_tokens and cache_read_input_tokens from the API usage response on every turn — the CacheStats.hit_rate and estimated_savings_pct properties exist precisely to surface whether your breakpoints are landing; without this verification, a misplaced marker silently charges the 1.25× write surcharge every request while yielding zero cache hits.
  3. Do keep each cached prefix byte-for-byte identical between requests — Anthropic's prefix cache misses from the first character of divergence, so any runtime variation in the system prompt text, tool definition schemas, or Mem0 memory blocks resets those tokens to full input pricing and restarts the five-minute TTL clock.

Don'ts

  1. Don't attach cache_control to volatile content like the conversation history tail or the current user message — because these blocks change on every turn, the prefix diverges immediately, and you incur the 25% cache-write penalty (cache_creation_input_tokens * 1.25×) on every single request with no offsetting cache-read savings.
  2. Don't merge the static system prompt and Mem0 memories into a single text block before marking it with cache_control — Mem0 memories are session-stable but not cross-user-stable, so fusing them with the system prompt invalidates the highest-value prefix (the one shared across all users) every time MemoryClient.search() returns a different memory set, destroying the cache hits that were previously hitting at 0.1× for the system prompt and tool definitions.
  3. Don't assume that adding a CACHE_MARKER = {"type": "ephemeral"} entry is sufficient without verifying the estimated_savings_pct stays positive — the CacheStats formula subtracts the write penalty (cache_creation_input_tokens * 0.25) from the read savings (cache_read_input_tokens * 0.9), meaning a low hit-to-write ratio produces negative net savings: you pay more than uncached input pricing.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering