top of page

Build Your First AI Chatbot with Memory Using Python and OpenAI

  • Jun 15
  • 11 min read


Introduction


Most AI chatbot demos are stateless: every message you send is treated as the first. The model has no idea what you said three turns ago, cannot refer back to details you shared earlier, and cannot build a coherent conversation over time. This is the biggest gap between a demo and a real chatbot.


In this tutorial, we fix that. We build an AI Chatbot with Memory that maintains the full conversation history across every turn, passes it to the model on each request, and lets the assistant refer back to anything said earlier in the session. The chatbot also tracks token usage and cost per turn and saves every session automatically to a structured JSON file.


The key insight behind memory in LLM chatbots is simple: the model itself is stateless, but the application is not. Memory is not a special feature of the model. It is the application’s responsibility to collect every message, store it, and send the full history with every new request. This tutorial shows exactly how to do that.






What We’re Building


Feature

Detail

Multi-turn memory

Full conversation history sent to the model on every turn

Session auto-save

Saved automatically after every response to sessions/

Per-turn stats

Tokens and cost tracked for every message

Session summary

Total turns, tokens, input cost, output cost, total cost

Built-in commands

reset, history, stats, help, exit

Markdown rendering

Assistant replies rendered with Rich Markdown in the terminal




What is Conversational Memory?


A large language model processes one request at a time and has no internal state between calls. When you send a message, the model sees only what is in the current API request. It does not remember previous calls.


Conversational memory is the technique of building that context into every request. The application maintains a list of all previous messages and includes them in the messages array on every new API call:



messages = [
    {"role": "system",    "content": "You are a helpful assistant..."},
    {"role": "user",      "content": "My name is Ganesh."},
    {"role": "assistant", "content": "Nice to meet you, Ganesh!"},
    {"role": "user",      "content": "What is my name?"},   ← new message
]

The model reads the full thread and responds as if it has been in the conversation all along. The more context you include, the better the model can maintain coherence across a long session.


The tradeoff is cost and context length: every turn, the prompt grows by two messages. After many turns, the token count rises, and very long sessions may approach the model’s context limit. For GPT-4o-mini, the context window is 128,000 tokens, which is large enough for hundreds of conversational turns.




Tech Stack


Component

Tool

AI Model

GPT-4o-mini

API Client

openai Python SDK

Terminal UI

rich

Env Management

python-dotenv

Session Storage

JSON files in sessions/




Project Structure




chatbot_with_memory/
├── chatbot.py       # Chatbot class — history management, API call, cost tracking
├── main.py          # Terminal entry point — input loop, commands, display, auto-save
├── sessions/        # Auto-created — one JSON file per conversation session
├── requirements.txt # openai, python-dotenv, rich
└── .env             # API key, model, and pricing rates




Setting Up



1. Install Dependencies



pip install openai python-dotenv rich


2. Configure Environment


Create a .env file in the project folder:



# Your OpenAI API key — get yours at https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_api_key_here

# Model used for the chatbot (default: gpt-4o-mini)
MODEL=gpt-4o-mini

# GPT-4o-mini pricing per 1M tokens — update here if OpenAI changes rates
INPUT_COST_PER_1M=0.150
OUTPUT_COST_PER_1M=0.600


Pricing is read from .env rather than hardcoded so you can update rates or switch models without touching the source code. If you upgrade to gpt-4o, just change MODEL, INPUT_COST_PER_1M, and OUTPUT_COST_PER_1M together.




Building the Chatbot: chatbot.py


The chatbot class handles three responsibilities: maintaining conversation history, making the OpenAI API call with that history included, and tracking cost.



Imports and Pricing




import json                    # serialises session data to JSON for auto-save
import os                      # reads environment variables after load_dotenv()
from datetime import datetime  # generates session start/end timestamps and per-turn timestamps
from openai import OpenAI      # synchronous OpenAI client — this chatbot runs in a simple input loop
from dotenv import load_dotenv # reads .env and injects values into os.environ

