Идея создания этого пет-проекта возникла из желания написать собственного ИИ-агента. Я сформулировал для себя минимальные технические требования: агент должен иметь несколько состояний, уметь запускать тулзы и использовать RAG для поиска ответов на вопросы.
В итоге возникла идея написать персонального телеграм-ИИ-бота, который умеет запоминать нужную мне информацию, и когда мне надо — я могу его спросить, что он запомнил. Что-то вроде блокнота, только это будет ИИ-блокнот, который умеет отвечать на вопросы. В дополнение я решил добавить в него функцию, чтобы он мог запускать команды на сервере — причём команды, описанные человеческим языком, он будет переводить в команды для терминала.
Изначально я думал использовать LangChain. Очень хороший инструмент — позволяет подключать векторные базы данных, использовать различные LLM как для инференса, так и для эмбеддинга, а также описывать логику работы агента через граф состояний. Можно вызывать уже готовые тулзы. В целом, на первый взгляд всё выглядит удобно и просто, особенно когда смотришь типовые и несложные примеры.
Но, покопавшись немного глубже, мне показалось, что затраты на изучение этого фреймворка не оправдывают себя. Проще напрямую вызывать LLM, эмбеддинги и Qdrant через REST API. А логику работы агента описать в коде через enum, описывающий состояния, и делать match по этим состояниям.
К тому же LangChain изначально написан на Python. Я хотел бы писать на Rust, а использовать Rust-версию LangChain — сомнительное удовольствие, которое обычно упирается в самый неподходящий момент: что-то ещё не было переписано на Rust.
Для реализации магии RAG я решил использовать следующий алгоритм. Когда пользователь задаёт вопрос, из вопроса извлекаются ключевые слова при помощи LLM. Далее при помощи эмбеддинга вычисляется вектор по этим ключевым словам. Затем этот вектор передаётся в Qdrant, и ищутся ближайшие векторы от документов, которые уже есть в памяти. После этого из найденных документов формируется запрос к LLM, в который включаются найденные документы и вопрос пользователя. В итоге получаем ответ LLM, в котором учитываются данные, близкие по смыслу к вопросу. Соответственно, когда пользователь сообщает какую-то информацию боту, он её сохраняет в Qdrant, и для каждой информации указывается вектор, насчитанный через эмбеддинг. Другими словами, близкие по смыслу векторы имеют минимальное расстояние между собой. Так и работает поиск схожих по смыслу документов.
Проектирование
Сначала я придумал общую логику работы ИИ бота. Бот реагирует на команды пользователя:
-
Проверяет пароль перед началом работы.
-
Понимает, чего хочет пользователь (вопрос, утверждение, просьба забыть, команда в терминал и т.п.).
-
Работает с векторной базой Qdrant — умеет запоминать и забывать информацию.
-
Может по-человечески понять команду и выполнить её на сервере.
-
Всё это он делает, используя локальную LLM (через HTTP-запросы к API).
Потом я расписал подробно сценарий работы ИИ бота:
1. Пользователь отправляет сообщение в Телеграм
Пользователь пишет боту что угодно — вопрос, факт, просьбу, команду — всё, что угодно.
Бот получает сообщение с Telegram Bot API.
2. Проверка пароля
Сначала бот ждёт, когда пользователь введёт пароль. Он сравнивает введённый текст с переменной окружения BOT_PASSWORD.
-
Если пароль правильный, бот переходит в состояние Pending (готов к работе).
-
Если неправильный — снова просит ввести пароль.
3. Обработка сообщения
Когда бот в состоянии Pending, он анализирует сообщение. Чтобы понять, что именно отправил пользователь, вызывается LLM:
LLM получает текст и возвращает цифру:
-
Вопрос
-
Факт / утверждение
-
Просьба забыть
-
Команда для терминала
-
Всё остальное
4. Варианты действий в зависимости от типа сообщения
Тип 1: Вопрос
Бот просит LLM выделить ключевые слова из запроса, чтобы понять, о чём речь.
С этими ключевыми словами бот ищет наиболее подходящие документы в векторной базе Qdrant.
Затем он объединяет найденную информацию с исходным вопросом и снова обращается к LLM, чтобы получить финальный ответ.
Ответ отправляется пользователю.
Тип 2: Утверждение (сохранить информацию)
Бот создаёт эмбеддинг из текста и добавляет его в Qdrant.
Пользователю прилетает подтверждение: «Информация сохранена»
Тип 3: Просьба забыть
Бот ищет, что именно нужно забыть, используя ключевые слова.
Он уточняет у пользователя, точно ли стоит забыть это.
-
Если да → удаляет документ из Qdrant.
-
Если нет → оставляет как есть.
Тип 4: Команда в терминал
Бот просит LLM сформулировать команду для Linux по описанию.
Спрашивает пользователя, точно ли запускать команду:
-
Если да → запускает команду через std::process::Command и отправляет результат.
-
Если нет → команда не выполняется.
Тип 5: Всё остальное
Если бот не понимает, что именно от него хотят, он просто вежливо и по-дружески отвечает с помощью LLM, как обычный чат-бот.
Написание кода
Начал писать код я для работы с LLM и embeddings. Вот список функций из ai.rs с кратким и понятным описанием:
llm(system: &str, user: &str) -> anyhow::Result
Что делает:
Отправляет запрос в чат-модель LLM (через OpenAI совместимый API).
Вход:
-
system — системное сообщение (например, инструкции для бота).
-
user — сообщение от пользователя.
Выход:
-
Возвращает ответ от модели в виде строки.
emb(input: &str) -> anyhow::Result>
Что делает:
Создаёт эмбеддинг для заданного текста с помощью модели эмбеддинга.
Вход:
-
input — текстовая строка, которую нужно закодировать.
Выход:
-
Вектор эмбеддинга Vec<f32>.
Далее я реализовал работу с Qdrant. Вот список функций из qdrant.rs:
add_document(id: i32, text: &str)
Добавляет документ в Qdrant.
-
Генерирует эмбеддинг для text с помощью emb().
-
Формирует Point и отправляет PUT запрос в Qdrant.Используется для запоминания информации ботом.
delete_document(id: i32)
Удаляет документ по ID из коллекции в Qdrant.
Отправляет POST запрос на points/delete.
create_collection()
Создаёт коллекцию в Qdrant.
-
Читает размерность эмбеддингов из .env.
-
Устанавливает метрику сравнения — Cosine.Полезно при первой инициализации бота.
delete_collection()
Удаляет всю коллекцию из Qdrant.
Полезно при смене модели эмбеддинга (другая размерность).
exists_collection() -> bool
Проверяет, существует ли коллекция в Qdrant.
Отправляет GET запрос — возвращает true, если есть.
last_document_id() -> i32
Находит максимальный ID среди всех документов.
Нужен, чтобы правильно инкрементировать ID при добавлении новых.
all_documents() -> Vec
Получает все документы из коллекции.
Постранично скроллит через scroll-запрос Qdrant.
search_one(query: &str) -> Document
Ищет один (наиболее релевантный) документ.
Используется при подтверждении удаления конкретной информации.
search_smart(query: &str) -> Vec
Умный поиск релевантных документов.
-
Делает обычный search().
-
Фильтрует результаты по distance > 0.6.
-
Если ни один не подходит — берёт самый первый. Используется при генерации ответов.
search(query: &str, limit: usize) -> Vec
Базовый поиск документов по векторному сходству.
-
Генерирует вектор запроса.
-
Отправляет points/search запрос в Qdrant.
-
Возвращает отсортированные документы с distance.
Потом используя кирпичики из ai.rs и qdrant.rs я написал логику работы бота в main.rs:
main
Главная асинхронная точка входа:
-
Загружает .env переменные.
-
Инициализирует коллекцию в Qdrant и печатает документы из памяти.
-
Создаёт Telegram-бота.
-
Запускает обработку сообщений (teloxide::repl), передавая управление Finite State Machine.
enum State
enum State { AwaitingPassword, Pending, ConfirmForget { info: String }, ConfirmCommand { message: String, command: String }, }
Финитный автомат состояний пользователя:
-
AwaitingPassword: ждет ввода пароля.
-
Pending: основной режим — пользователь прошёл авторизацию.
-
ConfirmForget: подтверждение удаления информации.
-
ConfirmCommand: подтверждение выполнения команды.
State::process
Главная точка входа, которая вызывает обработчик для текущего состояния:
pub fn process(input: &str, state: &State) -> anyhow::Result<(Self, String)>
Вызывает соответствующую функцию (по сути match по состоянию).
process_password
Проверка пароля, введённого пользователем:
pub fn process_password(input: &str) -> anyhow::Result<(Self, String)>
-
Если пароль совпадает с BOT_PASSWORD из .env, переходит в Pending.
-
Иначе остаётся в AwaitingPassword.
exec_pending
Самая важная часть: определяет тип сообщения пользователя (вопрос, инфа, команда и т.п.):
pub fn exec_pending(message: &str) -> anyhow::Result<(Self, String)>
-
Передаёт фразу в LLM и получает ответ: «1», «2», …, «5».
-
В зависимости от цифры вызывает нужную функцию:
-
1 → exec_answer
-
2 → exec_remember
-
3 → new_forget
-
4 → new_command
-
иначе → exec_chat
-
exec_answer
RAG-подход: вытаскивает релевантные документы и генерирует ответ:
pub fn exec_answer(message: &str) -> anyhow::Result<(Self, String)>
-
Извлекает ключевые слова из сообщения.
-
Ищет документы в Qdrant.
-
Кормит всё это LLM и получает ответ.
-
Возвращает Pending.
exec_remember
Просто добавляет новую информацию в Qdrant с автоинкрементом ID:
pub fn exec_remember(message: &str) -> anyhow::Result<(Self, String)>
exec_chat
Простой диалог с LLM без RAG:
pub fn exec_chat(message: &str) -> anyhow::Result<(Self, String)>
new_forget → exec_forget
Удаление информации из памяти:
-
new_forget: находит подходящий документ по ключевым словам и спрашивает подтверждение.
-
exec_forget: если пользователь подтвердил, удаляет документ.
new_command → exec_confirm_command
Выполнение команд:
-
new_command: превращает запрос пользователя в Linux-команду и спрашивает подтверждение.
-
exec_confirm_command: если «да», запускает команду и возвращает stdout/stderr.
is_condition
Универсальный способ понять: «пользователь согласен?» или «в этом сообщении есть команда?».
extract_tag, extract_number
Вспомогательные функции:
-
extract_tag: достаёт содержимое из тега <tag> в ответе LLM.
-
extract_number: находит первую цифру в ответе LLM.
init_qdrant и print_docs
-
init_qdrant: создаёт коллекцию, если её нет.
-
print_docs: печатает всю память при старте (удобно для отладки).
Docker & запуск
Для запуска используется docker-compose.yml. В нём два сервиса:
-
backend — сам бот.
-
qdrant — векторная база.
Dockerfile собирает Rust-проект и запускает бинарник внутри минимального debian:bullseye.
Инструкция для запуска:
# Скопируйте .env-example и в нем укажите свои переменные cp .env-example .env # Соберите и запустите контейнеры docker compose up --build
Пример чата с ботом
Evgeny Igumnov: Привет! Igumnov's memory extention: Неверный пароль. Попробуйте снова. Evgeny Igumnov: supersecret Igumnov's memory extention: Пароль принят. Вы можете продолжать работу с ботом. Evgeny Igumnov: День рождения у Кати 24 ноября. Igumnov's memory extention: Информация сохранена. Evgeny Igumnov: Какой у меня пароль от wifi? Igumnov's memory extention: Ваш пароль от Wi-Fi: 4040404040. Evgeny Igumnov: Запусти команду отображающую список файлов в папке текущей Igumnov's memory extention: Запустить команду "ls" ? Evgeny Igumnov: да Igumnov's memory extention: Результат работы команды all-files.sh Cargo.lock Cargo.toml docker-compose.yml Dockerfile LICENSE README.md src Evgeny Igumnov: Когда у Кати день рождения? Igumnov's memory extention: У Кати день рождения 24 ноября. Evgeny Igumnov: Забудь когда у Кати день рождения Igumnov's memory extention: 'День рождения у Кати 24 ноября.' Забыть информацию? Evgeny Igumnov: да Igumnov's memory extention: Информация забыта.
Что в итоге
Я получил код полноценного ИИ-агента:
-
Он умеет понимать и анализировать текст.
-
Имеет состояния и переключается между ними.
-
Работает как с памятью, так и с терминалом.
-
Всё написано на Rust: быстро, стабильно и предсказуемо.
Исходные коды ИИ телеграм бота тут: https://github.com/evgenyigumnov/ai-agent-telegram-bot
ссылка на оригинал статьи https://habr.com/ru/articles/895914/
Добавить комментарий