DE-1. DIY ассистент на LLM
- понедельник, 28 октября 2024 г. в 00:00:07
Привет Хабр, let's set the future.
Недавно у меня появилась идея фикс: 'Хочу собственного AI ассистента'. Казалось бы, нет никаких проблем - рынок предлагает массу готовых решений. Но моя вечная паранойя про утечку данных и стремление сделать все самому взяли верх. Решил поэкспериментировать и собрать ассистента своими руками, да еще как-то с учетом будущих возможностей для гибкой настройки. Времени на оптимизацию производительности и эстетический вид кода у меня не было, 'хочу здесь и сейчас', поэтому let me introduce this shit.
Думаю, стоит сразу описать вкратце окружение:
Для более эффективной работы в рамках linux окружения я использую WSL2 на Windows. На текущий момент используется дистрибутив Ubuntu-22.04.
По поводу главного устройства, которое будет вычислять наши тензоры. GPU на 8gb (пример gtx 1080 и выше) должно хватить. На самом деле если очень не понятно где и как посмотреть требования выбранной вами LLM к памяти, то можно воспользоваться таким ПО как LM Studio.
Чтобы все вычисления запустились на видеокарте, также стоит позаботиться о cuDNN драйверах. Тема установки стоит отдельной статьи, но благо такие уже есть: вариант 1 (все сам), вариант 2 (с помощью conda).
Ollama - фреймворк для локального запуска крупных языковых моделей. Это то, что обязательно нужно для запуска ядра ассистента - LLM. Процесс установки фреймворка описан на официальном сайте.
Для реализации ассистента я выбрал три ключевые нейросети:
STT - Whisper. Это модель для распознавания речи, разработанная OpenAI. Она способна обрабатывать аудиофайлы и переводить их в текст, поддерживает множество языков и может работать даже в условиях шума.
LLM - Llama3. Это относительно новая LLM, по сравнению со своими предшественниками, она обладает улучшенной производительностью и более совершенными параметрами модели. Она способна отвечать на вопросы, предоставлять информацию и даже вести беседы на основе заданного контекста.
TTS - Coqui AI. Система преобразования текста в речь, позволяет озвучивать текстовые ответы. Из всех open source решений предлагает достаточно естественное звучание и гибкость в настройках голоса и интонации на множестве языков.
Приступим. Самый первый модуль необходим для преобразования голоса в текст, и для этой задачи отлично подошла модель Whisper. Она имеет несколько конфигураций: base, small, medium и large. Наилучшие результаты показывает модель base, которая обеспечивает оптимальный баланс между производительностью и качеством распознавания.
Функционал следующего кода очень прост. Внутри класса WhisperService
происходит загрузка модели для преобразования аудио в текст с помощью библиотеки Whisper. Метод transcribe
принимает путь к аудиофайлу в формате WAV и, используя модель, преобразует его в текст.
from abc import ABC, abstractmethod
class BaseService(ABC):
def __init__(self, model):
self.s2t = model
@abstractmethod
def transcribe(self, path_to_wav_file: str):
"""
Abstract method to process audio files (in wav format) to text
"""
pass
class WhisperService(BaseService):
_BASE_MODEL_TYPE = 'base'
def __init__(self, model_type: str = _BASE_MODEL_TYPE) -> None:
import whisper
model = whisper.load_model(model_type)
super().__init__(model)
def use_model(self, path_to_wav_file: str, language=None):
return self.s2t.transcribe(path_to_wav_file, language=language)
def transcribe(self, path_to_wav_file: str, language=None) -> str:
result = self.use_model(path_to_wav_file, language=language)
return result['text']
Следующим важным звеном является модуль генерации текста. На текущий момент используется базовая LLM, в моей конфигурации _BASE_MODEL = llama3.1:latest
. Код представленный ниже реализует модуль, который взаимодействует с языковой моделью с использованием библиотеки langchain_ollama
. Основная цель модуля - отправка вопросов к модели и получение ответов. В методе ask_model
, который отвечает за формирование запросов к модели, используется регулярное выражение для определения конца предложений. Метод получает вопрос, отправляет его в модель и обрабатывает потоковый ответ. Ответы накапливаются в буфере, и как только в буфере обнаруживается завершенное предложение, оно извлекается и возвращается. Таким образом, метод эффективно обрабатывает длинные ответы и позволяет как можно скорее передать созданное предложение в TTS модуль.
import re
from langchain_ollama import ChatOllama
from config import LLM_MODEL
class LangChainService:
_BASE_MODEL = LLM_MODEL
def __init__(self, model_type: str = _BASE_MODEL):
self.model = ChatOllama(model=model_type)
self.context = ''
def ask_model(self, question: str):
buffer = ''
sentence_end_pattern = re.compile(r'[.!?]')
for chunk in self.model.stream(f'{self.context}\n{question}'):
buffer += str(chunk.content)
while True:
match = sentence_end_pattern.search(buffer)
if match:
end_idx = match.end()
sentence = buffer[:end_idx].strip()
sentence = sentence[0 : len(sentence) - 1]
yield sentence
buffer = buffer[end_idx:].strip()
else:
break
Ну и последний шаг, это преобразование ответа от бота в аудио формат. Этого можно достичь с помощью модуля для преобразования текста в речь, используя библиотеку XTTS. XTTSService
инициализирует модель TTS, загружая её на доступное устройство, будь то GPU или CPU. Основная функция этого сервиса заключается в методе processing
, который принимает текст и сохраняет его в виде аудиофайла формата WAV. Метод также позволяет указать язык и говорящего и скорость воспроизведения для более гибкой настройки.
from abc import ABC, abstractmethod
import torch
from config import TTS_XTTS_MODEL, TTS_XTTS_SPEAKER, TTS_XTTS_LANGUAGE
class BaseService(ABC):
def __init__(self, model):
self.t2s = model
@abstractmethod
def processing(self, text: str):
"""
Abstract method to process text to audio files (in wav format)
"""
pass
class XTTSService(BaseService):
_BASE_MODEL_TYPE = TTS_XTTS_MODEL
_BASE_MODEL_SPEAKER = TTS_XTTS_SPEAKER
_BASE_MODEL_LANGUAGE = TTS_XTTS_LANGUAGE
def __init__(self, model_type: str = _BASE_MODEL_TYPE) -> None:
from TTS.api import TTS
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f'Apply {device} device for XTTS calculations')
model = TTS(model_type).to(device)
super().__init__(model)
def processing(
self,
path_to_output_wav: str,
text: str,
language: str = _BASE_MODEL_LANGUAGE,
speaker: str = _BASE_MODEL_SPEAKER,
):
self.t2s.tts_to_file(text=text, file_path=path_to_output_wav, language=language, speaker=speaker, speed=2)
Чтобы быстро и без проблем собрать описанные выше модули и запустить ассистента, можно реализовать коммуникацию с ним через TelegramAPI. Плюсы: не нужно реализовывать клиента для записи и воспроизведения аудио. Минусы: не очень удобный UX, постоянно надо клацать кнопку записи в интерфейсе)
Telegram-бот разработан с использованием библиотеки python-telegram-bot
.
Команда /start
: Пользователь начинает взаимодействие с ботом, получая приветственное сообщение.
Обработка голосовых сообщений: Бот принимает голосовые сообщения от пользователей, проверяет их наличие и конвертирует в wav и сохраняет.
Распознавание речи: С помощью сервиса WhisperService
аудиофайлы преобразуются в текст.
Генерация ответов: С помощью LangChainService
текстовые команды обрабатываются, и генерируются текстовые ответы.
Преобразование текста в речь: Ответы преобразуются в голосовые сообщения с использованием XTTSService
.
Отправка ответов: Генерированные голосовые сообщения отправляются обратно пользователю.
Ниже представлена простыня, которая реализует описанную выше логику:
from telegram import Update
from telegram.ext import filters, Application, CommandHandler, CallbackContext, MessageHandler
from config import TELEGRAM_BOT_TOKEN
from src.generative_ai.services import LangChainService
from src.speech2text.services import WhisperService
from src.fs_manager.services import TelegramBotApiArtifactsIO
from src.audio_formatter.services import PydubService
from src.text2speech.services import XTTSService
from src.telegram_api.services import user_verification
from src.shared.hash import md5_hash
speech_to_text = WhisperService()
text_to_speech = XTTSService()
file_system = TelegramBotApiArtifactsIO()
formatter = PydubService()
langchain = LangChainService()
async def verify_user(update: Update) -> None:
user_id: str = str(update.effective_user.id) # type: ignore
user_verification(user_id)
async def start(update: Update, _: CallbackContext) -> None:
await verify_user(update)
await update.message.reply_text('Hello! I am your personal assistant. Let is start)') # type: ignore
async def handle_audio(update: Update, context: CallbackContext) -> None:
await verify_user(update)
artifact_paths = []
user_id: str = str(update.effective_user.id) # type: ignore
chat_id = update.message.chat_id # type: ignore
voice_message = update.message.voice # type: ignore
if not voice_message:
await update.message.reply_text('Please, send me audio file.') # type: ignore
return
input_file_path = await file_system.write_user_audio_file(user_id, voice_message)
artifact_paths.append(input_file_path)
output_file_path = formatter.processing(input_file_path, '.wav') # type: ignore
artifact_paths.append(output_file_path)
text_message = speech_to_text.transcribe(output_file_path)
for text_sentence in langchain.ask_model(text_message):
sentence_hash = md5_hash(text_sentence)
wav_ai_answer_filepath = file_system.make_user_artifact_file_path(
user_id=user_id, filename=f'{sentence_hash}.wav'
)
artifact_paths.append(wav_ai_answer_filepath)
text_to_speech.processing(wav_ai_answer_filepath, text_sentence)
ogg_ai_answer_filepath = formatter.processing(wav_ai_answer_filepath, '.ogg')
artifact_paths.append(ogg_ai_answer_filepath)
await send_voice_message(context=context, chat_id=chat_id, file_path=ogg_ai_answer_filepath)
file_system.delete_artifacts(user_id=user_id, filename_array=artifact_paths)
async def send_voice_message(context: CallbackContext, chat_id, file_path: str):
with open(file_path, 'rb') as voice_file:
await context.bot.send_voice(chat_id=chat_id, voice=voice_file)
def main() -> None:
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
application.add_handler(CommandHandler('start', start))
application.add_handler(MessageHandler(filters.VOICE & ~filters.COMMAND, handle_audio))
application.run_polling()
if __name__ == '__main__':
main()
После работы с Telegram-ботом я пришёл к выводу, что его функционал не совсем удобен при реализации полноценного голосового ассистента. Бот хотя и предоставляет базовые возможности взаимодействия, ограничивает меня в плане пользовательского опыта. Поэтому я стал думать, как можно менее болезненно реализовать клиентское приложение.
Самым очевидным решением для меня оказался браузерный клиент на основе WebSocket. Плюсы: подключение устройств записи и воспроизведения звука через браузер, возможность реализации клиента на любом устройстве.
Вот такой клиент получился на скорую руку. Здесь все просто записанные фреймы на постоянной основе шлются на бек, в то время как аудио ответы собираются в очередь и синхронно воспроизводятся с помощью функции playNextAudio
. Ниже представлен код клиента:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chekov</title>
</head>
<body>
<button id="startBtn">Start Recording</button>
<button id="stopBtn">Stop Recording</button>
<button id="enableAudioBtn">Enable Audio Playback</button>
<script>
const TIME_SLICE = 100;
const WS_HOST = "localhost";
const WS_PORT = 8765;
const WS_URL = `ws://${WS_HOST}:${WS_PORT}`;
const ws = new WebSocket(WS_URL);
ws.onopen = () => console.log("WebSocket connection established");
ws.onclose = () => console.log("WebSocket connection closed");
ws.onerror = (e) => console.error("WebSocket error:", e);
ws.onmessage = (event) => collectVoiceAnswers(event);
let mediaRecorder;
let audioEnabled = false;
const audioQueue = [];
let isPlaying = false;
async function startRecord() {
const userMediaSettings = { audio: true };
const stream = await navigator.mediaDevices.getUserMedia(userMediaSettings);
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = streamData;
mediaRecorder.start(TIME_SLICE);
}
function streamData(event) {
if (event.data.size > 0 && ws.readyState === WebSocket.OPEN) {
wsSend(event.data);
}
}
function wsSend(data) {
ws.send(data);
}
function stopRecord() {
if (mediaRecorder) {
mediaRecorder.stop();
}
}
function collectVoiceAnswers(event) {
if (!audioEnabled) return;
const { data, type } = JSON.parse(event.data);
const audioData = atob(data);
const byteArray = new Uint8Array(audioData.length);
for (let i = 0; i < audioData.length; i++) {
byteArray[i] = audioData.charCodeAt(i);
}
const audioBlob = new Blob([byteArray], { type: "audio/wav" });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audioQueue.push({ audio, type });
if (!isPlaying) {
playNextAudio();
}
}
async function playNextAudio() {
if (audioQueue.length === 0) {
isPlaying = false;
return;
}
isPlaying = true;
const { audio, type } = audioQueue.shift();
try {
await new Promise((resolve, reject) => {
audio.onended = resolve;
audio.onerror = reject;
audio.play().catch(reject);
});
playNextAudio();
} catch (e) {
console.error("Error playing audio:", e);
isPlaying = false;
}
}
document.getElementById("startBtn").addEventListener("click", startRecord);
document.getElementById("stopBtn").addEventListener("click", stopRecord);
</script>
</body>
</html>
Реализацию серверной части, которая обрабатывает WebSocket-соединения и взаимодействует с остальной частью ассистента, вы можете получить соответствующий серверный файл по указанной ссылке. Также по это ссылке в репозитории можно найти quick start guide.
Вот и все. Для дальнейшего улучшения ассистента, включая добавление новых функций(именно функций для ассистирования, чтобы бот начал оправдывать свое название), таких как сохранение заметок, поиск информации и другие полезные фичи, стоит рассмотреть возможность файн-тюнинга LLM, чтобы выдавать унифицированные ответы в формате {command, message}
. Также полезным будет реализация постпроцессинга для обработки команд с использованием классических алгоритмов на основе вывода LLM.
А на этом все. Спасибо, что дочитали до конца!