LangChain Conversation History and Chat Models

You now understand how memory stores conversation history and how models process messages. This topic brings those ideas together and shows you how to build a complete, production-ready conversational loop. You will learn how Chat Models handle multi-turn dialogue, how to structure your conversation management code cleanly, and how to handle the common patterns that appear in real chatbot applications.

The Telephone Operator Analogy

Old telephone operators connected callers by physically plugging cables between switchboard ports. When you called someone, the operator noted who you were, who you wanted to reach, and connected the right cables. Chat Models work similarly — they accept a structured list of "cables" (messages) from different parties, process the full conversation context, and generate the next appropriate response.

Conversation as a Structured List:
┌────────────────────────────────────────────────────┐
│  [1] SystemMessage:  "You are a cooking tutor."    │
│  [2] HumanMessage:  "How do I make pasta sauce?"   │
│  [3] AIMessage:     "Start with olive oil..."      │
│  [4] HumanMessage:  "What oil should I use?"       │
│         ↑                                          │
│   Model receives this list and generates [5]       │
└────────────────────────────────────────────────────┘

Building a Complete Chat Loop

A working chatbot needs four things: a prompt template with memory support, a model, a parser, and a loop that accepts user input and calls the chain. Here is a fully functional command-line chatbot:

from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.output_parsers import StrOutputParser

load_dotenv()

# Build the chain
model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.5)
parser = StrOutputParser()

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a knowledgeable and friendly assistant called Nova. "
               "Answer concisely and accurately. Ask follow-up questions when helpful."),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}")
])

chain = prompt | model | parser

# Conversation state
history = []

def chat(user_input: str) -> str:
    response = chain.invoke({"history": history, "input": user_input})
    history.append(HumanMessage(content=user_input))
    history.append(AIMessage(content=response))
    return response

# Main conversation loop
print("Nova: Hello! I am Nova. Ask me anything. Type 'quit' to exit.\n")

while True:
    user_text = input("You: ").strip()

    if not user_text:
        continue

    if user_text.lower() in ["quit", "exit", "bye"]:
        print("Nova: Goodbye! Have a great day!")
        break

    response = chat(user_text)
    print(f"Nova: {response}\n")

Run this script and you have a working chatbot that remembers everything you say during the session. The history list grows with each turn. The MessagesPlaceholder injects it into the prompt automatically.

System Message Best Practices

The system message is the single most important part of your chatbot's prompt. It sets the tone, rules, and persona for the entire conversation. A weak system message produces an AI that acts generic and inconsistent. A strong system message produces a focused, reliable assistant.

Weak System Message

"You are a helpful assistant."

This tells the model almost nothing. The AI fills in the blanks with its training defaults, which may not match your application's needs at all.

Strong System Message

"You are Nova, a customer support assistant for CloudStore, a cloud storage service.

Your job:
- Answer questions about CloudStore plans, pricing, and features
- Help users with account issues and billing questions
- Guide users through technical setup steps clearly

Your rules:
- Only discuss topics related to CloudStore and general cloud storage concepts
- If a question is outside your scope, say: 'That is outside my area. Let me connect you with our team.'
- Always be polite and patient
- Do not make promises about pricing or features you are not certain about

Tone: Professional but friendly. Use clear, jargon-free language."

This system message gives the AI a name, a specific job, clear behavioral rules, and a defined scope. The result is a chatbot that behaves consistently and predictably.

Handling Edge Cases in Conversation

Empty or Whitespace Input

def chat_safe(user_input: str) -> str:
    # Reject empty input before calling the API
    if not user_input or not user_input.strip():
        return "Please type a message."

    response = chain.invoke({"history": history, "input": user_input.strip()})
    history.append(HumanMessage(content=user_input.strip()))
    history.append(AIMessage(content=response))
    return response

Very Long User Input

MAX_INPUT_LENGTH = 2000  # characters

def chat_with_limits(user_input: str) -> str:
    if len(user_input) > MAX_INPUT_LENGTH:
        return f"Your message is too long ({len(user_input)} characters). Please keep it under {MAX_INPUT_LENGTH} characters."

    return chat_safe(user_input)

API Errors

def chat_resilient(user_input: str) -> str:
    try:
        return chat_safe(user_input)
    except Exception as e:
        # Log the error internally but show a friendly message to the user
        print(f"Internal error: {e}")
        return "I am having trouble connecting right now. Please try again in a moment."

Adding Commands to Your Chatbot

Real chatbots often support special commands: clearing history, changing language, switching modes. Handle these before calling the AI chain.

COMMANDS = {
    "/clear": "Conversation history cleared.",
    "/help": "Commands: /clear (clear history), /help (show help), /status (show info)",
    "/status": None  # Handled dynamically
}

def handle_command(command: str) -> str | None:
    if command == "/clear":
        history.clear()
        return COMMANDS["/clear"]

    if command == "/help":
        return COMMANDS["/help"]

    if command == "/status":
        return f"Nova active. History: {len(history)} messages. Model: gpt-3.5-turbo"

    return None  # Not a command

def chat_with_commands(user_input: str) -> str:
    # Check for command first
    if user_input.startswith("/"):
        result = handle_command(user_input.lower())
        if result is not None:
            return result
        return "Unknown command. Type /help for available commands."

    return chat_resilient(user_input)

