WORKBENCH

Managing context.

What should go into a model's context window, and what happens when it fills up?

Listen · 15 min0:00 / 14:36

A model’s context is everything it can see when it answers: the instructions, the conversation so far, every file and log it has looked at. It has no other memory. If something isn’t in there this time, the model doesn’t know it, and only so much fits.

For an agent that runs for dozens of steps, what you put in there quietly decides three things at once. Whether it can still see the fact it read twenty steps ago. Whether that fact gets drowned out by everything piled around it. And how big the bill is, which can easily be eight times larger than it needs to be for reasons that have nothing to do with the task. Most people handle this by feel. The point here is to put numbers on it, so you can say which approach wins on which task, and by how much.

It stands on The KV cache, by hand, which showed what every word in the model’s view costs in memory, and Paged attention and prefix caching, which showed why an unchanging start of a prompt is cheap to reuse. It sets up What makes a task long-horizon? and Inside an agent harness, where tasks run far longer than anything fits.

Today

Do The KV cache, by hand and Paged attention and prefix caching first; How much memory does a model need? helps with the scaling question at the end. Budget an evening to read and build the agent loop, and a second to run the experiments. API spend should be roughly $5-15 for everything if you use a small model and the budget guard in Task 1 (an estimate from the token counts below, not a receipt).

The Problem

You already do this well. Your coding agent finds code by walking the AST instead of dumping files, and you trim what goes into each API call without thinking hard about it. So here is a scene you have probably half-lived.

The agent is 40 steps into a refactor. Early on, at step 3, it read settings.py and saw TIMEOUT_MS = 2350. At step 9 it ran the server and got a 12,000-token log with one line that mattered: E4127 upstream closed. Now, at step 38, it needs both facts. One of three things happens:

  1. It runs out of room. The prompt is past the model’s limit. The API returns an error, or your harness quietly cuts the oldest messages to make it fit, and the config value goes with them. The agent re-reads the file (more tokens) or, worse, guesses 3000.
  2. It’s all still there, and it doesn’t matter. Both facts are in the window, buried at token 8,000 and token 31,000 of a 90,000-token prompt. The model writes a fix that uses the wrong timeout, because a dozen other TIMEOUT_MS lines from other files are in there too.
  3. It works, and the bill is strange. Each step sent the entire history again. Step 38 alone was 90,000 input tokens. The whole run was 2 million input tokens for a task whose interesting content was maybe 5,000.

None of these is the model being dumb. They follow from how the window works, and each can be measured. Today’s job: turn “I manage context well by instinct” into numbers in a table.

The Idea

The window is a desk, not a library

Picture a researcher at a small desk, with a library behind her. Every few minutes an assistant clears the desk and lays out fresh papers, and she reads all of them, start to finish, before she says anything. She has no memory between rounds: if it isn’t on the desk, she doesn’t know it.

That is a language model in an agent loop. The desk is the context windowThe maximum number of tokens a model can take as input in one request (plus, for most APIs, the tokens it writes back). Everything the model knows about your task this step has to fit in it.. The papers are tokensThe chunks of text a model reads and writes, usually pieces of words. In English prose a token is about 4 characters or three quarters of a word; code is often similar, but check with your model’s tokenizer.. The assistant laying out papers is your harness. And the model reads everything, every time.

The tokenizers lesson showed what a token is. Here is what a token costs once it’s in the window. Four separate bills.

Memory. Every token in the prompt leaves behind a key and a value vector in every layer: the KV cacheThe stored keys and values for every token processed so far, kept so the model doesn’t recompute them for each new token. It grows by one entry per layer for every token.. From the KV cache lesson, Llama 3.1 8B in bf16 stores 128 KiB per token. A 100,000-token agent session is about 12.2 GiB of KV cache, close to the size of the model’s own weights (about 16 GB in bf16). On an API you don’t see this, but the provider does, and it shows up in the price and in the rate limits.

Money. APIs charge per token, separately for input and output. As an example, take $3 per million input tokens and $15 per million output tokens, roughly a mid-tier model in 2025 (check the current pricing page for whatever you use). A 100K-token prompt is $0.30 per call, before the model writes anything.

Prefill time. Before the model writes its first token it has to run the whole prompt through every layer, the prefillThe first phase of a request: process all prompt tokens at once to build the KV cache. It is compute-heavy, and its time grows with prompt length. The time it takes is most of the time to first token.. That is roughly 2 floating-point operations per parameter per token, plus attention, where each token looks at every token before it. For Llama 3.1 8B (32 layers, width 4,096), the attention work for one new token at position n is about 4 × 32 × 4,096 × n FLOPs, which equals the 16 GFLOPs of weight work at n ≈ 30,000. So past about 30K tokens, attention starts to dominate and prefill grows faster than linearly. At an assumed 100 TFLOP/s of real throughput, prefill takes about 0.15 s for 1K tokens, 1.5 s for 8K, 8 s for 32K, and over a minute for 128K (back-of-envelope; real engines and real GPUs differ, and providers use much bigger GPUs and tricks). You will measure your own API’s version of this curve in the Build.

Attention. The fourth cost doesn’t show up on a bill. More tokens means more things competing for the model’s attention. We’ll come back to this; it’s the subtle one.

