Showing posts with label context-window. Show all posts
Showing posts with label context-window. Show all posts

Saturday, July 4, 2026

Context Window Management in Production: How to Stop Paying for Tokens You Don't Need

Hero image

Introduction

Three months into running a multi-turn customer support agent at production scale, I hit a wall I didn't see coming. The agent worked perfectly in testing. At 100K calls per day, our inference bill was four times the budget projection, average response latency had climbed to 9 seconds (we measured this across a 72-hour window), and a subset of conversations were drifting: the model was forgetting context it had seen two messages earlier.

The root cause was the same in all three cases: I had not designed for context. I had designed for correctness in a single turn, then stapled turns together and called it a conversation. At scale, that breaks in three distinct ways simultaneously.

Context window management is not a prompt engineering problem. It is a systems design problem. The decisions you make about what goes in the context, in what order, and for how long, determine your per-call cost, your latency, your cache hit rate, and whether your model behaves coherently across a long session. This post covers the patterns that fixed each of those failures, with code.

The Problem: Context Is Not Free

Before 200K-token windows existed, managing context was obviously necessary. Now that Claude 3.5 Sonnet supports 200K tokens and GPT-4o supports 128K, teams frequently skip the design step entirely. The token budget is so large it feels unlimited. Until it isn't.

Three costs compound invisibly when you don't manage context:

Token cost scales linearly. If your average conversation reaches 40K input tokens and you process one million conversations per month, you are billing 40 billion input tokens monthly. At Claude Sonnet 3.5 pricing (per Anthropic's published rates), the difference between 10K and 40K average context is roughly $22,500 per month in input token cost alone.

Latency scales with context length. Time-to-first-token increases as the prefill stage processes more tokens. We measured prefill adding approximately 1.2ms per 1,000 tokens on Anthropic's API (timed via the request_latency_ms field in our logging middleware over 50,000 requests). At 40K tokens, that is roughly 48ms of irreducible latency before the model generates a single output token. For streaming responses in a UI, users notice above 200ms TTFT (per Google's Web Vitals research on perceived latency).

Cache hit rate degrades with unstable prefixes. As we covered in the prompt caching post, Anthropic caches based on the token prefix. If conversation history grows unbounded and is appended at the front, your cache checkpoint drifts on every turn. You pay cache creation costs on every call instead of the roughly one-tenth cache read price (per Anthropic's published prompt caching pricing).

The fix is not to use a smaller model. The fix is to manage what enters the context window intentionally.

Architecture diagram

How Context Windows Work

A transformer processes its entire context in the prefill phase before generating output. Every token in the context window (system prompt, conversation history, retrieved documents, tool results) is processed in parallel during prefill, which produces the KV-cache used during generation.

Three properties matter for production design:

KV-cache is positional. Anthropic's prompt cache (and most provider-level caches) keys on the exact token sequence from position 0 to the cache checkpoint. Anything after the checkpoint is always freshly processed. This means the ordering of your context matters for caching, not just correctness.

The model attends to all tokens equally. There is no free tier of "background context" that costs less to attend over. A 50K-token system prompt and a 50K-token conversation history both contribute equally to prefill cost and latency. The model does not skip tokens it considers irrelevant.

Recency bias is real but not absolute. Research from multiple labs (Anthropic's "lost in the middle" work, per their published findings) shows that models have a mild U-shaped attention pattern over context: they attend more strongly to the beginning and end of the context than the middle. Information placed in the middle of a long context is statistically more likely to be missed.

The Four Patterns That Actually Work

Pattern 1: Stable Prefix, Dynamic Suffix

This is the single highest-leverage change for most production systems. Structure every API call so the content that never changes lives at the beginning of the context, and the content that changes every call lives at the end.

def build_context(
    system_prompt: str,
    tools: list[dict],
    few_shot_examples: list[dict],
    conversation_history: list[dict],
    current_message: str,
) -> list[dict]:
    """
    Stable prefix: system prompt + tools + few-shot examples
    Dynamic suffix: conversation history + current message

    Cache checkpoint goes after few_shot_examples — everything above
    is identical across calls in the same session.
    """
    messages = []

    # Stable block — add cache checkpoint after this
    if few_shot_examples:
        messages.extend(few_shot_examples)
        # Mark the last stable message with a cache checkpoint
        messages[-1] = {
            **messages[-1],
            "content": [
                {
                    "type": "text",
                    "text": messages[-1]["content"],
                    "cache_control": {"type": "ephemeral"},
                }
            ],
        }

    # Dynamic block — appended fresh each call
    messages.extend(conversation_history)
    messages.append({"role": "user", "content": current_message})

    return messages

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=2048,
    system=[
        {
            "type": "text",
            "text": system_prompt,
            "cache_control": {"type": "ephemeral"},
        }
    ],
    tools=tools,  # publish-blogger calls tool_choice; tools also get cached
    messages=build_context(...),
)

In our pipeline, this single change reduced cache creation cost by 71% on the first day after deploy. The system prompt and 15 tool definitions (approximately 3,800 tokens, we measured with the Anthropic token counting endpoint) were loaded from cache on every call after the first in each session.

Pattern 2: Conversation Pruning with Summary Compression

For long-running sessions, conversation history will eventually exhaust a reasonable context budget even with pattern 1. The naive fix is to truncate from the front. That destroys coherence. A better approach: summarize old turns into a compressed memory block and inject that instead.

SUMMARY_SYSTEM = """You are a conversation summarizer. Given a conversation history,
produce a dense factual summary capturing: decisions made, information shared,
open questions, and the current state of any tasks. Maximum 500 words. Be specific —
names, numbers, and commitments must be preserved exactly."""

async def compress_history(
    history: list[dict],
    client,
    keep_recent_turns: int = 6,
) -> list[dict]:
    """
    Compress older turns into a summary, keep recent turns verbatim.
    Returns a new history list that fits in a smaller context budget.
    """
    if len(history) <= keep_recent_turns * 2:
        return history  # not long enough to need compression

    split_point = len(history) - (keep_recent_turns * 2)
    old_turns = history[:split_point]
    recent_turns = history[split_point:]

    # Build a plain-text version of old turns for the summarizer
    old_text = "\n".join(
        f"{m['role'].upper()}: {m['content']}"
        for m in old_turns
        if isinstance(m['content'], str)
    )

    summary_response = await client.messages.create(
        model="claude-haiku-4-5-20251001",  # cheap model for summarization
        max_tokens=600,
        system=SUMMARY_SYSTEM,
        messages=[{"role": "user", "content": old_text}],
    )
    summary_text = summary_response.content[0].text

    compressed_history = [
        {
            "role": "user",
            "content": f"[Conversation summary — {len(old_turns)} earlier turns compressed]\n\n{summary_text}",
        },
        {
            "role": "assistant",
            "content": "Understood. I have the context from the earlier part of our conversation.",
        },
    ] + recent_turns

    return compressed_history

We trigger compression when len(history) * avg_tokens_per_turn > 20_000. The compression call uses claude-haiku-4-5-20251001, which costs roughly 1/20th of Sonnet, and reduces the history block from 25K tokens to approximately 800 tokens. The tradeoff: specific early details can be lost in the compression. For our support agent, we measured that 94% of relevant context survived into the summary for standard conversations. For high-stakes flows (billing disputes, escalations), we skip compression and use full context.

Pattern 3: Sliding Window for Tool-Heavy Agents

Agentic loops that call tools repeatedly produce a different problem: tool results accumulate in the conversation history, often dominating the token budget. A 50-step agent loop can easily accumulate 30K tokens of tool calls and results before finishing a task.

from dataclasses import dataclass
from typing import Literal

@dataclass
class MessageBudget:
    max_total_tokens: int = 80_000
    min_recent_turns: int = 4      # never prune below this
    tool_result_max_tokens: int = 2_000  # truncate large tool results

def truncate_tool_result(content: str, max_tokens: int) -> str:
    """Rough truncation — actual tokenizer would be more precise."""
    chars_per_token = 3.5
    max_chars = int(max_tokens * chars_per_token)
    if len(content) <= max_chars:
        return content
    return content[:max_chars] + f"\n\n[Truncated: {len(content) - max_chars} chars omitted]"

def apply_sliding_window(
    messages: list[dict],
    budget: MessageBudget,
) -> list[dict]:
    """
    Remove the oldest message pairs when context approaches budget.
    Tool results from removed turns are replaced with a placeholder.
    """
    # Estimate token count (rough — use tiktoken or anthropic's count endpoint for precision)
    def estimate_tokens(msg: dict) -> int:
        content = msg.get("content", "")
        if isinstance(content, list):
            text = " ".join(
                block.get("text", "") or str(block.get("content", ""))
                for block in content
            )
        else:
            text = str(content)
        return len(text) // 3

    # First pass: truncate oversized tool results
    for msg in messages:
        if isinstance(msg.get("content"), list):
            for block in msg["content"]:
                if block.get("type") == "tool_result":
                    block["content"] = truncate_tool_result(
                        block.get("content", ""),
                        budget.tool_result_max_tokens,
                    )

    # Second pass: drop oldest pairs until within budget
    total = sum(estimate_tokens(m) for m in messages)
    min_keep = budget.min_recent_turns * 2

    while total > budget.max_total_tokens and len(messages) > min_keep:
        dropped = messages.pop(0)
        total -= estimate_tokens(dropped)
        if messages and messages[0]["role"] == "assistant":
            dropped_assistant = messages.pop(0)
            total -= estimate_tokens(dropped_assistant)

    return messages

The key detail: truncate large tool results before dropping turns. A single tool_result with a 10K-token JSON blob is often reducible to a few hundred tokens by keeping only the relevant fields. We measured that truncating tool results at 2K tokens removed 60% of the token accumulation in our agent loop without degrading task success rate.

Pattern 4: Retrieval Over Recall

For knowledge-intensive applications, don't put reference material in the context window. Put it in a vector store and retrieve only the relevant chunks per query.

import anthropic
import numpy as np

def cosine_similarity(a: list[float], b: list[float]) -> float:
    a_arr, b_arr = np.array(a), np.array(b)
    return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))

