使用-Streamlit-和-Chainlit-快速构建聊天机器人原型
使用 Streamlit 和 Chainlit 快速构建聊天机器人原型
原文:
towardsdatascience.com/rapid-prototyping-of-chatbots-with-streamlit-and-chainlit/
因此,Streamlit 于 2019 年作为一个 Python 框架推出,简化了需要用户界面(UI)的 AI 应用程序的原型设计过程。数据科学家和工程师可以专注于后端部分(例如,训练 ML 模型并通过 API 公开预测端点),只需几行 Python 代码,Streamlit 就可以启动一个用户友好、可定制的 UI。Chainlit 也是一个 Python 框架,最近于 2023 年推出,专门解决原型设计对话式 AI 应用程序(即聊天机器人)的痛点。虽然 Streamlit 和 Chainlit 在某些方面相似,但也有重要的区别。在本文中,我们将通过构建端到端演示聊天机器人应用程序来检查这两个框架的优缺点,并提供实际建议。
注意: 下文中所有图表均由本文作者创建。
端到端聊天机器人演示
本地设置
为了简单起见,我们将构建演示应用程序,以便可以使用开源大型语言模型(LLMs)在本地环境中轻松测试,这些 LLMs 通过 Ollama 访问,Ollama 是一种在用户本地机器上以用户友好的方式下载、管理和与开源 LLMs 交互的工具。
当然,演示可以在以后修改以用于生产,例如,通过利用 OpenAI 或 Google 等公司提供的最新 LLM,以及在 AWS、Azure 或 GCP 等常用超大规模云服务上部署聊天机器人。以下所有实现步骤已在 macOS Sequoia 15.6.1 上测试,Linux 和 Windows 上应大致相似。
前往这里下载并安装 Ollama。通过在终端运行此命令来检查安装是否成功:
ollama --version
我们将使用 Google 的轻量级 Gemma 2 模型,该模型具有 20 亿个参数,可以使用以下命令下载:
ollama pull gemma:2b
模型文件大小约为 1.7 GB,因此下载可能需要几分钟,具体取决于您的网络连接。使用以下命令验证模型是否已下载:
ollama list
这将显示迄今为止通过 Ollama 下载的所有模型。
接下来,我们将使用uv,一个快速且用户友好的 Python 项目管理工具,来设置项目目录。按照这里的说明安装uv,并使用以下命令验证安装:
uv --version
在您的本地机器上合适的位置初始化一个名为chatbot-demos的项目目录,如下所示:
uv init --bare chatbot-demos
如果没有指定--bare选项,uv在初始化过程中会创建一些标准工件,例如main.py、README.md和一个 Python 版本固定文件,但对我们这些演示来说这些不是必需的。最小化过程只创建一个pyproject.toml文件。
在chatbot-demos项目目录中,创建一个包含以下依赖项的requirements.txt文件:
chainlit==2.7.2
ollama==0.5.3
streamlit==1.49.1
现在在项目目录内创建一个虚拟 Python 3.12 环境,激活该环境,并安装依赖项:
uv venv --python=3.12
source .venv/bin/activate
uv add -r requirements.txt
检查依赖项是否已安装:
uv pip list
我们将实现一个名为LLMClient的类,用于后端功能,该功能可以与以 UI 为中心的功能解耦,这是 Streamlit 和 Chainlit 等框架的关键区别。例如,LLMClient可以处理诸如在 LLM 提供商之间选择、执行 LLM 调用、与外部数据库交互以进行检索增强生成(RAG)以及记录对话历史以便后续分析等任务。以下是一个LLMClient的示例实现,保存在一个名为llm_client.py的文件中:
import logging
import time
from datetime import datetime, timezone
from typing import List, Dict, Optional, Callable, Any, Generator
import os
import ollama
LOG_FILE = os.path.join(os.path.dirname(__file__), "conversation_history.log")
logger = logging.getLogger("conversation_logger")
logger.setLevel(logging.INFO)
if not logger.handlers:
fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
fmt = logging.Formatter("%(asctime)s - %(message)s")
fh.setFormatter(fmt)
logger.addHandler(fh)
class LLMClient:
def __init__(
self,
provider: str = "ollama",
model: str = "gemma:2b",
temperature: float = 0.2,
retriever: Optional[Callable[[str], List[str]]] = None,
feedback_handler: Optional[Callable[[Dict[str, Any]], None]] = None,
logger: Optional[Callable[[Dict[str, Any]], None]] = None
):
self.provider = provider
self.model = model
self.temperature = temperature
self.retriever = retriever
self.feedback_handler = feedback_handler
self.logger = logger or self.default_logger
def default_logger(self, data: Dict[str, Any]):
logging.info(f"[LLMClient] {data}")
def _format_messages(self, messages: List[Dict[str, str]]) -> str:
return "\n".join(f"{m['role'].capitalize()}: {m['content']}" for m in messages)
def _stream_provider(self, prompt: str, temperature: float) -> Generator[str, None, None]:
if self.provider == "ollama":
for chunk in ollama.generate(
model=self.model,
prompt=prompt,
stream=True,
options={"temperature": temperature}
):
yield chunk.get("response", "")
else:
raise ValueError(f"Streaming not implemented for provider: {self.provider}")
def stream_generate(
self,
messages: List[Dict[str, str]],
on_token: Callable[[str], None],
temperature: Optional[float] = None
) -> Dict[str, Any]:
start_time = time.time()
if self.retriever:
query = messages[-1]["content"]
docs = self.retriever(query)
if docs:
context_str = "\n".join(docs)
messages = [{"role": "system", "content": f"Use this context:\n{context_str}"}] + messages
prompt = self._format_messages(messages)
assembled_text = ""
temp_to_use = temperature if temperature is not None else self.temperature
try:
for token in self._stream_provider(prompt, temp_to_use):
assembled_text += token
on_token(token)
except Exception as e:
assembled_text = f"Error: {e}"
latency = time.time() - start_time
result = {
"text": assembled_text,
"timestamp": datetime.now(timezone.utc),
"latency": latency,
"provider": self.provider,
"model": self.model,
"temperature": temp_to_use,
"messages": messages
}
self.logger({
"event": "llm_stream_call",
"provider": self.provider,
"model": self.model,
"temperature": temp_to_use,
"latency": latency,
"prompt": prompt,
"response": assembled_text
})
return result
def record_feedback(self, feedback: Dict[str, Any]):
if self.feedback_handler:
self.feedback_handler(feedback)
else:
self.logger({"event": "feedback", **feedback})
def log_interaction(self, role: str, content: str):
logger.info(f"{role.upper()}: {content}")
基本 Streamlit 演示
在项目目录中创建一个名为st_app_basic.py的文件,并粘贴以下代码:
import streamlit as st
from llm_client import LLMClient
MAX_HISTORY = 5
llm_client = LLMClient(provider="ollama", model="gemma:2b")
st.set_page_config(page_title="Streamlit Basic Chatbot", layout="centered")
st.title("Streamlit Basic Chatbot")
if "messages" not in st.session_state:
st.session_state.messages = []
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# User input
if prompt := st.chat_input("Type your message..."):
st.session_state.messages.append({"role": "user", "content": prompt})
st.session_state.messages = st.session_state.messages[-MAX_HISTORY:]
llm_client.log_interaction("user", prompt)
with st.chat_message("assistant"):
response_container = st.empty()
state = {"full_response": ""}
def on_token(token):
state["full_response"] += token
response_container.markdown(state["full_response"])
result = llm_client.stream_generate(st.session_state.messages, on_token)
st.session_state.messages.append({"role": "assistant", "content": result["text"]})
llm_client.log_interaction("assistant", result["text"])
以这种方式在localhost:8501启动应用程序:
streamlit run st_app_basic.py
如果应用程序没有在您的默认浏览器中自动打开,请手动导航到 URL(localhost:8501)。您应该看到一个基本的聊天界面。在提示字段中输入以下问题并按 Enter 键:
摄氏度转换成华氏度的公式是什么?
图 1 显示了结果:

