Conversational AI › Technical Architecture
Language AI HandbookAll chapters Conversational AI › Technical ArchitectureOne section · Sections in this chapter5 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let me walk through the concrete technical components of modern conversational AI systems, moving from message formatting through context management to the more complex patterns that handle longer interactions.
Message Formatting and Role Tags
Modern LLMs trained for dialogue use structured message formats. The OpenAI chat format uses a messages array where each message has a role (system, user, or assistant) and content. Different models use different template formats, but the underlying principle is the same: mark up the conversation so the model knows who said what. Under the hood, these role-tagged messages are concatenated into a single string using a chat template specific to the model, with special tokens marking role boundaries.
messages = [
{
"role": "system",
"content": "You are a helpful travel assistant. Help users plan trips and book travel.",
},
{"role": "user", "content": "I want to go somewhere warm this winter."},
{
"role": "assistant",
"content": "Great! There are many wonderful warm destinations for winter travel. "
"Are you interested in beach destinations, cultural cities, or adventure travel? "
"And do you have a particular region in mind, like the Caribbean, Southeast Asia, or Europe?",
},
{
"role": "user",
"content": "Beach sounds perfect. I'm open to Southeast Asia.",
},
]This structured format is passed to the model, which generates the next assistant turn. The model has been trained to maintain this format and respect the roles. The system message establishes the persona and constraints. User and assistant messages alternate, with the model expected to continue the assistant role.
Building a Stateful Conversation Loop
The core of a conversational application is a loop that accumulates messages and calls the model:
def create_conversation(system_prompt):
"""Initialize a conversation with a system prompt."""
return [{"role": "system", "content": system_prompt}]
def add_user_message(messages, user_input):
"""Append a user message to the conversation."""
messages.append({"role": "user", "content": user_input})
return messages
def add_assistant_message(messages, assistant_response):
"""Append an assistant message to the conversation."""
messages.append({"role": "assistant", "content": assistant_response})
return messages
def format_for_display(messages):
"""Format conversation for readable output."""
output_lines = []
for msg in messages:
if msg["role"] == "system":
continue
prefix = "You: " if msg["role"] == "user" else "Assistant: "
output_lines.append(f"{prefix}{msg['content']}")
return "\n\n".join(output_lines)Conversation structure: Total messages: 5 System messages: 1 User turns: 2 Assistant turns: 2 Formatted conversation: You: I want to go somewhere warm this winter. Assistant: Great! Southeast Asia is wonderful in winter. Are you thinking beach or culture? You: Beach. Maybe Thailand? Assistant: Thailand is perfect. Koh Samui and Phuket are both beautiful in December-February.
The conversation history grows with each exchange. The model sees the full history on every call, which gives it the context it needs to maintain coherence. The simplicity of this pattern is one of the reasons LLM-based conversational systems became so popular: the core loop is just a list that you append to and pass to the model.
Context Window Management
For long conversations, you need a strategy to manage context length. Let's implement a simple sliding window approach that keeps the system prompt and the N most recent turns:
def trim_conversation(messages, max_turns=10):
"""
Keep system message and the most recent max_turns user+assistant pairs.
Args:
messages: Full conversation history
max_turns: Maximum number of user/assistant turn pairs to keep
Returns:
Trimmed message list
"""
system_messages = [m for m in messages if m["role"] == "system"]
non_system = [m for m in messages if m["role"] != "system"]
# Each "turn" is one user + one assistant message
max_messages = max_turns * 2
if len(non_system) > max_messages:
non_system = non_system[-max_messages:]
return system_messages + non_system
def estimate_token_count(messages, chars_per_token=4):
"""Rough token count estimate based on character count."""
total_chars = sum(len(m["content"]) for m in messages)
return total_chars // chars_per_tokenOriginal conversation: 25 messages (~284 tokens) Trimmed conversation: 11 messages (~130 tokens) Token reduction: 54% Last retained user message: 'What's the best time to visit?'
Token count management is essential for keeping latency and cost under control. In production systems, you may also want to prioritize which parts of the conversation to keep: a tool-use result from several turns ago may be more important than small talk in between. A naive sliding window discards early turns equally regardless of their content. More sophisticated approaches assign importance scores to turns and retain the most important ones regardless of recency.
Simulating Slot-Filling Logic
Let's implement a simple slot-filling system for a restaurant booking task. This illustrates the logic that more sophisticated systems apply:
class RestaurantBookingState:
"""Track the state of a restaurant booking conversation."""
REQUIRED_SLOTS = ["date", "time", "party_size", "cuisine"]
OPTIONAL_SLOTS = ["dietary_restrictions", "price_range", "occasion"]
def __init__(self):
self.slots = {
slot: None for slot in self.REQUIRED_SLOTS + self.OPTIONAL_SLOTS
}
self.confirmed = False
def fill_slot(self, slot_name, value):
"""Fill a slot with an extracted value."""
if slot_name in self.slots:
self.slots[slot_name] = value
def missing_required(self):
"""Return list of required slots that haven't been filled."""
return [s for s in self.REQUIRED_SLOTS if self.slots[s] is None]
def is_complete(self):
"""Check if all required slots are filled."""
return len(self.missing_required()) == 0
def get_next_question(self):
"""Return a question to ask for the next missing required slot."""
missing = self.missing_required()
if not missing:
return None
questions = {
"date": "What date would you like to book for?",
"time": "What time would you prefer?",
"party_size": "How many people will be dining?",
"cuisine": "Do you have a cuisine preference?",
}
return questions.get(
missing[0], f"Could you tell me your {missing[0]}?"
)
def summarize(self):
"""Return a summary of filled slots."""
filled = {k: v for k, v in self.slots.items() if v is not None}
return filledSimulating restaurant booking slot filling:
User provided 'date': 'Saturday the 25th'
-> System asks: 'What time would you prefer?'
User provided 'party_size': '4'
-> System asks: 'What time would you prefer?'
User provided 'cuisine': 'Italian'
-> System asks: 'What time would you prefer?'
Current state:
Required slots filled: 3/4
Missing: ['time']
Filled data: {'date': 'Saturday the 25th', 'party_size': '4', 'cuisine': 'Italian'}This structured state tracking ensures the system collects all needed information before attempting to complete the task. In practice, an LLM-based system would use this state to construct targeted prompts rather than asking questions randomly. The state object is passed to the model at each turn as a structured representation of what's known and what's still needed.
Implementing Conversation Memory with Summarization
For longer conversations, summarization-based memory allows maintaining coherence without unlimited context growth:
def create_summary_from_history(messages, topic="the conversation so far"):
"""
Create a compact summary of conversation history.
In production, this would call an LLM to generate the summary.
Here we simulate the output format.
"""
user_msgs = [m for m in messages if m["role"] == "user"]
topics_discussed = len(user_msgs)
# In production: call LLM to summarize
# For demonstration: extract key info from messages
summary_parts = []
for msg in messages:
if msg["role"] == "user" and len(msg["content"]) > 20:
summary_parts.append(msg["content"][:60])
return (
f"[CONVERSATION SUMMARY: The user discussed {topics_discussed} topics including: "
+ "; ".join(summary_parts[:3])
+ ("..." if len(summary_parts) > 3 else "")
+ "]"
)
def compress_conversation(messages, keep_recent=4, system_prompt=None):
"""
Compress old conversation turns into a summary message.
Args:
messages: Full conversation history
keep_recent: Number of recent turns to keep verbatim
system_prompt: Optional system prompt to prepend
Returns:
Compressed message list with summary
"""
non_system = [m for m in messages if m["role"] != "system"]
if len(non_system) <= keep_recent * 2:
return messages # Not long enough to compress
to_summarize = non_system[: -keep_recent * 2]
to_keep = non_system[-keep_recent * 2 :]
summary_text = create_summary_from_history(to_summarize)
summary_message = {"role": "system", "content": summary_text}
system_messages = [m for m in messages if m["role"] == "system"]
return system_messages + [summary_message] + to_keepConversation compression with summarization: Original: 25 messages (~284 tokens) Compressed: 8 messages (~119 tokens) Token reduction: 58% Generated summary message: [CONVERSATION SUMMARY: The user discussed 9 topics including: What's the weather like in Tokyo?; Tell me about Japanese food.; What are some good rame...
The compression approach trades detail for scale. You lose verbatim history but keep the essential context needed to continue the conversation coherently. In production, the summarization step should itself be an LLM call with a prompt like: "Summarize the key facts, preferences, and decisions from this conversation in 150 words or fewer." The quality of the summary significantly affects how well the model handles references to summarized turns.
Key Parameters
The key parameters for designing conversational AI systems are:
- max_turns / window_size: The number of recent turns retained verbatim in the sliding window. Larger windows provide more context but increase latency and cost proportionally.
- keep_recent: In summarization-based memory, the number of turns kept verbatim after compression. Recent turns are kept intact so the model can reference immediate context precisely.
- compression_trigger: The turn count at which summarization begins. Set this based on typical conversation lengths and acceptable token budgets.
- chars_per_token: The approximate ratio used to estimate token counts from character count. Typically 3.5-4.5 characters per token for English text.
- system prompt length: Longer system prompts define behavior more precisely but consume tokens at every turn. Aim for the shortest prompt that achieves the desired behavior.