Skip to content

06 · Conversation State & Memory

LLM APIs are stateless: every request stands alone, and the model "remembers" only what you put in the messages list. That makes you the memory manager. Do it naively and costs balloon (you re-pay for the whole history on every turn) until you overflow the context window; do it well and long conversations stay cheap, fast, and coherent. This module builds a conversation manager with truncation and summarization, and shows what state belongs in the system prompt versus the message list.

A minimal conversation manager

from dotenv import load_dotenv
import anthropic

load_dotenv()
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"

class Conversation:
    def __init__(self, system: str):
        self.system = system
        self.messages: list[dict] = []

    def send(self, user_text: str) -> str:
        self.messages.append({"role": "user", "content": user_text})
        response = client.messages.create(
            model=MODEL, max_tokens=1000,
            system=self.system, messages=self.messages,
        )
        reply = response.content[0].text
        # Append the assistant turn — this IS the memory
        self.messages.append({"role": "assistant", "content": reply})
        self.last_usage = response.usage
        return reply

convo = Conversation("You are a concise cooking assistant.")
print(convo.send("I have eggs, spinach, and feta. Dinner idea?"))
print(convo.send("Make it lower-carb."))          # "it" resolves because history is sent
print(convo.last_usage.input_tokens)              # grows every turn

Watch input_tokens climb turn after turn — that's the cost of naive memory. A 50-turn conversation resends turn 1 fifty times.

Strategy 1: Sliding-window truncation

Keep only the last N turns. Simple, predictable, and fine for chat where old turns stop mattering:

MAX_TURNS = 10  # keep the last 10 user+assistant pairs

def truncated(messages: list[dict]) -> list[dict]:
    if len(messages) <= MAX_TURNS * 2:
        return messages
    kept = messages[-MAX_TURNS * 2:]
    # History must start with a "user" message — drop a leading assistant turn
    while kept and kept[0]["role"] == "assistant":
        kept = kept[1:]
    return kept

Two gotchas: the trimmed history must still start with a user message, and if you're using tools (module 5), never cut between a tool_use and its tool_result — trim at whole-exchange boundaries.

The failure mode is obvious: the model abruptly forgets your name, the budget you stated, the decision from turn 3. Which leads to…

Strategy 2: Summarize old turns

Instead of dropping old history, compress it — use the model itself to write a summary, then replace those turns with it:

def summarize_turns(messages: list[dict]) -> str:
    transcript = "\n".join(
        f"{m['role']}: {m['content']}" for m in messages
        if isinstance(m["content"], str)
    )
    response = client.messages.create(
        model="claude-haiku-4-5",   # summarization is easy — use the cheap model
        max_tokens=400,
        messages=[{"role": "user", "content":
            "Summarize this conversation in under 150 words. Preserve all "
            "facts, names, numbers, preferences, and decisions:\n\n" + transcript}],
    )
    return response.content[0].text


class SummarizingConversation(Conversation):
    KEEP_RECENT = 6          # last 6 messages stay verbatim
    TRIGGER = 20             # summarize when history exceeds 20 messages

    def send(self, user_text: str) -> str:
        if len(self.messages) > self.TRIGGER:
            old, recent = self.messages[:-self.KEEP_RECENT], self.messages[-self.KEEP_RECENT:]
            summary = summarize_turns(old)
            self.messages = [
                {"role": "user", "content": f"<conversation_summary>{summary}</conversation_summary>"},
                {"role": "assistant", "content": "Understood — I have the context."},
                *recent,
            ]
        return super().send(user_text)

This is exactly what production assistants (including Claude-based coding agents) do, and hosted APIs increasingly offer it server-side as automatic "compaction." Rule of thumb: recent turns verbatim, older turns summarized, critical facts never dropped.

System-prompt state vs. message state

Two different homes for two different kinds of state:

System prompt Message list
Holds Stable facts & rules: persona, user profile, preferences, today's date The flowing dialogue: questions, answers, tool results
Changes Rarely (per session) Every turn
Survives truncation Always — it's outside the history Only if you keep/summarize it
def build_system(profile: dict) -> str:
    return f"""You are a personal assistant.

<user_profile>
Name: {profile['name']}
Diet: {profile['diet']}
Timezone: {profile['tz']}
</user_profile>

Follow the profile silently; don't recite it."""

convo = Conversation(build_system({"name": "Priya", "diet": "vegetarian", "tz": "CET"}))

Durable facts you learn mid-conversation ("I'm allergic to peanuts") deserve promotion out of the transient history — into the profile that feeds the system prompt, or into a small database/file. That's the seed of long-term memory across sessions, which the capstone implements with a notes file.

Caching caveat

Providers cache long stable prompt prefixes to cut costs (Level 2 covers this). Editing the system prompt every turn breaks that caching — keep the system prompt stable within a session and put per-turn facts in messages.

Knowing when to act: count tokens

Trigger summarization by token budget, not message count, using the free counter from module 2:

def history_tokens(system: str, messages: list[dict]) -> int:
    return client.messages.count_tokens(
        model=MODEL, system=system, messages=messages,
    ).input_tokens

if history_tokens(convo.system, convo.messages) > 8_000:
    ...  # summarize now

How It Actually Works

LLM APIs are stateless: the server holds no memory of your previous requests between calls. Every "conversation" you have is really the same thing repeated — you resend the entire message history on every single request, and the model re-reads it from scratch each time as one long token sequence, exactly like it would if you'd typed the whole transcript in one giant message. There's no persistent server-side session tied to a conversation; the "memory" lives entirely in whatever list of messages your client code maintains and resends.

This is precisely why token counting matters for state management: each resend costs you the full context length in tokens, and that length grows with every turn — a 20-turn conversation might resend thousands of tokens that were already "seen" and paid for in earlier calls just to keep context. (Prompt caching, in Level 2, is the specific optimization that avoids fully re-processing that repeated prefix on the server side.) It's also why context windows put a hard ceiling on conversation length: history plus new input plus reserved output space must all fit inside one model's fixed attention span, so summarization or truncation strategies exist purely to keep that resent history under the limit as conversations grow.

Cheat sheet

Concept Key fact
Statelessness The model sees only what's in this request's messages
Memory cost Full history is re-billed as input every turn
Sliding window Keep last N turns; must still start with user; don't split tool exchanges
Summarization Replace old turns with a model-written summary; keep recent turns verbatim
Cheap summarizer Use the fast model (claude-haiku-4-5) for summaries
System-prompt state Stable facts/rules; survives truncation; keep it stable for caching
Message state The dialogue itself; what gets trimmed/summarized
Trigger Summarize on a token budget via count_tokens, not message count

Exercise

Extend SummarizingConversation with a remember(fact: str) method that appends durable facts to a facts list which is injected into the system prompt inside <known_facts> tags. Then script a 15-turn conversation where you state your name and favorite cuisine in turn 1, call remember() for both, chat about unrelated topics for 12 turns (enough to trigger summarization), and finally ask "what's my name and what cuisine do I like?" Verify the answer survives. Re-run without remember() and a brutal KEEP_RECENT = 2 — does the summary alone preserve the facts?