load_dotenv()  # must be called before any os.getenv() — injects .env values into the process environment

_PRICING = {
    "input":  float(os.getenv("INPUT_COST_PER_1M",  "0.150")),  # cost per 1M input tokens — read from .env so no code change needed when rates change
    "output": float(os.getenv("OUTPUT_COST_PER_1M", "0.600")),  # cost per 1M output tokens — float() converts the env string to a number
}



System Prompt




_SYSTEM_PROMPT = (
    "You are a helpful, friendly, and conversational AI assistant. "
    "You remember everything said earlier in this conversation and refer back to it naturally when relevant. "  # instructs the model to use the history it receives
    "Keep your responses concise and clear unless the user asks for detail."  # prevents unnecessarily long replies
)



The system prompt is sent as the first message on every API call. It sets the assistant’s persona and instructs it to actively use the conversation history it receives. Without this instruction, the model might technically have access to the history but not refer back to it naturally.



Call Metadata




_CALL_METADATA = {
    "dev_name":    "Ganesh",      # identifies who made the API call — visible in the OpenAI dashboard
    "project":     "codex-test",  # project label for grouping API calls
    "environment": "local",       # marks calls as local development, not production
    "purpose":     "testing",     # intent label for usage log reviews
}



The Chatbot Class



class Chatbot:
    """A conversational AI chatbot that maintains full message history across turns."""

    def __init__(self):
        self.client   = OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))  # synchronous client — no async needed for a single-user input loop
        self.model    = os.getenv("MODEL", "gpt-4o-mini")                # model read from .env — defaults to gpt-4o-mini if not set
        self.history  = []    # the conversation memory — list of {"role": ..., "content": ...} dicts that grows with every turn
        self.total_prompt_tokens     = 0   # cumulative input tokens across all turns in this session
        self.total_completion_tokens = 0   # cumulative output tokens across all turns in this session
        self.session_start           = datetime.now().isoformat()  # recorded when the chatbot is initialised — used in the saved session file
        self.turn_stats              = []  # one cost/token record per turn — appended on every chat() call


self.history is the core of the memory system. Every user message and every assistant reply is appended here. On the next turn, the full list is included in the API call so the model sees the entire conversation.



The chat() Method




    def chat(self, user_message: str) -> dict:
        """Send a user message, get a reply, and update the conversation history."""
        self.history.append({"role": "user", "content": user_message})  # add the new user message to history before the API call

        messages = [{"role": "system", "content": _SYSTEM_PROMPT}] + self.history  # prepend the system prompt — it is not stored in history to avoid duplication

        response = self.client.chat.completions.create(
            model=self.model,      # model from .env
            messages=messages,     # full history including the new message — this is what gives the model its memory
            max_tokens=1024,       # generous limit — chatbot replies can be detailed; 1024 is enough for most conversational responses
            metadata=_CALL_METADATA,  # attached for dashboard tracking — does not affect model output
        )

        reply = response.choices[0].message.content or ""  # the assistant's text response — or "" guards against a None content field
        self.history.append({"role": "assistant", "content": reply})  # add the reply to history so it is included in the next turn's context

        prompt_tokens     = response.usage.prompt_tokens      # tokens used by the system prompt + full conversation history + new message
        completion_tokens = response.usage.completion_tokens  # tokens used by the assistant's reply
        self.total_prompt_tokens     += prompt_tokens      # accumulate for session summary
        self.total_completion_tokens += completion_tokens  # accumulate for session summary

        input_cost  = round((prompt_tokens     / 1_000_000) * _PRICING["input"],  6)  # cost of this turn's input tokens in USD
        output_cost = round((completion_tokens / 1_000_000) * _PRICING["output"], 6)  # cost of this turn's output tokens in USD

        turn_record = {
            "turn":               len(self.history) // 2,   # turn number — history has 2 entries per turn (user + assistant)
            "timestamp":          datetime.now().isoformat(),  # when this turn completed
            "user_message":       user_message,              # the user's input for this turn
            "reply":              reply,                     # the assistant's response
            "prompt_tokens":      prompt_tokens,             # input tokens for this turn
            "completion_tokens":  completion_tokens,         # output tokens for this turn
            "total_tokens":       prompt_tokens + completion_tokens,  # combined for this turn
            "input_cost":         input_cost,                # input cost in USD for this turn
            "output_cost":        output_cost,               # output cost in USD for this turn
            "turn_cost":          round(input_cost + output_cost, 6),  # total cost for this turn
        }
        self.turn_stats.append(turn_record)               # store the record for the session file
        return {**turn_record, "session_cost": self.session_cost()}  # spread turn_record and add the running session total


