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
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.
2 · The window is a budget essential
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.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
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.
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
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).
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
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.
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
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.
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?