Context Engineering: Memory, Windowing & Retrieval
Prompting is about how you ask; context engineering is about what the model can see when you ask. It's the discipline of deciding — for every call — exactly which tokens fill the finite context window: system prompt, history, retrieved knowledge, tool results. Get this right and everything downstream gets easier.
Learning objectives
- Explain context engineering: choosing what goes in the window.
- Budget the context window across system/history/RAG/tools.
- Compress, rank, and cache to fit more signal in fewer tokens.
- Design a context strategy for a production system.
code/pe2-context-engineering/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.1 · Prompt vs context engineering essential
Here's a moment you'll recognise if you've shipped anything with an LLM. You spend an afternoon polishing the wording of a prompt — "be concise", "think step by step", "you are an expert" — and the answers barely move. Then, almost by accident, you paste in the one paragraph of documentation the model actually needed, and suddenly it's right every time. You didn't write a better prompt. You gave it better context. That gap — between how you ask and what you supply — is the whole subject of this lesson, and it's the lever most people reach for last when it should be first.
The reason is simple and worth saying plainly: a language model has no memory and no access to anything outside the text you hand it on this call. It cannot look up your refund policy, recall yesterday's conversation, or peek at the database. Whatever isn't in the context window on this request does not exist as far as the model is concerned. So the quality ceiling of any answer is set by what you put in front of it — phrasing only decides how well it uses what's already there.
An analogy that sticks: imagine hiring a brilliant contractor who has total amnesia between meetings. Prompt engineering is coaching them on how to work — "explain your reasoning, double-check the numbers." Context engineering is handing them the right folder of documents before they start. A world-class contractor with the wrong folder gives you a confident, well-structured, completely wrong answer. That's the most common failure in production LLM systems, and it is almost never a prompting problem — it's a context problem wearing a prompting mask.
The common mistake beginners make is treating the context as an afterthought — dumping in "everything that might help" and trusting the model to sort it out. But the window is finite, every token competes for the model's attention, and irrelevant material actively drowns the signal (you'll meet this "lost in the middle" effect in §3). Context engineering is the discipline of deciding, deliberately, what earns a place in that window and how to arrange it — retrieval, memory, windowing, compaction, caching. It is a systems problem, not a wordsmithing one, and the rest of this lesson builds the toolkit piece by piece.
Prompt engineering is how you phrase the ask. Context engineering is what information you put in the window and how you structure it — often the bigger lever. The model can only use what's in its context; curating that is the craft.
Read the two top boxes side by side — they are the two distinct levers — then follow both arrows down into the single model at the bottom.
- The blue box on the left is prompt engineering: the wording of the request ("be concise", "think step by step") — coaching the amnesiac contractor on how to work.
- The green box on the right is context engineering: the actual information you place in the window — system prompt, history, retrieved knowledge, tool results — the folder of documents you hand over before they start.
- Both arrows converge on the model at the bottom, but the payoff line underneath is the point: the model can only use what is in the context, so the green lever sets the ceiling on the answer while the blue lever only decides how well that ceiling is reached.
In short: phrasing tunes how the model works; context decides what it can possibly know — which is why the right documents beat clever wording.
2 · The window is a budget essential
Picture the failure this section prevents. Your assistant works beautifully in testing. In production, a customer has a long chat, your retrieval pulls back a dozen documents, and the API call comes back not with an answer but with an error: the request exceeds the model's context length. Or worse — it doesn't error, it just silently truncates, and the model answers based on half the conversation it was supposed to see. Both failures come from the same blind spot: treating the context window as infinite when it is, in fact, a fixed budget you are always spending.
Think of the window as a jar that holds exactly so many marbles — 8,000, 200,000, whatever your model allows. Every marble you drop in is committed and cannot be reused. The system prompt is marbles. The conversation history is marbles. Each tool definition is marbles. And crucially, the space the model needs to write its answer is marbles too — you have to leave the jar partly empty on purpose, or there's no room for the reply. Whatever's left after all of that is your budget for retrieved documents. It is not "as much RAG as you want"; it is "exactly this many tokens, and not one more."
The reason this deserves its own section — rather than being obvious — is that the sections compete invisibly. A longer system prompt silently steals room from retrieval. A growing chat history silently steals room from both. Nobody sees the trade-off happen; they just see quality degrade or requests fail once real usage stretches the inputs. The engineer's job is to make that competition explicit: decide up front how many tokens each consumer may claim, and check the arithmetic before you send, not after the provider rejects you.
The common mistake is reserving nothing for the output. People fill the window right up to the limit with input and are baffled when the model's answer gets cut off mid-sentence — the reply had nowhere to go. Always carve out a fixed reserve_out slot first, then treat everything else as spendable. The tiny function below is nothing more than that piece of accounting: add up the committed marbles, subtract from the jar, and tell you whether you're over budget before it becomes a runtime crash. It looks trivial, and that's the point — the discipline is in running it every time, not in the maths.
The context window is finite and every token costs money + attention. It's split across the system prompt, conversation history, retrieved context (RAG), tool definitions, and room for the response. Engineering context = allocating that budget.
budget() computes, and why it can come back negative.
Read the top bar as the whole token budget, then scan the coloured blocks below it left to right — those are the consumers that spend it.
- The top box is the full context window — a single fixed size (200,000 tokens here). Everything below must fit inside it.
- The blue blocks (system, history, tool defs) plus the amber block (
reserve_out, the room you keep free for the model's reply) are the fixed costs — you commit them first. The amber one is the slot beginners forget, which is why answers get cut off mid-sentence. - The green block on the right is what's left over — your RAG budget. The brand arrows sum the fixed costs into
used; the dashed arrow shows RAG gets only the remainder. If the fixed costs exceed the jar, that remainder goes negative andover_budgetisTrue.
In short: fixed sections spend first, retrieval spends last — so a longer system prompt or history silently shrinks how much evidence you can afford.
budget.pydef budget(window, system, history, tools, reserve_out):
used = system + history + tools + reserve_out
for_rag = window - used
return {"available_for_rag": for_rag, "over_budget": for_rag < 0}
print(budget(window=200_000, system=2_000, history=8_000, tools=1_500, reserve_out=4_000))
print(budget(window=8_000, system=2_000, history=8_000, tools=1_500, reserve_out=4_000))
{'available_for_rag': 184500, 'over_budget': False}
{'available_for_rag': -7500, 'over_budget': True}
The model's context window is a fixed number of tokens (think of it as a jar that holds a set number of marbles). Everything you send — the system prompt, the chat history, the tool descriptions, and the space you reserve for the model's reply — all shares that one jar. This tiny function does the bookkeeping: it adds up what's already claimed, then tells you how much room is left for retrieved documents (RAG).
budget(window, system, history, tools, reserve_out)takes the total window size and the token cost of each thing you plan to put in it.windowis the jar; the rest are marbles you've already committed.used = system + history + tools + reserve_outsums everything that is not retrieved context — includingreserve_out, the room you deliberately keep free for the answer the model will write.for_rag = window - usedis what's left over — the budget you can spend on retrieved knowledge. If this goes negative, you've promised more than the jar holds.- The function returns a small dictionary:
available_for_rag(the leftover room) andover_budget(True whenfor_ragis below zero).
What the output means: The first call uses a huge 200,000-token window, so ~184,500 tokens are free for RAG and over_budget is False. The second call uses a small 8,000-token window with the same demands, so it comes up 7,500 tokens short — for_rag is -7500 and over_budget is True. Same requests, different jar: one fits, one doesn't.
Try this: Lower the first call's window to 15_000 and see it flip to over-budget. This is exactly the calculation you run before deciding how many documents you can afford to retrieve.
3 · Intermediate — rank & trim retrieved context intermediate
Everyone's first instinct is wrong here, and it's an understandable instinct: more information must be better. If the model needs facts to answer well, surely handing it twenty relevant-looking documents beats handing it three? So you crank up your retriever's top-k, stuff the window, and — the answers get worse. Slower, vaguer, sometimes flatly ignoring the one document that mattered. This surprises people so reliably it has a name.
The effect is called "lost in the middle." Language models attend most strongly to what's at the very start and the very end of their context, and material buried in the middle of a long stuffed window gets comparatively ignored — not deleted, just drowned out. Every irrelevant chunk you add doesn't sit politely in a corner; it competes for the model's finite attention against the chunks that actually answer the question. Think of asking a colleague a quick question while sliding forty pages of loosely-related notes across the desk. The one paragraph they needed is in there somewhere, but now they have to find it, and the noise makes them worse at the task, not better.
So the goal flips from "retrieve everything relevant" to "retrieve generously, then curate ruthlessly." You cast a wide net at the retrieval step — better to have the right chunk somewhere in your candidate pile than to miss it — and then you rank those candidates by relevance and keep only the strongest few that fit your token budget from §2. Signal in, noise out. The window is precious; every slot should earn its place by being more useful than the chunk it displaced.
The mechanism is a greedy fit: sort the candidates best-score-first, then walk down the list adding each chunk only if it still fits the budget, stopping when you run out of room. That word "greedy" is a real algorithmic idea — take the locally best option at each step — and here it's exactly right because a higher-scoring chunk is always worth more than a lower one, so grabbing the best available first can't backfire. The common mistake it guards against is the reverse: keeping chunks in retrieval order or by whichever arrived first, so a low-value 500-token chunk hogs room a high-value one needed. Rank first, then fit. The lab below is that pattern in a dozen lines, and it is the beating heart of context selection in every serious RAG system.
More context isn't better — irrelevant chunks dilute attention ("lost in the middle"). Retrieve generously, then rank and keep only the top-k that fit the budget.
Follow the two arrows left to right: the raw retrieved chunks get sorted into the middle column, then that ranked list is walked against the budget on the right.
- The left column is what retrieval hands back, in whatever order it arrived — each chunk labelled with its relevance score and its token cost.
- The middle column is the same chunks re-sorted highest-score-first. Walking down it, each chunk is green (kept) if it still fits the running total, or red (skipped) if not —
hourswould fit but ranks too low to reach, andtriviais both low-value and too big. - The amber box is the payoff: a 600-token budget keeps 550 tokens of the highest-value evidence, leaving the noise out. That is the "greedy fit" the lab codes.
In short: sort by relevance, then take from the top while the budget lasts — signal in, noise out.
rank.pydef fit_context(chunks, budget_tokens):
"""chunks: [(text, score, tokens)]. Keep highest-score chunks that fit."""
ranked = sorted(chunks, key=lambda c: c[1], reverse=True)
kept, used = [], 0
for text, score, tok in ranked:
if used + tok <= budget_tokens:
kept.append(text); used += tok
return kept, used
chunks = [("refund policy", 0.9, 300), ("hours", 0.4, 100),
("shipping", 0.7, 250), ("history trivia", 0.1, 500)]
kept, used = fit_context(chunks, budget_tokens=600)
print("kept:", kept, "| tokens:", used)
kept: ['refund policy', 'shipping'] | tokens: 550
When you search a knowledge base you often get back more chunks of text than will fit in your budget — and stuffing in irrelevant ones actually hurts the answer (the model gets 'lost in the middle'). The fix: sort the chunks best-first, then greedily keep only the top ones that still fit the token budget.
- Each chunk is a triple
(text, score, tokens): the content, how relevant the search thought it was (higher = better), and how many tokens it costs. ranked = sorted(chunks, key=lambda c: c[1], reverse=True)sorts the list byc[1]— the score — withreverse=Trueso the highest scores come first. Now the most relevant chunks are at the front.- The loop walks the ranked chunks and, for each, checks
if used + tok <= budget_tokens— 'does adding this one still fit?'. If yes, it keeps the text and adds its cost to the runningusedtotal. If not, it simply skips it. - It returns the list of
kepttexts and the totalusedtokens.
What the output means: With a 600-token budget it keeps 'refund policy' (score 0.9, 300 tok) and 'shipping' (score 0.7, 250 tok) for 550 tokens total. The 0.4 'hours' chunk would fit but ranks lower than shipping, and 'history trivia' (score 0.1) is both low-value and too big — both are dropped. You kept the signal and left out the noise.
Try this: Raise budget_tokens to 900 and re-run — now 'hours' sneaks in too, because there's room after the top two. This greedy 'best-first, stop when full' pattern is the core of context selection in real RAG systems.
4 · Advanced — compress & structure advanced
Sooner or later the budget from §2 stops being enough, and trimming from §3 can only cut so much before you're throwing away things you actually need. This is the wall every long-running assistant hits: a conversation that's gone thirty turns deep, or a set of documents that all genuinely matter but collectively overflow the window. You can't just drop half of it. The move now isn't to discard information — it's to compress it, to pack the same signal into fewer tokens. This is "context rot" in action: as the window fills over a long session, older material has to be squeezed down or it slowly crowds out everything else.
The clearest example is conversation history. Turn twenty-five of a chat doesn't need turn one word-for-word — but it might need to know that turn one established the customer's account number and that they're annoyed. So you keep the last few turns verbatim, because recent turns carry the live intent the model is mid-way through acting on, and you collapse everything older into a short summary line: "earlier: customer verified account, disputed a double charge, wants a refund." One line where there were fifteen. Think of it as taking meeting minutes instead of keeping the full transcript — you lose the exact words but preserve what happened, at a fraction of the space.
The same instinct applies beyond history. Deduplicate overlapping retrieved chunks — two documents that say nearly the same thing waste a slot each when one would do. And structure what remains: wrap sections in clear delimiters or headings so the model can tell the system instructions from the retrieved evidence from the user's question. A model parses a well-labelled context far more reliably than a wall of concatenated text, the same way you skim a documented report faster than an undifferentiated dump.
One placement rule ties this section to §3's "lost in the middle": put your most important context at the start or the end, where attention is strongest, and let the compressible filler live in the middle. The common mistake here is compressing too aggressively or too early — summarising a turn the model still needs in full, and watching it lose the thread. Compression is a lossy trade, so protect the recent and the critical, and only squeeze what you can genuinely afford to blur. The lab keeps the last N turns intact and summarises the rest — a crude clip here, an LLM summary in production, but the "keep-recent, summarise-the-old" shape is exactly what real agents run.
Techniques to fit more signal in fewer tokens: summarize old history, dedup overlapping chunks, structure with clear delimiters so the model parses reliably, and put the most important context where the model attends best (start/end).
Read it as a before → after: the tall stack on the left is the raw history, the arrow is the compression step, and the shorter stack on the right is what you actually send.
- The left column is the full conversation — five turns, each costing tokens on every new call as the chat grows.
- The amber box on the right is the older turns (1–3) collapsed into one summary line that just situates the thread — the lossy trade you can afford because those turns are no longer the live intent.
- The green boxes are the two most recent turns kept verbatim — recent context matters most, so it is protected from compression. Five items become three, at a fraction of the cost.
In short: protect the recent turns word-for-word, squeeze the old ones into a summary — the shape every long-running agent uses.
compress.pydef compress_history(turns, keep_recent=2):
"""Keep recent turns verbatim; summarize the rest into one line."""
if len(turns) <= keep_recent:
return turns
old, recent = turns[:-keep_recent], turns[-keep_recent:]
summary = f"[summary of {len(old)} earlier turns: " + \
"; ".join(t[:20] for t in old) + "]"
return [summary] + recent
hist = ["user asked about refunds", "assistant explained policy",
"user asked about shipping", "assistant gave times",
"user asks about returns now"]
for line in compress_history(hist): print("-", line)
- [summary of 3 earlier turns: user asked about ref; assistant explained ; user asked about sh]
- assistant gave times
- user asks about returns now
A long conversation keeps growing, and every past turn costs tokens on every new call. Instead of sending the whole history forever, you keep the last few turns word-for-word (recent context matters most) and squash everything older into a single short summary line.
compress_history(turns, keep_recent=2)takes the list of conversation turns and how many recent ones to keep untouched (default 2).if len(turns) <= keep_recent:— if the conversation is still short, there's nothing to compress, so it returns the turns unchanged.old, recent = turns[:-keep_recent], turns[-keep_recent:]splits the list:turns[-2:]is the last two turns (kept verbatim), andturns[:-2]is everything before them (to be summarized).- The
summaryline stitches the old turns into one string, usingt[:20]to keep only the first 20 characters of each — a crude but cheap way to shrink them. The function returns[summary] + recent: one summary line followed by the recent turns.
What the output means: Five turns become three lines: one [summary of 3 earlier turns: ...] line (each old turn clipped to 20 chars), then the two most recent turns in full. You've preserved the immediate context while cutting the older turns down to a fraction of their token cost.
Try this: Change keep_recent to 1 and re-run — now 4 turns get summarized and only the very last one stays verbatim. Real systems summarize with an LLM instead of clipping to 20 characters, but the keep-recent / summarize-the-rest shape is identical.
5 · Professional — prompt caching professional
Here's where context engineering stops being about quality and starts being about the bill. Suppose you've done everything right — a tight, well-structured 10,000-token system prompt full of instructions, tool definitions, and a few examples. It's great. It's also sent on every single call, and if you serve a hundred requests you've paid to process those same 10,000 tokens a hundred times: a million tokens of pure repetition. The model re-reads the identical prefix from scratch each time as if it had never seen it. That's the waste prompt caching exists to kill.
The idea is exactly what it sounds like. The first time the provider sees your big stable prefix, it does the expensive work of processing it and saves that computed state. On later calls that begin with the same prefix, it reuses the saved state instead of recomputing — and charges you a small fraction (roughly a tenth) for the cached read. You pay full freight once to "warm" the cache, then a discount forever after. It's the difference between rebuilding your development environment from source every morning versus restoring it from a snapshot in seconds.
This is the same mechanism you met as the KV-cache in IC3, but lifted from the token level to the prompt level and exposed as a feature you control. And the control is the whole game, because caching only works on an exact, unchanged prefix. That gives you one non-negotiable layout rule: put your largest stable content first — system prompt, tool definitions, fixed few-shot examples — and put the volatile per-request material (the user's question, freshly retrieved chunks) after it. Order your context stable-to-volatile and the cache boundary lands naturally where it saves the most.
The common mistake sabotages the cache without anyone noticing: slipping something that changes — a timestamp, a request ID, a per-user greeting — into the front of the prompt. Change one token near the start and the entire prefix after it is now "different," the cache misses, and you're back to paying full price on every call while wondering where your savings went. Keep the front byte-for-byte identical across calls. The lab below models the arithmetic so you can feel the shape of the win: the payoff is small for a couple of calls and enormous for a prefix reused hundreds of times, which tells you exactly when caching is worth wiring up.
When a large context prefix repeats across calls (a long system prompt, a big RAG doc), prompt caching reuses its computed state — a big cost/latency win. The same lever as IC3, applied at the prompt level.
Compare the top row (first call) with the middle row (later calls); the red box on the right is the mistake that throws the saving away.
- Each row is one request laid out left to right: the blue/green block is the large stable prefix (system prompt, tool defs, fixed examples), and the amber block is the volatile per-request material (the user's question, freshly retrieved chunks) — always placed after the prefix.
- On the first call the prefix is processed at full price and its computed state is saved (the dashed arrow). On later calls the identical prefix turns green — reused from cache at roughly a tenth of the cost — while the volatile part is still processed fresh every time.
- The red box is the trap: slip one changing token (a timestamp, a request ID) into the front and the whole prefix counts as different, the cache misses, and you pay full price again.
In short: put the biggest unchanging content first and keep it identical across calls — that is what makes the cache hit and the bill drop.
cache.pydef cache_savings(prefix_tokens, calls, cache_read_ratio=0.1):
"""First call pays full; later calls pay cache_read_ratio for the prefix."""
no_cache = prefix_tokens * calls
with_cache = prefix_tokens + prefix_tokens * cache_read_ratio * (calls - 1)
return round(no_cache), round(with_cache), round(100*(no_cache-with_cache)/no_cache)
nc, wc, saved = cache_savings(prefix_tokens=10_000, calls=100)
print(f"no cache: {nc} tok | cached: {wc} tok | saved {saved}% on the prefix")
no cache: 1000000 tok | cached: 109000 tok | saved 89% on the prefix
If the same big chunk of text sits at the start of many calls — a long system prompt or a large reference document — the model normally re-reads and re-processes it every single time. Prompt caching lets the provider remember that prefix after the first call, so repeat calls pay only a small fraction to reuse it. This function estimates how much you'd save.
cache_savings(prefix_tokens, calls, cache_read_ratio=0.1)takes the size of the repeated prefix, how many times you call it, and the discount for reads from cache (0.1means a cached read costs 10% of a fresh one).no_cache = prefix_tokens * calls— without caching you pay full price for the prefix on every call.with_cache = prefix_tokens + prefix_tokens * cache_read_ratio * (calls - 1)— you pay full price once to fill the cache, then only the cheap ratio for each of the remainingcalls - 1reads.- It returns both totals plus the percentage saved, so you can see the win at a glance.
What the output means: For a 10,000-token prefix reused across 100 calls: no cache = 1,000,000 tokens; with cache = 109,000 tokens — an 89% saving on the prefix. The more calls that share the same prefix, the closer the saving climbs toward the full 90% discount.
Try this: Drop calls to 2 and re-run — the saving shrinks a lot, because you still pay the full first call and only save on one repeat. Caching pays off most when a stable prefix is reused many times.
6 · Tech-lead — a context strategy for the system tech-lead
Everything so far has been a tool. This section is about not leaving those tools in the hands of whoever happens to be writing each feature. The failure it prevents is subtle and organisational: five engineers each build a slightly different context-assembly routine, each guesses at how many tokens to reserve, each handles over-budget requests their own way — and the system's behaviour under load becomes a lottery. One endpoint truncates silently, another crashes, a third quietly disables caching. A tech lead's job is to make context assembly a property of the platform, decided once, rather than an ad-hoc string-concat re-invented per feature.
The shift in thinking is from "how do I build a good context for this request" to "what is the policy every request obeys." That means fixing the allocations up front: the output reserve is this many tokens, history is capped at that many, tool definitions at that many — and retrieval gets whatever remains, no more. Hard caps on the sections you control turn an unbounded, unpredictable input into a bounded, predictable one. It's the difference between a kitchen where every cook eyeballs the portions and one with a written recipe: the second is the only one that tastes the same at scale.
The move that most separates a lead's design from a beginner's is how it handles going over budget. The naive system lets the request balloon and crashes when the provider rejects it — a runtime error in a customer's face. A lead makes over-budget a graceful degradation, not a failure: apply the caps, and if there's still not enough room, trim retrieval down to what fits and compress history harder, so the request succeeds with slightly less evidence rather than dying. You reject cleanly only in the genuinely impossible case where the fixed sections plus the output reserve already exceed the window. Everything short of that should bend, not break.
Finally, a strategy you can't see is a strategy you can't defend. The lead pins the cache boundary system-wide (stable prefix first, always) so caching savings are automatic rather than accidental, and measures token cost per request as a first-class metric — because the same pipeline that keeps answers accurate is the one that keeps the monthly bill survivable, and you cannot manage what you don't watch. Context, at this altitude, is an engineered, budgeted, measured pipeline. The exercises that follow build it one stage at a time and then compose the whole thing.
A lead designs the whole context pipeline: fixed budget allocations, a retrieval+rank+trim stage, history compression, and caching of stable prefixes — then monitors token cost per request. This is what keeps a RAG/agent system both accurate and affordable at scale.
Follow the top row of boxes left to right — that's the order every request flows through — then read the two branches hanging below the rank+trim stage.
- The five top boxes are the pipeline every request obeys: allocate a fixed budget, compress history, rank+trim retrieval into what remains, cache the stable prefix, and measure token cost per request as a first-class metric.
- The dashed "over budget?" arrow drops into the fail-safe. The amber box is graceful degradation: trim retrieval and compress history harder so the request still succeeds with slightly less evidence.
- The red box is the only case that fails outright — when the fixed sections plus the reserved output already exceed the window, so nothing you trim can help. Everything short of that bends rather than breaks.
In short: make context a platform property — the same budgeted, cached, measured pipeline for every request, degrading gracefully instead of crashing.
Exercise PE2.1 — Engineer a context pipeline
Context: The payoff of context engineering is a concrete token saving over the naive "stuff everything in" approach — measured, not asserted.
Your task: For a RAG endpoint, set a window budget, retrieve+rank+trim chunks, compress history, and estimate caching savings on the system prefix, then report tokens saved versus a naive stuff-everything baseline.
Requirements:
- Set the window budget for the fixed sections
- Retrieve, rank, and trim chunks into the RAG budget
- Compress the conversation history
- Estimate caching savings on the stable system prefix
- Report tokens saved against a naive stuff-everything baseline
💡 Hint: Each move — budget, trim, compress, cache — contributes a slice of the saving; sum them against the naive baseline to get the headline number.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The context window is a budget: system, history, tool defs, and the reserved output slot all consume it, and what remains is for retrieved evidence. The budget tells you before the API rejects the call.
Your task: Implement budget() that computes tokens left for RAG after the fixed sections, and show one healthy and one over-budget case.
Requirements:
- Subtract system + history + tools + reserved output from the window
- Return the tokens available for RAG
- Flag when the result is negative (over budget)
- Show a large-window healthy case
- Show a small-window over-budget case with the deficit
💡 Hint: It is one subtraction — the value is making the over-budget case visible before the provider rejects the request.
Show solution
The budget calculator, exactly as the lesson defines it (pure stdlib):
def budget(window, system, history, tools, reserve_out):
used = system + history + tools + reserve_out
for_rag = window - used
return {"available_for_rag": for_rag, "over_budget": for_rag < 0}
print(budget(window=200_000, system=2_000, history=8_000, tools=1_500, reserve_out=4_000))
# {'available_for_rag': 184500, 'over_budget': False}
print(budget(window=8_000, system=2_000, history=8_000, tools=1_500, reserve_out=4_000))
# {'available_for_rag': -7500, 'over_budget': True}
Every token spent on system, history, tool defs, and the reserved output slot is a token unavailable for retrieved evidence. The same content that fits comfortably in a 200k window blows a 8k window by 7,500 tokens — the budget tells you before the API rejects the call.
Context: You can't stuff every retrieved chunk into the window — you keep the most relevant that fit. Dropping low-value chunks also fights the "lost in the middle" effect where noise dilutes the model's attention.
Your task: Implement fit_context() that greedily keeps the highest-scoring chunks that fit a token budget, and show which survive a 600-token budget.
Requirements:
- Sort chunks by relevance score, highest first
- Add chunks greedily while they fit the token budget
- Return the kept chunks and the tokens used
- Drop the low-value chunks first
- Demonstrate the survivors under a 600-token budget
💡 Hint: Greedy-by-score is the whole algorithm — sort descending, then take while the running total stays under budget.
Show solution
Greedy fit by relevance score (pure stdlib, runnable):
def fit_context(chunks, budget_tokens):
ranked = sorted(chunks, key=lambda c: c[1], reverse=True) # by score desc
kept, used = [], 0
for text, score, tok in ranked:
if used + tok <= budget_tokens:
kept.append(text); used += tok
return kept, used
chunks = [("refund policy", 0.9, 300), ("hours", 0.4, 100),
("shipping", 0.7, 250), ("history trivia", 0.1, 500)]
kept, used = fit_context(chunks, budget_tokens=600)
print(kept, "using", used, "tokens") # ['refund policy', 'shipping'] using 550
You cannot stuff every retrieved chunk in — you keep the most relevant ones that fit. Greedy-by-score drops the low-value "history trivia" first, which also fights the "lost in the middle" effect where irrelevant chunks dilute the model's attention.
Context: Long histories eat the budget. Keeping the most recent turns verbatim while summarising the rest into one line reclaims budget for retrieval and output — the same move a production agent makes as a conversation grows.
Your task: Implement compress_history() that keeps the recent turns verbatim and summarises the older ones into a single line, and show a 5-turn conversation compressing to 3 items.
Requirements:
- Keep a configurable number of recent turns verbatim
- Summarise all older turns into one situating line
- Return early when history is already short enough
- Preserve the live intent carried by the recent turns
- Demonstrate 5 turns compressing to a summary plus the last two
💡 Hint: Recent turns carry the live intent, so protect them; the older turns collapse into one line that just situates the thread.
Show solution
History compression — recent verbatim, old summarized (pure stdlib):
def compress_history(turns, keep_recent=2):
if len(turns) <= keep_recent:
return turns
old, recent = turns[:-keep_recent], turns[-keep_recent:]
summary = f"[summary of {len(old)} earlier turns: " + \
"; ".join(t[:20] for t in old) + "]"
return [summary] + recent
turns = ["hi I need help with billing",
"my card was charged twice",
"the amount was 49 dollars",
"can you refund one charge",
"yes please do it today"]
out = compress_history(turns, keep_recent=2)
print(len(out), "items") # 3: one summary + last two turns
for t in out: print(" -", t)
Recent turns carry the live intent, so they stay verbatim; older turns compress to a single situating line. This reclaims budget for retrieval and output while preserving the thread — the same move a production agent makes as a conversation grows.
Context: Caching a stable prefix means the first call pays to write it and every later call re-reads it at ~10% of the price. That is why you put the largest stable content — system, few-shot, tool defs — first.
Your task: Implement cache_savings() and show the ~89% saving for a 10k-token prefix over 100 calls.
Requirements:
- Model the no-cache cost as prefix × calls
- Model the cached cost as one full write plus discounted re-reads
- Use a cache-read ratio of ~10% of the input price
- Return the no-cache total, cached total, and percent saved
- Show the 10k-prefix, 100-call case saving ~89%
💡 Hint: Only the first call pays full price for the prefix; the arithmetic is a full write plus (calls−1) discounted reads.
Show solution
The caching savings model, as the lesson defines it (pure arithmetic):
def cache_savings(prefix_tokens, calls, cache_read_ratio=0.1):
no_cache = prefix_tokens * calls
with_cache = prefix_tokens + prefix_tokens * cache_read_ratio * (calls - 1)
saved_pct = round(100 * (no_cache - with_cache) / no_cache)
return round(no_cache), round(with_cache), saved_pct
nc, wc, pct = cache_savings(prefix_tokens=10_000, calls=100, cache_read_ratio=0.1)
print(f"no cache: {nc:,} with cache: {wc:,} saved: {pct}%")
# no cache: 1,000,000 with cache: 109,000 saved: 89%
The first call pays to write the cache; every later call re-reads the stable prefix at ~10% of the price. Over 100 calls a 10k-token prefix drops from 1,000,000 billed tokens to 109,000 — an 89% cut on the prefix, which is why you put the largest stable content (system + few-shot + tool defs) first.
Context: A production endpoint chains all four moves — compute the RAG budget, rank and trim chunks into it, compress history, and mark the stable prefix cacheable. A real context builder is this pipeline, not a single f-string.
Your task: Compose the four moves into one build_context() and run it end to end offline.
Requirements:
- Compute the RAG budget after a compressed history
- Rank and trim chunks into that budget
- Name the cacheable stable prefix (system + tool defs), placed first
- Return the budget, kept chunks, and cacheable prefix together
- Run the whole pipeline on sample chunks offline
💡 Hint: The moves reinforce each other — compressing history and trimming chunks free budget, and caching the stable prefix cuts per-call cost.
Show solution
The moves composed into one pipeline (pure stdlib, runnable):
def budget(window, system, history, tools, reserve_out):
return window - (system + history + tools + reserve_out)
def fit_context(chunks, budget_tokens):
kept, used = [], 0
for text, score, tok in sorted(chunks, key=lambda c: c[1], reverse=True):
if used + tok <= budget_tokens:
kept.append(text); used += tok
return kept
def build_context(window, system_tok, history_turns, tool_tok, reserve, chunks):
hist_tok = 200 if len(history_turns) > 2 else 100 # after compression
rag_budget = budget(window, system_tok, hist_tok, tool_tok, reserve)
kept = fit_context(chunks, rag_budget)
return {"cacheable_prefix": ["system", "tool_defs"], # stable -> cache first
"rag_budget": rag_budget, "chunks_kept": kept}
chunks = [("refund", 0.9, 300), ("shipping", 0.7, 250), ("trivia", 0.1, 500)]
print(build_context(8_000, 2_000, ["a","b","c","d"], 1_500, 1_000, chunks))
The four moves reinforce each other: compressing history and trimming chunks free budget, and caching the stable prefix (system + tool defs, placed first) cuts cost per call. A production context builder is this pipeline, not a single f-string.
Context: As tech lead you set the allocation once and make over-budget a graceful trim, not a runtime crash: cap history and tools, reserve the output slot, then give retrieval whatever remains — a property of the platform, not each feature.
Your task: Encode the system-wide context policy with fixed allocations, an over-budget fail-safe (trim retrieval, then compress harder), and a named cached prefix, and prove it recovers an over-budget request.
Requirements:
- Apply hard caps to the controllable sections (history, tools)
- Reserve the output slot
- Reject cleanly only when prompt + reserve exceed the window
- Otherwise trim retrieval to whatever budget remains
- Name the cached prefix and demonstrate an over-budget request recovering
💡 Hint: The fail-safe order matters: cap the controllable sections first, then trim retrieval — a graceful trim beats a runtime crash.
Show solution
The system context policy with an over-budget fail-safe (pure logic):
POLICY = {"reserve_out": 4_000, "max_history": 2_000, "max_tools": 1_500}
def enforce(window, system, history, tools, rag_want):
# step 1: hard caps on the controllable sections
history = min(history, POLICY["max_history"])
tools = min(tools, POLICY["max_tools"])
fixed = system + history + tools + POLICY["reserve_out"]
rag_budget = window - fixed
if rag_budget < 0:
return {"status": "REJECT: prompt+reserve exceed window", "rag": 0}
# step 2: trim retrieval to whatever budget remains
rag = min(rag_want, rag_budget)
return {"status": "OK", "cached_prefix": ["system", "tool_defs"],
"rag_tokens": rag, "trimmed": rag < rag_want}
print(enforce(8_000, 2_000, 6_000, 3_000, rag_want=5_000)) # caps history+tools, trims RAG
A lead sets the allocation once and makes over-budget a graceful trim, not a runtime crash: cap history and tools, reserve the output slot, then give retrieval whatever remains. Naming the cached prefix system-wide (stable content first) makes the cost policy a property of the platform, not each feature.
✓ Checkpoint — you can move on when you can…
- Explain context vs prompt engineering.
- Budget the window across its consumers.
- Rank/trim retrieved context and compress history.
- Design a cached, measured context pipeline.
Knowledge check check yourself
The lesson treats the context window as a budget. Across which consumers is that budget split, and what does 'engineering context' therefore mean?
Show answer
Why does the lesson say 'more context isn't better', and what retrieve-then-fit strategy does it prescribe?