The key line is messages = [system] + self.history. Every call sends the complete conversation from the beginning. This is how the model appears to remember: it is not storing anything internally. The application is replaying the entire thread every time.



Supporting Methods


reset() clears the history and all counters — the next message starts a fresh conversation:




    def reset(self) -> None:
        """Clear conversation history and reset token counters."""
        self.history                  = []   # wipe all previous messages — next turn starts with no context
        self.total_prompt_tokens      = 0    # reset cumulative token counts
        self.total_completion_tokens  = 0
        self.session_start            = datetime.now().isoformat()  # reset session start time
        self.turn_stats               = []   # clear per-turn records for the new session



session_cost() computes the running total cost from accumulated token counts:




    def session_cost(self) -> float:
        input_cost  = (self.total_prompt_tokens     / 1_000_000) * _PRICING["input"]   # total input cost across all turns
        output_cost = (self.total_completion_tokens / 1_000_000) * _PRICING["output"]  # total output cost across all turns
        return round(input_cost + output_cost, 6)  # combined session cost in USD



save_session() writes the full session to disk including the summary header, per-turn records, and the raw message history:


    def save_session(self, path: str) -> None:
        """Save the full conversation history and stats to a JSON file."""
        session = {
            "session_start":           self.session_start,                  # when the session began
            "session_end":             datetime.now().isoformat(),           # when save_session was called
            "total_turns":             len(self.history) // 2,              # number of complete user/assistant exchanges
            "total_prompt_tokens":     self.total_prompt_tokens,            # total input tokens across all turns
            "total_completion_tokens": self.total_completion_tokens,        # total output tokens across all turns
            "total_tokens":            self.total_prompt_tokens + self.total_completion_tokens,  # grand total
            "total_input_cost":        round((self.total_prompt_tokens     / 1_000_000) * _PRICING["input"],  6),  # total input cost in USD
            "total_output_cost":       round((self.total_completion_tokens / 1_000_000) * _PRICING["output"], 6),  # total output cost in USD
            "total_cost":              self.session_cost(),                  # combined session cost in USD
            "turns":                   self.turn_stats,                     # list of per-turn records with tokens and cost
            "history":                 self.history,                        # raw message list — can be replayed or analysed
        }
        with open(path, "w", encoding="utf-8") as f:
            json.dump(session, f, indent=2, ensure_ascii=False)  # indent=2 keeps the file human-readable; ensure_ascii=False preserves unicode



Building the Terminal App: main.py


The entry point handles the input loop, commands, rendering, and auto-save.



Imports and Setup




import time                   # measures elapsed time per turn
from datetime import datetime  # generates the session filename timestamp
from pathlib import Path       # constructs the sessions/ directory path

from rich.console import Console   # renders coloured text and rules in the terminal
from rich.panel import Panel       # renders the assistant reply in a bordered box
from rich.markdown import Markdown # renders the assistant's reply as formatted Markdown

from chatbot import Chatbot   # the chatbot class that handles history and API calls

console = Console()   # single Console instance shared across all display functions
bot     = Chatbot()   # single Chatbot instance — its history grows throughout the session

SESSIONS_DIR    = Path(__file__).parent / "sessions"  # sessions/ folder sits next to main.py
_session_file: Path | None = None  # set once on the first auto-save — the same file is reused for the whole session


