Posted on 23 Feb 2026

Everyone says building a chatbot with LLMs is easy. Just call an API, send a prompt, and get a response.
That illusion lasts exactly until you try to turn it into something real β structured, extensible, configurable, and production-ready.
This article walks you through the process step by step, using Python and LangChain. Instead of jumping directly to advanced patterns, we will progressively build a chatbot from the ground up β understanding each layer as we go.
You should read this article if:
The content is based on a structured GitHub tutorial series:
π https://github.com/sasadangelo/langchain-tutorials
The final result of these lessons will be ChatterPy: a Python LLM chatbot built with LangChain, multi-provider, multi-protocol, multi-model, highly configurable, and ready to support Retrieval-Augmented Generation (RAG).
Letβs start by clarifying three foundational concepts that often create confusion: Providers, Protocols, and Models.
When working with LangChain, the same chatbot can run on different vendors, different APIs, and even locally. To avoid confusion, you must clearly separate three concepts: Providers, Protocols, and Models.
Providers are the companies that give you access to LLMs.
Examples:
They expose APIs (usually via API keys) and handle hosting, billing, and infrastructure.
When you configure credentials in LangChain, you are selecting a provider. Some providers, like Meta, give you tools (e.g. Ollama) to run the models on your local machines.
A protocol defines how your application talks to the model. Important distinction:
OpenAI (the company) is not the same as the OpenAI API protocol.
The OpenAI API format has become a de facto standard. Many tools support it, including:
If two systems expose the same protocol, your LangChain code often doesnβt need to change. However, there are other proprietary protocols like Ollama, Anthropic, WatsonX, etc. Usually, for each of them, LangChain offers a dedicated class.
Models are the actual LLMs generating text. Examples:
Providers expose models. Protocols define how you call them. Models produce the output.
Understanding this separation makes your chatbot architecture flexible and portable.
Before building a structured chatbot, we start from the simplest possible interaction:
Send a question. Receive a response.
In this section, we show how to use three different protocols:
The key idea is simple:
The combination of URL + API Key determines which provider you connect to.
Once the protocol is supported, switching provider often requires only configuration changes β not architectural changes.
Ollama allows you to run models locally.
from langchain_core.messages.ai import AIMessage
from langchain_ollama import ChatOllama
# Connect to a local Ollama server
chat: ChatOllama = ChatOllama(model="llama3.1:latest")
response: AIMessage = chat.invoke(input="Who is Robinson Crusoe?")
print(response.content)
This connects to the Ollama server running locally (default: http://localhost:11434).
You can use any model downloaded in Ollama, such as:
OpenAI defined an API format that has become a de facto standard. Many systems support it:
from dotenv import load_dotenv
from langchain_core.messages.ai import AIMessage
from langchain_openai import ChatOpenAI
load_dotenv()
chat: ChatOpenAI = ChatOpenAI(
model="llama3.1:latest",
base_url="http://localhost:11434/v1"
)
response: AIMessage = chat.invoke(input="Who is Robinson Crusoe?")
print(response.content)
By changing base_url and the API key, you can switch from a local server to OpenAI cloud β without changing the application logic.
IBM provides access to models through WatsonX.
import os
from typing import Any
from dotenv import load_dotenv
from ibm_watsonx_ai.metanames import GenTextParamsMetaNames as GenParams
from langchain_core.messages.ai import AIMessage
from langchain_ibm import ChatWatsonx
load_dotenv()
parameters: dict[str, Any] = {
GenParams.DECODING_METHOD: "sample",
GenParams.MIN_NEW_TOKENS: 1,
GenParams.MAX_NEW_TOKENS: 200,
GenParams.TEMPERATURE: 0.7,
}
space_id: str | None = os.getenv("WATSONX_SPACE_ID")
chat: ChatWatsonx = ChatWatsonx(
model_id="ibm/granite-4-h-small",
url="https://eu-de.ml.cloud.ibm.com",
space_id=space_id,
params=parameters,
)
response: AIMessage = chat.invoke(input="Who is Robinson Crusoe?")
print(response.content)
Notice that in all three cases the interaction pattern is identical:
response = chat.invoke(input="...")
print(response.content)
Only the configuration changes. This is the foundation of a multi-provider, multi-protocol chatbot architecture β and it is the first building block of ChatterPy.
invoke waits for the LLM to finish generating the entire response before returning it. On long answers this creates a noticeable delay β you stare at a blank line until the model is done. Modern chatbots like ChatGPT and Claude mitigate this with streaming: text appears word-by-word as it is generated.
All three Chat<Provider> classes expose a stream method alongside invoke. Instead of returning a single AIMessage, it yields AIMessageChunk objects one at a time. The code is identical across all providers:
for chunk in chat.stream(input="Who is Robinson Crusoe?"):
print(chunk.content, end="", flush=True)
print("")
end="" keeps chunks on the same line; flush=True makes each chunk appear immediately; the final print("") moves to a new line. Everything else β configuration, factory, tools β stays the same; you only swap invoke for stream where you print the response.
A robust Python chatbot requires a robust project structure.
Dependency management, separation between production and development dependencies, reproducible environments, and tools like pre-commit hooks are not optional in serious projects.
For dependency management, I recommend using uv, which provides fast and deterministic environment resolution.
I have already covered proper Python project setup in detail in this article. The project used in this tutorial is based on the following blueprint.
If you want to understand the full setup (dependencies, tooling, configuration, project layout), please refer to that article.
In Lesson 1 we saw that switching between providers, protocols, and models is mostly a configuration concern.
In Lesson 2, we introduce a configuration-driven approach.
Instead of hardcoding provider logic, we define everything in a config.yaml file:
protocol:
name: "ollama"
api_url: http://localhost:11434
model:
name: "llama3.1:latest"
See the full example here. The idea is simple:
config.yaml.envConfiguration loading and validation are handled using Pydantic Settings, which I previously covered in detail in this article. At application startup:
This approach ensures that:
The configuration implementation is available here. Since configuration management is not the main focus of this article, I recommend reading the code directly for implementation details.
Once configuration is externalized, the next step is to leverage it to dynamically instantiate the correct protocol implementation.
Instead of spreading if/else logic across the codebase, we introduce:
We start with an abstract base class:
from abc import ABC, abstractmethod
from langchain_core.language_models.base import LanguageModelInput
from langchain_core.messages import AIMessage
class LLMProtocol(ABC):
def __init__(self) -> None:
self.create_protocol()
@abstractmethod
def create_protocol(self) -> None:
pass
@abstractmethod
def invoke(self, messages: LanguageModelInput) -> AIMessage:
pass
This guarantees that every provider implementation exposes the same interface.
For example, the Ollama implementation:
from core import chatterpy_config
from langchain_core.language_models.base import LanguageModelInput
from langchain_core.messages import AIMessage
from langchain_ollama import ChatOllama
from protocols.protocol import LLMProtocol
class OllamaProtocol(LLMProtocol):
def create_protocol(self) -> None:
self._protocol = ChatOllama(
model=chatterpy_config.protocol.model.name
)
def invoke(self, messages: LanguageModelInput) -> AIMessage:
return self._protocol.invoke(input=messages)
Each provider (OpenAI, WatsonX, etc.) implements the same contract.
Finally, we centralize protocol selection:
from typing import Any
from core import ProtocolName, chatterpy_config
from protocols.ollama_protocol import OllamaProtocol
from protocols.openai_protocol import OpenAIProtocol
from protocols.watsonx_protocol import WatsonXProtocol
from protocols.protocol import LLMProtocol
class LLMProtocolFactory:
protocols: dict[ProtocolName, Any] = {
ProtocolName.OLLAMA: OllamaProtocol,
ProtocolName.OPENAI: OpenAIProtocol,
ProtocolName.WATSONX: WatsonXProtocol,
}
@classmethod
def get_protocol(cls) -> LLMProtocol:
protocol_name = chatterpy_config.protocol.name
protocol_class = cls.protocols.get(protocol_name)
if not protocol_class:
raise ValueError(f"Unsupported provider: {protocol_name}")
return protocol_class()
Now the application simply calls
protocol = LLMProtocolFactory.get_protocol()
And the correct implementation is instantiated based on configuration.
This is the foundation of a multi-provider, multi-protocol, and multi-model chatbot architecture.
Once configuration allows you to choose provider, protocol, and model, a chatbot is nothing more than an infinite loop of interactions:
from dotenv import load_dotenv
from langchain_core.messages import AIMessage, HumanMessage
from protocols import LLMProtocol, LLMProtocolFactory
load_dotenv()
protocol: LLMProtocol = LLMProtocolFactory.get_protocol()
try:
while True:
user_input: str = input("You: ")
ai_response: AIMessage = protocol.invoke(
messages=[HumanMessage(content=user_input)]
)
print(f"Assistant: {ai_response.content}")
except EOFError:
print("\nBye.")
Thatβs it. A chatbot is simply:
Notice the use of HumanMessage and AIMessage. These abstractions hide protocol-specific details.
Different models expect different prompt formats. For example:
Without abstraction, you would need to manually adapt prompts for each provider.
LangChain normalizes this through message classes, allowing your chatbot loop to remain identical across:
This completes the foundation of ChatterPy: a configuration-driven, multi-provider, multi-protocol chatbot built around a simple but powerful loop.
When you talk to ChatGPT, it feels like the chatbot understands context and remembers previous messages. In reality, an LLM is just a function:
Input text β Output text
It has no memory. The perception of memory is an illusion created by sending previous messages back to the model at every interaction.
If you ask:
βWho is Robinson Crusoe?β
The model will answer correctly. But if you immediately follow with:
βHow did he get to the desert island?
Our current chatbot will likely respond that it does not know who βheβ refers to and will ask for more context.
Why?
Because we are only sending the last message to the model. There is no conversation history being preserved.
This is the context problem.
A real chatbot must:
In other words, we must explicitly implement the illusion of memory.
We will address this problem β and implement conversation memory properly β in the next article.
In this article, we built the foundation of a multi-provider, multi-protocol, and multi-model chatbot using Python and LangChain. We covered:
The next step will be to implement memory and context management, turning ChatterPy from a simple loop into a conversational agent capable of maintaining state and context across multiple interactions.
If you enjoyed this article, donβt forget to give it a clap π, share it with your friends π, and follow me for more tips and tutorials on software development π. Your support helps me create more content like this β thank you! π