图 1:初始 Streamlit 问答
现在,提出这个后续问题:
你能用 Python 实现那个公式吗?
由于我们的演示实现会跟踪最多 5 条之前的对话历史,聊天机器人将能够将“那个公式”与下面的提示中的公式关联起来,如图 2 所示:

图 2:后续 Streamlit 问答
随意尝试一些更多的提示。要关闭应用程序,请在终端中执行Control + c。
基本 Chainlit 演示
在项目目录中创建一个名为cl_app_basic.py的文件,并粘贴以下代码:
import chainlit as cl
from llm_client import LLMClient
MAX_HISTORY = 5
llm_client = LLMClient(provider="ollama", model="gemma:2b")
@cl.on_chat_start
async def start():
await cl.Message(content="Welcome! Ask me anything.").send()
cl.user_session.set("messages", [])
@cl.on_message
async def main(message: cl.Message):
messages = cl.user_session.get("messages")
messages.append({"role": "user", "content": message.content})
messages[:] = messages[-MAX_HISTORY:]
llm_client.log_interaction("user", message.content)
state = {"full_response": ""}
def on_token(token):
state["full_response"] += token
result = llm_client.stream_generate(messages, on_token)
messages.append({"role": "assistant", "content": result["text"]})
llm_client.log_interaction("assistant", result["text"])
await cl.Message(content=result["text"]).send()
以这种方式在localhost:8000(注意端口不同)启动应用程序:
chainlit run cl_app_basic.py
为了比较,我们将运行之前相同的两个提示。结果如图 3 和图 4 所示:

图 3:初始 Chainlit 问答

图 4:后续链式问答
如前所述,在尝试了一些更多提示后,通过在终端中执行 Control + c 来关闭应用。
高级 Streamlit 示例
现在,我们将扩展基本的 Streamlit 示例,在左侧添加一个带有滑块小部件的持久侧边栏,用于切换 LLM 的温度参数,一个按钮用于下载聊天历史,以及每个聊天机器人响应下方的反馈按钮(“有帮助”,“没有帮助”)。在 Streamlit 中,自定义应用布局和添加全局小部件相对容易,但在 Chainlit 中可能比较繁琐——感兴趣的读者可以尝试一下,以亲身体验困难。
这里是扩展后的 Streamlit 应用,保存在名为 st_app_advanced.py 的文件中:
import streamlit as st
from llm_client import LLMClient
import json
MAX_HISTORY = 5
llm_client = LLMClient(provider="ollama", model="gemma:2b")
st.set_page_config(page_title="Streamlit Advanced Chatbot", layout="wide")
st.title("Streamlit Advanced Chatbot")
# Sidebar controls
st.sidebar.header("Model Settings")
temperature = st.sidebar.slider("Temperature", 0.0, 1.0, 0.2, 0.1) # min, max, default, increment size
st.sidebar.download_button(
"Download Chat History",
data=json.dumps(st.session_state.get("messages", []), indent=2),
file_name="chat_history.json",
mime="application/json"
)
if "messages" not in st.session_state:
st.session_state.messages = []
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# User input
if prompt := st.chat_input("Type your message..."):
st.session_state.messages.append({"role": "user", "content": prompt})
st.session_state.messages = st.session_state.messages[-MAX_HISTORY:]
llm_client.log_interaction("user", prompt)
with st.chat_message("assistant"):
response_container = st.empty()
state = {"full_response": ""}
def on_token(token):
state["full_response"] += token
response_container.markdown(state["full_response"])
result = llm_client.stream_generate(
st.session_state.messages,
on_token,
temperature=temperature
)
llm_client.log_interaction("assistant", result["text"])
st.session_state.messages.append({"role": "assistant", "content": result["text"]})
# Feedback buttons
col1, col2 = st.columns(2)
if col1.button("Helpful"):
llm_client.record_feedback({"rating": "up", "comment": "User liked the answer"})
if col2.button("Not Helpful"):
llm_client.record_feedback({"rating": "down", "comment": "User disliked the answer"})
图 5 展示了一个示例截图:

图 5:高级 Streamlit 功能演示
高级 Chainlit 示例
接下来,我们将扩展基本的 Chainlit 示例,添加每条消息的交互式动作和多模态输入处理(在我们的案例中是文本和图像)。Chainlit 框架的聊天原生原语使得实现这些功能比在 Streamlit 中更容易。再次,鼓励感兴趣的读者尝试使用 Streamlit 重复这些功能,以体验差异。
这里是扩展后的 Chainlit 应用,保存在名为 cl_app_advanced.py 的文件中:
import os
import json
from typing import List, Dict
import chainlit as cl
from llm_client import LLMClient
MAX_HISTORY = 5
DEFAULT_TEMPERATURE = 0.2
SESSIONS_DIR = os.path.join(os.path.dirname(__file__), "sessions")
os.makedirs(SESSIONS_DIR, exist_ok=True)
llm_client = LLMClient(provider="ollama", model="gemma:2b", temperature=DEFAULT_TEMPERATURE)
def _session_file(session_name: str) -> str:
safe = "".join(c for c in session_name if c.isalnum() or c in ("-", "_"))
return os.path.join(SESSIONS_DIR, f"{safe or 'default'}.json")
def _save_session(session_name: str, messages: List[Dict]):
with open(_session_file(session_name), "w", encoding="utf-8") as f:
json.dump(messages, f, ensure_ascii=False, indent=2)
def _load_session(session_name: str) -> List[Dict]:
path = _session_file(session_name)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return []
@cl.on_chat_start
async def start():
cl.user_session.set("messages", [])
cl.user_session.set("session_name", "default")
cl.user_session.set("last_assistant_idx", None)
await cl.Message(
content=(
"Welcome! Ask me anything."
),
actions=[
cl.Action(name="set_session_name", label="Set session name", payload={"turn": None}),
cl.Action(name="save_session", label="Save session", payload={"turn": "save"}),
cl.Action(name="load_session", label="Load session", payload={"turn": "load"}),
],
).send()
@cl.action_callback("set_session_name")
async def set_session_name(action):
await cl.Message(content="Please type: /name YOUR_SESSION_NAME").send()
@cl.action_callback("save_session")
async def save_session(action):
session_name = cl.user_session.get("session_name")
_save_session(session_name, cl.user_session.get("messages", []))
await cl.Message(content=f"Session saved as '{session_name}'.").send()
@cl.action_callback("load_session")
async def load_session(action):
session_name = cl.user_session.get("session_name")
loaded = _load_session(session_name)
cl.user_session.set("messages", loaded[-MAX_HISTORY:])
await cl.Message(content=f"Loaded session '{session_name}' with {len(loaded)} turn(s).").send()
@cl.on_message
async def main(message: cl.Message):
if message.content.strip().startswith("/name "):
new_name = message.content.strip()[6:].strip() or "default"
cl.user_session.set("session_name", new_name)
await cl.Message(content=f"Session name set to '{new_name}'.").send()
return
messages = cl.user_session.get("messages")
user_text = message.content or ""
if message.elements:
for element in message.elements:
if getattr(element, "mime", "").startswith("image/"):
user_text += f" [Image: {element.name}]"
messages.append({"role": "user", "content": user_text})
messages[:] = messages[-MAX_HISTORY:]
llm_client.log_interaction("user", user_text)
state = {"full_response": ""}
msg = cl.Message(content="")
def on_token(token: str):
state["full_response"] += token
cl.run_sync(msg.stream_token(token))
result = llm_client.stream_generate(messages, on_token, temperature=DEFAULT_TEMPERATURE)
messages.append({"role": "assistant", "content": result["text"]})
llm_client.log_interaction("assistant", result["text"])
msg.content = state["full_response"]
await msg.send()
turn_idx = len(messages) - 1
cl.user_session.set("last_assistant_idx", turn_idx)
await cl.Message(
content="Was this helpful?",
actions=[
cl.Action(name="thumbs_up", label="Yes", payload={"turn": turn_idx}),
cl.Action(name="thumbs_down", label="No", payload={"turn": turn_idx}),
cl.Action(name="save_session", label="Save session", payload={"turn": "save"}),
],
).send()
@cl.action_callback("thumbs_up")
async def thumbs_up(action):
turn = action.payload.get("turn")
llm_client.record_feedback({"rating": "up", "turn": turn})
await cl.Message(content="Thanks for your feedback!").send()
@cl.action_callback("thumbs_down")
async def thumbs_down(action):
turn = action.payload.get("turn")
llm_client.record_feedback({"rating": "down", "turn": turn})
await cl.Message(content="Thanks for your feedback.").send()
图 6 展示了一个示例截图:

图 6:高级 Chainlit 功能演示
实用指南
如前文所述,使用 Streamlit 和 Chainlit 都可以快速原型化简单的聊天机器人应用。在我们实现的基礎示例中,有一些架构上的相似之处:调用 Ollama 和会话记录是通过 LLMClient 类抽象的,上下文大小是通过名为 MAX_HISTORY 的常量变量限制的,历史记录被序列化为纯文本聊天格式。然而,如高级示例所示,每个框架的范围略有不同,这取决于用例,并带来一定的优缺点,以及相关的实际建议。
Streamlit 是一个通用框架,用于构建和部署以数据为中心的交互式 Web 应用,而 Chainlit 专注于构建和部署对话式人工智能应用。因此,如果聊天机器人是原型中的核心,使用 Chainlit 可能更有意义;如上述代码示例所示,Chainlit 处理了几个样板操作细节(例如,内置的聊天功能,包括原生输入指示器、消息流和 Markdown/代码渲染)。但如果聊天机器人嵌入到更大的 AI 产品中,Streamlit 可能能够更好地处理更大的应用范围(例如,将聊天界面与数据可视化、仪表板、全局小部件和自定义布局相结合)。
此外,在人工智能应用中的对话元素可能需要以异步方式处理,以确保良好的用户体验(UX),因为消息可以随时到达,需要在其他任务进行时(例如,调用另一个 API 或流式传输模型输出)快速处理。Chainlit 通过使用 Python 的async和await关键字,使得原型化异步聊天逻辑变得简单,确保应用能够处理并发操作而不会阻塞用户界面。该框架负责管理 WebSocket 连接和自定义轮询的低级细节,因此每当触发事件(例如,发送消息、流式传输令牌、状态改变)时,Chainlit 的事件处理逻辑会自动触发所需的 UI 更新。相比之下,Streamlit 使用同步通信,这会导致应用脚本在每次用户交互时重新运行;对于需要处理多个并发进程的复杂应用,Chainlit 可能比 Streamlit 提供更平滑的用户体验。
最后,除了主要关注基于聊天应用带来的限制之外,Chainlit 比 Streamlit 晚几年发布,因此目前技术上不够成熟,开发者社区也较小;例如,目前可用的第三方扩展、社区贡献的示例和故障排除资源较少。尽管 Chainlit 发展迅速,差距正在积极解决,但开发者可能会在版本之间遇到偶尔的破坏性更改、高级用例的文档不够全面,以及某些部署环境的集成指南有限。因此,如果产品团队希望利用 Chainlit 的潜在长期架构优势来原型化以聊天为中心的人工智能应用,他们应该准备在定制开发、实验以及与框架维护者和相关社区论坛的直接互动上进行一些额外的短期投资,以解决问题和请求额外的功能。

浙公网安备 33010602011771号