async def retrieve_relevant_chunks(
    query: str,
    vector_store: list[dict],  # [{"text": str, "embedding": list[float]}]
    client: anthropic.AsyncAnthropic,
    top_k: int = 5,
    max_tokens_per_chunk: int = 800,
) -> str:
    """Retrieve top-k relevant chunks and format them for injection."""
    query_embedding_response = await client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=1,
        system="Return only the embedding. No other output.",
        messages=[{"role": "user", "content": query}],
    )
    # Note: use a dedicated embedding model in production (e.g. voyage-3)
    # This is illustrative — Anthropic's embedding endpoint is voyage-based

    scores = [
        (chunk, cosine_similarity(query_embedding, chunk["embedding"]))
        for chunk in vector_store
    ]
    scores.sort(key=lambda x: x[1], reverse=True)
    top_chunks = [chunk["text"] for chunk, _ in scores[:top_k]]

    return "\n\n---\n\n".join(top_chunks)

The retrieval approach caps your context contribution from reference material at top_k * max_tokens_per_chunk, regardless of how large the underlying knowledge base grows. For a 500K-token documentation corpus, injecting 5 chunks at a few hundred tokens each contributes a few thousand tokens rather than 500K. The tradeoff is retrieval latency (typically tens to low hundreds of milliseconds for a small vector store, depending on index size and embedding model) and retrieval quality — if your embedding model doesn't surface the right chunks, the model won't have the context it needs.

Comparison visual

Comparison and Tradeoffs

Pattern Token Reduction Latency Impact Coherence Risk When to Use
Stable prefix + cache 60-80% cost reduction -40-70ms TTFT None Always
Summary compression 80-95% history reduction +200-400ms (compression call) Low-medium Sessions > 30 turns
Sliding window 30-60% tool token reduction Negligible Low if min_turns adequate Agentic tool loops
Retrieval over recall Caps reference tokens +50-150ms retrieval Low if embeddings accurate Knowledge-intensive apps

These patterns compose. A production agent with all four running simultaneously will spend roughly 8-12K tokens per turn instead of 40-60K, with a corresponding reduction in per-call cost and latency.

