Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Conversation Design for LLMs: Structuring System Prompts, Roles, and Message Threads with the Chat Completions API

Conversation Design for LLMs: Structuring System Prompts, Roles, and Message Threads with the Chat Completions API

AI & Machine Learning🌱 Foundation15 min readAug 8, 2026Updated Aug 8, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • How the Chat Completions API Actually Works
  • The Three Roles: System, User, and Assistant
  • Writing Effective System Prompts
  • Anatomy of a Good System Prompt
  • Building Multi-Turn Conversations
  • Injecting Dynamic Context into Messages
  • Using the Assistant Role to Prime Responses
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Conversation Design for LLMs: Structuring System Prompts, Roles, and Message Threads with the Chat Completions API

    Introduction

    Imagine you're building a customer support tool for a SaaS company. You fire up the OpenAI API, send a user's question, and get back a perfectly reasonable answer — except it's written like a philosophy essay, it reveals internal pricing logic the company considers proprietary, and it completely ignores the specific tone guide in the brand handbook. The model is smart enough. The problem is you haven't given it the right instructions, in the right structure, at the right time.

    This is where conversation design comes in. The Chat Completions API — the foundation of ChatGPT and tools like it — doesn't just accept a single question and return an answer. It accepts a structured conversation thread made up of multiple messages, each assigned to a specific role. How you assemble that thread is one of the most consequential decisions you'll make when building with LLMs. Get it right, and the model behaves like a focused, reliable specialist. Get it wrong, and you're fighting the model on every call.

    By the end of this lesson, you'll understand exactly how the Chat Completions API structures conversations and why, and you'll be able to build well-designed message threads from scratch.

    What you'll learn:

    • What the three message roles are (system, user, assistant) and when to use each
    • How to write effective system prompts that reliably shape model behavior
    • How to structure multi-turn conversation threads to maintain context
    • How to inject dynamic context — like customer data or retrieved documents — into messages
    • Common mistakes that cause models to ignore your instructions or drift off-task

    Prerequisites

    • Basic familiarity with Python (you can read and write simple functions)
    • An OpenAI API key (you can get one at platform.openai.com)
    • The openai Python library installed (pip install openai)
    • A vague sense that LLMs take text in and produce text out — no deeper knowledge required

    How the Chat Completions API Actually Works

    Before you write a single line of prompt text, you need a mental model of what you're actually sending to the API.

    When you call the Chat Completions endpoint, you're not sending a string of text. You're sending a list of message objects. Each message object has two fields: a role and content. The role tells the model who is speaking, and the content is what they said.

    Here's the simplest possible call:

    from openai import OpenAI
    
    client = OpenAI()  # uses your OPENAI_API_KEY environment variable
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "user", "content": "What's the difference between a data lake and a data warehouse?"}
        ]
    )
    
    print(response.choices[0].message.content)
    

    That works, but it's the API equivalent of walking up to a stranger and asking them a technical question with zero context. The model will answer, but it has no idea who you are, what you need the answer for, what level of detail is appropriate, or what tone to use.

    The power of the Chat Completions API comes from building a richer, more deliberate thread. To do that, you need to understand the three roles.


    The Three Roles: System, User, and Assistant

    Think of a conversation in the API as a script, and the roles as the characters in that script. There are exactly three:

    system — This is your backstage director. The system message sets the stage before any real conversation begins. It tells the model what it is, how it should behave, what it can and can't talk about, what tone to use, and any domain-specific facts it should treat as ground truth. The user never sees this message (in a well-built application, anyway). It's purely instructional.

    user — This is the human in the conversation. User messages represent inputs from the person interacting with your application. This is where questions, commands, and data live.

    assistant — This is the model's voice. When the model responds, its output is labeled as an assistant message. Here's the key insight: when you're building a multi-turn conversation, you include previous assistant responses in the thread so the model can see what it already said. This is how context is maintained.

    Let's see all three in action:

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": "You are a senior data engineer helping junior analysts at a fintech company. Explain technical concepts clearly and concisely. Use plain language, avoid jargon where possible, and always give a practical example. Do not discuss competitor products."
            },
            {
                "role": "user",
                "content": "Can you explain what database indexing is?"
            }
        ]
    )
    

    That system message changes everything. The model now knows its audience (junior analysts), its style (clear, concise, practical examples), and its constraints (no competitor discussion). The same underlying model produces a very different output with that context than without it.


    Writing Effective System Prompts

    The system prompt is the single highest-leverage piece of text in your application. A thoughtful system prompt can eliminate entire categories of problems. A vague one forces you to patch issues downstream forever.

    Anatomy of a Good System Prompt

    Good system prompts cover four things:

    1. Identity and role — Who is the model, and what is its job?

    You are a SQL assistant for Meridian Analytics, a business intelligence consultancy.
    Your job is to help data analysts write, debug, and optimize SQL queries.
    

    2. Behavioral guidelines — How should it communicate?

    Always explain your reasoning before giving a final query. When a user's question is
    ambiguous, ask a clarifying question rather than guessing. Keep explanations under
    150 words unless the user asks for more detail.
    

    3. Domain constraints — What should it know or avoid?

    The company uses BigQuery as its primary data warehouse. All SQL examples should
    use standard BigQuery syntax (not MySQL or PostgreSQL syntax). Do not write queries
    that use SELECT * — always specify column names explicitly.
    

    4. Output format — What shape should responses take?

    When providing a SQL query, format it in a fenced code block. Follow the query
    with a brief plain-language explanation of what it does.
    

    Put it all together:

    system_prompt = """
    You are a SQL assistant for Meridian Analytics, a business intelligence consultancy.
    Your job is to help data analysts write, debug, and optimize SQL queries.
    
    Communication style:
    - Explain your reasoning before providing a final query
    - Ask a clarifying question if the user's request is ambiguous
    - Keep explanations concise (under 150 words) unless asked for more detail
    
    Technical constraints:
    - All SQL must use BigQuery syntax
    - Never use SELECT * — always name columns explicitly
    - When referencing table names, use the format `project.dataset.table`
    
    Output format:
    - Present SQL in a fenced code block with the sql language tag
    - Follow every query with a brief plain-language explanation
    """
    

    Notice this isn't a wall of rules — it's organized, scannable, and specific. Models respond well to structure within the system prompt itself. Headers and bullet points are completely valid inside a system message.

    Tip: Be specific rather than abstract. "Be helpful and accurate" means nothing. "If you're not confident about a specific BigQuery function, say so and suggest the user verify in the official documentation" is something the model can actually act on.


    Building Multi-Turn Conversations

    Here's the thing that surprises most people new to the API: the model has no memory between API calls. Every call is completely stateless. If you want the model to remember what was said two messages ago, you have to send those messages again.

    This means building a multi-turn conversation is really just the act of maintaining a list and appending to it as the conversation progresses.

    Let's build a simple conversation loop to make this concrete:

    from openai import OpenAI
    
    client = OpenAI()
    
    # Start with just the system message
    conversation_history = [
        {
            "role": "system",
            "content": """You are a data quality analyst assistant for a retail company.
            Help analysts identify and resolve data quality issues in their datasets.
            Be specific and practical in your recommendations."""
        }
    ]
    
    def chat(user_message):
        # Add the new user message to history
        conversation_history.append({
            "role": "user",
            "content": user_message
        })
        
        # Call the API with the full conversation history
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=conversation_history
        )
        
        assistant_reply = response.choices[0].message.content
        
        # Add the assistant's response to history so future turns remember it
        conversation_history.append({
            "role": "assistant",
            "content": assistant_reply
        })
        
        return assistant_reply
    
    # Simulate a multi-turn conversation
    print(chat("I have a customer table with about 15% null values in the email column. What should I check first?"))
    print("---")
    print(chat("Good point. I ran that check and it turns out nulls are clustered in records from before 2021. Is that a meaningful pattern?"))
    print("---")
    print(chat("How would I write a SQL query to flag all those pre-2021 records with null emails?"))
    

    Notice what happens in that third turn. The user says "those pre-2021 records" — a reference that only makes sense given the conversation history. Because we're sending the full conversation_history list on every call, the model has all the context it needs to understand what "those" refers to. Remove the history, and the model would be lost.

    Warning: Conversation history grows with every turn, and the model has a finite context window — a maximum amount of text it can process in one call. For GPT-4o, this is 128,000 tokens (roughly 96,000 words), which is enormous. But for long-running applications — think a support chat session that goes on for an hour — you'll eventually need a strategy for trimming or summarizing old history. For now, just be aware the limit exists.


    Injecting Dynamic Context into Messages

    One of the most powerful patterns in LLM application design is context injection: dynamically inserting relevant data into the message thread before you call the API. This is how you connect LLMs to real information.

    Suppose you're building a tool that helps sales reps prepare for customer calls. Before the call, you want the model to be aware of the specific account's history. You might pull that data from a CRM and inject it directly into the conversation.

    def prepare_call_briefing(account_data: dict, rep_question: str) -> str:
        # Dynamically build a context block from real data
        context_block = f"""
    Account: {account_data['company_name']}
    Plan: {account_data['plan_tier']}
    Monthly spend: ${account_data['monthly_spend']:,.2f}
    Contract renewal: {account_data['renewal_date']}
    Open support tickets: {account_data['open_tickets']}
    Last interaction: {account_data['last_interaction_summary']}
    """
        
        messages = [
            {
                "role": "system",
                "content": """You are a sales intelligence assistant. Your job is to help
                account executives prepare for customer calls. Be concise and strategic.
                Focus on renewal risk, expansion opportunities, and relationship health."""
            },
            {
                "role": "user",
                "content": f"Here is the account profile for my upcoming call:\n\n{context_block}\n\nMy question: {rep_question}"
            }
        ]
        
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages
        )
        
        return response.choices[0].message.content
    
    # Usage
    account = {
        "company_name": "Brightfield Logistics",
        "plan_tier": "Enterprise",
        "monthly_spend": 12400,
        "renewal_date": "2025-09-01",
        "open_tickets": 3,
        "last_interaction_summary": "CSM call in April; expressed frustration with API rate limits"
    }
    
    print(prepare_call_briefing(
        account,
        "What should I focus on in this renewal call, and what are the red flags?"
    ))
    

    This pattern scales to almost anything: retrieved documents, database query results, user profile data, sensor readings. You're not fine-tuning the model or doing anything exotic — you're just putting the right information in the message at the right time.

    Tip: When injecting large blocks of context, clearly label them. Use headings like ### Account Data or ### Retrieved Documents within the message content. This helps the model distinguish between data and instructions.


    Using the Assistant Role to Prime Responses

    There's one more powerful technique worth understanding: you can write assistant messages yourself, without the model having generated them. This is called seeding the conversation or priming, and it lets you shape how the model continues.

    For example, if you want the model to always begin its response in a specific format, you can include a partially-written assistant message and let the model complete it. Some APIs support this directly; others require you to structure the prior turn to lead the model there.

    More practically, you can use fabricated assistant messages to establish a prior "agreement" with the model:

    messages = [
        {
            "role": "system",
            "content": "You are a senior data analyst reviewing ETL pipeline code."
        },
        {
            "role": "user",
            "content": "I'd like you to review my Python ETL code and give feedback on structure, error handling, and performance. Can we use a consistent format for your feedback?"
        },
        {
            "role": "assistant",
            "content": "Absolutely. For each piece of code you share, I'll structure my feedback in three sections: **Structure** (organization and readability), **Error Handling** (robustness and edge cases), and **Performance** (efficiency and scalability). Ready when you are."
        },
        {
            "role": "user",
            "content": "Great. Here's the first function:\n\n```python\ndef load_to_warehouse(df, table_name):\n    conn = get_connection()\n    df.to_sql(table_name, conn)\n    conn.close()\n```"
        }
    ]
    

    By inserting that pre-written assistant message, you've established a format agreement that the model will now follow reliably for the rest of the conversation, without needing to repeat it in every turn.

    Warning: Don't use fabricated assistant messages to make the model "agree" to bypass its safety guidelines. That's both ineffective and against usage policies. The technique is for formatting and workflow conventions, not jailbreaking.


    Hands-On Exercise

    Let's put everything together. Build a multi-turn research assistant for a data analyst who is exploring an unfamiliar dataset.

    Your task: Write a Python script that:

    1. Sets up a system prompt defining the assistant as a "data exploration guide" that helps analysts understand unfamiliar datasets. It should ask clarifying questions, suggest relevant analyses, and explain statistical concepts in plain language.

    2. Starts the conversation by injecting a realistic dataset description as a user message (make one up — something like a monthly sales table with 15 columns, three years of data, and some known quality issues).

    3. Runs a 3-turn conversation loop where you simulate the analyst asking:

      • "Where should I start exploring this dataset?"
      • "What do you mean by checking for cardinality? I'm not familiar with that term."
      • "Can you suggest a Python snippet to check for duplicate primary keys?"
    4. After each assistant response, print the role and content clearly so you can see the full thread growing.

    Stretch goal: Modify the script to print the full conversation_history list after the final turn. Count the total number of messages. Notice how the thread includes system, user, and assistant messages interspersed — that's the full context the model sees on every call.


    Common Mistakes & Troubleshooting

    Mistake: Putting everything in the user message instead of the system message

    New builders often write long instructions inside the user message because it feels more natural. The problem is that user-message instructions compete with the actual user input and can get confused or overridden in later turns. Persistent behavioral instructions belong in the system message, where they set the frame for the entire conversation.

    Mistake: Forgetting to append assistant responses to history

    This is the most common bug in multi-turn applications. The conversation feels coherent for the first two turns, then the model starts acting like it has no memory. Check your code — you almost certainly forgot to append {"role": "assistant", "content": assistant_reply} after each call.

    Mistake: System prompts that are vague or contradictory

    "Be helpful but also be concise but also be thorough" is not a useful instruction. When you have competing directives, the model will do its best to average them out, which usually satisfies neither goal. Prioritize explicitly: "Be concise by default. If the user asks for more detail, expand your response."

    Mistake: Not handling the context window

    If you're building a persistent chat application and you're not tracking the approximate size of your conversation history, you will eventually hit the context limit and get an API error. Build a simple token counter using the tiktoken library and trim or summarize old messages when you approach the limit.

    Mistake: Injecting data without labeling it

    When you inject a block of database results or retrieved documents into a user message, the model can struggle to distinguish the injected data from the user's actual question. Always use clear labels and separators.

    # ❌ Hard to parse
    content = f"{retrieved_docs}\n\nWhat are the main themes in these documents?"
    
    # ✅ Clearly labeled
    content = f"### Retrieved Documents\n\n{retrieved_docs}\n\n### Question\n\nWhat are the main themes in these documents?"
    

    Summary & Next Steps

    You've just covered the architectural core of every LLM-powered application. Let's recap what you now know:

    • The Chat Completions API accepts a list of messages, not a single string. Each message has a role and content.
    • System messages set persistent behavioral context. They're your highest-leverage tool.
    • User messages carry the human input. Assistant messages carry the model's previous responses.
    • Multi-turn conversations work by maintaining and re-sending the full message history on every API call.
    • You can inject dynamic context — database results, user data, retrieved documents — directly into messages.
    • You can prime the assistant role to establish formatting conventions without repeating instructions.

    These patterns aren't just academic. Every serious LLM application — from code assistants to customer support bots to data analysis tools — is built on exactly this foundation.

    Where to go next:

    • Prompt engineering in depth — learn techniques like chain-of-thought prompting and few-shot examples to get better outputs for complex reasoning tasks
    • Retrieval-Augmented Generation (RAG) — learn how to connect your chat application to external knowledge bases so the model can answer questions about your specific data
    • Function calling — learn how to give the model the ability to call real functions in your codebase, turning it from a text generator into an active agent
    • Managing context and memory — explore strategies for summarization, windowing, and long-term memory storage for production chat applications

    The conversation design skills you've built here are the foundation for all of it.

    Learning Path: Building with LLMs

    Previous

    Implementing LLM Router Architecture: Dynamically Selecting Models by Cost, Latency, and Task Complexity in Production

    Related Articles

    AI & Machine Learning🌱 Foundation

    From Vague to Precise: How to Diagnose and Fix Prompts That Return Unhelpful AI Responses

    16 min
    AI & Machine Learning🔥 Expert

    Structured Output Contracts for AI Agents: Enforcing JSON Schemas, Retry Logic, and Validation Pipelines in Production

    25 min
    AI & Machine Learning🔥 Expert

    Implementing LLM Router Architecture: Dynamically Selecting Models by Cost, Latency, and Task Complexity in Production

    25 min

    On this page

    • Introduction
    • Prerequisites
    • How the Chat Completions API Actually Works
    • The Three Roles: System, User, and Assistant
    • Writing Effective System Prompts
    • Anatomy of a Good System Prompt
    • Building Multi-Turn Conversations
    • Injecting Dynamic Context into Messages
    • Using the Assistant Role to Prime Responses
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps