Multiturn Chat & Conversation State

Data Engineering & Processing beginner Building with AI 12 min read

Overview

Large language models are stateless by design — each API call is processed independently with no inherent memory of previous interactions. Yet users expect chatbots and AI assistants to remember what was said earlier in a conversation: references like “the hotel I mentioned earlier” or “change the second option” are meaningless without context from prior turns. Managing conversation state is the engineering discipline of maintaining this continuity.

This challenge sits at the intersection of data engineering and context engineering. Every turn of conversation consumes tokens from the model’s finite context window. A 10-turn conversation can easily accumulate 5,000–15,000 tokens of history, competing for space with system prompts, retrieved documents, and the model’s response. Research shows that LLMs exhibit an average 39% performance drop in multi-turn conversations compared to single-turn interactions, making effective state management not just a convenience but a quality imperative.

For AI engineers building production chat systems, understanding conversation state management patterns is essential. The choice between full-history replay, sliding windows, hierarchical summarization, and semantic retrieval from conversation history has direct impact on response quality, latency, cost, and the user’s perception of the system’s intelligence.

How It Works

The Stateless API Model

LLM APIs like OpenAI’s Chat Completions and Anthropic’s Messages API accept a messages array. Each message has a role (system, user, assistant) and content. The model sees only what’s in this array — it has no server-side session memory. To create the illusion of a continuous conversation, you must include all relevant prior messages in every request.

# Basic multi-turn chat with OpenAI
messages = [
    {"role": "system", "content": "You are a hotel booking assistant."},
    {"role": "user", "content": "I'm looking for a hotel in Paris."},
    {"role": "assistant", "content": "I'd be happy to help! What dates..."},
    {"role": "user", "content": "Next weekend, and I need a pool."},
    # The model now has context from all prior turns
]
response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages
)
Key Concept

LLMs are stateless — they have no built-in memory between API calls. Conversation continuity is an illusion created by replaying the full (or curated) message history with every request. The engineering challenge is deciding what to include and how to compress it.

Strategy 1: Full History Replay

The simplest approach: append every user and assistant message to the messages array and send the entire history with each request. This works well for short conversations (under ~10 turns) but breaks down as conversations grow longer.

Strategy 2: Sliding Window

Keep only the most recent N messages (or N tokens), discarding older turns as new ones arrive. This is a FIFO (first-in, first-out) approach that prioritizes recency.

# Sliding window: keep last 10 messages
MAX_HISTORY = 10

def add_message(messages, new_message):
    messages.append(new_message)
    # Always keep system message + last N messages
    system = [m for m in messages if m["role"] == "system"]
    history = [m for m in messages if m["role"] != "system"]
    return system + history[-MAX_HISTORY:]

Strategy 3: Hierarchical Summarization

Periodically compress older conversation turns into a summary while keeping recent turns verbatim. This balances completeness with efficiency — critical facts survive compression, while recent context remains detailed.

# Hierarchical summarization pattern
def manage_context(messages, max_tokens=4000):
    total_tokens = count_tokens(messages)
    if total_tokens <= max_tokens:
        return messages

    # Split: keep system + last 6 messages verbatim
    system_msg = messages[0]
    recent = messages[-6:]
    older = messages[1:-6]

    # Summarize older messages with a cheaper model
    summary = summarize_conversation(older)

    return [
        system_msg,
        {"role": "system", "content": f"Conversation summary: {summary}"},
        *recent
    ]
Implementation Pattern

A common production pattern uses a background sidecar: a smaller, cheaper LLM watches the conversation in the background and continuously updates a running summary in the system prompt. This avoids the latency hit of on-demand summarization.

Strategy 4: Semantic Retrieval from History

Embed each conversation turn into a vector store indexed by thread_id. When processing a new user message, embed the message and retrieve the most semantically relevant prior turns — not just the most recent ones. This ensures that topically relevant context resurfaces even from much earlier in the conversation.

Strategy 5: Hybrid Approach

Production systems often combine multiple strategies. A common hybrid architecture uses:

  1. Buffer memory: The last 4–6 messages kept verbatim for immediate context.
  2. Summary memory: A running summary of the entire conversation, updated every N turns.
  3. Vector memory: All turns embedded in a vector store, with the top-K most relevant retrieved per query.

This provides recent detail, long-term continuity, and topical relevance — all within a bounded token budget.

Context-Aware Embeddings for Retrieval

When embedding user queries in a multi-turn context, the raw query text alone is often insufficient. “What about the second one?” is meaningless without knowing what “the second one” refers to. Before embedding for retrieval, the system should resolve references by prepending relevant context: “What about the second one?” becomes “What about the Marriott hotel option mentioned in the Paris hotel search?” This contextualization dramatically improves retrieval quality in RAG systems operating within multi-turn conversations.

Practical Applications

Customer Support Chatbots

A customer contacts support about a billing issue, provides their account number, describes the problem, and then asks a follow-up question. The chatbot needs to retain the account number and problem description across all turns. In practice, critical structured data (account IDs, order numbers) is often extracted into a separate state object rather than relying solely on conversation history, ensuring it survives any summarization or windowing.

AI Assistants with Tool Use

When an assistant calls tools (database lookups, API calls, calculations), the tool results become part of the conversation history. A multi-step workflow might involve searching for hotels, filtering by price, then booking — each step’s results feed into the next. Managing this state efficiently means tracking which tool results are still relevant and which can be summarized or dropped.

RAG with Conversational Context

In a RAG system, each user query triggers a retrieval step. But the query “tell me more about that” requires understanding what “that” refers to from prior turns. Effective multi-turn RAG systems rewrite the query using conversation context before embedding it for retrieval. This query rewriting step — often done by the LLM itself — is critical for retrieval accuracy in conversational settings.

Session Management at Scale

Production systems serving thousands of concurrent users need to store and retrieve conversation state efficiently. Common patterns include storing conversation history in Redis (for speed), PostgreSQL (for durability), or a combination. Each conversation is identified by a session_id or thread_id, with configurable TTLs (time-to-live) for automatic cleanup of stale sessions.

Key Takeaways

Further Reading