Module 6: Prompt Composition and Chaining
5. Context Window Management
Overview
The context window is one of the most critical and limited resources when you work with LLMs. Understanding how it works, how to measure it precisely, and which strategies to use when your content exceeds the limit is fundamental to building robust systems.
This capsule covers the three main strategies: summarization chains, sliding window, and hierarchical summarization, along with criteria for choosing when to use each one.
The context window problem
gpt-4o-mini: 128K tokens of context
gpt-4o: 128K tokens of context
claude-3-5-haiku: 200K tokens of context
How much is a token? Roughly:
- 1 token ≈ 0.75 words in English
- 1 token ≈ 0.5 words in Spanish (more tokens per character)
- 1 A4 page of text ≈ 500 tokens
- A book (200 pages) ≈ 100,000 tokens
The real problem:
- A company's annual report: 150,000 tokens → DOES NOT FIT in a single prompt
- A 10K-line codebase: ~100,000 tokens → Doesn't fit alongside the analysis prompt
- A 2-hour chat conversation: ~30,000 tokens → May run out of context
But even when it DOES fit, there's another problem: "Lost in the Middle"
The model has worse recall for information in the MIDDLE of the context window.
Best remembered: the beginning and the end.
Counting tokens accurately
from openai import OpenAI
import tiktoken
from typing import Union
client = OpenAI()
def count_tokens(text: str, model: str = "gpt-4o-mini") -> int:
"""
Counts tokens accurately using tiktoken.
Far more precise than the word-based heuristic.
Args:
text: Text to tokenize
model: OpenAI model to count tokens for
Returns:
Exact number of tokens
"""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
# Fallback for new models
encoding = tiktoken.get_encoding("cl100k_base")
return len(encoding.encode(text))
def count_message_tokens(messages: list[dict], model: str = "gpt-4o-mini") -> int:
"""
Counts tokens for a list of messages (chat format).
Includes the overhead tokens of the chat format.
"""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
encoding = tiktoken.get_encoding("cl100k_base")
# Overhead per message (approximate for GPT-4)
tokens = 3 # Base system
for msg in messages:
tokens += 4 # Overhead per message
tokens += len(encoding.encode(msg.get("content", "")))
tokens += len(encoding.encode(msg.get("role", "")))
return tokens
def chunks_by_tokens(
text: str,
max_tokens_per_chunk: int = 4000,
overlap_tokens: int = 200,
model: str = "gpt-4o-mini"
) -> list[str]:
"""
Splits a text into chunks of a maximum token size.
Includes overlap to keep coherence between chunks.
Args:
text: Text to split
max_tokens_per_chunk: Maximum tokens per chunk
overlap_tokens: Overlap tokens between consecutive chunks
model: Model used to count tokens
Returns:
List of text chunks
"""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
encoding = tiktoken.get_encoding("cl100k_base")
tokens = encoding.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = min(start + max_tokens_per_chunk, len(tokens))
chunk_tokens = tokens[start:end]
chunk_text = encoding.decode(chunk_tokens)
chunks.append(chunk_text)
start = end - overlap_tokens # Overlap for continuity
if start >= len(tokens):
break
return chunks
# Example usage:
if __name__ == "__main__":
long_text = "This is a sample text. " * 1000 # ~6K tokens
total_tokens = count_tokens(long_text)
print(f"Total tokens: {total_tokens:,}")
chunks = chunks_by_tokens(long_text, max_tokens_per_chunk=1000, overlap_tokens=100)
print(f"Number of chunks: {len(chunks)}")
for i, chunk in enumerate(chunks[:3]):
print(f"Chunk {i+1}: {count_tokens(chunk)} tokens")
Strategy 1: Summarization chain
The simplest strategy: split the text into chunks, summarize each one, and concatenate the summaries.
def summarization_chain(
text: str,
max_tokens_chunk: int = 4000,
tokens_per_summary: int = 200,
extra_instruction: str = "",
verbose: bool = False
) -> str:
"""
Basic summarization strategy:
1. Split into chunks
2. Summarize each chunk
3. Concatenate the summaries
Best for: Documents where a summary of each section is enough.
Not ideal when: You need to preserve specific details.
Args:
text: Long text to summarize
max_tokens_chunk: Maximum tokens per chunk
tokens_per_summary: Tokens for each chunk's summary
extra_instruction: What to preserve in the summary (e.g. "keep exact figures")
Returns:
Concatenated summarized text
"""
chunks = chunks_by_tokens(text, max_tokens_chunk)
if len(chunks) == 1:
return text # No need to split
if verbose:
print(f"Split into {len(chunks)} chunks for summarization")
summaries = []
for i, chunk in enumerate(chunks):
if verbose:
print(f" Summarizing chunk {i+1}/{len(chunks)}...")
prompt = f"""Summarize the following text fragment in {tokens_per_summary} words.
{f'Additional instruction: {extra_instruction}' if extra_instruction else ''}
Preserve: proper nouns, dates, specific numbers, main ideas.
Text:
{chunk}"""
summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=tokens_per_summary * 2
).choices[0].message.content
summaries.append(summary)
return "\n\n".join(summaries)
# Example with a specific instruction:
report_text = "TechCorp's annual report..." * 500 # Simulate a long text
summary = summarization_chain(
report_text,
extra_instruction="keep every financial figure and percentage",
verbose=True
)
print(f"Original text: {count_tokens(report_text):,} tokens")
print(f"Summary: {count_tokens(summary):,} tokens")
Strategy 2: Sliding window
Processes the text in sliding windows with overlap. Useful when you need to keep contextual coherence.
def sliding_window_analysis(
text: str,
question: str,
window_tokens: int = 3000,
overlap_tokens: int = 300,
combine: bool = True
) -> Union[list[str], str]:
"""
Sliding window analysis.
Useful for: finding specific information, coherent local analysis.
Args:
text: Long text to analyze
question: The question or task to apply on each window
window_tokens: Window size in tokens
overlap_tokens: Overlap tokens between windows (for coherence)
combine: If True, combines results into a final synthesis
Returns:
List of results per window, or a combined synthesis
"""
chunks = chunks_by_tokens(text, window_tokens, overlap_tokens)
results = []
for i, chunk in enumerate(chunks):
# Tell the model the context of the window
context = f"[Fragment {i+1} of {len(chunks)} of the document]"
prompt = f"""{context}
{question}
Fragment:
{chunk}
Answer based ONLY on this fragment. If the information is not in this fragment, say "Not found in this fragment"."""
result = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=400
).choices[0].message.content
results.append({
"fragment": i + 1,
"total_fragments": len(chunks),
"result": result
})
if not combine:
return results
# Combine results from all windows
results_str = "\n\n".join([
f"Fragment {r['fragment']}:\n{r['result']}"
for r in results
if "Not found" not in r['result']
])
if not results_str.strip():
return "The requested information was not found in any fragment of the document."
synthesis = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Synthesize these per-fragment analysis results:
{results_str}
Produce a coherent answer with no repetition for: {question}"""}],
temperature=0,
max_tokens=500
).choices[0].message.content
return synthesis
# Example: Find specific information in a long document
document = "Long contract text..." * 300
question = "What are the penalties for breach mentioned in the document?"
result = sliding_window_analysis(document, question, window_tokens=2000)
print(result)
Strategy 3: Hierarchical summarization
For very long documents, summarize across multiple levels.
def hierarchical_summarize(
text: str,
level_1_tokens: int = 3000,
level_1_summary_tokens: int = 300,
level_2_summary_tokens: int = 500,
goal: str = "general summary"
) -> dict:
"""
Hierarchical summarization for very long documents.
Level 1: Summarize each chunk (fine granularity)
Level 2: Summarize the level 1 summaries (medium granularity)
Level 3: Final summary of the level 2 summaries (coarse granularity)
Args:
text: Very long text
level_1_tokens: Chunk size for level 1
level_1_summary_tokens: Tokens per summary at level 1
level_2_summary_tokens: Tokens for the level 2 summary
goal: The goal of the summary (steers the focus)
Returns:
dict with the summaries of each level
"""
# LEVEL 1: Summarize each chunk
print("Level 1: Summarizing individual chunks...")
chunks_n1 = chunks_by_tokens(text, level_1_tokens)
summaries_n1 = []
for i, chunk in enumerate(chunks_n1):
print(f" Chunk {i+1}/{len(chunks_n1)}")
summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Summarize this fragment for the goal: {goal}
Be concise but preserve key data (numbers, names, dates).
Target: {level_1_summary_tokens} words.
Fragment:
{chunk}"""}],
temperature=0,
max_tokens=level_1_summary_tokens * 2
).choices[0].message.content
summaries_n1.append(summary)
# Check whether level 1 is already small enough
text_n1 = "\n\n---\n\n".join(summaries_n1)
tokens_n1 = count_tokens(text_n1)
if tokens_n1 <= 4000:
# Level 1 already fits in context, do just one more level
print(f"Level 2: Final synthesis (level 1 is already manageable: {tokens_n1} tokens)")
final_summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Goal: {goal}
Based on these summaries of the document's sections, produce a coherent final summary:
{text_n1}
The final summary must be {level_2_summary_tokens} words with no repetition."""}],
temperature=0,
max_tokens=level_2_summary_tokens * 2
).choices[0].message.content
return {
"levels": 2,
"n_chunks_level1": len(chunks_n1),
"summaries_level1": summaries_n1,
"final_summary": final_summary
}
# LEVEL 2: We need another summarization level
print(f"Level 2: The level 1 summaries are still large ({tokens_n1} tokens). Summarizing level 2...")
chunks_n2 = chunks_by_tokens(text_n1, level_1_tokens)
summaries_n2 = []
for i, chunk in enumerate(chunks_n2):
summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"Synthesize these partial summaries (goal: {goal}):\n\n{chunk}"}],
temperature=0,
max_tokens=level_2_summary_tokens
).choices[0].message.content
summaries_n2.append(summary)
# FINAL LEVEL: Synthesis of level 2
text_n2 = "\n\n".join(summaries_n2)
final_summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Produce the final executive summary of the document.
Goal: {goal}
Based on:
{text_n2}
Be thorough but concise. Highlight the most important points."""}],
temperature=0,
max_tokens=level_2_summary_tokens * 2
).choices[0].message.content
return {
"levels": 3,
"n_chunks_level1": len(chunks_n1),
"n_chunks_level2": len(chunks_n2),
"summaries_level1": summaries_n1,
"summaries_level2": summaries_n2,
"final_summary": final_summary
}
When to use each strategy
def select_context_strategy(
text: str,
task: str,
model: str = "gpt-4o-mini"
) -> dict:
"""
Recommends the context management strategy based on the size of the text
and the type of task.
Returns:
dict with the recommended strategy and its rationale
"""
text_tokens = count_tokens(text, model)
MODEL_LIMITS = {
"gpt-4o-mini": 128_000,
"gpt-4o": 128_000,
"claude-3-5-haiku-20241022": 200_000
}
limit = MODEL_LIMITS.get(model, 128_000)
available_tokens = limit - 2000 # Reserve room for the prompt and the answer
# Task types that require querying section by section
search_tasks = ["find", "search", "extract specific", "where", "when", "who"]
is_search = any(t in task.lower() for t in search_tasks)
if text_tokens <= available_tokens * 0.8:
# The text fits comfortably in context
return {
"strategy": "direct_context",
"description": "The text fits in the context window. Use it directly.",
"text_tokens": text_tokens,
"available_tokens": available_tokens,
"estimated_chunks": 1,
"estimated_calls": 1
}
if text_tokens <= available_tokens * 2:
# Slightly exceeds the context
if is_search:
return {
"strategy": "sliding_window",
"description": "Section-by-section search with overlap to find specific information.",
"text_tokens": text_tokens,
"estimated_chunks": 2,
"estimated_calls": 3 # 2 chunks + 1 synthesis
}
return {
"strategy": "summarization_chain",
"description": "Summarize into 2-3 chunks, concatenate.",
"text_tokens": text_tokens,
"estimated_chunks": 2,
"estimated_calls": 3
}
if text_tokens <= available_tokens * 10:
# Moderately long
if is_search:
return {
"strategy": "sliding_window",
"description": "Sliding window with a final synthesis.",
"text_tokens": text_tokens,
"estimated_chunks": text_tokens // 3000,
"estimated_calls": (text_tokens // 3000) + 1
}
return {
"strategy": "hierarchical_summarization_2_levels",
"description": "Summarize chunks, then synthesize the summaries.",
"text_tokens": text_tokens,
"estimated_chunks": text_tokens // 3000,
"estimated_calls": text_tokens // 3000 + 1
}
# Very long: needs the full hierarchy
return {
"strategy": "hierarchical_summarization_3_levels",
"description": "Summarization across 3 hierarchical levels.",
"text_tokens": text_tokens,
"estimated_chunks": text_tokens // 3000,
"estimated_calls": text_tokens // 2000 # Rough estimate
}
Strategy comparison table
| Strategy | Max tokens | Preserves details | Cost | When to use |
|---|---|---|---|---|
| Direct context | < 100K | ✓✓✓ | 1x | Whenever it fits |
| Summarization chain | Unlimited | ✓✓ (with instructions) | N× chunks | Summaries where the synthesis is enough |
| Sliding window | Unlimited | ✓✓✓ per section | N× chunks + synthesis | Searching for specific info |
| Hierarchical | Unlimited | ✓ (loses detail) | N² | Extremely long documents |
Integrating with Anthropic (Claude)
import anthropic
client_anthropic = anthropic.Anthropic()
def summarization_chain_claude(
text: str,
max_chars_chunk: int = 15000,
instruction: str = "Executive summary preserving key data"
) -> str:
"""
Summarization chain using Claude.
Claude has a 200K token window, but the same strategy
applies for extremely long documents.
"""
# Claude-3-5-haiku supports up to 200K tokens of context
if len(text) < 150000: # Roughly 100K tokens
# The text fits directly
message = client_anthropic.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=1000,
messages=[{"role": "user", "content": f"{instruction}:\n\n{text}"}]
)
return message.content[0].text
# Split into chunks if it's too long
chunks = [text[i:i+max_chars_chunk] for i in range(0, len(text), max_chars_chunk)]
summaries = []
for chunk in chunks:
message = client_anthropic.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=400,
messages=[{"role": "user", "content": f"Summarize in 300 words, preserving important data:\n\n{chunk}"}]
)
summaries.append(message.content[0].text)
# Final synthesis
synthesis_input = "\n\n---\n\n".join(summaries)
synthesis_msg = client_anthropic.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=800,
messages=[{"role": "user", "content": f"Synthesize these partial summaries into a coherent executive report:\n\n{synthesis_input}"}]
)
return synthesis_msg.content[0].text
Handling "Lost in the Middle"
def process_with_optimal_position(
long_context: str,
question: str,
n_key_fragments: int = 3
) -> str:
"""
Mitigates the "Lost in the Middle" problem by placing the most relevant
information at the beginning and at the end of the context.
The model remembers the start and the end of the context window best.
"""
# Step 1: Identify the most relevant fragments
chunks = chunks_by_tokens(long_context, max_tokens_per_chunk=1000)
relevance = []
for i, chunk in enumerate(chunks):
score_resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"How relevant is this fragment for answering: '{question}'?\nScore 0-10. The number only.\n\nFragment: {chunk[:300]}"}],
temperature=0,
max_tokens=5
).choices[0].message.content.strip()
try:
score = float(score_resp[:3])
except ValueError:
score = 5.0
relevance.append((score, i, chunk))
# Sort by relevance and take the best ones
relevance.sort(reverse=True)
key_fragments = relevance[:n_key_fragments]
# Step 2: Place the key fragments at the beginning of the context
optimized_context = "MOST RELEVANT FRAGMENTS (for your reference):\n\n"
for score, i, chunk in sorted(key_fragments, key=lambda x: x[0], reverse=True):
optimized_context += f"[Fragment {i+1}, relevance {score:.1f}/10]:\n{chunk}\n\n"
# Step 3: Answer with the optimized context
answer = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""{optimized_context}
Based on these relevant fragments, answer:
{question}"""
}],
temperature=0,
max_tokens=500
).choices[0].message.content
return answer
Troubleshooting
Problem 1: Information lost in the chunks
Symptom: A chunk's summary doesn't mention important data that was in it.
Cause: The summary prompt is too generic and the model decides what to preserve.
Solution:
def summarize_preserving_specifics(
chunk: str,
types_to_preserve: list[str] = None
) -> str:
"""Summary that guarantees preservation of specific data types."""
if types_to_preserve is None:
types_to_preserve = ["numbers and percentages", "proper nouns", "dates", "URLs or references"]
types_str = ", ".join(types_to_preserve)
return client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Summarize the following text (max 200 words).
CRITICAL: Preserve LITERALLY every: {types_str}
If you can't fit them into the summary, list them at the end under "Key data:"
Text: {chunk}"""}],
temperature=0,
max_tokens=350
).choices[0].message.content
Problem 2: Incoherence between summaries of consecutive chunks
Symptom: The final summary reads disjointed; each chunk seems unrelated to the previous one.
Solution: Pass the previous chunk's context into the next one:
def summarization_chain_with_context(text: str, max_tokens_chunk: int = 3000) -> str:
"""Summarization where each chunk knows the context of the previous one."""
chunks = chunks_by_tokens(text, max_tokens_chunk)
summaries = []
previous_context = ""
for i, chunk in enumerate(chunks):
prompt = f"""Summarize this fragment ({i+1} of {len(chunks)}).
{'Context from previous fragments: ' + previous_context[:300] if previous_context else ''}
Current fragment:
{chunk}
Produce a 150-200 word summary that is coherent with the previous context."""
summary = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=300
).choices[0].message.content
summaries.append(summary)
previous_context = summary # The current summary becomes context for the next one
return "\n\n".join(summaries)
Problem 3: Very high cost with many chunks
Symptom: A 50K-token document with 2K chunks = 25 calls just for the first level.
Solution: Use larger chunks and summarize in 2 passes if needed:
def efficient_summarization(text: str, call_budget: int = 10) -> str:
"""
Summarization that respects a budget of API calls.
Adjusts the chunk size automatically.
"""
total_tokens = count_tokens(text)
tokens_per_call = total_tokens // call_budget
tokens_per_call = max(tokens_per_call, 2000) # Minimum 2K per chunk
print(f"Text: {total_tokens:,} tokens, Budget: {call_budget} calls")
print(f"Adjusted chunk size: {tokens_per_call:,} tokens")
return summarization_chain(text, max_tokens_chunk=tokens_per_call)
Exercises
Exercise 1: Build an extractor for specific information
Write a function that uses a sliding window to extract ALL mentions of dates, amounts, and people's names from a long document. The result must be a deduplicated, sorted list.
See solution
import re
def extract_entities_sliding_window(document: str) -> dict:
"""Extracts specific entities using a sliding window."""
chunks = chunks_by_tokens(document, max_tokens_per_chunk=2000, overlap_tokens=200)
dates = set()
amounts = set()
people = set()
for i, chunk in enumerate(chunks):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"""Extract from the text:
1. Dates (format: DD/MM/YYYY or text such as "January 2026")
2. Monetary amounts (€/$) with the exact value
3. People's names
Respond in JSON: {{"dates": [], "amounts": [], "people": []}}
Text: {chunk}"""}],
temperature=0,
response_format={"type": "json_object"}
).choices[0].message.content
try:
data = json.loads(response)
dates.update(data.get("dates", []))
amounts.update(data.get("amounts", []))
people.update(data.get("people", []))
except Exception:
pass
return {
"dates": sorted(list(dates)),
"amounts": sorted(list(amounts)),
"people": sorted(list(people))
}
Exercise 2: Compare strategies
For a ~10K token document, measure and compare:
- Direct context (if it fits)
- Summarization chain (2K chunks)
- Hierarchical (2 levels)
Compare: summary quality, number of calls, tokens used.
See solution
import time
def compare_strategies(text: str, evaluation_question: str) -> dict:
"""Compares the 3 strategies on the same document."""
tokens = count_tokens(text)
results = {}
# Strategy 1: Direct (if it fits)
if tokens < 100000:
t0 = time.time()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"Summarize: {text}"}],
temperature=0, max_tokens=400
).choices[0].message.content
results["direct"] = {
"summary": resp, "time": time.time()-t0, "calls": 1,
"tokens_input": tokens
}
# Strategy 2: Summarization chain
t0 = time.time()
summary_sc = summarization_chain(text, max_tokens_chunk=2000, verbose=False)
results["summarization_chain"] = {
"summary": summary_sc, "time": time.time()-t0,
"calls": len(chunks_by_tokens(text, 2000)),
"tokens_input": tokens
}
# Compare
print(f"Original tokens: {tokens:,}")
for strategy, data in results.items():
print(f"\n{strategy}:")
print(f" Time: {data['time']:.2f}s")
print(f" Calls: {data['calls']}")
print(f" Summary: {data['summary'][:100]}...")
return results
Summary
- Direct context: Whenever the text fits (~80% of the limit to leave headroom). Best quality.
- Summarization chain: For moderately long documents (2-3x the limit). Simple and effective.
- Sliding window: For searching specific information. Preserves local detail.
- Hierarchical: For very long documents (>10x the limit). Loses some detail at each level.
- Counting tokens: Use tiktoken, not heuristic estimates.
- Lost in the Middle: Place critical information at the start or the end of the context.