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 unicodeBuilding 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.pyThe 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
Get in touch: codersarts.com | contact@codersarts.com
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:
Build a Cost-Efficient Writing Quality Checker with Tiered Model Routing and OpenAI
Build Your First A2A Agent: An Email Drafting Pipeline Using Python and OpenAI
Building an AI Interview Prep Agent with Qwen 3.7 Max and Streamlit
https://www.codersarts.com/post/building-an-ai-interview-prep-agent-with-qwen-3-7-max-and-streamlit
Building an AI Book Recommender with Kimi K2 and Streamlit
https://www.codersarts.com/post/building-an-ai-book-recommender-with-kimi-k2-and-streamlit




Comments