Memory & Multi-step Design
The context window is finite, so memory is a design problem. Short-term strategies (windowing, summarization), long-term memory as retrieval, and using sub-agents to isolate context on multi-step tasks — keeping the agent focused, cheap, and coherent over long runs.
- Explain why a finite context window makes memory a deliberate design problem, not a free feature.
- Manage short-term memory with windowing/trimming and running summarization.
- Implement long-term memory as retrieval — store facts, fetch the relevant ones per turn (memory as RAG).
- Use sub-agents to isolate context on multi-step tasks so the main agent stays focused and cheap.
Python for JavaScript developers:
messages[-N:]— the last N elements of a list (negative slice), likearr.slice(-N).sum(len(m) for m in msgs)— a generator-expression sum, likemsgs.reduce((s,m)=>s+m.length,0).list.pop(0)— remove and return the first element, likearr.shift().- A "sub-agent" is just another agent call with its OWN fresh message list — a function that runs a task and returns a short result.
Every code block is commented line-by-line.
Why memory is a design problem
An LLM is stateless: it only knows what's in the current context window, and that window is finite (and billed per token). Every turn you re-send the whole conversation. So on any non-trivial run you hit three walls:
- The window fills up. Long conversations or long tool outputs overflow the context limit.
- Cost and latency grow with history. Re-sending 50 turns every call is expensive and slow.
- Signal gets diluted. A wall of stale history buries the few facts that actually matter ("lost in the middle").
So "memory" isn't a feature you turn on — it's how you decide what the model sees each turn. Two horizons: short-term (this conversation) and long-term (facts that outlive it).
Short-term memory: windowing and trimming
The simplest strategy: keep only the most recent messages that fit a budget, drop the oldest. Always keep the system message and enough recent turns for coherence.
def trim_history(messages: list, max_tokens: int = 3000) -> list:
system = messages[0] # always keep the system prompt (index 0)
rest = messages[1:] # the conversational turns
# Drop oldest turns until the remaining ones fit the token budget:
while sum(count_tokens(m["content"]) for m in rest) > max_tokens:
rest.pop(0) # remove the OLDEST turn (like arr.shift())
return [system] + rest # system + the recent window that fits
Cheap and predictable — but it forgets anything that scrolled out of the window. That's fine for a customer-support chat, fatal for "earlier you told me my account id".
↳ Exercise 1 has you implement token-budget trimming and confirm a fact stated 30 turns ago is gone once it scrolls out — motivating the next strategy.
Short-term memory: summarization
Instead of dropping old turns, compress them. When history gets long, summarize the older portion into a compact running summary and keep only that plus the recent turns:
def summarize_memory(messages: list, keep_recent: int = 6) -> list:
system = messages[0]
old, recent = messages[1:-keep_recent], messages[-keep_recent:] # split: old to compress, recent to keep verbatim
if not old: # nothing old enough to summarize yet
return messages
# Ask the model to distill the old turns into durable facts/decisions:
summary = llm(f"Summarize the key facts and decisions from this conversation:\n{format(old)}")
summary_msg = {"role": "system", "content": f"Summary of earlier conversation: {summary}"}
return [system, summary_msg] + recent # system + running summary + verbatim recent turns
This preserves the gist of a long conversation at a fixed token cost, at the price of an extra LLM call and some lossiness. Most production chat agents use a hybrid: summary of old + verbatim recent window.
↳ Exercise 2 has you add summarization and verify the same 30-turns-ago fact survives — because it was folded into the summary instead of dropped.
Long-term memory: retrieval
Some facts must outlive the conversation entirely — user preferences, past decisions, profile data. You don't keep those in the message list at all; you store them externally and retrieve the relevant ones each turn. This is exactly RAG (Track 3), pointed at memories instead of documents:
# WRITE: when something worth remembering happens, embed and store it.
def remember(fact: str, user_id: str):
memory_store.add(embed(fact), text=fact, user_id=user_id) # a vector store keyed by user
# READ: at each turn, retrieve the few memories relevant to the current message and inject them.
def recall(query: str, user_id: str, k: int = 3) -> list[str]:
return memory_store.search(embed(query), user_id=user_id, k=k) # top-k relevant memories for THIS user
def build_context(user_message: str, user_id: str) -> list:
memories = recall(user_message, user_id) # e.g. ["prefers metric units", "based in Paris"]
system = {"role": "system",
"content": "Relevant memory:\n" + "\n".join(memories)} # inject only what's relevant NOW
return [system, {"role": "user", "content": user_message}]
Key insight: you retrieve only the memories relevant to the current turn, so long-term memory scales to thousands of facts without bloating context — the model sees the 3 that matter, not all 3,000. (Libraries like LangGraph's store, Mem0, and Letta package this.)
↳ Exercise 3 has you store a handful of user facts, then retrieve only the relevant ones for a new question — memory as RAG, scaling past what any window could hold.
Multi-step design: sub-agents for context isolation
On a big multi-step task, stuffing every step's tool output into one agent's context poisons it — irrelevant detail piles up, cost climbs, focus drifts. The fix is context isolation via sub-agents: delegate a subtask to a separate agent with its own fresh context, and return only the distilled result to the main agent.
def research_subagent(subtopic: str) -> str:
# A SEPARATE agent with its OWN clean message list — its intermediate steps never touch the main context.
messages = [{"role": "system", "content": "Research the subtopic; return a 3-line summary only."},
{"role": "user", "content": subtopic}]
return run_agent(messages) # does its own tool calls internally…
# …and returns ONLY the summary — the caller never sees its 20 intermediate tool observations.
def orchestrator(task: str) -> str:
subtopics = plan(task) # break the task into independent pieces
# Each sub-agent works in isolation; the main agent only collects their compact results:
findings = [research_subagent(st) for st in subtopics] # (run concurrently in practice)
return synthesize(task, findings) # combine the distilled outputs into the final answer
The main agent's context stays small and on-task; each sub-agent's messy intermediate work is thrown away after it returns its summary. This is how deep-research and multi-step agents stay coherent — and it's the supervisor/worker shape the next topic (Multi-agent patterns) formalizes. It also connects back to LangGraph: sub-agents are often nodes with their own scoped state.
↳ Exercise 4 has you split a task across two sub-agents that each return a short summary, and confirm the orchestrator's context never sees their intermediate tool calls — only the distilled results.
Choosing a strategy
- Windowing — simple chats where old context is safely disposable.
- Summarization — long single conversations where the gist must persist at bounded cost.
- Retrieval (long-term) — facts that outlive the conversation or exceed any window (preferences, history).
- Sub-agent isolation — multi-step tasks where per-step detail would poison one shared context.
They compose: a production assistant often runs a summarized short-term window + retrieved long-term memories + sub-agents for heavy subtasks.
Pitfalls
- Unbounded history. Re-sending the full transcript every turn overflows the window and inflates cost — always trim or summarize.
- Dropping facts you needed. Pure windowing forgets; use summarization or retrieval for anything that must persist.
- Storing everything long-term. Remember selectively — a vector store of every message is noisy; store durable facts/decisions.
- One giant context for a multi-step task. Isolate subtasks in sub-agents and return summaries, or the main context degrades.
- Never forgetting (privacy). Long-term memory holds user data — support deletion and scope it per user (Security/Governance track).
Recap
- The context window is finite and billed, so memory is about deciding what the model sees each turn.
- Short-term: window/trim (simple, forgets) or summarize (compresses the gist at bounded cost); hybrids are common.
- Long-term: store facts externally and retrieve the relevant few per turn — memory as RAG, scaling past any window.
- Multi-step: use sub-agents to isolate context, returning only distilled results so the main agent stays focused and cheap.
- Strategies compose; pick per need and mind privacy/deletion for stored memory.
- Worked examples map to the exercises: trimming (Ex 1), summarization (Ex 2), retrieval memory (Ex 3), sub-agent isolation (Ex 4).
Memory & Multi-step Design — Exercises
Reuse your chat client and (for Ex 3) the embedding/vector-store pieces from the RAG track. A rough
count_tokens is fine — len(text.split()) approximates well enough to feel the mechanics.
Exercise 1 — Token-budget trimming
Implement trim_history(messages, max_tokens) that keeps the system message and drops the oldest turns until
the rest fit the budget. Run a 30-turn conversation, state a fact early ("my account id is 7788"), and confirm
it's gone from the trimmed context by the end.
Reference solution
def count_tokens(t): return len(t.split()) # rough proxy
def trim_history(messages, max_tokens=200):
system, rest = messages[0], messages[1:]
while sum(count_tokens(m["content"]) for m in rest) > max_tokens:
rest.pop(0) # drop the oldest turn
return [system] + rest
# After many turns, the early "account id is 7788" message has been popped:
trimmed = trim_history(long_conversation)
assert not any("7788" in m["content"] for m in trimmed) # the fact scrolled out and is forgotten
Trimming is cheap and predictable but forgets: anything outside the recent window is gone. That's acceptable for disposable chat context and fatal for facts you needed later — which motivates summarization and retrieval.
Exercise 2 — Summarization memory
Replace trimming with summarization: compress everything older than the last 6 turns into a running summary message, keep the recent 6 verbatim. Confirm the early "account id 7788" survives via the summary.
Reference solution
def summarize_memory(messages, keep_recent=6):
system, old, recent = messages[0], messages[1:-keep_recent], messages[-keep_recent:]
if not old:
return messages
summary = llm("Summarize the key facts and decisions:\n" +
"\n".join(m["content"] for m in old))
return [system, {"role":"system","content":f"Summary: {summary}"}] + recent
compact = summarize_memory(long_conversation)
# The summary should mention the account id even though the original turn was compressed away:
assert any("7788" in m["content"] for m in compact)
Summarization keeps the gist at a fixed token cost — the fact survives folded into the summary instead of being dropped. Cost: an extra LLM call and some lossiness. Hybrid (summary of old + verbatim recent) is the common production choice.
Exercise 3 — Long-term memory as retrieval
Store 5–6 user facts in a vector store ("prefers metric units", "based in Paris", "account id 7788", …). For a new question ("what's the weather?"), retrieve only the relevant memories and confirm irrelevant ones (account id) are NOT pulled in.
Reference solution
facts = ["prefers metric units", "based in Paris", "account id 7788",
"signed up in 2024", "dislikes long emails"]
for f in facts:
memory_store.add(embed(f), text=f)
def recall(query, k=2):
return memory_store.search(embed(query), k=k)
print(recall("what's the weather like?")) # -> ['based in Paris', 'prefers metric units'] — NOT the account id
Only the memories semantically relevant to this turn are injected, so long-term memory scales to thousands of facts without bloating context — the model sees the 2 that matter, not all 6. That's RAG pointed at memories.
Exercise 4 — Sub-agent context isolation
Write an orchestrator that splits "compare the pricing and security of two competitors" into subtasks, hands each to a sub-agent that returns a 3-line summary, and synthesizes. Confirm the orchestrator's message list never contains the sub-agents' intermediate tool calls.
Reference solution
def subagent(subtask):
msgs = [{"role":"system","content":"Research this; return a 3-line summary ONLY."},
{"role":"user","content":subtask}]
return run_agent(msgs) # its own fresh context; does tool calls internally
def orchestrator(task):
subtasks = ["competitor A pricing & security", "competitor B pricing & security"]
findings = [subagent(st) for st in subtasks] # each returns only a short summary
return synthesize(task, findings)
# The orchestrator only ever holds `findings` (short summaries) — never the sub-agents' 20 tool observations.
Each sub-agent's messy intermediate work is discarded after it returns its summary, so the main context stays small and on-task. This is how multi-step/deep-research agents stay coherent — and it's the supervisor/worker shape the Multi-agent Patterns topic formalizes next.