← Back to DevBytes

How to Implement Stateful Conversations with Local Models

How to Implement Stateful Conversations with Local Models

Stateful conversations are the backbone of any meaningful chatbot or AI assistant experience. Unlike one-off completions, where each prompt is treated independently, stateful conversations retain context across multiple turns — allowing the model to reference earlier messages, remember user preferences, and maintain coherent dialogue. When you combine this with local models (models running on your own hardware via tools like Ollama, llama.cpp, or Hugging Face Transformers), you gain full control over data privacy, latency, and cost.

This tutorial walks through everything you need to know: what stateful conversations are, why they matter, how to implement them with local models, and the best practices that separate toy demos from production-ready systems.

What Is a Stateful Conversation?

A stateful conversation is one where the system maintains a record of the exchange between the user and the assistant. Each new user message is processed in the context of the entire prior conversation, not in isolation. The "state" is simply the running history of messages — typically stored as a list of role-tagged entries (user, assistant, and sometimes system).

Local models are large language models that run entirely on your own machine or servers. Popular options include Llama 3, Mistral, Phi-3, and Gemma, served through runtimes like Ollama or llama.cpp. Because these models are stateless by design — they accept a prompt and return a completion — the responsibility for managing conversation state falls on your application code.

Why Stateful Conversations Matter

How to Implement Stateful Conversations

The core implementation pattern is straightforward: maintain a list of message dictionaries, append each new user message, send the entire list to the model, append the model's response, and repeat. Let's build this step by step using Ollama, one of the most popular local model runtimes.

Prerequisites

Before you begin, install Ollama and pull a model. On macOS or Linux, you can install Ollama from the official website, then run:

ollama pull llama3.2
ollama serve

This starts a local API server at http://localhost:11434. You will also need Python 3.9+ and the requests library:

pip install requests

Step 1: Define the Conversation State Class

The cleanest approach is to encapsulate conversation state in a class. This keeps the message history, system prompt, and model configuration together in one manageable object.

import requests
import json

class StatefulConversation:
    def __init__(self, model="llama3.2", system_prompt="You are a helpful assistant."):
        self.model = model
        self.messages = [{"role": "system", "content": system_prompt}]
        self.api_url = "http://localhost:11434/api/chat"

    def add_user_message(self, content):
        self.messages.append({"role": "user", "content": content})

    def add_assistant_message(self, content):
        self.messages.append({"role": "assistant", "content": content})

    def send(self, user_input):
        self.add_user_message(user_input)
        payload = {
            "model": self.model,
            "messages": self.messages,
            "stream": False
        }
        response = requests.post(self.api_url, json=payload)
        response.raise_for_status()
        assistant_content = response.json()["message"]["content"]
        self.add_assistant_message(assistant_content)
        return assistant_content

    def get_history(self):
        return self.messages

Notice how the send method appends the user's message, sends the entire message history to the model, and then appends the assistant's response. This is the fundamental loop of stateful conversation.

Step 2: Run an Interactive Conversation

Now let's use the class in a simple REPL-style loop:

if __name__ == "__main__":
    convo = StatefulConversation(
        model="llama3.2",
        system_prompt="You are a friendly coding tutor. Answer concisely."
    )

    print("Chat started. Type 'quit' to exit.\n")

    while True:
        user_input = input("You: ")
        if user_input.strip().lower() in ("quit", "exit"):
            break
        reply = convo.send(user_input)
        print(f"Assistant: {reply}\n")

    print("\n--- Conversation History ---")
    for msg in convo.get_history():
        print(f"[{msg['role']}] {msg['content']}")

When you run this, you can ask follow-up questions and the model will remember the context. For example:

You: My name is Alice.
Assistant: Nice to meet you, Alice! How can I help you today?

You: What did I just tell you my name was?
Assistant: You told me your name is Alice.

Without state management, the second question would fail because the model would have no memory of the first exchange.

Step 3: Persisting Conversations Across Sessions

In-memory state is lost when your application restarts. For real applications, you need to persist conversation history to disk or a database. Here is a simple JSON-based persistence layer:

import os

