Skip to content
beginner Phase 11 · Memory and Conversation State

Managing Conversation History

Store turns, cap history length, and decide what the model sees on each request.

45m
0 problems
Topic Progress 0%

The Turn You Cannot Replay

A bug report lands: the assistant answered with a flight date the user never mentioned in this session. You open the service logs and find the latest user string and nothing else — the full messages array that was actually sent was never written anywhere, so the bug cannot be reproduced.

A turn is the unit of that record. Persist one row per message rather than one row per exchange, because replay has to rebuild the array exactly:

from dataclasses import dataclass, field
import time, tiktoken

enc = tiktoken.encoding_for_model("gpt-4o-mini")

@dataclass
class Turn:
    conversation_id: str
    seq: int                       # monotonic within a conversation
    role: str                      # system | user | assistant
    content: str
    created_at: float = field(default_factory=time.time)
    tokens: int = 0                # filled at write time

    def __post_init__(self):
        self.tokens = len(enc.encode(self.content))

def append_turn(conversation_id, turns, role, content):
    turns.append(Turn(conversation_id, len(turns), role, content))

Input: append_turn("c-9f2", [], "user", "Move my standup from the 14th to the 21st")

Output: Turn(conversation_id='c-9f2', seq=0, role='user', content='Move my standup from the 14th to the 21st', created_at=1750000000.31, tokens=13)

Each column earns its place. seq restores order when two workers write concurrently. created_at answers "what did the model see at 10:04?" without parsing log timestamps. tokens is computed once at write time, so trimming later counts integers instead of re-encoding the whole log. Role and content stay in separate fields because the resend step needs the array shape — Chat Messages and Roles covers that contract.

Storing the whole request body as one blob is the tempting shortcut: one insert per exchange instead of one per message. It defeats per-turn token accounting, partial replay, and the ownership filter from the next chapter, all of which need to address a single turn.

Replay is then a query, not a feature: SELECT seq, role, content FROM turns WHERE conversation_id = %s ORDER BY seq returns the array in the only order that matters — the order it was sent.

Trimming to a Token Budget

History grows monotonically and the context window does not, so every request needs a declared rule for what gets sent. "Send everything" works until turn 40, and its failure mode is a provider 400 rather than a visible bug in your code.

Four retention policies, each trading one failure for another:

Policy Sends Cost What it loses
Full log Everything Tokens grow linearly, then a hard 400 Nothing until it fails
Sliding window, last N turns System + newest turns Constant tokens Decisions made early in the session
Token-budgeted window System + newest turns that fit Constant tokens, exact count Same as sliding, minus size surprises
Window + head summary System + summary + newest turns One summarization call per trim Exact wording of old turns

A token-budgeted window is the sliding policy with an honest counter:

def window(history, budget=1500):
    system = [t for t in history if t.role == "system"]   # pinned, never enters the loop
    body   = [t for t in history if t.role != "system"]

    used, kept = sum(t.tokens for t in system), []
    for t in reversed(body):                              # newest first
        if used + t.tokens > budget:
            break
        kept.insert(0, t)
        used += t.tokens

    while kept and kept[0].role != "user":                # strict endpoints reject an assistant-first array
        kept.pop(0)
    return system + kept

Input: 6 turns, system prompt 40 tokens, turn counts [310, 295, 402, 388, 517, 64], budget 1500.

Output: system prompt plus the 4 newest turns (seq 2–5): 1,411 tokens sent, 89 left under budget.

Walking it: the loop pulls seq 5 (64), 4 (517), 3 (388) and 2 (402) — 1,371 body tokens, 1,411 with the 40-token system prompt. The next candidate, 295, would reach 1,706 and blow the 1,500 budget, so seq 0 and 1 are dropped. Two lines carry the policy. reversed(body) declares that the oldest turns are sacrificed first, which keeps the current question alive; and the system entry is filtered out before the loop, so standing instructions survive every trim. The final while removes an orphaned assistant turn so strict endpoints such as Anthropic's do not answer with 400: roles must alternate.

How much history the budget may claim, and where the reserve comes from, is Context Window Budgeting. When dropping is the wrong answer — the session holds a decision the user made at turn 3 — the escape hatch is Summarizing Long Conversations.

Who Owns Which Conversation

A single-user demo stores history under one key. Production stores thousands of conversations under keys built from two identifiers, and its interesting failures are authorization failures, not storage failures.

The key is user_id : conversation_id, and both parts belong inside the query rather than in a handler that a future endpoint might skip. conversation_id alone lets a client holding a guessed or leaked id read someone else's turns; user_id alone collapses a user's unrelated conversations into one stream.

def load_history(pool, user_id, conversation_id, limit=200):
    with pool.cursor() as cur:
        cur.execute(
            """SELECT seq, role, content, tokens FROM turns
               WHERE user_id = %s AND conversation_id = %s
               ORDER BY seq DESC LIMIT %s""",
            (user_id, conversation_id, limit),
        )
        rows = cur.fetchall()      # both predicates are in WHERE: no check to forget
    return list(reversed(rows))

