Context Engineering
Treating the context window as a budget you spend, not a container you fill.
Introduction
Prompt engineering is about what you say. Context engineering is about what the model can see when you say it, and it is where most production AI systems actually succeed or fail. A million-token window sounds like it makes this a non-problem. It does the opposite: it makes it possible to spend a fortune on latency and tokens without noticing, and it makes the difference between a well-ordered prompt and a careless one worth real money on every single request.
1. The Window Is a Budget
Everything the model considers has to fit in one window, and everything in that window is paid for on every request. Not once: every turn re-sends the whole conversation, because the API is stateless. A tool result you fetched twenty turns ago is still being transmitted, still being processed, and still being billed.
What Competes for the Window
Figure 1: Output shares the budget with input. A prompt that fills the window leaves the model no room to answer, which surfaces as truncation rather than as an error.
Two of these grow without anyone deciding they should. History grows every turn, and retrieved documents grow whenever someone tunes recall upward. Those are the two that quietly consume a system.
2. Measure Before You Optimise
Token counts are not a function of character count, and they are model specific. Every example below shares one policy document, built here so the numbers reproduce:
# handbook.py - the shared policy document every example in this lesson imports
"""Builds the policy handbook every example in this lesson shares.
A real handbook is a static file you load. This one is generated so the
examples reproduce byte for byte on any machine.
"""
CATEGORIES = [
"apparel", "footwear", "electronics",
"small appliances", "furniture", "sporting goods",
]
REGIONS = ["EU", "UK", "US"]
CLAUSE = """\
{n}. Returns and refunds for {category} in {region}
Standard window. A customer in {region} may return unused {category} within 30
days of delivery for a full refund to the original payment method. The item must
carry its original tags and packaging. Refunds are issued within 5 business days
of the warehouse scanning the return.
Final sale. Items marked FINAL SALE at checkout are not returnable. Agents must
not offer a refund, a replacement, or store credit for a final sale item, and
must not promise a manager review. The correct response is to state the policy
and offer a repair quote where the category supports one.
Defects. A {category} item that arrives damaged or faulty is returnable for 12
months regardless of any final sale marking, because a defect claim is a
statutory right and not a courtesy return. Ask for photographs before issuing a
prepaid label.
Exceptions requiring escalation. Orders above 500 in local currency, orders
placed with a corporate account, and any return where the customer cites a
statutory right the agent cannot confirm are escalated to a supervisor.
"""
_sections = [
CLAUSE.format(n=i, category=category, region=region)
for i, (category, region) in enumerate(
((c, r) for c in CATEGORIES for r in REGIONS), start=1
)
]
HANDBOOK = (
"RETURNS POLICY HANDBOOK\n"
"Internal reference for support agents. Follow it exactly.\n\n"
+ "\n".join(_sections)
)Now measure it with the API rather than guessing:
# count_tokens.py - what the same document costs on two models
import anthropic
from handbook import HANDBOOK
client = anthropic.Anthropic()
question = "Can I return a final sale item?"
for model in ["claude-haiku-4-5", "claude-sonnet-5"]:
count = client.messages.count_tokens(
model=model,
system=HANDBOOK,
messages=[{"role": "user", "content": question}],
)
ratio = len(HANDBOOK) / count.input_tokens
print(f"{model:17} input_tokens = {count.input_tokens:5} chars/token = {ratio:.2f}")
print("handbook characters:", len(HANDBOOK))Expected Output:
claude-haiku-4-5 input_tokens = 4460 chars/token = 4.42 claude-sonnet-5 input_tokens = 6259 chars/token = 3.15 handbook characters: 19702
Identical bytes, and a 40 percent difference: 4,460 tokens to Haiku 4.5, 6,259 to Sonnet 5. "Model specific" is not a caveat here, it is the headline. A budget computed against one model is simply wrong for another, and the same document lands on opposite sides of a cache threshold depending on which one you send it to.
tiktoken for Claude. It is OpenAI's local tokenizer library, the one behind most token-counting snippets you will find online, and it is free and instant because it never leaves your machine. That is the appeal and also the trap: the vocabulary is OpenAI's, so against Claude it always undercounts, which is the direction that costs you money. Measured against the handbook above and two other corpora with o200k_base, counting the text alone as count(2x) - count(1x) so the fixed per-request overhead drops out:| Corpus | tiktoken | Haiku 4.5 | Sonnet 5 |
|---|---|---|---|
| English prose (the handbook) | 4,121 | 4,446 (-7.3%) | 6,243 (-34.0%) |
| Python source | 1,801 | 2,184 (-17.5%) | 2,844 (-36.7%) |
| Spanish prose | 1,721 | 2,120 (-18.8%) | 2,800 (-38.5%) |
The error is smallest on English prose against Haiku and worst everywhere else, reaching nearly 40 percent. Characters per token varies the same way: 4.42 on Haiku 4.5 against 3.15 on Sonnet 5 for one document. Any ratio you carry in your head is a sanity check for one model, never a budget input.
3. Prompt Caching Is an Architectural Constraint
Caching is the single highest-leverage thing in this lesson, and it is usually described as a parameter you add. It is not. It is a constraint on how you order your prompt, and the parameter only pays off if the ordering is right.
The rule is prefix match. The cache key is the exact bytes of the prompt up to each breakpoint. Rendering order is tools, then system, then messages. Any byte that changes invalidates everything after it. So stable content goes first, volatile content goes last, and that is the whole design.
# caching.py - the same handbook, marked as a cacheable prefix
import anthropic
from handbook import HANDBOOK
client = anthropic.Anthropic()
# Mark the stable prefix as cacheable. Everything up to and including this
# block (tools, then system) is what gets cached.
stable_system = [{
"type": "text",
"text": HANDBOOK,
"cache_control": {"type": "ephemeral"},
}]
def ask(question):
r = client.messages.create(
model="claude-haiku-4-5",
max_tokens=100,
system=stable_system,
messages=[{"role": "user", "content": question}],
)
return r.usage
u1 = ask("Can I return a final sale item?")
print("request 1 (cold)")
print(" cache_creation_input_tokens :", u1.cache_creation_input_tokens)
print(" cache_read_input_tokens :", u1.cache_read_input_tokens)
print(" input_tokens (uncached) :", u1.input_tokens)
u2 = ask("What about EU cooling-off periods?")
print("request 2 (warm, different question)")
print(" cache_creation_input_tokens :", u2.cache_creation_input_tokens)
print(" cache_read_input_tokens :", u2.cache_read_input_tokens)
print(" input_tokens (uncached) :", u2.input_tokens)Expected Output:
request 1 (cold) cache_creation_input_tokens : 4447 cache_read_input_tokens : 0 input_tokens (uncached) : 13 request 2 (warm, different question) cache_creation_input_tokens : 0 cache_read_input_tokens : 4447 input_tokens (uncached) : 13
Read the numbers. The first request writes 4,447 tokens into the cache. The second request asks a completely different question and reads all 4,447 back, processing just 13 new tokens. Cache reads cost about a tenth of the input price, so the handbook effectively became free from the second request onward.
usage field | Meaning | Relative cost |
|---|---|---|
cache_creation_input_tokens | Written to cache this request | 1.25x (5 min TTL), 2x (1 hour TTL) |
cache_read_input_tokens | Served from cache | ~0.1x |
input_tokens | Processed at full price | 1x |
Break-even follows from those multipliers: with the default 5 minute TTL, two requests pay for the write (1.25 + 0.1 against 2.0 uncached). The 1 hour TTL doubles the write cost, so it needs three. Use the long TTL for bursty traffic with gaps, not as a default.
cache_creation_input_tokens: 0 forever. The handbook above is 4,447 tokens as Haiku 4.5 counts them, which clears that model's 4,096 floor with little to spare. Sonnet 5 reads the same document as 6,259 tokens against a 1,024 floor, which is the practical reason the threshold has to be checked per model rather than per document.4. Silent Cache Invalidators
Here is the same handbook and the same two questions, with one difference: a timestamp at the front of the system prompt. It is the kind of line that gets added for debugging and never removed.
# invalidator.py - one line at the front of the prefix, and the cache is gone
import anthropic
from datetime import datetime
from handbook import HANDBOOK
client = anthropic.Anthropic()
def ask_with(system, question):
r = client.messages.create(
model="claude-haiku-4-5",
max_tokens=100,
system=system,
messages=[{"role": "user", "content": question}],
)
return r.usage
def volatile_system():
# A timestamp at the FRONT of the prefix. Looks harmless. Is not.
header = f"Current time: {datetime.now().isoformat()}\n\n"
return [{
"type": "text",
"text": header + HANDBOOK,
"cache_control": {"type": "ephemeral"},
}]
v1 = ask_with(volatile_system(), "Can I return a final sale item?")
v2 = ask_with(volatile_system(), "What about EU cooling-off periods?")
print("request 1: created =", v1.cache_creation_input_tokens,
"| read =", v1.cache_read_input_tokens)
print("request 2: created =", v2.cache_creation_input_tokens,
"| read =", v2.cache_read_input_tokens)Expected Output:
request 1: created = 4467 | read = 0 request 2: created = 4467 | read = 0
Every request now writes the cache and reads nothing. Same content, same questions, and the caching went from paying for itself to costing 1.25x forever. Nothing warns you: the code is correct, the responses are correct, only the bill changes.
system = (
f"Current time: {datetime.now()}\n"
f"User: {user.name} ({user.id})\n"
+ HANDBOOK
)system = [{
"type": "text",
"text": HANDBOOK,
"cache_control": {"type": "ephemeral"},
}]
# time and user go in the user turn,
# AFTER the breakpoint
messages = [{"role": "user", "content":
f"[{now}] {user.name} asks: {q}"}]Audit for these when a cache hit rate is unexpectedly zero:
| Pattern | Why it breaks the cache |
|---|---|
datetime.now() in the system prompt | Prefix differs on every single request |
uuid4() or a request id near the front | Same: every request is byte-unique |
json.dumps(d) without sort_keys=True | A dict assembled in a different order serializes to different bytes |
| User name or id interpolated into the system prompt | One cache entry per user instead of one shared entry |
Conditional system sections (if flag: system += ...) | Every flag combination is a separate prefix |
| Tool list built per user or per request | Tools render at position zero, so this invalidates everything |
| Switching models mid-conversation | Caches are scoped per model; there is no sharing |
The diagnostic is always the same: log cache_read_input_tokens. If it is zero across requests that should share a prefix, diff the rendered bytes of two consecutive requests and the culprit falls out immediately.
5. When It Stops Fitting
Long-running agents eventually approach the window. There are two server-side tools, and they do genuinely different things.
Summarize or Prune
Figure 2: Compaction trades detail for meaning. Context editing drops content it judges finished with. Long agents often use both.
Compaction summarizes earlier context into a compaction block:
# compaction.py - summarizing a 56,000-token conversation server-side
import anthropic
from handbook import HANDBOOK
client = anthropic.Anthropic()
# Nine handbook revisions: enough to cross the trigger on the next request.
messages = []
for i in range(1, 10):
messages.append({"role": "user", "content": f"Revision {i}:\n\n{HANDBOOK}"})
messages.append({"role": "assistant", "content": f"Recorded revision {i}."})
messages.append({"role": "user", "content": "What is the final sale rule?"})
before = client.messages.count_tokens(model="claude-sonnet-5", messages=messages)
print("conversation before :", before.input_tokens, "tokens")
response = client.beta.messages.create(
betas=["compact-2026-01-12"],
model="claude-sonnet-5",
max_tokens=4000,
messages=messages,
context_management={"edits": [{
"type": "compact_20260112",
"trigger": {"type": "input_tokens", "value": 50_000}, # 50,000 is the minimum
}]},
)
print("content blocks :", [b.type for b in response.content])
for step in response.usage.iterations:
print(f" {step.type:10} input_tokens = {step.input_tokens}")
# CRITICAL: append the whole content list, not just the text. The compaction
# block carries encrypted state, so it cannot be rebuilt from the summary text.
messages.append({"role": "assistant", "content": response.content})Expected Output:
conversation before : 56373 tokens content blocks : ['compaction', 'text'] compaction input_tokens = 56458 message input_tokens = 801
The iterations breakdown is the whole story. The compaction pass read 56,458 tokens and wrote a summary; the request that actually answered the question then ran on 801. The conversation kept going with a fraction of its own history in front of it.
content list. The compaction block is not just a summary, it carries an encrypted_content field, so it cannot be reconstructed from text you extracted. Drop it and the next request re-sends the entire history, which looks like the feature doing nothing.Two constraints worth knowing before you reach for it. The trigger has a floor: trigger.value must be at least 50000. And it is not available on every model, so a request that sets it on Haiku 4.5 is rejected outright rather than ignored.
Context editing clears rather than summarizes:
# context_editing.py - dropping tool results once the loop gets long
import anthropic
from handbook import HANDBOOK
client = anthropic.Anthropic()
TOOLS = [{
"name": "read_policy",
"description": "Return the full policy text for one region. The output is large.",
"input_schema": {
"type": "object",
"properties": {"region": {"type": "string"}},
"required": ["region"],
},
}]
messages = [{"role": "user", "content":
"Read the policy for the EU, then the UK, then the US, one at a time. "
"Then give me the final sale rule in one sentence."}]
for turn in range(1, 6):
response = client.beta.messages.create(
betas=["context-management-2025-06-27"],
model="claude-sonnet-5",
max_tokens=2000,
tools=TOOLS,
messages=messages,
context_management={"edits": [
# clear_thinking_20251015 must come FIRST when both are used.
{"type": "clear_thinking_20251015"},
{"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": 5000},
"keep": {"type": "tool_uses", "value": 1}},
]},
)
print(f"turn {turn}: input_tokens = {response.usage.input_tokens:5}")
for edit in response.context_management.applied_edits:
cleared = getattr(edit, "cleared_input_tokens", 0)
print(f" applied {edit.type} ({cleared} tokens cleared)")
messages.append({"role": "assistant", "content": response.content})
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
break
# A real tool would read a file; this returns a large slice of the handbook.
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": c.id, "content": HANDBOOK[:6000]}
for c in calls
]})Expected Output:
turn 1: input_tokens = 478
turn 2: input_tokens = 2542
turn 3: input_tokens = 4672
turn 4: input_tokens = 3036
applied clear_tool_uses_20250919 (3858 tokens cleared)The first three turns grow the way any tool loop grows. On the fourth, the input crossed the 5,000-token trigger, the two oldest tool results were dropped, and the request ran on 3,036 tokens instead of the roughly 6,900 it was heading for. The agent still answered: it had already used what those results contained.
clear_thinking_20251015 must be first in the edits list whenever it is present, and it requires thinking to be enabled or adaptive, which is why the example above runs on Sonnet 5 rather than Haiku 4.5. Reverse the order and you get clear_thinking_20251015 must be the first strategy; use it on a model without thinking and you get requires thinking to be enabled or adaptive.Reach for editing when a long tool-calling agent is carrying tool output it will never consult again, and for compaction when the conversation itself is the thing that got long. For state that must outlive the session entirely, you want memory rather than either: see the memory tiers in Lesson 13.
6. A Million Tokens Does Not Retire Retrieval
A recurring argument says large windows make RAG obsolete: stop retrieving, just send everything. It is wrong on four counts, and only the first is about capability.
| Objection | Detail |
|---|---|
| Your corpus is bigger | 1M tokens is roughly 750,000 words. A modest document store or codebase exceeds that comfortably. |
| You pay for all of it, every turn | Sending 500K tokens of context on each request costs the same as sending it once, times the number of turns. Retrieval sends the 2 percent that matters. |
| Latency scales with input | Time to first token is a function of how much has to be processed. Users notice. |
| Signal-to-noise still matters | Filling a window with mostly irrelevant documents makes the answer worse, not just slower. |
What large windows genuinely changed is the chunking problem. You no longer need to shred documents into 512-token fragments and hope the right one ranks first. Retrieve whole documents, or whole files, and let the model do the locating. That is a real improvement to how you build RAG, not an argument for deleting it. See Lesson 8 for the retrieval mechanics themselves.
Key Takeaways
- Everything in the window is billed every turn - the API is stateless, so history is re-sent and re-processed on each request.
- Caching is prefix match - stable content first, volatile content last. The ordering is the design;
cache_controlonly harvests it. - A timestamp in the system prompt costs you the entire cache - verified above: 4,447 tokens read per request became 0.
- Below the minimum prefix, caching silently does nothing - 512 tokens on Opus 5, 1,024 on Sonnet 5, 4,096 on Haiku 4.5. No error, just zeros.
- Measure with
count_tokens, nevertiktoken- it is the wrong tokenizer and it undercounts by 7 to 39 percent depending on model and corpus. - Token counts are model specific - one document measured 4,460 tokens on Haiku 4.5 and 6,259 on Sonnet 5, so a budget is per model, not per document.
- Compaction summarizes, context editing prunes - 56,458 tokens became an 801-token follow-up request, and compaction requires appending the full
contentlist back. - Large windows improved RAG, they did not replace it - retrieve bigger chunks, not everything.