sessionfile is set to None at startup and assigned once on the first auto-save. Every subsequent save overwrites the same file rather than creating a new one per turn.



Auto-Save




def auto_save() -> None:
    global _session_file
    if not bot.history:           # nothing to save if no messages have been exchanged yet
        return
    SESSIONS_DIR.mkdir(exist_ok=True)   # create sessions/ if it does not exist — exist_ok=True prevents an error on subsequent calls
    if _session_file is None:
        _session_file = SESSIONS_DIR / f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"  # filename is set once using the time of the first save
    bot.save_session(str(_session_file))  # overwrite the same file — always contains the latest state

auto_save() is called after every response and on exit. Because sessionfile is assigned only once, a 10-turn conversation produces one file, not ten.



Commands




def print_help() -> None:
    console.print("  [bold]Commands:[/bold]")
    console.print("    [cyan]reset[/cyan]   — clear conversation and start fresh")    # wipes history and resets counters
    console.print("    [cyan]history[/cyan] — show the conversation so far")          # prints all messages in colour
    console.print("    [cyan]stats[/cyan]   — show session token usage and cost")     # prints cumulative token and cost totals
    console.print("    [cyan]exit[/cyan]    — quit the chatbot\n")                    # saves and exits


def print_stats() -> None:
    console.print(f"\n  [bold]Session Stats:[/bold]")
    console.print(f"    Turns            : {len(bot.history) // 2}")                               # number of complete exchanges
    console.print(f"    Prompt tokens    : {bot.total_prompt_tokens}")                             # cumulative input tokens
    console.print(f"    Completion tokens: {bot.total_completion_tokens}")                         # cumulative output tokens
    console.print(f"    Total tokens     : {bot.total_prompt_tokens + bot.total_completion_tokens}")  # grand total
    console.print(f"    Session cost     : ${bot.session_cost():.6f}\n")                           # total cost in USD


def print_history() -> None:
    if not bot.history:
        console.print("  [dim]No conversation yet.[/dim]\n")
        return
    console.print()
    for msg in bot.history:
        role  = msg["role"].title()                             # "user" becomes "User", "assistant" becomes "Assistant"
        color = "cyan" if msg["role"] == "user" else "green"   # user messages in cyan, assistant replies in green
        console.print(f"  [{color}][{role}][/{color}]  {msg['content']}\n")



The Input Loop




def run() -> None:
    print_header()
    console.print("  Type your message and press Enter. Type [bold]help[/bold] to see commands.\n")

    while True:
        try:
            user_input = input("  You: ").strip()   # .strip() removes accidental leading/trailing whitespace
        except (KeyboardInterrupt, EOFError):
            console.print("\n  Goodbye.")
            auto_save()   # save on Ctrl+C — the session is not lost if the user closes the terminal
            break

        if not user_input:
            continue   # ignore empty input — loop back to the prompt

        cmd = user_input.lower()

        if cmd in ("exit", "quit"):
            console.print("  Goodbye.")
            auto_save()   # save on clean exit — final state is written before the process terminates
            break

        if cmd == "help":
            print_help()
            continue

        if cmd == "reset":
            bot.reset()   # clears history and resets all counters — next message starts fresh
            console.print("  [yellow]Conversation cleared. Starting fresh.[/yellow]\n")
            continue

        if cmd == "stats":
            print_stats()
            continue

        if cmd == "history":
            print_history()
            continue

        console.print("  [dim]Thinking...[/dim]")   # shown immediately so the user sees activity during the API call

        try:
            start  = time.time()
            result = bot.chat(user_input)            # sends the message + full history to the model and waits for the reply
            elapsed = round(time.time() - start, 2)  # wall-clock time for this turn
        except Exception as exc:
            console.print(f"  [red]Error: {exc}[/red]\n")
            continue   # on error, skip the display and loop back to the prompt

        console.print()
        console.print(Panel(
            Markdown(result["reply"]),                      # render the reply as Markdown — handles bold, lists, code blocks
            title="[bold green]Assistant[/bold green]",
            border_style="green",
            padding=(1, 2),                                 # 1 line vertical padding, 2 chars horizontal
        ))

        console.print()
        auto_save()   # persist the updated session after every response — the file is always up to date



Running the Chatbot


python main.py

The terminal displays:




How the Session File Looks



Every session is saved to sessions/session_YYYYMMDD_HHMMSS.json:




{
  "session_start": "2026-06-15T10:52:38.749079",
  "session_end": "2026-06-15T10:53:49.380267",
  "total_turns": 3,
  "total_prompt_tokens": 323,
  "total_completion_tokens": 65,
  "total_tokens": 388,
  "total_input_cost": 4.8e-05,
  "total_output_cost": 3.9e-05,
  "total_cost": 8.7e-05,
  "turns": [
    {
      "turn": 1,
      "timestamp": "2026-06-15T10:52:45.913718",
      "user_message": "My name is John and I work as a Python developer.",
      "reply": "Nice to meet you, John! As a Python developer, what kind of projects are you currently working on or interested in?",
      "prompt_tokens": 65,
      "completion_tokens": 25,
      "total_tokens": 90,
      "input_cost": 1e-05,
      "output_cost": 1.5e-05,
      "turn_cost": 2.5e-05
    },
    {
      "turn": 2,
      "timestamp": "2026-06-15T10:52:51.089823",
      "user_message": "What do I do for work?",
      "reply": "You mentioned that you work as a Python developer. If you’d like to share more about the specific projects or technologies you’re using, I’d love to hear about it!",
      "prompt_tokens": 105,
      "completion_tokens": 35,
      "total_tokens": 140,
      "input_cost": 1.6e-05,
      "output_cost": 2.1e-05,
      "turn_cost": 3.7e-05
    },
    {
      "turn": 3,
      "timestamp": "2026-06-15T10:52:56.108679",
      "user_message": "What is my name?",
      "reply": "Your name is John.",
      "prompt_tokens": 153,
      "completion_tokens": 5,
      "total_tokens": 158,
      "input_cost": 2.3e-05,
      "output_cost": 3e-06,
      "turn_cost": 2.6e-05
    }
  ],
  "history": [
    {
      "role": "user",
      "content": "My name is John and I work as a Python developer."
    },
    {
      "role": "assistant",
      "content": "Nice to meet you, John! As a Python developer, what kind of projects are you currently working on or interested in?"
    },
    {
      "role": "user",
      "content": "What do I do for work?"
    },
    {
      "role": "assistant",
      "content": "You mentioned that you work as a Python developer. If you’d like to share more about the specific projects or technologies you’re using, I’d love to hear about it!"
    },
    {
      "role": "user",
      "content": "What is my name?"
    },
    {
      "role": "assistant",
      "content": "Your name is John."
    }
  ]
}


Type any message. The assistant replies, the session is saved automatically, and you can keep the conversation going as long as you like.




Who Can Benefit


  • Students who want to understand how LLM memory actually works at the code level

  • Developers building their first conversational AI application from scratch

  • Product teams exploring chatbot prototypes before committing to a full framework

  • Businesses that want a simple, local chatbot for internal Q&A or support assistance

  • Researchers studying how context length and conversation history affect LLM response quality




How Codersarts Can Help


Building production-grade chatbots involves more than memory: you need persistent user profiles, multi-session history, safety filters, integration with your product’s data, and scalable deployment. If you need help taking a prototype to production, Codersarts provides end-to-end development and mentorship.


  • Custom AI chatbot development with memory, tools, and retrieval

  • One-on-one mentorship and code reviews

  • Project-based learning with real-world applications





Continue Your AI Learning Journey with Codersarts


If you enjoyed this article and would like to discover more about modern AI applications, production-ready LLM systems, and real-world RAG and MCP implementations, be sure to explore these other blogs from Codersarts:








Comments


bottom of page