Managing Context
LLMs are stateless. Context is your responsibility — you send it, or it doesn't exist.
The central challenge
Every time you call an LLM, it starts from scratch. There is no server-side session, no implicit memory. The model only sees the messages array you send in that request — nothing more.
This has profound implications for cost, latency, and architecture. This lesson walks through five strategies, each solving a problem the previous one creates:
| # | Strategy | Problem it solves | New problem it creates |
|---|---|---|---|
| 1 | Stateless | Simplest possible call | No memory at all |
| 2 | Full history | Model remembers everything | Quadratic cost growth |
| 3 | Sliding window | Bounded cost | Forgets old context |
| 4 | Summarization | Preserves old context cheaply | Lossy compression |
| 5 | Long-term memory | Persists across sessions | Needs user identity + storage |
Each call is independent
The Chat Completions API is stateless by design. Two separate calls share nothing:
# Turn 1: tell the model your name
r1 = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[{"role": "user", "content": "My name is Alice."}],
)
# Turn 1: "Nice to meet you, Alice!"
# Turn 2: ask for your name — but we DON'T send turn 1
r2 = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[{"role": "user", "content": "What is my name?"}],
)
# Turn 2: "I don't have access to your name."
The model can't answer because it literally never saw the first message.
Single-shot tasks: translation, classification, extraction — anything where each request is self-contained. Cost per call is constant and predictable.
Provider-managed state: the Responses API
OpenAI's Responses API can keep conversation state server-side. Chain turns using previous_response_id:
resp1 = client.responses.create(model="gpt-4.1-mini", input="My name is Alice.")
resp2 = client.responses.create(
model="gpt-4.1-mini",
input="What is my name?",
previous_response_id=resp1.id, # server remembers turn 1
)
# "Your name is Alice."
Convenient, but it's a black box — you don't control what's kept or trimmed. For the rest of this lesson, we'll manage context ourselves.
How ChatGPT works under the hood
The fix is simple: keep a messages list and append every exchange. On each turn, send the whole thing.
history = []
# Turn 1
history.append({"role": "user", "content": "My name is Alice."})
r1 = client.chat.completions.create(model=MODEL, messages=history)
history.append({"role": "assistant", "content": r1.choices[0].message.content})
# Turn 2 — the model now sees BOTH messages
history.append({"role": "user", "content": "What is my name?"})
r2 = client.chat.completions.create(model=MODEL, messages=history)
# "Your name is Alice."
It works — but on turn 50, you send 50 turns of history. On turn 500, you send 500 turns.
The quadratic problem
If each turn adds ~300 tokens, the input tokens on turn N are roughly 300N. The cumulative input tokens across all turns grow as ~150N².
| Turn | Prompt tokens | Cumulative input |
|---|---|---|
| 1 | 18 | 18 |
| 4 | 148 | 329 |
| 8 | 471 | 1,723 |
| 12 | 720 | 4,337 |
The cost math
At gpt-4.1-mini pricing ($0.40/M input tokens), a 10-turn conversation is pennies. Scale to 500 turns and you're at ~$15 cumulative input cost for a single conversation.
Latency grows too: more input tokens = more processing time. By turn 100+, users may wait 5–10 seconds per response.
Bounded cost, lossy context
The simplest fix: only send the last N turns. Cost is now bounded regardless of conversation length.
WINDOW_TURNS = 2
window = full_history[-2 * WINDOW_TURNS :] # last 2 exchanges
# Ask something that requires early context
messages = window + [{"role": "user", "content": "What is my name?"}]
r = client.chat.completions.create(model=MODEL, messages=messages)
# "I don't have that information." ← name was in turn 1, outside the window
Cost is bounded, but we've lost important context. Alice's name and project details were in turns 1–5, outside the window. We need a way to compress old context, not just drop it.
What is a "turn"?
A turn is one user message plus all subsequent assistant and tool activity until the next user input. When trimming, always cut at turn boundaries — never split a user message from its response.
Turn 1: user("Hi") → assistant("Hello!")
Turn 2: user("Look up X") → tool_call("search") → tool_result(...) → assistant("Here's X")
Turn 3: user("Thanks") → assistant("You're welcome!")
↑
With window=2, keep turns 2-3. Turn 1 is dropped entirely.
Compress old context, don't drop it
Instead of a separate summarization step, ask the model to do both in one call:
- Answer the user's question
- Update a running memory summary
Each turn sends: [system prompt with current memory] + [user message]. The model returns both an answer and updated memory as structured JSON.
PROMPT_TEMPLATE = Template("""You are a helpful assistant.
CURRENT MEMORY:
{{ memory }}
USER MESSAGE:
{{ user_message }}
Return JSON: {"answer": "...",
"updated_memory": {"facts": [...], "preferences": [...], "goals": [...]}}""")
# Each turn: render template → call API → parse JSON → update memory
memory_text = json.dumps(memory)
prompt = PROMPT_TEMPLATE.render(memory=memory_text, user_message=user_message)
r = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
parsed = json.loads(r.choices[0].message.content)
answer = parsed["answer"]
memory = parsed["updated_memory"] # carry forward to next turn
How it works
[SYSTEM] You are a helpful assistant.
CURRENT MEMORY: {"facts": ["Name: Alice", "Location: Trento"], ...}
... return {"answer": "...", "updated_memory": {...}} ...
[USER] What is my name and what do I do? ← only the current turn
One API call per turn. Bounded tokens. No growing history. The memory acts as lossy compression of the entire conversation.
Memory stays bounded
Running an 8-turn conversation with memory, prompt tokens stay roughly flat:
| Turn | Prompt tokens | Memory size (chars) |
|---|---|---|
| 1 | 140 | 133 |
| 3 | 190 | 283 |
| 5 | 198 | 283 |
| 8 | 204 | 283 |
Compare this to section 2 where tokens grew linearly per turn and quadratically in total.
Two complementary approaches
The OpenAI Agents SDK formalizes two short-term memory strategies that can be combined:
Context Trimming
Drop older turns wholesale. Deterministic, zero latency, preserves recent context verbatim. But causes abrupt context loss.
Best for: independent task sequences, operational automation, local-context workflows.
Context Summarization
When turns exceed a limit, compress everything before the last N turns into a synthetic message pair. The old history is replaced with a summary injected as a fake user→assistant exchange.
Best for: multi-issue support, planning, RAG-heavy workflows, policy Q&A.
Synthetic message injection
When summarizing, the old conversation prefix is replaced with a synthetic pair:
[Synthetic user]: "Summarize the conversation so far."
[Synthetic assistant]: "{structured summary of turns 1-7}"
[Real turn 8]: user("What metrics should I use?")
[Real turn 9]: user("Remind me: what's my project?")
↑
Last N turns remain verbatim; older turns are compressed into the summary.
Summary prompt design tips
- Structured sections: organize by topic (facts, decisions, tools, status) not chronologically
- Max length: cap at ~200 words to keep the compression useful
- Contradiction checking: resolve conflicts between what the user said vs. what the system found
- Uncertainty marking: flag uncertain facts as "UNVERIFIED" rather than guessing
- Tool insights: capture which operations worked/failed and why
Persisting across sessions
Everything above is short-term memory — it dies when the conversation ends. Real applications need to remember users across conversations.
The extract → save → load → inject pattern
1. End of session: extract
Ask the LLM to pull out structured facts worth remembering: facts, preferences, decisions, expertise — not ephemeral details.
2. Save to storage
Persist those facts to disk or a database, keyed by user ID.
3. Start of next session: load & inject
Load the user's facts and inject them into the system prompt. The model "remembers" a user it never actually spoke to.
# End of session 1: extract facts
extracted = extract_long_term_facts(session1_conversation)
save_user_memory("marco", extracted)
# {"facts": ["PhD student in Trento", "Studies GNNs", ...],
# "preferences": ["short, direct explanations"]}
# Start of session 2: inject into system prompt
loaded = load_user_memory("marco")
messages = [
{"role": "system", "content": f"""You are a helpful assistant.
LONG-TERM MEMORY (from previous sessions):
Facts: {loaded['facts']}
Preferences: {loaded['preferences']}
Use this memory naturally."""},
{"role": "user", "content": "What do you know about me?"},
]
# "You're Marco, a PhD student in Trento studying GNNs for drug discovery..."
This is the pattern behind ChatGPT's "Memory" feature. It requires user identity — you need to know whose facts to load.
Who does what?
| Product | Short-term memory | Long-term memory |
|---|---|---|
| ChatGPT | Conversation history | "Memory" feature (extracted facts) |
| Claude | Conversation context | Project Knowledge (uploaded docs) |
| GitHub Copilot | Current file + open tabs | Repository patterns, coding style |
| Customer service bot | Current ticket | Customer history, past issues |
Long-term memory means storing personal data. In production: clear data policies, user controls (view/delete), secure storage, GDPR compliance. A JSON file is fine for learning, not for production.
When to use which strategy
| Strategy | Cost | Context quality | Best for |
|---|---|---|---|
| Stateless | Constant | None | Single-shot tasks (translate, classify, extract) |
| Full history | Quadratic | Perfect | Short conversations (<20 turns), debugging |
| Sliding window | Bounded | Recent only | Independent task sequences, automation |
| Window + summary | Bounded | Good (lossy) | Long conversations, support, planning |
| Long-term memory | Bounded + storage | Cross-session | Personalized assistants, returning users |
Key design questions
- How long are your conversations? — 5 turns? Full history is fine. 100+ turns? You need windowing or summarization.
- Does old context matter? — If each turn is independent (e.g., code completion), a small window suffices. If users reference early decisions, you need summarization.
- Do users return? — One-off interactions don't need long-term memory. Returning users benefit enormously from it.
- What should memory forget? — Not all information deserves to be remembered. Selective memory (project facts vs. chitchat) is often better than total recall.
Further reading: OpenAI Agents SDK Session Memory
The OpenAI Agents SDK cookbook formalizes trimming and summarization as TrimmingSession and SummarizingSession classes. Key patterns:
- Turn boundary detection: scan backward from the end to find the Nth user message; never split a turn
- Async lock discipline: release the lock during slow summarization calls, recheck conditions after
- Metadata separation: keep observability data (timestamps, synthetic flags) out of what the model sees
- Eval strategy: transcript replay, LLM-as-judge on summary quality, entity matching on subsequent turns
Key points
- LLMs are stateless. Context is your responsibility — you send it, or it doesn't exist.
- Full history doesn't scale. Cost and latency grow quadratically with conversation length.
- Memory is lossy compression. You choose what to keep and what to lose. The summarizer decides what matters.
- Short-term vs long-term. Short-term resets with the conversation; long-term persists across sessions and requires user identity.
- Design for your constraints. Window size, summary format, persistence strategy — all tradeoffs with no universal right answer.
Lab
Open lesson4_managing_context.py to walk through each strategy with live API calls. Exercises in lesson4_exercises.py (solutions in lesson4_solutions.py).
CLI scripts for the full interactive experience:
uv run python labs/03_context/1_stateless_agent.py
uv run python labs/03_context/2_stateful_agent.py
uv run python labs/03_context/3_agent_with_memory.py
uv run python labs/03_context/4_agent_with_long_term_memory.py