class PersistentConversation(StatefulConversation):
    def __init__(self, session_id, model="llama3.2", system_prompt="You are a helpful assistant.", storage_dir="sessions"):
        self.session_id = session_id
        self.storage_dir = storage_dir
        os.makedirs(storage_dir, exist_ok=True)
        self.file_path = os.path.join(storage_dir, f"{session_id}.json")

        if os.path.exists(self.file_path):
            self.messages = self._load()
            self.model = model
            self.api_url = "http://localhost:11434/api/chat"
        else:
            super().__init__(model, system_prompt)
            self._save()

    def _load(self):
        with open(self.file_path, "r", encoding="utf-8") as f:
            return json.load(f)

    def _save(self):
        with open(self.file_path, "w", encoding="utf-8") as f:
            json.dump(self.messages, f, indent=2)

    def send(self, user_input):
        reply = super().send(user_input)
        self._save()
        return reply

Now each conversation session is identified by a unique ID and saved to disk after every turn. You can resume any session by constructing a PersistentConversation with the same session_id.

Step 4: Managing Context Window Limits

Every model has a maximum context window — the total number of tokens it can process at once. For Llama 3.2, this is typically 128,000 tokens, but smaller models may have 4,000 or 8,000. As conversations grow longer, you will eventually exceed this limit. You need a strategy to trim or summarize old messages.

A common approach is a sliding window that keeps the system prompt and the most recent N messages:

class ManagedConversation(StatefulConversation):
    def __init__(self, model="llama3.2", system_prompt="You are a helpful assistant.", max_messages=20):
        super().__init__(model, system_prompt)
        self.max_messages = max_messages

    def _trim_history(self):
        if len(self.messages) <= self.max_messages:
            return
        system_messages = [m for m in self.messages if m["role"] == "system"]
        non_system = [m for m in self.messages if m["role"] != "system"]
        trimmed = non_system[-(self.max_messages - len(system_messages)):]
        self.messages = system_messages + trimmed

    def send(self, user_input):
        self._trim_history()
        return super().send(user_input)

For more sophisticated use cases, you can implement summarization: periodically send the oldest messages to the model with a prompt like "Summarize this conversation so far," then replace those messages with a single system message containing the summary.

Step 5: Multi-Session Management

Real applications often handle many concurrent users, each with their own conversation state. A session manager keeps track of active conversations by ID:

class SessionManager:
    def __init__(self, model="llama3.2", default_system_prompt="You are a helpful assistant."):
        self.model = model
        self.default_system_prompt = default_system_prompt
        self.sessions = {}

    def get_or_create(self, session_id):
        if session_id not in self.sessions:
            self.sessions[session_id] = PersistentConversation(
                session_id=session_id,
                model=self.model,
                system_prompt=self.default_system_prompt
            )
        return self.sessions[session_id]

    def send(self, session_id, user_input):
        convo = self.get_or_create(session_id)
        return convo.send(user_input)

    def list_sessions(self):
        return list(self.sessions.keys())

You can then expose this through a web framework like FastAPI:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
manager = SessionManager(model="llama3.2")

class ChatRequest(BaseModel):
    session_id: str
    message: str

@app.post("/chat")
def chat(req: ChatRequest):
    reply = manager.send(req.session_id, req.message)
    return {"reply": reply, "session_id": req.session_id}

Best Practices

Streaming Responses

For a more responsive experience, enable streaming so tokens are delivered as they are generated. Here is how to modify the send method for streaming:

def send_streaming(self, user_input):
    self.add_user_message(user_input)
    payload = {
        "model": self.model,
        "messages": self.messages,
        "stream": True
    }
    response = requests.post(self.api_url, json=payload, stream=True)
    response.raise_for_status()

    full_response = ""
    for line in response.iter_lines():
        if line:
            chunk = json.loads(line)
            token = chunk["message"]["content"]
            full_response += token
            print(token, end="", flush=True)

    print()  # newline after streaming completes
    self.add_assistant_message(full_response)
    return full_response

Conclusion

Implementing stateful conversations with local models is a powerful pattern that gives you full ownership of your AI application's data, behavior, and costs. The core concept is simple — maintain a running list of messages and send the full history with each request — but production-quality implementations require attention to persistence, context window management, multi-session handling, and error resilience. By following the architecture and best practices outlined in this tutorial, you can build chat experiences that rival cloud-based APIs while keeping every token of conversation data on hardware you control. Start with the basic StatefulConversation class, add persistence when you need session continuity, introduce context trimming as conversations grow, and layer in streaming and web APIs to serve real users. The result is a flexible, private, and cost-effective conversational AI system that you fully understand and can extend in any direction your application demands.

— Ad —

Google AdSense will appear here after approval

← Back to all articles