Streaming Responses for Better User Experience

When the model generates long responses, the user stares at a blank screen waiting. Streaming displays tokens as they are generated, creating a typing effect that feels more natural.

def chat_streaming(user_input: str):
    """Print response as it streams, return full response."""
    full_response = ""

    print("Nova: ", end="", flush=True)

    for chunk in chain.stream({"history": history, "input": user_input}):
        print(chunk, end="", flush=True)
        full_response += chunk

    print()  # Newline after response finishes

    # Save the complete response to history
    history.append(HumanMessage(content=user_input))
    history.append(AIMessage(content=full_response))

    return full_response

The flush=True parameter forces Python to display each chunk immediately rather than buffering it. Without this, the entire response would still appear all at once at the end.

Context-Aware Follow-Up Questions

A common pattern in chatbots is asking clarifying questions based on the conversation context. You can build this by having the model check whether it has enough information before answering.

clarifying_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a helpful assistant. When a user asks a question:
1. If you have all the information needed, answer directly.
2. If you need clarification to give a good answer, ask ONE specific question.
Never ask multiple questions at once. Be direct and helpful."""),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}")
])

With this system message, the chatbot naturally asks for details when needed and answers directly when it has enough context. The conversation feels genuinely helpful rather than scripted.

Building a Context Summary Display

For long conversations, it helps to show the user a brief summary of what the AI knows about them. This builds trust and helps users verify that the AI has the right information.

def get_context_summary() -> str:
    """Ask the AI to summarize what it knows about the user from history."""
    if not history:
        return "No conversation history yet."

    summary_prompt = ChatPromptTemplate.from_messages([
        ("system", "Summarize the key facts you know about the user from this conversation in 2-3 bullet points. Be concise."),
        MessagesPlaceholder(variable_name="history"),
        ("human", "What do you know about me from our conversation?")
    ])

    summary_chain = summary_prompt | model | parser
    return summary_chain.invoke({"history": history})

# Show summary when user types /status
print(get_context_summary())

Detecting Conversation Topic Changes

Some applications need to know when the user switches topics so they can reset context or apply different handling logic. Build a simple topic detection step:

def detect_topic_change(current_input: str) -> bool:
    """Return True if the user seems to be changing topics."""
    if len(history) < 2:
        return False

    detection_prompt = ChatPromptTemplate.from_messages([
        ("system", "Classify whether the new message is on a completely different topic from the conversation history. Reply with only: same_topic or new_topic"),
        ("human", "History summary: {history_summary}\n\nNew message: {new_message}")
    ])

    # Get last few messages as summary
    recent = history[-4:] if len(history) >= 4 else history
    history_text = " | ".join([f"{m.type}: {m.content}" for m in recent])

    detection_chain = detection_prompt | model | parser
    result = detection_chain.invoke({
        "history_summary": history_text,
        "new_message": current_input
    })

    return "new_topic" in result.lower()

Conversation Flow Diagram

User types message
        │
        ▼
Is it a command? ──Yes──▶ Handle command, return result
        │No
        ▼
Is input valid? ──No───▶ Return validation error message
        │Yes
        ▼
Build prompt:
  [System] + [History] + [New Human Message]
        │
        ▼
Send to Chat Model
        │
        ▼
Receive AIMessage
        │
        ▼
Extract text with StrOutputParser
        │
        ▼
Save [HumanMessage, AIMessage] to history
        │
        ▼
Display response to user
        │
        ▼
Loop back to "User types message"

Testing Your Chatbot

Manual testing (typing messages yourself) reveals surface issues. Systematic testing catches deeper problems. Write tests that check specific behaviors:

def test_memory():
    """Verify the chatbot remembers facts across turns."""
    test_history = []

    def test_chat(msg):
        resp = chain.invoke({"history": test_history, "input": msg})
        test_history.append(HumanMessage(content=msg))
        test_history.append(AIMessage(content=resp))
        return resp

    test_chat("My name is Vikram and I am a teacher.")
    response = test_chat("What is my profession?")

    assert "teacher" in response.lower(), f"Memory test failed. Response: {response}"
    print("Memory test passed!")

def test_system_prompt_adherence():
    """Verify the chatbot stays in scope."""
    test_history = []
    response = chain.invoke({
        "history": test_history,
        "input": "Tell me the stock price of Apple"
    })
    # Should redirect if out of scope
    print(f"Out-of-scope response: {response}")

test_memory()
test_system_prompt_adherence()

Conversation Export Feature

Users often want to save or share their conversations. Add a simple export function:

def export_conversation(history: list, filename: str = "conversation.txt"):
    """Export conversation to a readable text file."""
    lines = ["=== Conversation Export ===\n"]
    for msg in history:
        speaker = "You" if msg.type == "human" else "Nova"
        lines.append(f"{speaker}: {msg.content}\n")

    with open(filename, "w") as f:
        f.writelines(lines)

    print(f"Conversation saved to {filename}")

Summary

A complete conversational application needs a clear system prompt, a history management mechanism, input validation, command handling, error handling, and streaming output. Chat Models process structured message lists where each message has a type (system, human, or ai). The conversation loop builds the message list incrementally, appending new turns after each exchange. Testing specific behaviors catches memory and scope issues early.

Leave a Comment