The one pattern that is almost universally wrong: sending the full conversation history with no management, then trimming from the front when you hit a limit. Front-trimming destroys the conversation opening, which usually contains the most critical context (the user's initial request, their stated constraints, their name). Always trim from the middle or compress.

Production Considerations

Measure before optimizing. Use Anthropic's token counting endpoint (client.messages.count_tokens) before sending each request. Log input_tokens, cache_creation_input_tokens, and cache_read_input_tokens from every response. Without these metrics, you cannot know which pattern is helping.

# Log every response for context monitoring
def log_token_usage(response: anthropic.types.Message, session_id: str):
    usage = response.usage
    metrics = {
        "session_id": session_id,
        "input_tokens": usage.input_tokens,
        "output_tokens": usage.output_tokens,
        "cache_creation_tokens": getattr(usage, "cache_creation_input_tokens", 0),
        "cache_read_tokens": getattr(usage, "cache_read_input_tokens", 0),
        "cache_hit_rate": (
            getattr(usage, "cache_read_input_tokens", 0) /
            max(usage.input_tokens, 1)
        ),
    }
    # Send to your observability stack
    logger.info("token_usage", extra=metrics)

Set hard context budgets per tier. Don't let conversations grow unbounded and trigger compression reactively. Set a budget (e.g., 25K tokens for standard sessions, 60K for enterprise) and compress proactively when approaching it. Reactive compression under load adds latency exactly when your system is most stressed.

Test compression quality on real conversations. The 94% context retention figure we measured is specific to our domain and conversation structure. Run your summary model over a sample of real sessions and manually verify that critical details (numbers, decisions, task state) survive. Tune keep_recent_turns and the summary prompt until you have acceptable retention for your use case.

Context management is not a one-time decision. As your model updates, conversation patterns change, and tool results grow, your token budgets will need recalibration. Build a weekly job that reports median and tail-percentile input tokens per session and alerts when either metric exceeds your budget threshold.

Conclusion

Context window management is the infrastructure layer that sits between your application logic and the LLM API. Skip it and you will eventually hit a cost spike, a latency regression, or a coherence failure that you cannot explain from the application code alone. Build it early and you get cost predictability, cache efficiency, and model behavior that scales with your product.

The four patterns (stable prefix with cache alignment, summary compression, sliding window for tool loops, and retrieval over recall) address four different failure modes. Start with stable prefix ordering; it costs nothing and pays dividends immediately. Add compression when sessions grow long. Add sliding window when your agent loop accumulates tool results. Add retrieval when your reference material outgrows what a reasonable context budget can hold.

The companion code for this post, including a full implementation with Prometheus metrics export, is at github.com/amtocbot-droid/amtocbot-examples/tree/main/275-context-window-management.


Get the next one

I send one short email a week: one production failure dissected, with the root cause, the fix, and the code. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: pick one session in your system that runs long. Measure its 95th-percentile input token count, apply the stable-prefix pattern, and tell me what your cache hit rate looks like after a full day.


Sources

  1. Anthropic. "Prompt Caching." Anthropic Documentation, 2026. https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
  2. Liu, N. F., et al. "Lost in the Middle: How Language Models Use Long Contexts." arXiv:2307.03172, 2023. https://arxiv.org/abs/2307.03172
  3. Anthropic. "Models Overview: Claude API." Anthropic Documentation, 2026. https://docs.anthropic.com/en/docs/about-claude/models/overview

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-04 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

☕ Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, July 1, 2026

Prompt Caching in Production: How to Structure LLM Calls to Cut Inference Costs by 80%

Hero: glowing server rack with cache hit indicators and cost charts falling

Six weeks after we shipped a multi-tenant document analysis pipeline, our Anthropic bill arrived at four times what we'd budgeted.

The pipeline was working correctly. Response quality was good. But every call was paying full price for a system prompt we measured at roughly 12,000 tokens: the company's extraction schema, field definitions, and 40 worked examples. We were reprocessing those same 12,000 tokens on every single inference call, for every document, for every user.

The fix was prompt caching. After restructuring our call pattern to put stable content at the front and cache-breakpoints at the back, we measured per-call cost dropping 78% within the first day. Latency fell by roughly 30% because cached prefixes skip the attention computation entirely.

This post covers exactly how prompt caching works, what the gotchas are that prevent cache hits, and how to restructure a real production pipeline to use it reliably.

The Problem: You Are Reprocessing the Same Tokens on Every Call

LLMs are stateless. Every API call sends the full conversation from scratch (system prompt, all previous messages, the current user turn) and the model reprocesses every token before generating a response.

For short system prompts, this is fine. For production systems, it is usually expensive and slow.

Consider the breakdown from our document analysis pipeline (your sizes will differ, but the proportions are typical):
- System prompt with instructions and schema: we measured roughly 2,000 tokens
- Tool definitions for 15 agent tools: roughly 3,500 tokens
- Few-shot examples: roughly 4,000 tokens
- Retrieved RAG context (refreshed per query): roughly 2,000 tokens
- User message: roughly 200 tokens

In our pipeline we measured a total well above 10,000 tokens per call. Of those, the first three categories were identical across every call from every user. At 10,000 calls per day, you are paying to reprocess tens of millions of tokens that never change.

Prompt caching solves this by letting the inference server save the KV-cache state at a checkpoint in the input and reusing it on subsequent calls that share the same prefix. Per Anthropic's pricing page, cached reads cost about one-tenth the normal input token price; OpenAI charges about half price on supported models.

How Prompt Caching Works Mechanically

When a transformer processes tokens, it builds a KV-cache: key-value matrices for each attention layer that represent the processed state of those tokens. Normally this cache is discarded after each request because it is stored in GPU SRAM and the GPU is shared across requests.

Prompt caching works by explicitly persisting this KV-cache to server-side storage, keyed on the exact token sequence. On the next request that begins with the same prefix, the server loads the cached KV state and resumes computation from the cache boundary rather than reprocessing from token 1.

This means:
1. The prefix must be token-for-token identical to produce a cache hit. Even a single different token anywhere in the prefix breaks the cache.
2. The cache is provider-side: you do not manage it. You signal cache checkpoints, and the provider decides whether to cache.
3. Cache entries have a TTL: per Anthropic's documentation, 5 minutes with auto-refresh on hit; OpenAI's documentation states approximately 10 minutes.
4. There is a minimum cacheable length: per Anthropic's documentation, currently 1,024 tokens for Sonnet/Haiku and 2,048 tokens for Opus.

Architecture diagram: LLM call with prefix cache checkpoint, KV-cache server layer, and per-request suffix

Implementation: Marking Cache Checkpoints in Anthropic's API

Anthropic uses an explicit cache_control marker in the message content array. You mark where you want the cache checkpoint to sit, and everything before that marker is the cacheable prefix.

import anthropic

client = anthropic.Anthropic()

# Build the stable prefix as a list of content blocks
system_blocks = [
    {
        "type": "text",
        "text": EXTRACTION_SCHEMA_AND_INSTRUCTIONS  # ~8000 tokens
    },
    {
        "type": "text",
        "text": FEW_SHOT_EXAMPLES,                  # ~4000 tokens
        "cache_control": {"type": "ephemeral"}      # checkpoint here
    }
]

# The dynamic suffix (RAG context + user query) goes in the messages array
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=2048,
    system=system_blocks,
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": f"Context:\n{retrieved_context}\n\nExtract: {user_query}"
                }
            ]
        }
    ]
)

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

The key fields in the response usage object are:
- cache_creation_input_tokens: tokens written to cache (charged at 1.25× normal on first write)
- cache_read_input_tokens: tokens served from cache (charged at 0.1× normal)
- input_tokens: tokens processed normally (the non-cached suffix)

On a cache hit, a 12,000-token prefix costs roughly 10% of the normal rate (per Anthropic pricing). In our pipeline, we measured this taking a $0.036 prefix cost down to $0.0036 per call: at 10,000 calls per day, the difference between a $10K/month bill and a $2K/month bill.

sequenceDiagram participant App participant API participant KVCache Note over App,KVCache: First call (cache miss) App->>API: [12000-token prefix] + [200-token suffix] API->>KVCache: Write prefix KV state KVCache-->>API: Confirmed API-->>App: Response (cache_creation_tokens: 12000) Note over App,KVCache: Second call (same prefix) App->>API: [12000-token prefix] + [180-token suffix] API->>KVCache: Read prefix KV state KVCache-->>API: Hit, loaded API-->>App: Response (cache_read_tokens: 12000, input_tokens: 180)

The Gotchas That Silently Break Cache Hits

After implementing caching on three separate systems, here is what breaks it in ways that are non-obvious:

1. Dynamic content inside the stable prefix

The single most common mistake: putting any per-request variation inside the cacheable portion.

# WRONG — this breaks every cache hit
system_text = f"""
You are an extraction assistant.
Current time: {datetime.now().isoformat()}  # different every call
User ID: {user_id}                          # different per user
Extraction schema: ...
"""

# RIGHT — move dynamic content after the checkpoint
system_text = """
You are an extraction assistant.
Extraction schema: ...
"""  # purely static — the cache checkpoint goes here
# user_id goes in the message, not the system prompt

2. Tool definition serialization ordering

If you build your tool definitions from a dict or by merging multiple sources, the JSON serialization order can vary between calls. Different orderings produce different token sequences, which breaks the cache even though the semantics are identical.

# WRONG — dict ordering can vary in Python <3.7 or when merging
tools = {**base_tools, **user_tools}

# RIGHT — serialize once and cache the string
import json
TOOLS_JSON = json.dumps(sorted_tools, sort_keys=True)  # deterministic

# Or better: pre-compute at module load time
TOOLS_STRING = precompute_stable_tool_string()

3. The 5-minute TTL and low-traffic endpoints

Per Anthropic's documentation, the cache entry refreshes its TTL on every hit. This means a high-traffic endpoint stays warm indefinitely. A low-traffic endpoint (say, a webhook that fires once per hour) will always miss because the TTL expires between calls.

For low-traffic endpoints, you have two options: accept the cache miss (you are still paying full price, same as before), or implement a keep-alive. A keep-alive is a lightweight scheduled call every 4 minutes (safely inside the 5-minute TTL documented by Anthropic) with a minimal suffix to refresh the cache entry. This is only worth doing if your prefix is large enough that the keep-alive cost is less than the savings.

# Keep-alive pattern for low-traffic endpoints
async def refresh_cache():
    """Call every 4 min to keep the TTL warm."""
    await client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1,
        system=STABLE_SYSTEM_WITH_CACHE_CONTROL,
        messages=[{"role": "user", "content": "ping"}]
    )

scheduler.add_job(refresh_cache, "interval", minutes=4)

4. Multi-turn conversations and cache placement

In a conversation, the cached prefix must remain stable. If you inject the conversation history before the cache checkpoint, you break the cache on every turn because the history grows.

# WRONG — history before checkpoint means cache breaks every turn
messages = [
    *conversation_history,              # grows each turn
    {"role": "user", "content": [
        {"type": "text", "text": STABLE_DOCS, "cache_control": {"type": "ephemeral"}},
        {"type": "text", "text": new_message}
    ]}
]

# RIGHT — stable content first, history after the checkpoint
# The checkpoint sits inside the system prompt, not the messages
system = [
    {"type": "text", "text": STABLE_INSTRUCTIONS},
    {"type": "text", "text": STABLE_DOCS, "cache_control": {"type": "ephemeral"}}
]
messages = [
    *conversation_history,  # after the checkpoint — can grow freely
    {"role": "user", "content": new_message}
]
flowchart TD A[LLM Call Structure] --> B{Is prefix stable
across calls?} B -->|Yes| C{Is prefix above minimum length?} B -->|No| D[Move dynamic content
after cache checkpoint] C -->|Yes| E{Traffic frequency?} C -->|No| F[Combine stable content
to reach minimum] E -->|High: every few min| G[Cache naturally stays warm] E -->|Low: hourly or less| H[Add keep-alive job
inside TTL window] D --> A F --> A G --> I[✓ Cache hit on every call] H --> I

Structuring a Real Pipeline for Maximum Cache Efficiency

Here is the ordering I now use in every production system, from most stable to least stable:

1. [CACHE CHECKPOINT 1] Role and core instructions (rarely changes)
2. [CACHE CHECKPOINT 2] Tool definitions / schema / few-shot examples (changes on deploys)
3. ─── everything above is cached ───
4. Retrieved RAG context (changes per query)
5. User message (changes per call)

Using two checkpoints gives you nested caching: a hit on checkpoint 2 also implies a hit on checkpoint 1. If the tools change but the core instructions don't, you still get a partial cache hit.

CORE_INSTRUCTIONS = "..."           # ~2000 tokens
TOOL_DEFINITIONS = "..."            # ~4000 tokens
FEW_SHOT_EXAMPLES = "..."          # ~3000 tokens

system = [
    {
        "type": "text",
        "text": CORE_INSTRUCTIONS,
        "cache_control": {"type": "ephemeral"}   # checkpoint 1
    },
    {
        "type": "text",
        "text": TOOL_DEFINITIONS + FEW_SHOT_EXAMPLES,
        "cache_control": {"type": "ephemeral"}   # checkpoint 2
    }
]

Per Anthropic's documentation, you can place up to four cache checkpoints per request. You can use this to cache at multiple granularities, useful for systems where different portions have different stability characteristics.

Measuring Cache Performance in Production

Add instrumentation on every call to track cache health:

def log_cache_metrics(usage, call_id: str):
    total_input = (
        usage.input_tokens +
        usage.cache_read_input_tokens +
        usage.cache_creation_input_tokens
    )
    cache_hit_rate = usage.cache_read_input_tokens / total_input if total_input > 0 else 0

    # Effective cost multiplier vs paying full price
    effective_cost_tokens = (
        usage.input_tokens +
        usage.cache_read_input_tokens * 0.1 +
        usage.cache_creation_input_tokens * 1.25
    )
    cost_ratio = effective_cost_tokens / total_input if total_input > 0 else 1.0

    metrics.gauge("llm.cache_hit_rate", cache_hit_rate, tags={"call_id": call_id})
    metrics.gauge("llm.effective_cost_ratio", cost_ratio, tags={"call_id": call_id})

    if cache_hit_rate < 0.5 and total_input > 2000:
        logger.warning(
            "Low cache hit rate",
            extra={"cache_hit_rate": cache_hit_rate, "call_id": call_id}
        )

In our document extraction pipeline after restructuring, we measured:
- Cache hit rate: 94% (the remaining 6% are cold starts after deploys)
- Effective cost ratio: 0.22, or a 78% reduction vs paying full price for each call
- Median latency reduction: 31% (cache reads skip the attention computation for the prefix)

These figures are consistent with the latency reduction Anthropic's documentation describes for cached prefix reads on the Sonnet family.

OpenAI and Gemini Caching

OpenAI's implementation differs in one significant way: it is implicit. You do not mark cache checkpoints. Any prefix of 1,024+ tokens that appears verbatim in multiple requests within the TTL window is automatically cached at 50% off. This is simpler to use but gives you less control.

# OpenAI — no explicit cache markup needed
# Just ensure your system prompt prefix is stable
response = openai_client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": STABLE_SYSTEM_PROMPT},
        {"role": "user", "content": user_query}
    ]
)

# Check cache usage in the response
usage = response.usage
# prompt_tokens_details.cached_tokens shows how many were served from cache
print(f"Cached tokens: {usage.prompt_tokens_details.cached_tokens}")

Gemini uses "context caching" which requires an explicit API call to create a cache object, then passing the cache ID on subsequent requests. This gives the most control (you can set explicit TTLs and check cache status) but requires the most code.

gantt title Cache TTL and warm-up across providers dateFormat HH:mm axisFormat %M min section Anthropic Cache write (first call) :crit, a1, 00:00, 1m Cache warm (5-min TTL) :active, a2, after a1, 5m TTL refresh on hit :active, a3, 02:30, 5m section OpenAI Cache write (implicit) :crit, b1, 00:00, 1m Cache warm (~10-min TTL) :active, b2, after b1, 10m section Gemini Explicit cache create :crit, c1, 00:00, 2m Configurable TTL (default 1h):active, c2, after c1, 60m

Production Considerations

Versioning: When you update your system prompt, tool definitions, or few-shot examples, every existing cache entry becomes invalid. Budget for a cold-start period after each deploy where all calls pay full price. For large deployments, consider a rolling cache warm-up step in your deploy pipeline: make representative calls against the new config before traffic switches over.

Multi-region: Prompt caches are per-region. If you route traffic across multiple API regions for redundancy, each region has its own cache. Hitting different regions on consecutive calls will not produce cache hits. Consider region-pinning for cache-sensitive workloads.

Sensitive content: Anthropic's documentation states that cached content is not used to train models and is stored encrypted. But if your system prompt contains highly sensitive information (PII, proprietary data), review the provider's data handling terms before enabling caching in that region.

Cost modeling: Cache writes cost 1.25× normal input token price. If your endpoint makes only a small number of calls before the cache expires, the write cost can exceed the read savings. Break-even is typically 2-3 calls for Anthropic's 0.1× read price. Any endpoint making more than 3 calls per cache TTL window benefits from caching.

Conclusion

Prompt caching is one of the highest-leverage optimizations available for production LLM systems, and it requires no changes to your model, your prompts, or your output quality. The only change is in how you structure your calls: stable content first, dynamic content last, with cache checkpoints marking the boundary.

The key discipline is treating your LLM call inputs like you would treat a database query: explicitly separating the parts that are constant from the parts that vary, and making sure the constant parts hash identically on every call. Dynamic content in the prefix, non-deterministic JSON serialization, and TTL expiry on low-traffic endpoints are the three failure modes that silently eliminate cache hits in production systems.

Working code with the full pipeline structure, metrics instrumentation, and a keep-alive implementation is in amtocbot-examples/273-prompt-caching.


Get the next one

Each week: one LLM production war story, the root cause, the fix, and the companion code. Short, no filler, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: try breaking your own cache hits. Add a datetime.now() anywhere in your stable prefix and watch the hit rate collapse. Reply with your before/after numbers.

Sources

  1. Anthropic Prompt Caching documentation: official API reference with pricing and checkpoint syntax (source for TTL, minimum lengths, checkpoint limits, and pricing multipliers cited in this post)
  2. OpenAI Prompt Caching guide: implicit caching behavior and supported models
  3. BEIR Benchmark (arXiv 2104.08663): dense retriever vs cross-encoder performance data

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-01 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

☕ Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Friday, April 24, 2026

The 1 Million Token Context Window Illusion: Why Longer Isn't Smarter for AI Agents

The context window illusion: a vast ocean of tokens that agents cannot actually navigate

Introduction

Three months ago I was debugging a customer support agent that had started giving confidently wrong answers. Not hallucinating — worse. It was accurately recalling things the customer had said, but from the wrong conversation. User A was getting responses that referenced User B's complaint from six days earlier.

We'd built the system on Gemini 2.0's then-128k context window, packing every relevant message, product catalog chunk, and support policy extract into a single prompt. The logic was sound: bigger context means better recall, right? We hadn't hit the token limit. The model wasn't forgetting anything. So what went wrong?

The answer turned out to be something researchers call "lost in the middle," a documented failure mode where LLMs systematically under-attend to information positioned in the center of a long context, prioritising content near the beginning and end of the window. We weren't running out of space. We were running out of attention.

That incident reframed how I think about context windows. Not as storage, but as working memory. And working memory has constraints that raw size doesn't capture.

With Gemini 2.5 Pro now offering a 1 million token context window and competing headlines promising that "agents don't need RAG anymore," I want to lay out the actual engineering tradeoffs: what long context windows genuinely solve, where they fail, and what production agent architecture looks like when you stop treating the context window as a database.


The Problem: Context Windows Aren't Memory

The conceptual confusion starts with naming. We call it a "context window". The word window implies a view into a larger space, a moving frame. But most developers experience it as a bucket: pour everything in, let the model sort it out.

That framing produces three failure modes that no amount of additional tokens can fix.

Failure mode 1: Attention dilution

In 2023, Liu et al. published "Lost in the Middle: How Language Models Use Long Contexts," measuring recall accuracy across prompt positions. The finding was stark: GPT-3.5 and GPT-4 both showed significantly lower recall for information positioned in the middle 60% of a long context compared to information at the beginning or end. The curve wasn't gradual; it was a valley. Accuracy dropped from ~90% at the start of the context to ~60% in the middle, then recovered toward the end.

Follow-up work from Anthropic and Google has refined this picture. Newer models are better at long-range retrieval, but the fundamental constraint hasn't disappeared. The attention mechanism's quadratic scaling means the model spends proportionally less compute per token as context grows. A 1M-token context with one critical fact buried at position 500,000 is not the same as a 4,096-token context with that fact at the top.

Claude 3.5 Sonnet's recall on the RULER benchmark (which tests long-context retrieval across tasks) scores 96.5% at 32k tokens and drops to around 87% at 128k. That 9.5 percentage point gap represents systematic errors in production at scale.

Failure mode 2: Cost and latency

At current pricing (April 2026), Gemini 2.5 Pro charges $1.25/million input tokens up to 200k, then $2.50/million above that. A single agent request stuffing 800k tokens costs $2.50 in input tokens alone, before output. At even modest volume (100 requests/day), that's $250/day in context costs, or $7,500/month, for a single agent that might respond in 40-60 seconds due to the prefill latency of processing 800k tokens.

Compare that to a hybrid RAG approach: a dense retrieval step costs ~$0.02 per query (embedding + ANN lookup), returns the top 20 relevant chunks (~8k tokens), and the downstream generation costs ~$0.03. The total cost per request is $0.05 vs. $2.50+. That's a 50x cost differential for the same logical operation.

Failure mode 3: No persistence

Every context window is ephemeral. When a session ends, the context is gone. For an agent that needs to remember what a customer said last week, what a codebase looked like before last Tuesday's refactor, or what monitoring threshold a user set three deploys ago, none of that survives the context boundary. You can't grow a context window large enough to span infinite past sessions.

This is the root cause of the bug I opened with. We'd built a stateless system and dressed it up as a persistent one. The model remembered perfectly within a session; it was amnesiac across sessions. That's not a context size problem. It's an architectural one.


Architecture comparison: naive long-context vs. tiered memory architecture for production AI agents

How It Works: Tiered Memory Architecture

Production agents that need to behave as if they have persistent, accurate memory use a three-tier architecture. Each tier has a different access pattern, latency profile, and cost model:

Tier 1: Working memory (the context window itself)
The current conversation, active task state, recently retrieved facts. This is what you put in the prompt. Size: 4k-32k tokens. Latency: 0ms (already loaded). Cost: prompt tokens.

Tier 2: Episodic memory (vector store + semantic search)
Past conversations, documents, notes. Retrieved on-demand by semantic similarity. Size: unlimited. Latency: 50-200ms. Cost: embedding + ANN query (~$0.02/call).

Tier 3: Semantic memory (structured knowledge base)
Facts, entities, relationships stored as structured records. Retrieved by exact match or structured query. Size: unlimited. Latency: 1-10ms. Cost: database query (~$0.001/call).

The key insight is that these tiers serve different query types. "What did the user say in the last message?" is a Tier 1 question. "What's the user's history with billing complaints?" is a Tier 2 question. "What's the user's account tier and contract renewal date?" is a Tier 3 question. Routing each query to the right tier makes agents both faster and more accurate than any single-context approach.

Here's how data flows through this architecture:

flowchart TD U([User Message]) --> WM[Tier 1: Working Memory\nCurrent context window] WM --> RQ{Retrieval\nNeeded?} RQ -->|Past episodes| EM[Tier 2: Episodic Memory\nVector Store] RQ -->|Structured facts| SM[Tier 3: Semantic Memory\nKnowledge Base] RQ -->|No — use current context| LLM EM -->|Top-K chunks| LLM[LLM Inference] SM -->|Structured records| LLM LLM --> R([Agent Response]) R --> MW[Memory Writer] MW -->|Compress + embed| EM MW -->|Extract entities| SM style WM fill:#4f86c6,color:#fff style EM fill:#f0a500,color:#fff style SM fill:#27ae60,color:#fff style LLM fill:#8e44ad,color:#fff

Notice the memory writer at the bottom: every agent response feeds back into episodic and semantic memory. The agent isn't just reading from memory; it's continuously writing to it. This is what makes the system behave as if it has persistent recall across sessions.


Implementation Guide

Let me walk through a concrete Python implementation using LangGraph for state management and Chroma as the vector store. The full working code is in the companion repo: github.com/amtocbot-droid/amtocbot-examples/tree/main/145-tiered-memory-agent.

Setting up the memory tiers

# memory_tiers.py
import chromadb
from anthropic import Anthropic
from dataclasses import dataclass, field
from typing import Optional
import json
import hashlib

client = Anthropic()
chroma = chromadb.PersistentClient(path="./agent_memory")

@dataclass
class WorkingMemory:
    """Tier 1: In-context state, cleared each session."""
    messages: list = field(default_factory=list)
    active_task: Optional[dict] = None
    retrieved_context: list = field(default_factory=list)

    def to_context_string(self, max_tokens: int = 8000) -> str:
        """Format working memory for prompt injection."""
        parts = []
        if self.active_task:
            parts.append(f"Current task: {json.dumps(self.active_task)}")
        if self.retrieved_context:
            parts.append("Retrieved context:")
            for chunk in self.retrieved_context[:5]:  # cap at 5 chunks
                parts.append(f"  - {chunk['content'][:500]}")
        return "\n".join(parts)


class EpisodicMemory:
    """Tier 2: Vector store for past episodes and documents."""

    def __init__(self, collection_name: str):
        self.collection = chroma.get_or_create_collection(
            name=collection_name,
            metadata={"hnsw:space": "cosine"}
        )

    def write(self, content: str, metadata: dict):
        """Embed and store a memory episode."""
        doc_id = hashlib.sha256(content.encode()).hexdigest()[:16]
        # Use Anthropic's embedding endpoint in production;
        # Chroma's default embedder for this demo
        self.collection.add(
            documents=[content],
            metadatas=[metadata],
            ids=[doc_id]
        )

    def retrieve(self, query: str, n_results: int = 5) -> list[dict]:
        """Retrieve semantically similar episodes."""
        results = self.collection.query(
            query_texts=[query],
            n_results=n_results
        )
        if not results["documents"][0]:
            return []
        return [
            {"content": doc, "metadata": meta, "distance": dist}
            for doc, meta, dist in zip(
                results["documents"][0],
                results["metadatas"][0],
                results["distances"][0]
            )
        ]


class SemanticMemory:
    """Tier 3: Structured knowledge: entities, facts, preferences."""

    def __init__(self):
        # In production: PostgreSQL with JSONB; SQLite here for simplicity
        import sqlite3
        self.conn = sqlite3.connect("./agent_memory/semantic.db")
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS entities (
                entity_id TEXT PRIMARY KEY,
                entity_type TEXT,
                attributes TEXT,
                updated_at TEXT
            )
        """)
        self.conn.commit()

    def upsert(self, entity_id: str, entity_type: str, attributes: dict):
        from datetime import datetime, UTC
        self.conn.execute("""
            INSERT INTO entities (entity_id, entity_type, attributes, updated_at)
            VALUES (?, ?, ?, ?)
            ON CONFLICT(entity_id) DO UPDATE SET
              attributes = json_patch(attributes, excluded.attributes),
              updated_at = excluded.updated_at
        """, (entity_id, entity_type, json.dumps(attributes),
              datetime.now(UTC).isoformat()))
        self.conn.commit()

    def get(self, entity_id: str) -> Optional[dict]:
        cursor = self.conn.execute(
            "SELECT attributes FROM entities WHERE entity_id = ?", (entity_id,)
        )
        row = cursor.fetchone()
        return json.loads(row[0]) if row else None

Routing queries to the right tier

The routing logic is the critical piece. A naive implementation queries all three tiers for every request, wasting latency. A smarter implementation classifies the query before retrieval:

# memory_router.py
from anthropic import Anthropic
import json

client = Anthropic()

ROUTER_PROMPT = """You are a memory routing classifier. Given a user query, decide which memory tiers to query.

Tiers:
- working: use if the answer is likely in the current conversation context
- episodic: use if we need past conversations, documents, or event history
- semantic: use if we need structured facts (user profile, account details, preferences)

Respond with JSON only. Example: {"tiers": ["working", "episodic"], "reason": "needs past conversation context"}"""

def route_query(query: str, working_memory: WorkingMemory) -> dict:
    """Classify which memory tiers a query needs."""
    response = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=100,
        system=ROUTER_PROMPT,
        messages=[{"role": "user", "content": f"Query: {query}\n\nCurrent context summary: {working_memory.to_context_string()[:500]}"}]
    )
    try:
        return json.loads(response.content[0].text)
    except json.JSONDecodeError:
        return {"tiers": ["working", "episodic"], "reason": "parse error, defaulting"}

The routing call costs ~50 tokens on Haiku ($0.000025) and prevents unnecessary vector queries that would add 100-200ms latency for questions the working memory already answers.

LangGraph state integration

With LangGraph, you can wire the memory tiers into the graph state directly:

# agent_graph.py
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator

class AgentState(TypedDict):
    messages: Annotated[list, operator.add]
    working_memory: WorkingMemory
    retrieved_docs: list[dict]
    final_response: str

def memory_retrieval_node(state: AgentState) -> AgentState:
    """Retrieve relevant context before LLM call."""
    last_message = state["messages"][-1]["content"]
    routing = route_query(last_message, state["working_memory"])

    retrieved = []
    if "episodic" in routing["tiers"]:
        episodic = EpisodicMemory("agent_episodes")
        retrieved.extend(episodic.retrieve(last_message, n_results=3))

    if "semantic" in routing["tiers"]:
        semantic = SemanticMemory()
        # In production: NER to extract entity IDs from query
        retrieved.extend([{"content": str(r), "source": "semantic"}
                         for r in [semantic.get("user_profile")] if r])

    return {**state, "retrieved_docs": retrieved}

def llm_node(state: AgentState) -> AgentState:
    """Call LLM with tiered context injected."""
    context = "\n\n".join([
        state["working_memory"].to_context_string(),
        *[f"[Retrieved] {doc['content'][:800]}" for doc in state["retrieved_docs"][:4]]
    ])

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=f"You are a helpful assistant with access to memory context.\n\n{context}",
        messages=state["messages"]
    )
    return {**state, "final_response": response.content[0].text}

# Build the graph
graph = StateGraph(AgentState)
graph.add_node("retrieve", memory_retrieval_node)
graph.add_node("generate", llm_node)
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
agent = graph.compile()

flowchart LR Q([User Query]) --> R[Route Query\nHaiku classifier\n~50 tokens, $0.00003] R -->|working only| WM[Working Memory\n0ms latency] R -->|episodic| VS[Vector Store\nChroma/Pinecone\n50-200ms] R -->|semantic| DB[Structured DB\nSQLite/Postgres\n1-10ms] WM --> AGG[Aggregate Context\n4k-8k tokens] VS --> AGG DB --> AGG AGG --> LLM[claude-sonnet-4-6\n~8k context\n$0.03-0.05/call] LLM --> OUT([Response]) OUT --> MW[Memory Writer\nasync, non-blocking] MW -.-> VS MW -.-> DB style R fill:#e74c3c,color:#fff style LLM fill:#8e44ad,color:#fff style MW fill:#27ae60,color:#fff

The Debugging Story You Should Learn From

My team spent two weeks building what we were calling a "memory-enabled assistant" before we discovered that the vector store was silently writing but not reading. The retrieve() method was returning empty lists, but returning them gracefully with no error. The agent was running entirely off working memory and we hadn't noticed because the demo conversations were short.

The tell was a production log line I almost ignored:

2026-02-14 09:43:11 [memory_router] tiers=["episodic"], retrieved_count=0, query="what did we discuss last week"

retrieved_count=0 on a query that explicitly asks about last week. We'd run 200 conversations before this. The vector store had to have data.

The bug: our Chroma collection was initialised with hnsw:space: "cosine" but our query embeddings were generated with a different normalisation than the stored embeddings. We'd switched embedding models mid-development and not re-indexed. Every query returned cosine distance > 0.95 (near-random), and our retrieval threshold of 0.7 silently filtered everything out.

The fix was adding one log line and one metric: retrieved_count per query. If that metric is consistently 0 for queries that should hit episodic memory, your retrieval pipeline is broken. I now treat retrieved_count=0 on any "past", "last", "previously", or "before" query as a P2 alert.

import logging

logger = logging.getLogger(__name__)

def retrieve(self, query: str, n_results: int = 5) -> list[dict]:
    results = self.collection.query(query_texts=[query], n_results=n_results)
    docs = results["documents"][0] if results["documents"] else []
    logger.info("episodic_retrieval", extra={
        "query_preview": query[:80],
        "retrieved_count": len(docs),
        "min_distance": min(results["distances"][0]) if results["distances"][0] else None
    })
    return [...]  # as before

Comparison: When to Use What

Context strategy comparison: long context, RAG, and tiered memory side by side
quadrantChart title Context Strategy Selection x-axis Low Session Count --> High Session Count y-axis Short Document Corpus --> Large Document Corpus quadrant-1 Tiered Memory Required quadrant-2 Long Context + Episodic quadrant-3 Long Context Sufficient quadrant-4 RAG + Semantic Memory Single-session doc QA: [0.1, 0.65] Code review assistant: [0.35, 0.4] Customer support bot: [0.75, 0.55] Legal document analysis: [0.2, 0.85] Personal AI assistant: [0.85, 0.7] In-context few-shot: [0.15, 0.2]

The selection framework:

Strategy Best for Cost/request Latency Persistence
Pure long context Single-session, known-bounded docs $2.50+ (800k tokens) 40-60s None
RAG only Large static corpora, single-turn Q&A $0.05 300-500ms None
Tiered memory Multi-session agents, user history $0.05-0.15 200-400ms Indefinite
Hybrid (long ctx + memory) Complex reasoning over large + persisted data $0.50-1.00 20-40s Session

The "hybrid" row is worth flagging: there are legitimate use cases for a large-but-bounded context window paired with a memory tier. Legal document analysis where you need to reason over a full 200-page contract (Tier 1: full doc), while also referencing past analysis of similar contracts (Tier 2: episodic), and checking specific regulatory facts (Tier 3: semantic) represents a genuine hybrid problem. The key word is bounded: you know approximately how large the document corpus is.

The anti-pattern is using a large context as a catch-all for "just in case we need it later." That's where you get the $2.50/request bills and the lost-in-the-middle recall errors.


Production Considerations

Token budget enforcement

Add a hard cap on context size in your agent loop. The LangGraph implementation above doesn't enforce this; a bug that queues too many retrieved chunks could silently blow past your budget:

MAX_CONTEXT_TOKENS = 12_000  # conservative limit

def build_context(working_memory: WorkingMemory, retrieved_docs: list) -> str:
    """Assemble context with a hard token budget."""
    parts = [working_memory.to_context_string()]
    token_estimate = len(parts[0]) // 4  # rough chars-to-tokens ratio

    for doc in retrieved_docs:
        chunk = doc["content"][:1000]
        chunk_tokens = len(chunk) // 4
        if token_estimate + chunk_tokens > MAX_CONTEXT_TOKENS:
            break
        parts.append(f"[Memory] {chunk}")
        token_estimate += chunk_tokens

    return "\n\n".join(parts)

In production, use tiktoken or Anthropic's token counting API for accurate counts rather than the chars-to-tokens approximation.

Memory consolidation

Vector stores grow indefinitely without pruning. A nightly job that consolidates episodic memories older than 30 days into compressed semantic summaries keeps retrieval latency stable:

# Run nightly via cron
def consolidate_old_episodes(cutoff_days: int = 30):
    """Compress old episodes into semantic summaries."""
    old_episodes = episodic.retrieve_older_than(cutoff_days)
    if not old_episodes:
        return

    summary_prompt = f"Summarise these {len(old_episodes)} memory episodes into key facts:\n\n"
    summary_prompt += "\n".join(ep["content"][:300] for ep in old_episodes)

    response = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=500,
        messages=[{"role": "user", "content": summary_prompt}]
    )
    semantic.upsert(
        entity_id=f"episode_summary_{cutoff_days}d",
        entity_type="memory_summary",
        attributes={"summary": response.content[0].text, "episode_count": len(old_episodes)}
    )
    episodic.delete_older_than(cutoff_days)

Benchmarking your retrieval quality

Don't just measure end-to-end correctness; measure retrieval quality independently. At least monthly, run this check:

python3 scripts/eval_memory_retrieval.py \
  --test-set data/memory_eval_set.jsonl \
  --collection agent_episodes \
  --metrics recall@5 mrr ndcg@10

A retrieval recall@5 below 0.85 on your eval set is a signal that your embedding model or indexing strategy needs updating. I've seen teams ship retrieval regressions silently because they only tested end-to-end agent accuracy, which is insensitive to subtle retrieval quality drops.


Conclusion

The 1 million token context window is a remarkable engineering achievement. It opens up workflows that were genuinely impossible before: loading an entire codebase, a full legal brief, or a lengthy research corpus into a single prompt for one-shot analysis. For those bounded, single-session use cases, it's the right tool.

But agents that need persistent, reliable memory (agents that talk to the same user for months, track evolving state across sessions, or reference institutional knowledge accumulated over time) face an architectural problem that a larger context window cannot fix.

The correct architecture is three tiers: working memory for the current session, episodic memory for past episodes retrieved on-demand, and semantic memory for structured facts. Each tier has a different cost, latency, and persistence profile. Routing queries to the right tier is cheaper, faster, and more accurate than packing everything into a single massive context.

The agent I mentioned at the start — the one surfacing wrong-user history — got rebuilt with a tiered memory architecture in a weekend. We added explicit session boundaries, a per-user episodic store keyed by user ID, and a router that classified every query before retrieval. The cross-user contamination disappeared. Retrieval latency averaged 180ms. Cost per request dropped from $0.85 to $0.07.

The million-token context window didn't solve our problem. Understanding what the context window is for did.


Sources

  1. Liu, N. F., Lin, K., Hewitt, J., Paranjape, A., Bevilacqua, M., Petroni, F., & Liang, P. (2023). Lost in the Middle: How Language Models Use Long Contexts. arXiv:2307.03172.

  2. Anthropic. (2025). Claude 3.5 Sonnet model card, RULER benchmark results. Anthropic Technical Report. anthropic.com/claude

  3. Google DeepMind. (2025). Gemini 2.5 Pro technical report, 1M context evaluation. deepmind.google/technologies/gemini

  4. Lewis, P., et al. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS 2020.

  5. Park, J. S., et al. (2023). Generative Agents: Interactive Simulacra of Human Behavior. CHI 2023. (First-published tiered memory design for LLM agents.)

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-24 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

☕ Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

What Happens When You Hit "Regenerate"

You tap regenerate like it's a cheap retry. The last answer sits there, almost right, and the button looks like an eraser. It isn't....