← Back to DevBytes

Prompt Caching for Cost Reduction with LangGraph: Complete Guide

Prompt Caching for Cost Reduction with LangGraph: Complete Guide

As LLM-powered applications grow in complexity, the cost of repeated API calls becomes a significant concern. LangGraph, with its stateful graph-based orchestration, often reuses large context blocks across nodes and iterations. Prompt caching—particularly Anthropic's prompt caching feature—offers a powerful way to reduce both latency and cost when working with large, repeated prompt prefixes. In this guide, you'll learn what prompt caching is, why it matters in LangGraph workflows, and how to implement it effectively with practical code examples.

What Is Prompt Caching?

Prompt caching is a feature offered by LLM providers (most notably Anthropic Claude) that allows the model to cache the processing of a prompt prefix. When subsequent requests share that same prefix, the cached computation is reused instead of being recomputed from scratch. This results in two major benefits:

The cache has a time-to-live (TTL), typically 5 minutes by default, which can be extended to 1 hour on some providers. Each cache hit refreshes the TTL. The key constraint is that the prefix must match exactly—any change in a single token invalidates the cache from that point forward.

Why Prompt Caching Matters in LangGraph

LangGraph applications frequently involve multi-step workflows where the same context is passed between nodes. Common patterns include:

Without caching, every node invocation that includes this large context pays full price for input tokens. In an agent loop that runs 10 iterations with a 10,000-token system prompt and document set, you'd pay for 100,000 input tokens just for the repeated context. With caching, you pay full price once and a fraction of the cost for the remaining 9 iterations. This can reduce your bill by 80% or more in such scenarios.

How Prompt Caching Works

Anthropic's API allows you to mark specific blocks in a message as cacheable using a cache_control parameter. You can place up to 4 cache breakpoints in a single request. The cache is keyed on the exact content preceding each breakpoint. When a new request shares that prefix, the cached portion is reused.

The structure typically looks like this:

[
  {
    "role": "system",
    "content": [
      { "type": "text", "text": "You are a helpful assistant..." },
      { "type": "text", "text": "[Large document context]", "cache_control": { "type": "ephemeral" } }
    ]
  },
  {
    "role": "user",
    "content": "What does the document say about X?"
  }
]

The cache_control marker tells the API to cache everything up to and including that block. Subsequent requests that include the same system message content will hit the cache.

Setting Up Your Environment

Before diving into code, install the required packages and set up your API keys:

pip install langgraph langchain-anthropic langchain-core

Set your Anthropic API key as an environment variable:

export ANTHROPIC_API_KEY="your-api-key-here"

Basic Prompt Caching with LangChain and Anthropic

LangChain's Anthropic integration supports prompt caching through the cache_control parameter on message content blocks. Let's start with a basic example before integrating it into LangGraph:

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage, HumanMessage

llm = ChatAnthropic(
    model="claude-3-5-sonnet-20241022",
    max_tokens=1024,
)

# Large context that we want to cache
large_context = "You are an expert legal assistant. " * 500  # Simulating a large prompt

system_message = SystemMessage(
    content=[
        {
            "type": "text",
            "text": large_context,
            "cache_control": {"type": "ephemeral"},
        }
    ]
)

# First call - cache is written
response1 = llm.invoke([system_message, HumanMessage(content="What is your role?")])
print("First call (cache write):", response1.usage_metadata)

# Second call - cache is hit
response2 = llm.invoke([system_message, HumanMessage(content="Summarize your capabilities.")])
print("Second call (cache hit):", response2.usage_metadata)

In the usage metadata, you'll see fields like cache_input_tokens and cache_read_input_tokens. The first call shows cache write tokens, while the second shows cache read tokens—confirming the cache was used.

Integrating Prompt Caching into LangGraph

Now let's build a LangGraph workflow that leverages prompt caching. We'll create a research agent that maintains a large document context across multiple reasoning steps.

from langgraph.graph import StateGraph, START, END
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from typing import TypedDict, Annotated, List
from langgraph.graph.message import add_messages
import os

# Initialize the model
llm = ChatAnthropic(
    model="claude-3-5-sonnet-20241022",
    max_tokens=2048,
)

# Define the graph state
class AgentState(TypedDict):
    messages: Annotated[List, add_messages]
    document_context: str
    question: str
    answer: str
    needs_more_info: bool

# A large document context that we want to cache
DOCUMENT_CONTEXT = """
[Large document content - e.g., a 50-page research paper, legal contract,
or technical specification. This content remains stable across all graph
iterations, making it an ideal candidate for prompt caching.]
""" * 100  # Simulating a large document

def create_cached_system_message(document_context: str) -> SystemMessage:
    """Create a system message with cache_control enabled."""
    return SystemMessage(
        content=[
            {
                "type": "text",
                "text": "You are a research assistant. Use the provided document context to answer questions accurately.",
            },
            {
                "type": "text",
                "text": f"Document context:\n{document_context}",
                "cache_control": {"type": "ephemeral"},
            },
        ]
    )

def research_node(state: AgentState) -> dict:
    """Process the question using the cached document context."""
    system_msg = create_cached_system_message(state["document_context"])
    
    messages = [system_msg] + state["messages"]
    response = llm.invoke(messages)
    
    return {
        "messages": [response],
        "answer": response.content,
        "needs_more_info": "need more information" in response.content.lower(),
    }

def review_node(state: AgentState) -> dict:
    """Review the answer and decide if refinement is needed."""
    system_msg = create_cached_system_message(state["document_context"])
    
    review_prompt = HumanMessage(
        content=f"Review this answer for completeness: {state['answer']}. If it's complete, say 'COMPLETE'. Otherwise, say what's missing."
    )
    
    messages = [system_msg] + state["messages"] + [review_prompt]
    response = llm.invoke(messages)
    
    needs_refinement = "COMPLETE" not in response.content.upper()
    
    return {
        "messages": [response],
        "needs_more_info": needs_refinement,
    }

def should_refine(state: AgentState) -> str:
    """Conditional edge to determine next step."""
    if state.get("needs_more_info", False):
        return "research"
    return END

# Build the graph
workflow = StateGraph(AgentState)

workflow.add_node("research", research_node)
workflow.add_node("review", review_node)

workflow.add_edge(START, "research")
workflow.add_edge("research", "review")
workflow.add_conditional_edges("review", should_refine, {
    "research": "research",
    END: END,
})

graph = workflow.compile()

In this workflow, both the research_node and review_node use the same cached system message containing the large document context. The first call writes the cache, and all subsequent calls—whether from the review node or from looping back to research—read from it.

Running the Graph and Observing Cache Hits

Let's run the graph and track token usage to verify caching is working:

initial_state = {
    "messages": [HumanMessage(content="What are the key findings in the document?")],
    "document_context": DOCUMENT_CONTEXT,
    "question": "What are the key findings?",
    "answer": "",
    "needs_more_info": False,
}

# Track total cost
total_input_tokens = 0
total_cache_read_tokens = 0
total_cache_creation_tokens = 0

# Wrap the LLM to track usage
original_invoke = llm.invoke

def tracking_invoke(messages, **kwargs):
    response = original_invoke(messages, **kwargs)
    usage = response.usage_metadata
    global total_input_tokens, total_cache_read_tokens, total_cache_creation_tokens
    total_input_tokens += usage.get("input_tokens", 0)
    total_cache_read_tokens += usage.get("cache_read_input_tokens", 0)
    total_cache_creation_tokens += usage.get("cache_creation_input_tokens", 0)
    print(f"  Input: {usage.get('input_tokens', 0)}, "
          f"Cache read: {usage.get('cache_read_input_tokens', 0)}, "
          f"Cache write: {usage.get('cache_creation_input_tokens', 0)}")
    return response

llm.invoke = tracking_invoke

result = graph.invoke(initial_state)

print("\n=== Final Answer ===")
print(result["answer"])
print("\n=== Token Usage Summary ===")
print(f"Total regular input tokens: {total_input_tokens}")
print(f"Total cache read tokens: {total_cache_read_tokens}")
print(f"Total cache creation tokens: {total_cache_creation_tokens}")

# Restore original
llm.invoke = original_invoke

You should observe that the first invocation has high cache_creation_input_tokens (the cache write), while subsequent invocations show high cache_read_input_tokens and very low regular input_tokens. This confirms the cache is working.

Advanced Pattern: Caching Tool Definitions

In agentic workflows, tool definitions can be large and are sent with every request. You can cache them too:

from langchain_core.tools import tool