Input: load_history(pool, "u-17", "c-9f2") where conversation c-9f2 belongs to u-44.

Output: [] — the row exists, but the user_id predicate excludes it, so the caller sees an empty conversation instead of another user's turns.

Failure modes to design against:

  • Enumerable ids — sequential conversation_id values are guessable; use unguessable ids and filter by owner, because either one alone is a weak wall.
  • Check in the handler, not the query — a new route reusing the loader without re-adding the predicate inherits the hole silently.
  • Two writers, one conversation — two tabs appending to the same key interleave seq values and corrupt replay; Session Persistence Patterns covers versioned writes for exactly this case.
  • Unbounded reads — without LIMIT, a 5,000-turn conversation ships its entire log on every request; the replay window is a cost decision as much as a correctness one.
  • No purge policy — rows outlive the session. Pick a retention window deliberately and enforce it with a scheduled delete, not with hope.

Where the log itself lives is a storage decision covered in Choosing a Vector Store for turn retrieval setups. What gets sent back to the model on the next turn is the trimming decision above; whether the dropped turns are compressed instead is Summarizing Long Conversations.

Quiz

1. Why compute the token count of a turn at write time instead of when the request is assembled?

Question 1 options

2. A token-budgeted window drops the two oldest turns, leaving the array starting with an assistant message. What happens next?

Question 2 options

3. Which query construction prevents one user from reading another user's conversation?

Question 3 options

4. Your service returns 400 context_length_exceeded at turn 42 although local logging shows the array as small. Which gap explains it?

Question 4 options

Flashcards

Question

Which fields belong in a persisted conversation turn?

Answer

conversation id, monotonically increasing seq, role, content, created_at, and a token count computed at write time. Seq rebuilds order, timestamps answer "what did the model see then", and the stored count makes trimming cheap.

Question

What are the four retention policies for history that outgrows the window, and what does each lose?

Answer

Full log (loses nothing until the provider 400s), sliding window of last N turns (loses early decisions), token-budgeted window (same loss, exact count), window plus head summary (loses exact wording, costs one summarization call).

Question

In what order does the token-budgeted window trim, and what is never trimmed?

Answer

Oldest turns first (iterate newest-to-oldest and stop at the budget), then drop any leading orphaned assistant turn so the array opens on a user turn. The system prompt is filtered out before the loop and never trimmed.

Question

Why key a session by both user_id and conversation_id?

Answer

conversation_id alone exposes another user's history to anyone holding the id; user_id alone merges a user's separate conversations. Both predicates belong inside the read query so no handler can forget the ownership check.

Question

When does history need summarizing instead of dropping?

Answer

When the session holds decisions, constraints, or preferences established early that later turns depend on. Dropping them loses instructions the model must still follow; summarizing keeps the fact and loses only the wording.

Revision Notes

Key Takeaways

  • 1. Persist one row per message with seq, role, content, timestamp, and a write-time token count so replay is a query
  • 2. Apply the retention policy at send time — storing everything and sending everything are different decisions
  • 3. Trim oldest-first with the system prompt pinned, then drop orphaned assistant turns so strict endpoints accept the array
  • 4. Scope reads with user_id and conversation_id together, inside the query
  • 5. Summarize instead of drop when early turns carry decisions the rest of the session still depends on

Interview Tips

  • • Draw the request path — store, trim, send — and name where each failure (400, role error, data leak) is caught
  • • Defend your retention policy out loud: what is sacrificed first, and why the system prompt is exempt
  • • Say why the ownership predicate lives in SQL rather than the handler; interviewers probe for the skipped-check bug
  • • Quantify the cost: turn 50 pays for turns 1–49 as input tokens, so history size, not answer length, drives the bill in long chats

Cheat Sheet

Conversation History Cheat Sheet

Turn record

@dataclass
class Turn:
    conversation_id: str
    seq: int            # monotonic per conversation -> replay order
    role: str           # system | user | assistant
    content: str
    created_at: float   # "what did the model see at 10:04?"
    tokens: int         # computed once at write time

Send path (token-budgeted window)

system = [t for t in history if t.role == "system"]   # pinned
body   = [t for t in history if t.role != "system"]
used, kept = sum(t.tokens for t in system), []
for t in reversed(body):                              # oldest sacrificed first
    if used + t.tokens > budget: break
    kept.insert(0, t); used += t.tokens
while kept and kept[0].role != "user": kept.pop(0)    # strict endpoints
return system + kept

Policy comparison

Policy Loses
Full log Nothing, until a 400 at turn ~40
Sliding last-N Early decisions
Token budget Early decisions, exact count
Window + summary Exact wording, one call per trim

Ownership: key = user_id:conversation_id, both predicates inside WHERE, unguessable ids, LIMIT on replay.

Failure modes: no send-side trimming; assistant-first arrays after trim; ownership checked in handlers; unbounded replay reads; no purge policy for old rows.

Next step: compressed heads → Summarizing Long Conversations; write conflicts → Session Persistence Patterns.

Continue Learning

Before You Start

Related Reading