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_idvalues 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
seqvalues 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?
2. A token-budgeted window drops the two oldest turns, leaving the array starting with an assistant message. What happens next?
3. Which query construction prevents one user from reading another user's conversation?
4. Your service returns 400 context_length_exceeded at turn 42 although local logging shows the array as small. Which gap explains it?
Flashcards
Question
Which fields belong in a persisted conversation turn?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.