@tool
def search_database(query: str) -> str:
    """Search the internal database for relevant records.
    
    Args:
        query: Natural language search query
        
    Returns:
        Matching records as formatted text
    """
    # Simulated database search
    return f"Results for: {query}"

@tool
def analyze_sentiment(text: str) -> str:
    """Analyze the sentiment of given text.
    
    Args:
        text: Text to analyze
        
    Returns:
        Sentiment analysis result
    """
    return "Positive sentiment detected"

# Bind tools with caching
llm_with_tools = llm.bind_tools(
    [search_database, analyze_sentiment],
    # The tool definitions are included in the system prompt
    # and will be cached along with other system content
)

def agent_node(state: AgentState) -> dict:
    system_msg = SystemMessage(
        content=[
            {
                "type": "text",
                "text": "You are a helpful agent with access to tools. Use them when needed.",
            },
            {
                "type": "text",
                "text": f"Context: {state['document_context']}",
                "cache_control": {"type": "ephemeral"},
            },
        ]
    )
    
    messages = [system_msg] + state["messages"]
    response = llm_with_tools.invoke(messages)
    
    return {"messages": [response]}

Advanced Pattern: Caching Conversation History

For chat applications with long histories, you can cache the conversation prefix while keeping the latest messages uncached. This is especially useful in LangGraph's MessagesState pattern:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, AnyMessage
from langgraph.graph import StateGraph, START, END, MessagesState

llm = ChatAnthropic(model="claude-3-5-sonnet-20241022", max_tokens=1024)

SYSTEM_PROMPT = "You are a knowledgeable assistant with expertise in many fields."

def build_cached_messages(messages: List[AnyMessage]) -> List[AnyMessage]:
    """Build message list with cache_control on the system prompt
    and on the conversation history prefix."""
    
    result = []
    
    # System prompt - always cached
    result.append(SystemMessage(
        content=[
            {
                "type": "text",
                "text": SYSTEM_PROMPT,
                "cache_control": {"type": "ephemeral"},
            }
        ]
    ))
    
    # If we have enough conversation history, cache the older portion
    if len(messages) > 4:
        # Cache everything except the last 2 messages
        history_to_cache = messages[:-2]
        recent_messages = messages[-2:]
        
        # Convert older messages to cached content blocks
        cached_content = []
        for msg in history_to_cache:
            role = "user" if isinstance(msg, HumanMessage) else "assistant"
            cached_content.append({
                "type": "text",
                "text": f"{role}: {msg.content}",
            })
        
        # Mark the last block of the cached history
        if cached_content:
            cached_content[-1]["cache_control"] = {"type": "ephemeral"}
            result.append({
                "role": "user",
                "content": cached_content,
            })
        
        result.extend(recent_messages)
    else:
        result.extend(messages)
    
    return result

def chat_node(state: MessagesState) -> dict:
    cached_messages = build_cached_messages(state["messages"])
    response = llm.invoke(cached_messages)
    return {"messages": [response]}

# Simple graph
workflow = StateGraph(MessagesState)
workflow.add_node("chat", chat_node)
workflow.add_edge(START, "chat")
workflow.add_edge("chat", END)
chat_graph = workflow.compile()

# Simulate a multi-turn conversation
messages = [HumanMessage(content="Tell me about quantum computing.")]
result = chat_graph.invoke({"messages": messages})

messages.append(result["messages"][-1])
messages.append(HumanMessage(content="How does it compare to classical computing?"))
result = chat_graph.invoke({"messages": messages})

messages.append(result["messages"][-1])
messages.append(HumanMessage(content="What are the practical applications?"))
result = chat_graph.invoke({"messages": messages})

print("Final response:", result["messages"][-1].content)

This pattern ensures that as the conversation grows, the older portion is cached and only the newest exchanges are processed at full cost.

Best Practices for Prompt Caching in LangGraph

To get the most out of prompt caching, follow these guidelines:

Calculating Cost Savings

Let's put together a utility to calculate and compare costs with and without caching:

