Сквозные демонстрации, сравнение плюсов и минусов, практические рекомендации
Делиться

Быстрое прототипирование — это итеративный процесс создания простых версий продукта и сбора регулярной обратной связи с пользователями для быстрой проверки важных предположений и гипотез, а также оценки ключевых рисков. Этот подход тесно связан с практикой гибкой разработки программного обеспечения и процессом «создание-измерение-обучение» в методологии бережливого стартапа и может значительно снизить затраты на разработку и сократить время вывода продукта на рынок. Быстрое прототипирование особенно полезно для успешного выпуска продуктов на основе ИИ, учитывая раннюю стадию развития соответствующих технологий, варианты использования и ожидания пользователей.
С этой целью в 2019 году был запущен Streamlit – фреймворк на Python, упрощающий процесс прототипирования приложений ИИ, требующих пользовательских интерфейсов (UI). Специалисты по анализу данных и инженеры могут сосредоточиться на бэкенд-частях (например, обучении модели машинного обучения и предоставлении конечной точки прогнозирования через API), и всего несколькими строками кода Python Streamlit позволяет создать удобный и настраиваемый пользовательский интерфейс. Chainlit, также фреймворк на Python, был запущен совсем недавно, в 2023 году, специально для решения проблем прототипирования приложений разговорного ИИ (например, чат-ботов). Хотя Streamlit и Chainlit во многом схожи, между ними есть и важные различия. В этой статье мы рассмотрим плюсы и минусы обоих фреймворков, создав сквозные демонстрационные приложения чат-ботов, и дадим практические рекомендации.
Примечание: Все рисунки в следующих разделах созданы автором данной статьи.
Демонстрации сквозного чат-бота
Локальная настройка
Для простоты мы создадим демонстрационные приложения таким образом, чтобы их можно было легко протестировать в локальной среде, используя большие языковые модели с открытым исходным кодом (LLM), доступ к которым осуществляется через Ollama — инструмент для загрузки, управления и взаимодействия с LLM с открытым исходным кодом в удобной для пользователя форме на локальном компьютере.
Конечно, демоверсии впоследствии можно модифицировать для использования в рабочей среде, например, используя новейшие LLM-модели от таких компаний, как OpenAI или Google, и развернув чат-бот на распространённом гипермасштабируемом сервере, таком как AWS, Azure или GCP. Все описанные ниже шаги реализации протестированы на macOS Sequoia 15.6.1 и должны быть примерно одинаковыми на Linux и Windows.
Перейдите по ссылке, чтобы скачать и установить Ollama. Убедитесь, что установка прошла успешно, выполнив следующую команду в Терминале:
оллама —версия
Мы будем использовать облегченную модель Gemma 2 от Google с параметрами 2B, которую можно загрузить с помощью этой команды:
оллама тянуть гемму:2б
Размер файла модели составляет около 1,7 ГБ, поэтому загрузка может занять несколько минут в зависимости от скорости вашего интернет-соединения. Убедитесь, что модель загружена, выполнив следующую команду:
список оллама
Здесь будут показаны все модели, загруженные через Ollama на данный момент.
Далее мы настроим каталог проекта с помощью uv — быстрого и удобного инструмента управления проектами для Python. Следуйте инструкциям по установке uv и проверьте установку с помощью следующей команды:
uv —версия
Инициализируйте каталог проекта с именем 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-пипов
Мы реализуем класс LLMClient для бэкенд-функциональности, которую можно отделить от функциональности, ориентированной на пользовательский интерфейс, что является ключевым отличием таких фреймворков, как Streamlit и Chainlit. Например, LLMClient может взять на себя такие задачи, как выбор поставщиков LLM, выполнение вызовов LLM, взаимодействие с внешними базами данных для генерации дополненных поиском данных (RAG) и ведение журнала истории разговоров для последующего анализа. Вот пример реализации LLMClient, хранящийся в файле llm_client.py:
импорт logging импорт времени из datetime импорт datetime, часовой пояс из ввода import List, Dict, Optional, Callable, Any, Generator импорт os импорт ollama LOG_FILE = os.path.join(os.path.dirname(__file__), «conversation_history.log») logger = logging.getLogger(«conversation_logger») logger.setLevel(logging.INFO) если не logger.handlers: fh = logging.FileHandler(LOG_FILE, encoding=»utf-8″) fmt = logging.Formatter(«%(asctime)s — %(message)s») fh.setFormatter(fmt) logger.addHandler(fh) класс LLMClient: def __init__( self, provider: str = «ollama», model: str = «gemma:2b», temperature: float = 0.2, извлекатель: Необязательный[Вызываемый[[str], Список[str]]] = Нет, обработчик_отклика: Необязательный[Вызываемый[[Dict[str, Any]], None]] = Нет, регистратор: Необязательный[Вызываемый[[Dict[str, Any]], None]] = Нет ): self.provider = поставщик self.model = модель self.temperature = температура self.retriever = извлекатель self.feedback_handler = обработчик_отклика self.logger = регистратор или 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, температура: float) -> Генератор[str, None, None]: если 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»Потоковая передача не реализована для поставщика: {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»Использовать этот контекст:n{context_str}»}] + messages prompt = self._format_messages(messages) collected_text = «» temp_to_use = temperature если температура не None else self.temperature try: for token in self._stream_provider(prompt, temp_to_use): collected_text += token on_token(token) except Exception as e: collected_text = f»Error: {e}» latency = time.time() — start_time result = { «text»: collected_text, «timestamp»: datetime.now(timezone.utc), «latency»: задержка, «provider»: self.provider, «model»: self.model, «temperature»: temp_to_use, «messages»: сообщения } self.logger({ «event»: «llm_stream_call», «provider»: self.provider, «model»: self.model, «temperature»: temp_to_use, «latency»: задержка, «prompt»: prompt, «response»: collected_text }) вернуть результат 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 = [] # Отображение истории чата для msg in st.session_state.messages: with st.chat_message(msg[«role»]): st.markdown(msg[«content»]) # Пользовательский ввод if prompt := st.chat_input(«Введите ваше сообщение…»): st.session_state.messages.append({«role»: «user», «content»: prompt}) st.session_state.messages = st.session_state.messages[-MAX_HISTORY:] llm_client.log_interaction(«пользователь», приглашение) с st.chat_message(«помощник»): response_container = st.empty() state = {«full_response»: «»} def on_token(токен): state[«full_response»] += токен response_container.markdown(state[«full_response»]) result = llm_client.stream_generate(st.session_state.messages, on_token) st.session_state.messages.append({«role»: «помощник», «content»: result[«текст»]}) llm_client.log_interaction(«помощник», result[«текст»])
Запустите приложение на localhost:8501 следующим образом:
streamlit запустите st_app_basic.py
Если приложение не открывается автоматически в браузере по умолчанию, перейдите по URL-адресу вручную (http://localhost:8501). Вы увидите простой интерфейс чата. Введите следующий вопрос в поле запроса и нажмите Enter:
Какова формула для перевода градусов Цельсия в градусы Фаренгейта?
На рисунке 1 показан результат:

А теперь задайте следующий вопрос:
Можете ли вы реализовать эту формулу на Python?
Поскольку наша демонстрационная реализация отслеживает историю разговоров для 5 предыдущих сообщений, чат-бот сможет связать «эту формулу» с формулой в предыдущем запросе, как показано на рисунке 2 ниже:

Можете поэкспериментировать с другими подсказками. Чтобы закрыть приложение, нажмите 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=»Добро пожаловать! Спросите меня о чем угодно.»).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 запустите cl_app_basic.py
Для сравнения мы выполним те же два запроса, что и раньше. Результаты показаны на рисунках 3 и 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») # Элементы управления боковой панели st.sidebar.header(«Настройки модели») temperature = st.sidebar.slider(«Температура», 0.0, 1.0, 0.2, 0.1) # мин., макс., по умолчанию, размер приращения st.sidebar.download_button( «Загрузить историю чата», 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 = [] # Отображение истории чата для msg в st.session_state.messages: with st.chat_message(msg[«role»]): st.markdown(msg[«content»]) # Пользовательский ввод if prompt := st.chat_input(«Введите ваше сообщение…»): 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»]}) # Кнопки обратной связи col1, col2 = st.columns(2) if col1.button(«Helpful»): llm_client.record_feedback({«rating»: «up», «comment»: «Пользователю понравился ответ»}) if col2.button(«Not Helpful»): llm_client.record_feedback({«rating»: «down», «comment»: «Пользователю не понравился ответ»})
На рисунке 5 показан пример снимка экрана:

Расширенная демонстрация 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=( «Добро пожаловать! Задайте мне любой вопрос.» ), actions=[ cl.Action(name=»set_session_name», label=»Установить имя сеанса», payload={«turn»: None}), cl.Action(name=»save_session», label=»Сохранить сеанс», payload={«turn»: «save»}), cl.Action(name=»load_session», label=»Загрузить сеанс», payload={«turn»: «load»}), ], ).send() @cl.action_callback(«set_session_name») async def set_session_name(action): await cl.Message(content=»Введите: /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 сохранено как '{session_name}'.»).send() @cl.action_callback(«load_session») async def load_session(action): session_name = cl.user_session.get(«session_name») загружено = _load_session(session_name) cl.user_session.set(«messages», load[-MAX_HISTORY:]) await cl.Message(content=f»Загружен сеанс '{session_name}' с {len(loaded)} очередью(ями).»).send() @cl.on_message async def main(message: cl.Message): if message.content.strip().startswith(«/name «): new_name = message.content.strip()[6:].strip() или «default» cl.user_session.set(«session_name», new_name) await cl.Message(content=f»Имя сеанса задано как '{new_name}'.').send() return messages = cl.user_session.get(«messages») user_text = message.content или «» 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, температура=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=»Было ли это полезно?», actions=[ cl.Action(name=»thumbs_up», label=»Да», payload={«turn»: turn_idx}), cl.Action(name=»thumbs_down», label=»Нет», payload={«turn»: turn_idx}), cl.Action(name=»save_session», label=»Сохранить сеанс», 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=»Спасибо за ваш отзыв!»).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=»Спасибо за ваш отзыв.»).send()
На рисунке 6 показан пример снимка экрана:

Практическое руководство
Как показано в предыдущем разделе, можно быстро создавать прототипы простых приложений чат-ботов как с помощью Streamlit, так и с помощью Chainlit. В реализованных нами базовых демонстрационных примерах наблюдалось несколько архитектурных сходств: вызовы Ollama и логирование разговоров были абстрагированы с помощью класса LLMClient, размер контекста ограничивался константной переменной MAX_HISTORY, а история сериализовалась в текстовый формат чата. Однако, как показывают расширенные демонстрационные примеры, область применения каждого фреймворка несколько различается, что влечет за собой определенные плюсы и минусы в зависимости от варианта использования, а также соответствующие практические рекомендации.
В то время как Streamlit — это универсальный фреймворк для интерактивных веб-приложений, ориентированных на данные, Chainlit ориентирован на создание и развертывание диалоговых приложений на основе ИИ. Таким образом, использование Chainlit может быть более целесообразным, если чат-бот является центральной частью прототипа; как показывают приведенные выше примеры кода, Chainlit отвечает за ряд стандартных рабочих деталей (например, встроенные функции чата для собственных индикаторов ввода, потоковую передачу сообщений и рендеринг кода/разметки). Но если чат-бот встроен в более крупный продукт на основе ИИ, Streamlit может лучше справиться с более широким спектром приложений (например, объединяя интерфейс чата с визуализацией данных, панелями мониторинга, глобальными виджетами и пользовательскими макетами).
Кроме того, элементы диалога в приложениях ИИ могут потребовать асинхронной обработки для обеспечения хорошего пользовательского опыта (UX), поскольку сообщения могут поступать в любой момент и должны быть обработаны быстро, пока выполняются другие задачи (например, вызов другого API или потоковая передача данных модели). Chainlit упрощает прототипирование асинхронной логики чата с помощью ключевых слов Python async и await, гарантируя, что приложение сможет обрабатывать параллельные операции, не блокируя пользовательский интерфейс. Фреймворк берёт на себя низкоуровневые детали управления соединениями WebSocket и настраиваемыми опросами, так что при возникновении события (например, отправка сообщения, потоковая передача токена, изменение состояния) логика обработки событий Chainlit автоматически запускает обновления пользовательского интерфейса по мере необходимости. Streamlit, напротив, использует синхронную коммуникацию, что приводит к перезапуску скрипта приложения при каждом взаимодействии пользователя; для сложных приложений, которым необходимо совмещать несколько параллельных процессов, Chainlit может обеспечить более плавный пользовательский интерфейс, чем Streamlit.
Наконец, помимо ограничений, связанных с ориентацией преимущественно на чат-приложения, Chainlit был выпущен на несколько лет позже Streamlit, поэтому в настоящее время он менее технически зрелый и имеет меньшее сообщество разработчиков; например, на данный момент доступно меньше сторонних расширений, примеров, предоставленных сообществом, и ресурсов по устранению неполадок. Несмотря на то, что Chainlit быстро развивается, а существующие пробелы активно устраняются, разработчики могут сталкиваться с периодическими критическими изменениями между версиями, менее полной документацией для расширенных вариантов использования и ограниченными рекомендациями по интеграции для определенных сред развертывания. Продуктовые команды, которые все еще хотят создавать прототипы ИИ-приложений на основе чат-ботов с использованием Chainlit из-за потенциальных долгосрочных архитектурных преимуществ, должны быть готовы к дополнительным краткосрочным инвестициям в разработку, эксперименты и прямое взаимодействие с разработчиками фреймворка и соответствующими форумами сообщества для решения проблем и запросов на дополнительную функциональность.
Источник: towardsdatascience.com

