Here’s what actually lands on the desk in a typical agent step.

FIG_001[ WHAT'S ON THE DESK ]
SYSTEM PROMPTTOOL DEFINITIONSMEMORY FILE (NOTES.MD)CACHE BREAKPOINTRETRIEVED CHUNKSHISTORYuser, assistant, user, …TOOL RESULTS (THE BIG ONES)CURRENT MESSAGE↓ MODEL WRITES HERESTABLEsame everystepGROWSevery stepoften 80%+of the tokens
One request in an agent loop, top to bottom. The top part rarely changes and can be cached. The bottom changes every step. The model reads all of it every time, then writes at the end.

Notice the proportions. In most coding and browsing agents the history and especially the tool results (file contents, test output, logs, web pages) are the bulk. The system prompt you agonize over is a rounding error. So most of context management is really “what do we do with tool results”.

The loop resends everything

Here is the part that changes how you think about cost. The model has no memory between calls. Your harness gives it memory by resending the whole conversation every time.

A minimal agent loop:

messages = [{"role": "user", "content": task}]
while True:
    reply = llm(system=SYSTEM, tools=TOOLS, messages=messages)   # sends ALL of messages
    messages.append({"role": "assistant", "content": reply.content})
    if reply.stop_reason != "tool_use":
        break
    results = [run_tool(call) for call in reply.tool_calls]
    messages.append({"role": "user", "content": results})          # history grows

Step 1 sends the task. Step 2 sends the task, the first reply, and the first tool result. Step 3 sends all of that plus the second pair. If each step adds about the same number of tokens, step k sends about k times that many.

Predict firstwrite it down

An agent runs 120 steps. The system prompt and tools are 2,000 tokens, and each step adds about 900 tokens (a tool result plus a short reply). Roughly how many input tokens do you pay for over the whole run? And at $3 per million?

Reveal

Step k sends about 2,000 + 900k tokens. Add that up over 120 steps: 120 × 2,000 + 900 × (0 + 1 + … + 119) = 240,000 + 900 × 7,140 ≈ 6.7 million input tokens, about $20. The final context is only 110,000 tokens, but you paid for it again and again on the way up. That’s the triangle under the line: half of 120 × 110,000.

This is quadratic: double the steps and you roughly quadruple the input tokens. With prompt caching (next section), almost all of each request is a prefix the provider already has, billed at a tenth of the price, and the same run costs about $2.40 (my arithmetic with the example prices, 1.25× for cache writes and 0.1× for cache reads).

FIG_002[ EVERY STEP RESENDS EVERYTHING ]
STEP 1SYS3K billedSTEP 25K billedSTEP 38K billedSTEP 411K billedPREFIX SEEN BEFORE: CACHE READ AT ~0.1×NEW: FULLPRICE
Four steps of an agent loop. Each request contains the whole previous request plus one new pair. Solid blue is what's new this step; pale is a prefix the provider has seen before. Without caching you pay full price for the pale part every time, so total cost is the area of the triangle.

Prompt caching: the prefix is the deal

You met the mechanism in Prefix caching: a server that has already computed the KV cache for some prefix of tokens can skip that prefill on the next request that starts with the same tokens. APIs sell this as prompt cachingAn API feature where the provider keeps the processed prefix of your prompt for a few minutes, and bills later requests that start with the exact same tokens at a large discount, with lower latency.. Anthropic makes you mark the end of the reusable part with a cache breakpointA marker in the request saying “cache everything up to here”. The next request reuses it only if every token before the marker is identical. (cache_control) and bills cache writes at 1.25× and cache reads at about 0.1× the normal input price, with a default lifetime of 5 minutes that refreshes on each hit. OpenAI does it automatically above about 1,024 tokens. Check each provider’s docs for current numbers.

An agent loop is the perfect customer for this, because the history only ever grows at the end. Each request is the previous one plus a bit. As long as nothing near the top changes, almost every input token is a cache read.

This gives you the first hard rule of context management, and it’s about order, not size:

Anything that changes goes as late in the prompt as possible. One changed token invalidates the cache for everything after it.

Put a timestamp in the first line of the system prompt and you pay full price for the whole prompt on every call. Reorder tool definitions between calls (say, by iterating over a Python set) and same thing. And here is the twist that matters for the strategies below: any strategy that edits old history breaks the cache. Dropping the oldest message changes what comes right after the system prompt. Summarizing old turns rewrites the middle. These save tokens in the window, but each edit forces the provider to re-process everything after the edit at full price (in fact at the 1.25× write price). Sometimes “fewer tokens” costs more money.

Attention dilution: why more context can make answers worse

Now the cost that isn’t on the bill. Recall how attention works from building GPT-2: the current token’s query is compared against every key, the scores go through a softmaxA function that turns a list of scores into weights that are all positive and add up to 1. Bigger scores get exponentially more weight., and the output is the weighted average of the values. The weights add up to 1. That’s the whole problem in one sentence: attention is a fixed budget split across every token on the desk.

Make it concrete. Say the one token that matters scores 3 points higher, as a logitA raw score before softmax. Adding 1 to a logit multiplies that item’s unnormalized weight by e, about 2.7., than each of the others, and all the others score the same. The relevant token’s share of attention is e³ / (e³ + n − 1):

Tokens in context (n)Relevant token’s weightScore lead needed to keep 50%
1,0001.97%6.9
10,0000.20%9.2
100,0000.02%11.5

(Computed with Python; the second column is ln(n − 1).) Each 10× more context, the relevant token needs about 2.3 more points of score lead just to hold its ground. Real models are much better than this toy: they have many heads and layers, and trained models learn very sharp attention for things like copying. But the pressure is real, and it gets much worse when the haystack contains near-misses: a hundred TIMEOUT_MS = ... lines, each scoring almost as high as the right one. That is attention dilutionThe tendency of a relevant piece of context to get less of the model’s attention as more tokens compete for it, especially when many of those tokens look similar to it., and it is why the second failure in The Problem happens even when everything fits.

Lost in the middle

Liu et al. (2023) measured something more specific. They gave models a question and 20 retrieved documents, exactly one of which contained the answer, and moved that document from first place to last. Accuracy traced a U: high when the answer was first, high-ish when it was last, and lowest in the middle. For GPT-3.5-Turbo with 20 documents, the middle positions scored below the closed-book baseline, where the model got no documents at all. Having the answer in the context, in the wrong place, was worse than not having it. (Numbers from memory of the paper; the shape is the robust part.)

FIG_003[ LOST IN THE MIDDLE (SHAPE) ]
HIGHLOWACCURACY1STMIDDLE20THPOSITION OF THE DOCUMENT WITH THE ANSWERCLOSED BOOK (NO DOCUMENTS)PRIMACYRECENCYWORSE THAN NO CONTEXT
The shape Liu et al. found for multi-document question answering: accuracy depends on where in the prompt the relevant document sits. Schematic, not their exact numbers. Newer models flatten this curve on easy tests, but it tends to come back with distractors and harder questions.

Why a U? It’s only partly understood, and there are several contributing stories. Training data puts important things at the start (titles, instructions) and the end (the question you’re answering right now), so models learn to look there. Causal attention means early tokens are seen by every later token, and models are known to park a lot of attention on the first few tokens regardless of content. Position encodings like the RoPE you implemented tend to make far-apart tokens score a bit lower on average, which favors what’s recent. Whatever the mix, the practical lessons are stable: put instructions and the question at the end, near where the model writes; put the most important material at the start or end, not buried; and fewer, more relevant documents beat more documents.

Needle in a haystack, and why passing it proves little

Kamradt’s test is simple: take a long filler document (he used Paul Graham essays), insert one sentence (“The best thing to do in San Francisco is eat a sandwich and sit in Dolores Park on a sunny day”) at some depth, ask the model what the best thing to do in San Francisco is, and repeat over a grid of lengths and depths. The output is a heat map. Early 2023-era long-context models had visible red patches. When Claude 2.1 scored poorly on it, Anthropic reported that adding one line to the start of the model’s answer (“Here is the most relevant sentence in the context:”) took it from 27% to 98%. That tells you how much of the result was about the test’s framing.

Today most frontier models score near-perfect on the plain version. That does not mean they use long context well, for three reasons:

  1. It’s a lexical match. The question shares rare words with the needle (“San Francisco”, “best thing”). The model can find it the way grep would. Benchmarks that remove the word overlap, so that finding the needle needs one step of reasoning (NoLiMa, 2025), show accuracy falling off steeply with length even for strong models.
  2. There is one needle and nothing like it. Real context has near-misses. RULER (NVIDIA, 2024) added multiple needles, distractors, tracing variables through chains, and aggregation tasks, and found that the effective context lengthThe longest input at which a model still meets some accuracy bar on a benchmark, as opposed to the maximum the API accepts. where models stay accurate is often much shorter than the advertised one.
  3. Finding isn’t using. Your agent must notice the timeout matters, combine it with the error from step 9, and write a fix. Chroma’s 2025 “Context Rot” report found performance degrading with input length even on simple tasks.

So treat the window size as a ceiling and the needle score as a smoke test. What matters is accuracy on your task at your lengths.

The toolbox

Every strategy for managing context answers one question: of everything the agent could know, what goes on the desk this step? Here they are, each with what it throws away. The pattern to notice is that none of them is free. They trade tokens for something: risk of missing information, cost of extra calls, or latency.

Stuff everything (preload). Put the whole corpus in the prompt once, cache it, ask away. Nothing is missed, and with caching it’s cheaper than you’d guess. It fails when the corpus doesn’t fit, when near-misses dilute attention, and on first-call latency (full prefill). Good for one document or a small repo you’ll query many times within the cache’s lifetime.

Retrieval (RAG). Keep the corpus outside and fetch the few pieces most likely to matter. RAGRetrieval-augmented generation. Before (or during) generation, search an external store for relevant passages and put only those in the prompt. lives or dies on two choices. First, chunkingHow you cut documents into pieces for retrieval: fixed size with overlap, by structure (functions, sections, paragraphs), or by meaning. The chunk is the unit that gets found and pasted in.: too small loses the surrounding meaning (“it returns 3”: what does?), too big wastes tokens. For code, one function or class per chunk with its path and imports attached beats fixed windows; your AST lookup already does this. Second, the search: keyword search (BM25A classic keyword-ranking formula: documents score higher for containing rare query words, with diminishing returns for repeats. Great at exact identifiers.) for identifiers, embeddingsVectors from a model that place texts with similar meaning near each other, so you can search by meaning rather than exact words. for paraphrase, and a cross-encoder reranker on top, the kind of model you train at work, often the biggest single win (see vector search). Throws away: whatever the search ranked low. Weak spot: multi-hop questions, unless the model can search repeatedly.

Just-in-time loading. Just-in-time loadingFetching information into context only at the step that needs it, through tools, instead of loading it all up front (preloading). is retrieval as tools: give the model grep, read lines 40-90, list directory, keep only light references (paths, line numbers) in context, and let it pull what it needs. Claude Code navigates repos this way, with no vector index. It costs more steps and latency and trusts the model to know what to look for. Most real systems preload a little (a memory file, the directory layout) and load the rest on demand.

Tool-result truncation. The cheapest big win. Cap every tool’s output (say 2,000 tokens), keep the head and the tail (errors are usually at the end), and say what you cut: [... 18,400 lines omitted; call read_log(start, end) for more ...]. A cousin is clearing old tool results: twenty steps after the agent acted on a file, replace its contents with a stub (“read utils.py, 340 lines”); Anthropic’s API calls this context editing. Throws away: verbatim old outputs. Breaks the cache at the edit, so do it in batches.

Summarization (compaction). Near the limit, have the model summarize the history, then continue from the summary plus the last few turns. compactionReplacing a long conversation history with a shorter model-written summary of it, so the agent can keep going in a fresh, smaller context. keeps the gist well and loses specifics badly: exact numbers, the one odd log line, the thing that seemed unimportant at summary time. Tell the compaction prompt what must survive (decisions, file paths, exact values used), and test it: a summarizer that drops one critical fact fails silently.

Structured memory files. Instead of hoping a summary keeps the right facts, have the agent write them down on purpose in a NOTES.md or progress file and re-read it when needed. This is the CLAUDE.md pattern; this site has one, with a “lessons learned” section that past lesson-writing agents added to. MemGPT (2023) took the idea furthest: the window as RAM, external storage as disk, the model paging between them, virtual memory for text. Throws away: whatever the agent didn’t think to write down.

Sub-agents. Give a sub-task (“find every caller of retry() that passes a timeout”) to a sub-agentA separate model call loop, started by the main agent with its own fresh context, that does one sub-task and returns only a short result. with its own clean window. It burns 50,000 tokens reading files, hands back a 1,000-token report, and its window is thrown away. Anthropic’s write-up of their multi-agent research system reported this beat a single agent on broad research questions while using far more tokens in total (around 15× a chat, by their count). Throws away: everything the sub-agent saw but didn’t report. Works best when sub-tasks are independent and the answer is much smaller than the evidence.

Now watch them side by side on one task.

FIG_004[ THE WINDOW FILLING UP ]
Strategy

In window - · billed input - · cache hits - · cost -

A toy 30-step coding task: greps, file reads, test runs and one 12K-token server log. Step 27 needs a config value read at step 3; step 28 needs one error line from the step 9 log. Pick a strategy, drag the step, shrink the window, and toggle the cache. Watch what's in the window, what got dropped, and what you paid. Prices are the example $3 / $15 per million.

Things to try, and what they should show you:

  1. Stuff at 64K. The repo alone is 40K. By step 9 the log pushes the request over the limit and the task is dead. Switch the window to 128K: it fits, every fact is visible, and with the cache on the whole run costs about a dollar, even though it bills the most tokens of any strategy (about 2.2M). Turn the cache off: about $6.70. Stuffing and caching are partners.
  2. Truncate at 64K. It never overflows, but by step 27 the config from step 3 and the log from step 9 are both gone. The agent will re-read (more steps) or guess. Look at the cache hit rate: every drop changes the start of the history.
  3. Summarize at 64K. One compaction. The small config fact survives; the exact line in the big log does not. This is the typical compaction failure: the gist survives, a specific detail is gone.
  4. Sub-agent. The main window ends under 7K tokens and the run is the cheapest, but the cache helps less (each sub-agent’s window is new) and you’re trusting five reports to have kept the right facts.
  5. Truncate and Summarize at 128K. Identical: nothing needs removing. Strategies only differ once you hit the wall, which is why your test tasks have to be long enough to hit it.
Predict firstwrite it down

Before you build anything: on the real benchmark in Build It (a 94K-token fake codebase, 30 questions in one session, every fifth question asking about an earlier answer), rank three strategies (a) stuff everything, (b) retrieval with a 40K sliding window, (c) retrieval with a rolling summary, on accuracy on the look-back questions, total input tokens, and dollars.

Reveal

My expectation, not a measurement (you’ll check it): on look-back questions, stuff and summary do well and the sliding window does badly, because the earlier answer has often slid out of a 40K window by the time it’s asked about. On total input tokens, stuff is highest (94K × 30 calls is about 2.9M before history), sliding window lowest. On dollars, it’s closer than tokens suggest: stuff is almost all cache reads after the first call, while the summary strategy breaks its cache every time it compacts. On two-hop accuracy, don’t assume stuffing wins: 100 files that all define TIMEOUT_MS is a haystack full of near-misses. That’s the one I’d most like you to measure.

FIG_005[ SUB-AGENTS KEEP THE DESK CLEAN ]
MAIN AGENTSYSWINDOW: ~8K3 SHORT REPORTSSUB-AGENT 1~50K READ, THROWN AWAYSUB-AGENT 2SUB-AGENT 3TASK →← ~1K REPORT EACH
The main agent hands out sub-tasks. Each sub-agent works in its own fresh window, reads as much as it needs, and returns a short report. Only the reports enter the main window. Total tokens go up; the main window's size, and its noise, go down.

Context engineering: the actual job

Put all of this together and you get a job description. At every step of an agent loop, the harness decides what goes on the desk. A good harness:

The last bullet is the one people skip, and it’s the rest of this lesson.

Build It

You’ll build a small harness and a benchmark that makes context management matter: a fake codebase with ~100 modules, all of which define the same two constants with different values, and 30 questions asked in one long session. Most questions need two hops (find a function, see which module it calls, read that module’s constant). Every fifth question refers back to an earlier answer, which is what a long task does to an agent’s memory.

Any API works. The code below uses the Anthropic Python SDK; the OpenAI SDK has the same shape, and a local model on the Spark behind an OpenAI-compatible server (vLLM or llama.cpp’s server) costs nothing and lets you see prefill time directly, though small local models will score lower. Use a small, cheap model for the main runs.

Set up: venv, key, budget, and the fake codebaseAgent: fine
mkdir ctxlab && cd ctxlab
python3 -m venv .venv && source .venv/bin/activate
pip install anthropic          # or: pip install openai
export ANTHROPIC_API_KEY=...    # never commit it; put it in your shell profile or a .env you gitignore

Save this as make_corpus.py. It writes corpus/services/*.py and questions.json, with answers.

"""Generate a fake codebase with planted, checkable facts, plus a session of questions.

python3 make_corpus.py --files 100 --seed 0   ->  corpus/  questions.json
"""
import argparse, json, os, random

WORDS = ("billing ledger refund invoice quota shard replica cache token session audit "
         "export import router queue worker crawler parser ranker index snapshot").split()
FILLER = '''
def {name}(items, limit={n}):
    """Walk the items and keep the ones under the limit."""
    out = []
    for i, x in enumerate(items):
        if i >= limit:
            break
        if isinstance(x, dict) and x.get("ok"):
            out.append(x)
    return out
'''

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--files", type=int, default=150)
    ap.add_argument("--filler", type=int, default=12, help="filler functions per file")
    ap.add_argument("--questions", type=int, default=30)
    ap.add_argument("--seed", type=int, default=0)
    a = ap.parse_args()
    rng = random.Random(a.seed)

    names = set()
    while len(names) < a.files:
        names.add(f"{rng.choice(WORDS)}_{rng.choice(WORDS)}")
    mods = sorted(names)
    facts = {m: {"TIMEOUT_MS": rng.randrange(100, 9999),
                 "RETRY_LIMIT": rng.randrange(1, 20),
                 "entry": f"handle_{m}",
                 "calls": None} for m in mods}
    for m in mods:  # each entry point calls one helper in another module
        facts[m]["calls"] = rng.choice([x for x in mods if x != m])

    os.makedirs("corpus/services", exist_ok=True)
    total = 0
    for m in mods:
        f, dep = facts[m], facts[m]["calls"]
        body = [f'"""Service module {m}."""', f"from services.{dep} import helper_{dep}", "",
                f"TIMEOUT_MS = {f['TIMEOUT_MS']}", f"RETRY_LIMIT = {f['RETRY_LIMIT']}", ""]
        fill = [FILLER.format(name=f"_step_{m}_{k}", n=rng.randrange(5, 500)) for k in range(a.filler)]
        entry = (f"\ndef {f['entry']}(request):\n    data = _step_{m}_0(request.items)\n"
                 f"    return helper_{dep}(data, retries=RETRY_LIMIT)\n")
        helper = f"\ndef helper_{m}(data, retries=1):\n    return [d for d in data][:retries]\n"
        fill.insert(rng.randrange(len(fill) + 1), entry)
        fill.insert(rng.randrange(len(fill) + 1), helper)
        text = "\n".join(body) + "".join(fill)
        total += len(text)
        with open(f"corpus/services/{m}.py", "w") as fh:
            fh.write(text)

    qs, asked = [], []
    for i in range(a.questions):
        if asked and i % 5 == 4:  # every 5th question looks back at an earlier one
            j = rng.choice(asked)
            m = qs[j]["module"]
            qs.append({"id": i, "kind": "recall", "module": m,
                       "q": f"Look back at question {j}. What is RETRY_LIMIT in the module whose "
                            f"function you named in that answer? Reply with the number only.",
                       "answer": str(facts[facts[m]["calls"]]["RETRY_LIMIT"])})
            continue
        m = rng.choice(mods)
        dep = facts[m]["calls"]
        qs.append({"id": i, "kind": "two-hop", "module": m,
                   "q": f"The function handle_{m} calls a helper from another module. "
                        f"Name that helper, then give TIMEOUT_MS from the helper's module. "
                        f"Reply as: helper_name, number",
                   "answer": f"helper_{dep}, {facts[dep]['TIMEOUT_MS']}"})
        asked.append(i)
    json.dump(qs, open("questions.json", "w"), indent=1)
    print(f"{a.files} files, {total:,} chars (~{total // 4:,} tokens by the chars/4 rule), "
          f"{len(qs)} questions")

if __name__ == "__main__":
    main()
python3 make_corpus.py --files 100 --seed 0
# 100 files, 374,799 chars (~93,699 tokens by the chars/4 rule), 30 questions

I ran it: that’s the output for seed 0. With tiktoken’s GPT-4 tokenizers this corpus is about 4.0 characters per token, so the chars/4 estimate is right for them; Claude’s tokenizer will give a somewhat different count, so get the real number from the API’s token counting endpoint or from usage on your first call. Use --files 150 (about 140K tokens) if your model’s window is 200K and you want stuffing to be uncomfortable.

An LLM call that logs every token, dollar and secondAgent: fine

Every experiment below is only as good as this ledger. One function, one JSONL line per call.

# llm.py
import json, os, time
import anthropic

MODEL = os.environ.get("CTX_MODEL", "claude-haiku-4-5")   # check the models page for current ids
PRICE = {"in": 1.00, "out": 5.00, "write": 1.25, "read": 0.10}  # $/M and multipliers; check pricing
BUDGET = float(os.environ.get("CTX_BUDGET", "5.00"))     # hard stop in dollars
client = anthropic.Anthropic()
spent = 0.0

def call(system, messages, tools=None, max_tokens=512, tag=""):
    global spent
    if spent > BUDGET:
        raise RuntimeError(f"budget exceeded: ${spent:.2f}")
    t0 = time.perf_counter()
    kw = dict(model=MODEL, max_tokens=max_tokens, system=system, messages=messages)
    if tools:
        kw["tools"] = tools
    r = client.messages.create(**kw)
    dt = time.perf_counter() - t0
    u = r.usage
    read = getattr(u, "cache_read_input_tokens", 0) or 0
    write = getattr(u, "cache_creation_input_tokens", 0) or 0
    cost = (u.input_tokens * PRICE["in"] + write * PRICE["in"] * PRICE["write"]
            + read * PRICE["in"] * PRICE["read"] + u.output_tokens * PRICE["out"]) / 1e6
    spent += cost
    with open("ledger.jsonl", "a") as f:
        f.write(json.dumps(dict(tag=tag, input=u.input_tokens, cache_read=read, cache_write=write,
                                output=u.output_tokens, cost=cost, seconds=dt)) + "\n")
    return r

Note that in Anthropic’s usage, input_tokens counts only the uncached part; the total prompt is input_tokens + cache_read_input_tokens + cache_creation_input_tokens. To cache a big system prompt, pass system as a list of text blocks and put "cache_control": {"type": "ephemeral"} on the last stable one. The SDK and field names are as of 2025; check the docs if anything has moved.

Sanity check: call it twice with the same 5,000-token system prompt marked for caching. The first call should show cache_write about 5,000, the second cache_read about 5,000 and a shorter seconds.

Tools: grep and read, with truncationHands only

Write the two tools the agent uses to look around, plus their JSON schemas for the API. The truncation is the part that teaches: decide the cap, keep the right end, and tell the model what you cut.

def grep(pattern: str, max_lines: int = 40) -> str:
    """Regex over corpus/**/*.py. Return 'path:lineno: line' rows, at most max_lines,
    then a footer saying how many matches were omitted."""

def read(path: str, start: int = 1, end: int | None = None, max_tokens: int = 2000) -> str:
    """Return numbered lines start..end. If that is more than max_tokens (chars/4 is fine),
    keep a head and a tail and put an explicit marker in the middle:
    '[... lines 61-340 omitted; call read with start/end to see them ...]'."""

TOOLS = [  # name, description, input_schema (JSON Schema) for each tool
]

Hints: a bad path or regex should return an error string, not raise, so the model can recover. Put the line numbers in; they let the model ask for precise ranges later. Test grep("TIMEOUT_MS") and look at what a near-miss haystack looks like: 100 lines that differ only in the file name and the number.

The agent loop, with three context strategiesHands only

One loop, three policies for what goes into messages before each call. All three run the 30 questions in order, in one session: each question is appended as a new user turn, and the agent can use tools as many times as it likes (cap it at 8 tool calls per question) before replying.

def run_session(strategy: str, questions: list[dict], window: int = 40_000) -> list[dict]:
    """Returns one record per question: id, kind, reply, correct, tokens, cost, seconds, steps."""

def build_context(strategy, history, window) -> list[dict]:
    """The only function that differs between strategies. Returns the messages to send."""

The three strategies:

  • (a) stuff. No tools. The whole corpus goes in the system prompt, each file wrapped like <file path="services/x.py">...</file>, marked for caching. History is append-only (questions and answers only), no window limit beyond the model’s own.
  • (b) retrieval only. System prompt is short; the agent has grep and read. History is append-only until the total passes window tokens, then drop the oldest whole question (its user turn, tool calls, tool results and answer) until it fits. Drop whole questions, never half a tool exchange: the API rejects a tool result whose tool call is missing.
  • (c) rolling summary plus retrieval. Same tools. When history passes 80% of window, call the model once with a compaction prompt over the oldest two thirds of the history, and replace those turns with one user message: "Notes from earlier in this session: ...". Keep the most recent third verbatim.

Hints: count tokens with the usage from the previous call (exact) plus chars/4 for what you’ve appended since (close enough). Write the compaction prompt yourself and make it say what must survive: every question number, its final answer, and the exact module names and numbers used. Then look at a real summary it produced and check it against questions.json. Score replies by normalizing (lowercase, strip spaces and punctuation) and comparing to answer.

Run the matrix and keep the ledgerAgent: review after

Run each strategy on three corpus seeds (0, 1, 2). That’s 9 sessions of 30 questions. Save per-question records to results.jsonl and keep ledger.jsonl. Then write report.py that prints, per strategy: two-hop accuracy, recall accuracy, total input tokens (cached + uncached), cache-read fraction, dollars, median and p90 seconds per question, and the largest context sent.

Before running seeds 1 and 2, look at seed 0’s numbers and check the budget guard. If stuffing at 94K tokens is too pricey for your model, drop to --files 60 for everything and say so in the table. When done, have an agent review the harness for bugs that would skew the comparison (a cache breakpoint in the wrong place, a tool result counted twice, scoring that’s too strict).

Lost in the middle, on your modelHands only

A separate script, needle.py. Build a haystack of synthetic records, with one needle, at a chosen depth, and ask about it. Use near-miss records, not essays, so it’s a fair test:

def haystack(n_tokens: int, depth: float, rng) -> tuple[str, str, str]:
    """Return (context, question, answer). The context is many lines like
    'Server rk-0412 in rack 7 was last patched on 2024-03-11.'  The needle is one such line
    for a server named in the question. depth=0.0 puts it first, 1.0 last."""

Grid: lengths 4K, 16K, 64K (and 128K if your model allows and your budget does), depths 0, 0.1, …, 1.0, five trials each with different seeds. Put the question after the context. That is 165 calls at the first three lengths, roughly 4.6M input tokens; at $1 per million, about $4.60. Three trials instead of five cuts that to under $3. Use a small model; the weaker the model, the more visible the curve.

Then a second variant that breaks the lexical match: the question asks about “the server in the same rack as rk-0412 that was patched most recently”, so the model has to find two lines and compare them. Same grid, if budget allows, at two lengths only.

Record accuracy per (length, depth) and time to first token per length (use streaming, or just total seconds with max_tokens small).

Stretch: a sub-agent strategyAgent: explain only

Add strategy (d): for each question, spawn a fresh sub-agent with a short system prompt, the tools and only that question. It returns its answer plus a one-line note (“handle_x → helper_y in module y, TIMEOUT_MS 1234, RETRY_LIMIT 5”). The main session’s history holds only the questions and those notes. Predict how it does on recall questions before you run it, then add a row to the table. Ask an agent to explain any API error you hit, but write the code yourself.

Done when
  • ledger.jsonl has a line for every API call, and summing it matches your provider’s usage dashboard within a few percent.
  • report.py prints the table below for strategies (a), (b), (c), on three seeds.
  • You have read at least one compaction summary your agent wrote, and found what it dropped.
  • You have a plot of accuracy against depth for at least three context lengths, for the literal and (if you ran it) the non-literal needle.
  • You can say, with your numbers, which strategy you’d ship for a 94K-token repo and which for a 10M-token one.

Measure

The strategy table

Fill this in from report.py. Means over three seeds; write a range if the seeds disagree a lot.

StrategyTwo-hop acc.Recall acc.Input tokens (total)Cache-read shareCostMedian s / questionMax context
(a) stuff
(b) retrieval, 40K window
(c) summary + retrieval
(d) sub-agent (stretch)
Your prediction for the winner on cost

What to expect, and why. These are predictions from the arithmetic above, not results I measured; I did not run the API experiments for this lesson.

The lost-in-the-middle grid

LengthDepth 00.10.20.30.40.50.60.70.80.91.0Seconds / call
4K
16K
64K
64K, non-literal

What you’ll probably see: with a current model and the literal question, a nearly flat row near 100% at 4K and 16K, maybe a few misses in the middle at 64K. That flat row is the “needle tests are saturated” result, and it’s worth seeing yourself. The non-literal row is where the curve should appear, dipping in the middle and falling with length. Seconds per call should grow with length; if it’s close to linear from 4K to 64K, the provider is fast enough that attention’s quadratic part isn’t visible yet at these lengths (recall the ~30K crossover for an 8B model). If you run the same grid on the Spark with a local model, time to first token is the prefill curve from The Idea, measured.

One number to carry

From your table, compute cost per correct answer for each strategy. That’s the number that decides architecture, because a strategy that’s 30% cheaper and 20% less accurate is usually not cheaper.

Break It

  1. Put the time at the top. Add f"Current time: {datetime.now()}" as the first line of the stuffed system prompt and rerun (a) on one seed. The cache-read share should collapse to near zero and the input cost should jump by roughly 9× (with the example multipliers: every call now writes the whole prompt at 1.25× instead of reading it at 0.1×). One changing token at the front invalidates everything after it. Move the line to the last user message and watch the cache come back.
  2. Remove the truncation. Make read return whole files with no cap, and make a giant file: python3 -c "print('x = 1\n' * 400000)" > corpus/services/huge_log.py. Ask a question that tempts the agent to read it. You’ll get a context-length error from the API, or, if it fits, one call that costs as much as the rest of the session. Every tool needs a cap.
  3. Starve the summarizer. Set the compaction to “summarize in at most 100 words” and rerun (c). Recall accuracy should drop toward (b)‘s. Then read the summary and list what it dropped. This is the failure mode of every compaction scheme: it doesn’t fail loudly, it just forgets.
  4. Poison the haystack. Add 30 files that define TIMEOUT_MS with a comment # DEPRECATED, unused and a different value, and have some handle_ functions mention them in a comment. Stuffing has to sort real from fake inside attention; retrieval has to sort them with grep and reading. Which degrades more on your model?

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. Why does an agent’s API bill grow faster than linearly with the number of steps, and what are two ways to fix it?

The model is stateless, so every call resends the whole conversation. If each step adds about the same number of tokens, step k sends about k times that, and the total over n steps is about n²/2 times the per-step size: quadratic. Fix one: prompt caching. The history is append-only, so each request’s prefix was already processed; the provider bills it at about a tenth of the price and skips most of the prefill. Total cost becomes close to linear, as long as nothing near the top of the prompt changes. Fix two: keep the per-step growth small: cap tool outputs, clear old tool results, push noisy exploration into sub-agents that return short reports. Compaction also caps the size, but each compaction rewrites the prefix and costs a full-price re-read, so do it rarely.

2. A vendor says their model scores 100% on needle-in-a-haystack at 1M tokens. What does that tell you, and what doesn’t it?

It tells you the model can find a single sentence that shares obvious words with the question, anywhere in a long input: a good sign that position encoding and attention work at that length. It doesn’t tell you the model can find information without word overlap (NoLiMa-style tests drop sharply), pick the right fact among many near-identical ones, combine two facts from far apart, or keep reasoning quality over that much text (RULER and “context rot” style results). Attention is a fixed budget split over all tokens, so near-misses compete for it. The only number I’d trust is accuracy on my own task at my own lengths.

3. Explain “lost in the middle” and what you change in a prompt because of it.

Liu et al. showed that models answer best when the relevant document is at the start or end of a long prompt and worst when it’s in the middle; for one model the middle was worse than giving no documents at all. The reasons are partly understood: training data puts important things at the start and end, models put a lot of attention on the first tokens, and position encodings favor recent tokens. So: put instructions and the question at the end, right before the model writes; put the most important material first or last; and send fewer, better-ranked documents rather than more, which is where a good reranker earns its keep.

4. When would you stuff a whole corpus into the prompt, and when would you retrieve? How does the answer change as the corpus grows?

Stuff when it fits with room to spare, you’ll ask many questions within the cache’s lifetime, and the content isn’t full of near-duplicates: one contract, one paper, a small repo. With caching, the repeat calls are cheap and nothing is ever missed. Retrieve when it doesn’t fit, when it changes too often to cache, or when it’s full of similar-looking items that would dilute attention. As the corpus grows past the window (say from 100K to 10M tokens) stuffing simply stops being possible, so the question becomes how good your retrieval is: chunking that follows the structure (functions for code), hybrid keyword plus embedding search, a reranker, and letting the agent search repeatedly for multi-hop questions.

5. How does this scale? You serve 1,000 concurrent agent sessions, each holding 100K tokens of context, on Llama 3.1 8B. What breaks first?

Memory. From the KV cache lesson, Llama 3.1 8B in bf16 stores 128 KiB per token. 100K tokens is about 12.2 GiB per session, so 1,000 sessions is about 12 TiB of KV cache, against 16 GB of weights. No single machine holds that; even a node of eight 80 GB GPUs has 640 GB. So you can hold maybe a few dozen such sessions per node at once, and the rest wait, get evicted (and pay full prefill again when they come back), or need KV offloaded to CPU memory or disk. Every token you keep out of context is memory you get back, which is why context management is also a serving-cost problem, not just an accuracy one. Prefill is the second thing: 100K-token prefills take seconds each, and they compete with decoding for the GPU. Prefix caching helps both, as long as sessions keep their prefixes stable.

Go Deeper

Connections

This lesson stands on What is the KV cache? (each token in context is memory on someone’s GPU) and Prefix caching (why a stable prompt start is cheap, and why editing old history is not). The retrieval half connects to Nearest neighbors at scale and to your ranking work: a reranker is a context-management tool. The scaling question leans on Memory math.

It sets up the rest of this track. What makes a task long-horizon? is about what happens when a task runs for far longer than any window, and Inside an agent harness takes apart a real one, where every design choice you made in build_context shows up again.