def calculate_cost_savings(
    num_calls: int,
    cached_tokens: int,
    uncached_tokens: int,
    model: str = "claude-3-5-sonnet"
):
    """
    Calculate cost savings from prompt caching.
    
    Args:
        num_calls: Number of API calls made
        cached_tokens: Number of tokens in the cached prefix
        uncached_tokens: Number of tokens NOT cached (per call)
        model: Model name for pricing
    """
    # Pricing per 1M tokens (example rates, verify current pricing)
    pricing = {
        "claude-3-5-sonnet": {
            "input": 3.00,
            "cache_write": 3.75,
            "cache_read": 0.30,
        },
        "claude-3-5-haiku": {
            "input": 0.80,
            "cache_write": 1.00,
            "cache_read": 0.08,
        },
    }
    
    rates = pricing.get(model, pricing["claude-3-5-sonnet"])
    
    # Without caching: every call pays full input price
    cost_without_cache = num_calls * (cached_tokens + uncached_tokens) * rates["input"] / 1_000_000
    
    # With caching: first call writes cache, rest read from it
    cost_with_cache = (
        # First call: cache write (cached tokens) + regular input (uncached)
        (cached_tokens * rates["cache_write"] + uncached_tokens * rates["input"]) / 1_000_000
        # Subsequent calls: cache read (cached tokens) + regular input (uncached)
        + (num_calls - 1) * (cached_tokens * rates["cache_read"] + uncached_tokens * rates["input"]) / 1_000_000
    )
    
    savings = cost_without_cache - cost_with_cache
    savings_pct = (savings / cost_without_cache) * 100 if cost_without_cache > 0 else 0
    
    print(f"Model: {model}")
    print(f"Calls: {num_calls}")
    print(f"Cached tokens per call: {cached_tokens:,}")
    print(f"Uncached tokens per call: {uncached_tokens:,}")
    print(f"Cost without caching: ${cost_without_cache:.4f}")
    print(f"Cost with caching:    ${cost_with_cache:.4f}")
    print(f"Savings:              ${savings:.4f} ({savings_pct:.1f}%)")
    
    return savings

# Example: 20 calls with 10,000 cached tokens and 500 uncached tokens
calculate_cost_savings(
    num_calls=20,
    cached_tokens=10_000,
    uncached_tokens=500,
    model="claude-3-5-sonnet"
)

Running this example shows that with 20 calls and 10,000 cached tokens, you can save over 80% on input token costs. The savings increase as the number of calls grows and as the ratio of cached-to-uncached tokens increases.

Handling Cache Misses Gracefully

Cache misses happen when the TTL expires or when the prefix changes. Your code should handle both scenarios transparently. Here's a robust wrapper pattern:

import time
from functools import wraps

class CachingLLMWrapper:
    """Wrapper that tracks cache performance and handles misses."""
    
    def __init__(self, llm):
        self.llm = llm
        self.cache_hits = 0
        self.cache_misses = 0
        self.last_cache_write_time = None
    
    def invoke(self, messages, **kwargs):
        response = self.llm.invoke(messages, **kwargs)
        usage = response.usage_metadata or {}
        
        cache_read = usage.get("cache_read_input_tokens", 0)
        cache_write = usage.get("cache_creation_input_tokens", 0)
        
        if cache_read > 0:
            self.cache_hits += 1
        elif cache_write > 0:
            self.cache_misses += 1
            self.last_cache_write_time = time.time()
        
        return response
    
    def get_stats(self):
        total = self.cache_hits + self.cache_misses
        hit_rate = (self.cache_hits / total * 100) if total > 0 else 0
        return {
            "cache_hits": self.cache_hits,
            "cache_misses": self.cache_misses,
            "hit_rate": f"{hit_rate:.1f}%",
        }
    
    def reset_stats(self):
        self.cache_hits = 0
        self.cache_misses = 0

# Usage in a LangGraph workflow
caching_llm = CachingLLMWrapper(
    ChatAnthropic(model="claude-3-5-sonnet-20241022", max_tokens=1024)
)

# Use caching_llm.invoke() in your graph nodes
# After running, check stats:
# print(caching_llm.get_stats())

Common Pitfalls to Avoid

Conclusion

Prompt caching is one of the most impactful cost optimization techniques available for LLM applications, and LangGraph's stateful, iterative workflows are an ideal use case. By strategically placing cache breakpoints on large, stable content like system prompts, document contexts, and tool definitions, you can reduce input token costs by up to 90% while also improving response latency. The key is to identify which parts of your prompts remain constant across graph iterations, place them at the beginning of your message lists, and use the cache_control parameter to mark cache boundaries. Combined with the monitoring and best practices outlined in this guide, you can build LangGraph applications that are both powerful and cost-efficient at scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles