Free lesson · GenAI Application Engineering
Build a context window composer with token budgets
You will build a ContextComposer class in context/composer.py assembling full LLM input via context engineering. The compose_context(user_id, conversation_id, user_message, tools, rag_results) method builds an ordered list of ContextSection objects with name, content, priority, and max_tokens fields. Sections assemble in order: system_prompt, tool_descriptions, mem0_memories (from UserMemoryManager.search_relevant_memories), conversation_history (from MessageRepository), rag_context, and user_message. You implement token counting via tiktoken.encoding_for_model('gpt-4o'). A TokenBudgetAllocator distributes the 128K context window across sections by priority, truncating lower-priority first. The compose method returns ComposedContext with messages, total_tokens, and section_breakdown.
Course: Full-Stack GenAI Applications · Chapter 3 · Context Engineering & Conversation Memory
Free to read — no subscription required.
Introduction
When you wire a Claude-powered assistant to long-running conversations, the 200,000-token window fills faster than you expect — a system prompt, twelve tool schemas, thirty user memories, fifty RAG chunks, and two hundred history turns can consume 74,000 tokens before the model emits a single output token. Without explicit per-section budgets, older context is silently evicted, instructions go missing mid-conversation, and the model starts contradicting earlier turns or forgetting user preferences. By the end of this lesson you'll be able to compose a multi-section LLM context window — system prompt, tool schemas, Mem0 memories, RAG chunks, and conversation history — under hard per-section token budgets, with deterministic eviction and Anthropic prompt caching applied to the stable prefix.
Key Terminology
- Context Section: A named region of the message list (system, tools, memories, history) with its own maximum token allocation.
- Token Budget: The upper bound of tokens a section may consume; all section budgets plus the output reserve must sum to no more than the model's context window.
- Stable Prefix: The leading portion of the request (system prompt + tool schemas) that does not change between turns and therefore qualifies for Anthropic prompt caching via
cache_controlmarkers.
Concepts
This section explains why a fixed 200,000-token window forces a sectioned, budgeted design, and how to allocate that budget across system prompt, tools, memories, RAG chunks, and conversation history.
Why Section-Based Composition Matters
A Claude 3.5 Sonnet context window holds 200,000 tokens. That sounds generous until you load a system prompt (800 tokens), 12 tool schemas (4,000 tokens), 30 Mem0 memories (1,500 tokens), 50 RAG chunks (8,000 tokens), and 200 conversation turns (60,000 tokens). You have consumed 74,300 tokens before the model generates a single output token—and output tokens count against the same window. Without budgeting, a long-running conversation silently pushes older context out of the window, and the model loses critical instructions or forgets user preferences. Section-based composition solves this by treating the context window as a fixed-capacity container partitioned into named sections, each with an explicit ceiling.
The key terms that underpin this design:
- Context Section: A named region of the message list (e.g., system, tools, memories, history) with a maximum token allocation.
- Token Budget: The upper bound of tokens a section may consume. Budgets across all sections must sum to less than or equal to the model's context window minus a reserved output buffer.
- Eviction Policy: The strategy applied when a section's content exceeds its budget. Conversation history typically uses oldest-first eviction; memories use relevance-score eviction.
- Stable Prefix: The portion of the message list that does not change between requests—system prompt plus tool schemas—which qualifies for Anthropic prompt caching via cache_control ephemeral markers.
- Composition Order: The sequence in which sections appear in the final message list. Anthropic models weight earlier tokens more heavily during attention, so high-priority context (system instructions, tool schemas) occupies the prefix.
Budget Allocation Strategy
Choosing the right token budgets requires understanding your application's access patterns. A customer support bot with short conversations but many tools should allocate more to tools_budget and less to history_budget. A personal assistant with month-long conversations needs the inverse. The following guidelines apply to most production deployments:
- Reserve 2-4% for output — Claude rarely generates responses longer than 4,096 tokens in conversational settings, but code generation or structured extraction tasks may need 8,192.
- Fix stable sections first — System prompts and tool schemas have predictable sizes. Measure them once and set budgets with 20% headroom for iteration.
- Cap memories at 1-2% — Mem0 memories are short factual statements (10-30 tokens each). Twenty memories consume roughly 400-600 tokens. A 2,000-token budget handles even aggressive retrieval.
- Give history the remainder — Conversation history is the most variable section and benefits most from a large budget. The history_budget property computes this dynamically.
- Monitor with Redis counts — The per-section token counts persisted to Redis by session_cache.store_counts allow you to build dashboards that reveal when conversations consistently hit eviction thresholds, signaling that budgets need rebalancing.
Code Walkthrough
This walkthrough builds the ContextComposer class end-to-end: first the budget configuration and tokenizer setup, then the assembly pipeline that loads each section, enforces budgets, evicts overflow, and attaches cache_control markers to the stable prefix.
The Composition Pipeline
The following diagram illustrates how the ContextComposer.compose_context method assembles the final message list from four data sources, applies per-section token budgets, and attaches cache_control markers to the stable prefix before returning the payload to the Anthropic API.
ContextComposer.compose_context orchestrates a token-budgeted assembly pipeline that prevents LLM context windows from silently overflowing. It allocates fixed budgets—1,000 tokens for the system prompt, 5,000 for tool schemas, 2,000 for Mem0 memory retrieval—then assigns the remaining window capacity to conversation history. When the assembled payload exceeds the window limit, the eviction loop discards the oldest history turns first. The final step attaches cache_control markers to the stable prefix (system prompt + tool schemas), enabling Anthropic's prompt caching to skip re-processing unchanging context on every call.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with top-down (TD) layout direction.
- Line 2: Defines the entry node "User Message Arrives" (A) with an edge to the "ContextComposer.compose_context" node (B), representing the start of the context assembly pipeline.
- Lines 3-6: Define four parallel branches emerging from node B, each representing a context section loaded with a specific token budget: the system prompt (1,000 tokens), tool schemas (5,000 tokens), Mem0 semantic memory search results (2,000 tokens), and conversation history (allocated the dynamic remainder of the context window).
- Lines 7-10: Converge all four parallel branches (C1–C4) into a single node D ("Assemble Ordered Sections"), indicating the loaded sections are merged into one ordered sequence.
- Line 11: Defines a decision diamond (E) that checks whether the total assembled token count exceeds the model's context window limit.
- Lines 12-13: Handle the overflow case — if the total exceeds the limit, the oldest conversation history turns are evicted (F), and the flow loops back to the size check (E), creating an iterative eviction loop until the payload fits.
- Line 14: Handles the success case — when the total is within the window limit, a cache_control breakpoint is attached to the stable prefix (system prompt + tools + memory) to enable Anthropic prompt caching.
- Line 15: Defines the terminal node (H) where the fully assembled messages array and tools payload are returned, ready to be sent to the LLM API.
The critical insight is that the system prompt and tool schemas form a stable prefix—they are identical across every request in a session. By marking the last block of that prefix with {"cache_control": {"type": "ephemeral"}}, Anthropic caches the KV computations for those tokens, reducing input cost by up to 90% on cache hits. Everything after the cache boundary—memories, history, the new user message—varies per request and is computed fresh.
Implementing the ContextComposer
The following implementation defines the ContextComposer class in context/composer.py. This class accepts a BudgetConfig dataclass that specifies per-section token ceilings, a MemoryClient instance from the Mem0 SDK for semantic memory retrieval, an async SQLAlchemy session factory for loading conversation history from PostgreSQL, and a Redis-backed SessionCache for tracking per-section token counts. The compose_context method is the single entry point that orchestrates the full assembly pipeline by calling private helpers _build_system_section, _build_memory_section, _build_history_section, and _apply_cache_markers.
Code snippet python
1from dataclasses import dataclass, field 2from typing import Any 3import tiktoken 4 5@dataclass 6class BudgetConfig: 7 model_context_window: int = 200_000 8 output_reserve: int = 4_096 9 system_budget: int = 1_000 10 tools_budget: int = 5_000 11 memories_budget: int = 2_000 12 rag_budget: int = 8_000 13 14 @property 15 def history_budget(self) -> int: 16 fixed = (self.system_budget + self.tools_budget 17 + self.memories_budget + self.rag_budget) 18 return self.model_context_window - self.output_reserve - fixed 19 20@dataclass 21class ComposedContext: 22 system: str 23 messages: list[dict[str, Any]] 24 tools: list[dict[str, Any]] 25 token_counts: dict[str, int] = field(default_factory=dict) 26 27class ContextComposer: 28 def __init__(self, budget: BudgetConfig, memory_client, 29 session_factory, session_cache): 30 self.budget = budget 31 self.memory_client = memory_client 32 self.session_factory = session_factory 33 self.session_cache = session_cache 34 self._enc = tiktoken.get_encoding("cl100k_base") 35 36 def _count_tokens(self, text: str) -> int: 37 return len(self._enc.encode(text)) 38 39 def _truncate_to_budget(self, text: str, budget: int) -> str: 40 tokens = self._enc.encode(text) 41 if len(tokens) <= budget: 42 return text 43 return self._enc.decode(tokens[:budget])
- Lines 1-2: Imports
dataclassandfieldfor configuration structs plustiktokenfor client-side token counting, which avoids a round-trip to the Anthropic tokenizer API. - Lines 4-14:
BudgetConfigdefines hard ceilings for each context section. Themodel_context_windowdefaults to 200,000 for Claude 3.5 Sonnet. Theoutput_reserveof 4,096 tokens guarantees space for the model's response. - Lines 16-19: The
history_budgetproperty dynamically computes remaining capacity by subtracting all fixed-section budgets and the output reserve from the total window, ensuring that conversation history flexes to fill available space. - Lines 21-25:
ComposedContextis thereturntype carrying the assembled system prompt, the message list, the tool schemas, and a diagnostic dictionary of per-section token counts for logging and Redis caching. - Lines 27-37:
ContextComposer.__init__accepts four collaborators: aBudgetConfig, the Mem0memory_clientfor semantic search, anasyncSQLAlchemysession_factoryfor database access, and asession_cachefor Redis-backed token tracking. Thetiktokenencoder is initialized once with thecl100k_baseencoding used by Claude-compatible tokenizers. - Lines 39-44:
_count_tokensand_truncate_to_budgetare utility methods. Truncation hard-cuts at the budget boundary, which is acceptable for system prompts and memory blocks where partial content still carries value.
Assembling the Full Context and Attaching Cache Markers
The next code block implements the compose_context method together with _apply_cache_markers and build_api_payload. compose_context is the core orchestration function: it calls into Mem0 for memory retrieval, loads paginated conversation history from PostgreSQL via SQLAlchemy async sessions, enforces per-section budgets, and returns the fully assembled ComposedContext ready for the Anthropic API. The method also integrates with the Redis-backed SessionCache to persist per-section token counts so that subsequent requests can skip recomputation of stable sections. The companion _apply_cache_markers and build_api_payload methods then convert that ComposedContext into the final API payload, attaching {"cache_control": {"type": "ephemeral"}} to the stable prefix so Anthropic returns a cache hit on subsequent requests and charges only 10% of the normal input token cost for the cached prefix.
Code snippet python
1async def compose_context(self, user_id: str, conversation_id: str, 2 user_message: str, tools: list[dict], 3 rag_results: list[str] | None = None 4 ) -> ComposedContext: 5 system_text = self._build_system_section() 6 tool_schemas = self._truncate_tools(tools) 7 8 memories = self.memory_client.search( 9 query=user_message, user_id=user_id, limit=20 10 ) 11 memory_block = self._build_memory_section(memories) 12 13 history = await self._load_history(conversation_id) 14 history = self._evict_history(history) 15 16 rag_block = "" 17 if rag_results is not None: 18 rag_block = "\n\n".join(rag_results) 19 rag_block = self._truncate_to_budget( 20 rag_block, self.budget.rag_budget 21 ) 22 23 system_full = self._assemble_system( 24 system_text, memory_block, rag_block 25 ) 26 27 messages = [*history, {"role": "user", "content": user_message}] 28 29 counts = { 30 "system": self._count_tokens(system_full), 31 "tools": self._count_tokens(str(tool_schemas)), 32 "memories": self._count_tokens(memory_block), 33 "history": sum(self._count_tokens(m["content"]) for m in messages), 34 } 35 await self.session_cache.store_counts(conversation_id, counts) 36 37 return ComposedContext( 38 system=system_full, messages=messages, 39 tools=tool_schemas, token_counts=counts, 40 ) 41 42def _apply_cache_markers(self, system_text: str, 43 tools: list[dict]) -> tuple[list[dict], list[dict]]: 44 system_blocks = [ 45 { 46 "type": "text", 47 "text": system_text, 48 "cache_control": {"type": "ephemeral"}, 49 } 50 ] 51 52 if tools: 53 tools[-1]["cache_control"] = {"type": "ephemeral"} 54 55 return system_blocks, tools 56 57def build_api_payload(self, composed: ComposedContext) -> dict: 58 system_blocks, tools = self._apply_cache_markers( 59 composed.system, composed.tools 60 ) 61 return { 62 "model": "claude-sonnet-4-20250514", 63 "max_tokens": self.budget.output_reserve, 64 "system": system_blocks, 65 "tools": tools, 66 "messages": composed.messages, 67 }
- Lines 1-4: The
compose_contextsignature accepts all inputs needed to compose a complete context:user_idfor Mem0 scoping,conversation_idfor loading history from PostgreSQL, the currentuser_message, the tool schemas list, and an optionalrag_resultslist that defaults to None. - Lines 5-6:
_build_system_sectionreturns the static system prompt string, while_truncate_toolsserializes and trims tool schemas to fit withintools_budget. - Lines 8-11: The Mem0
MemoryClient.searchcall performs semantic retrieval of up to 20 memories scoped to thisuser_id. The_build_memory_sectionhelper formats these into a structured text block with each memory on its own line, prefixed by its relevance score. - Lines 13-14:
_load_historyruns anasyncSQLAlchemy query against themessagestable filtered byconversation_id, ordered by creation timestamp ascending._evict_historythen removes the oldest turn pairs until the total token count fits withinself.budget.history_budget. - Lines 16-21: RAG results, when present, are joined and truncated to
rag_budget. Whenrag_resultsis None, the RAG block remains an empty string and consumes zero budget. - Lines 23-25:
_assemble_systemconcatenates the system prompt, memory block, and RAG block into a single system string with clear section delimiters (e.g.,<memories>...</memories>XML tags) so the model can distinguish context sources. - Line 27: The final message list prepends the evicted history and appends the new user message as the last entry.
- Lines 29-35: Token counts are computed per-section and persisted to Redis via
session_cache.store_counts, enabling the API layer to return token usage diagnostics and allowing the next request to compare counts without re-tokenizing stable sections. - Lines 37-40: Returns the
ComposedContextdataclass containing everything the Anthropic API call needs. - Lines 42-43:
_apply_cache_markersaccepts the system text and tool schemas, returning modified versions with cache markers attached. - Lines 44-50: The system prompt is wrapped in a content block dictionary with
"type": "text"and thecache_controlfield set to{"type": "ephemeral"}. This tells Anthropic to cache all KV computations for this block. The"ephemeral"type means the cache entry has a 5-minute TTL — sufficient for interactive conversations where requests arrive every few seconds. - Lines 52-53: If tool schemas are present, the last tool in the list receives its own
cache_controlmarker. Because Anthropic caches prefixes contiguously, marking the last tool extends the cache boundary to cover both the system prompt and all tool definitions. - Line 55: Returns the modified system blocks and tools as a tuple.
- Lines 57-67:
build_api_payloadis the final step that produces the dictionary passed directly toanthropic.AsyncAnthropic().messages.create(). Thesystemfield uses the block-array format (not a plain string) to supportcache_control. Themax_tokensis set from theoutput_reservebudget, and themessageslist contains the conversation history plus the new user turn — all of which fall outside the cached prefix and are computed fresh on every request.
Do's and Don'ts
Do's
- ✓Do derive
history_budgetas a computed property ofBudgetConfig— Calculate it asmodel_context_window - output_reserve - (system_budget + tools_budget + memories_budget + rag_budget)so conversation history automatically absorbs whatever the window has left; a hard-coded ceiling breaks silently whenever any other section's ceiling changes. - ✓Do attach
cache_control: {"type": "ephemeral"}exclusively to the stable prefix (system prompt + tool schemas) — These two sections are identical across every request in a session, so marking the last block of that prefix lets Anthropic cache KV computations and cut input cost by up to 90% on cache hits; sections that vary per request — Mem0 memories, RAG chunks, history — must never carry the marker. - ✓Do target oldest conversation history turns first in the
compose_contexteviction loop — When the assembled total exceeds the model context window, discarding the tail of_build_history_sectionoutput preserves the system prompt, tool schemas, and Mem0 memories that encode long-term user preferences; those sections are far more expensive to reconstruct than older dialogue turns.
Don'ts
- ✗Don't skip
output_reservewhen computing available context —BudgetConfigreserves 4,096 tokens for the model's generated output before dividing the remaining window among input sections; omitting that reserve causeshistory_budgetto expand into the generation headroom, leaving the model no tokens to emit a response and producing silent truncation or API errors. - ✗Don't rely on rough character counts instead of
_count_tokenswithcl100k_baseencoding — Claude's tokenizer splits on subwords, punctuation, and Unicode in ways that make character-based estimates diverge by 30–50% from real token counts; without calling tiktoken on each section before comparing it to its ceiling (memories_budget,tools_budget, etc.), sections silently overflow their budgets and trigger unnecessary history eviction. - ✗Don't apply
cache_controlto Mem0 memory results or RAG chunks — Because these sections are rebuilt from a semantic search on every user turn, their content changes with each request, making every cache lookup a miss; attaching{"cache_control": {"type": "ephemeral"}}to them wastes the cache slot without saving any compute, and can displace the stable-prefix cache entry that would have yielded the 90% cost reduction.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in Full-Stack GenAI Applications
- Ch 1Implement an Anthropic Claude streaming adapter
- Ch 1Build a Llama 4 Maverick streaming adapter via Together.ai
- Ch 2Extract structured output with Instructor + Pydantic
- Ch 2Build a usage logging system with token + cost capture
- Ch 3Build a context window composer with token budgetsYou are here
- Ch 3Implement Anthropic prompt caching with cache_control markers
- Ch 4Build a code validator with Gemini ToolCodeExecution