Хочу поделиться историей создания Telegram-бота, работающего полностью на локальной ИИ. В качестве языковой основы используется Ollama, а для генерации изображений — AUTOMATIC1111. Весь код написан на Python с библиотекой python-telegram-bot.
Почему выбрал именно Ollama? Потому что она бесплатна, есть множество открытых моделей и её очень просто развернуть в своем проекте. Если брать облачные решения от других компаний, например ChatGPT, то тут можно уперется в то, что за них нужно платить.
Модели я подбирал под свой компик: 5070 и 32 гб оперативы. Сервера своего нету, поэтому бот работает только когда я дома.
Бот продолжает развиваться. Следить за обновлениями и новыми фичами можно в моем Telegram-канале: https://t.me/rocet_0
Небольшая история создания: ошибки и победы.
Всё начиналось просто: сообщение пользователя -> ответ модели. Первой была выбрана легковесная Mistral. Но она часто «ловила галлюцинации» и была откровенно глуповата. Из команд работали только /start и /help.
Затем я переключился на Qwen2.5:14b. Эта модель показала себя отлично: работает быстро на моем компьютере, хорошо понимает русский язык и мыслит значительно глубже.
Пользователи не любят тишину. Первым делом я ограничил время ожидания ответа от Ollama, чтобы бот не зависал навечно, если модель «задумается»:
timeout=aiohttp.ClientTimeout(total=600)
Чтобы не создавать кучу подключений, сессия aiohttp теперь создается один раз при старте и живет всё время работы бота.
session = context.application.bot_data['session']
И наконец-то появилась обратная связь: функция send_action теперь показывает статус «печатает...», а сам ответ выводится в реальном времени через стрим — сообщение собирается по токенам на глазах у пользователя.
await context.bot.send_chat_action(chat_id=chat_id, action='typing')
Здесь меня ждала засада. Модель упорно пыталась отвечать LaTeX-разметкой (\( ... \), \frac), которую Telegram напрочь отказывался понимать.
Промты с просьбой «не использовать LaTeX» модель игнорировала.
Пришлось писать функцию-очиститель текста с помощью регулярных выражений.
# Убираем блочную математику text = re.sub(r'\\\[|\\\]|\\\(|\\\)', '', text)
# \frac{a}{b} → (a / b) text = re.sub(r'\\frac\{(.*?)\}\{(.*?)\}', r'(\1 / \2)', text)
# \sqrt{a} → √(a) text = re.sub(r'\\sqrt\{(.*?)\}', r'√(\1)', text)
# \text{...} → ... text = re.sub(r'\\text\{(.*?)\}', r'\1', text)
# Спецсимволы replacements = { r'\pm': '±', r'\times': '×', r'\cdot': '×', r'\div': '÷', r'\approx': '≈', r'\le': '≤', r'\ge': '≥', r'\neq': '≠', r'\quad': ' ', r'\,': '', r'\%': '%' }
#Обработка спец символов for latex, normal in replacements.items(): text = text.replace(latex, normal)
# степени ^{2} → ^2 text = re.sub(r'\^\{(.*?)\}', r'^\1', text)
# Убираем \left \right text = re.sub(r'\\left|\\right', '', text)
# Убираем любые оставшиеся \команды text = re.sub(r'\\[a-zA-Z]+', '', text)
# Убираем фигурные скобки text = text.replace('{', '').replace('}', '')
#Не забываем в стриме вызвать эту функцию.
В итоге все расчеты принимают человеческий вид.
Важно: Из-за чистки текст мог не меняться, что вызывало ошибку при попытке отредактировать сообщение (нельзя заменить сообщение на точно такое же). В стриме добавил проверку: if last_sent_text != new_text: message.edit_text(new_text).
Главное: добавление истории диалогов.
Без истории диалогов бот — просто игрушка. Хранить историю в оперативной памяти было ненадежно (при перезапуске всё пропадало). Внедрил SQLite. Вот например создание таблицы c историей:
with sqlite3.connect('memory.db') as conn: cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER, role TEXT, content TEXT, timestamp REAL ) """)
Теперь для каждого chat_id хранится своя история.
Следующая проблема — Markdown. Модель выдавала текст с жирным, но Telegram его не применял.
Просто достаточно прописать parse_mode="Markdown" в изменение тескта message.edit_text(text, parse_mode="Markdown")
Также в промте написал, чтобы ИИ отвечала на русском, так как она иногда ломается и выдает ответ на китайском языке (родном). Это не решает проблему на 100%, но уменьшает частоту её появления.
Одна модель — хорошо, а выбор — лучше. Добавил инлайн-кнопки для переключения.
Я просто использовал тескт для описания каждой модели и к сообщению кнопки, которые передают в current_model название используемой модели и потом эта переменная используется в генерации .
async with session.post(
OLLAMA_URL,
json={
"model": current_model,
[InlineKeyboardButton(f"📌 Универсальные", callback_data="ignore")],
[InlineKeyboardButton('Qwen 2.5 14B', callback_data='model_qwen2.5:14b')],
[InlineKeyboardButton('Llama 3 8B', callback_data='model_llama3:8b')],
[InlineKeyboardButton('Mistral 7B', callback_data='model_mistral:7b')],
[InlineKeyboardButton("➖" * 10, callback_data="ignore")],
Были добавлены: llama3:8b, mistral:7b, deepseek-coder:6.7b, codellama:7b, qwen2.5:7b-instruct, llama3:8b-instruct-q8_0, phi3:mini, gemma2:2b.
Важный нюанс: Кнопки не работали, пока я не добавил в app.run_polling() параметр allowed_updates=["message", "callback_query"].
Модели хорошо совместимы с системой: 5070 на 12 гб видеопамяти и 32 гб оперативы. Можно их использовать даже на системах слабее. Но даже на токой системе модельки изменяются долго, потому что сначала старая модель выгружается и только потом загружается новая.
В выводе пишется, что модель загружается и изменена, но пользователю этого не видно. Также есть проверка при переключении модели, что она не такая же, какая была до этого, чтобы не загружать повторно.
При загрузке есть прогрев модели. Ей отправляется коротенький промт 'Привет' с ограничением 'num_predict': 5. Это ускоряет формиравание последующих ответов, т.к. первый ответ модель форомирует долго.
Создал список, в котором хранятся модели и их описания, чтобы легче формировать сообщение с кнопками, и теперь есть отображение текущей модели при выборе.
Разбил в списке модели на категории и сделал выбор категории, а только потом выбор самой модели.
Добавил отдельную команду для очистки истории диалога с ИИ. Полезно, если хотите "чистый ответ"
cursor.execute("""
DELETE
FROM messages
WHERE chat_id = ?
""", (chat_id,))
Но столкнулся с проблемой: при смене модели Ollama выгружает старую и загружает новую. Это долго. Чтобы пользователь не думал, что бот завис, я добавил сообщение «Загружаю модель...».
Рядом с используемой моделью теперь стоит галочка. Она ставится при формировании текста обычным исключением: if current_model == mode_namel: text = f'✅ {model_name}'; else: text= '{model_name}'
Чтобы ускорить переключение между популярными моделями, внедрил TTL-кеширование . Модель при вызове добавляется в кеш, а при повторном использовании или использовании другим человеком она берется из кеша без повторной загрузки. Это ускоряет процесс смены модели. Кеширование решает проблему повторного прогрева модели другим пользователем.
Теперь, если модель уже была загружена кем-то другим, новый пользователь получает её мгновенно.
Добавил первую "визуальную" модель: glm-ocr. Она может извлекать текст из картинки. Для её реализации использовал отдельную функцию. Если пользователь отправляет фото, то фото идет не в основную, а в эту с возможностью обработки фото.
photo = message.photo[-1] file = await photo.get_file() file_path = f"temp_{chat_id}.jpg" await file.download_to_drive(file_path) with open(file_path, 'rb') as f: image_bytes = f.read() image_base64 = base64.b64encode(image_bytes).decode('utf-8')
Потом передаем фото модели и она извлекает текст.
Т.к. появились модели, которые генерируют код (модели вроде DeepSeek Coder) , то я добавил форматирование кода через функцию.
pattern = r"(?:\w+)?\n(.*?)" def repl(math): code = math.group(1) escaped = html.escape(code) return f"<pre><code>{escaped}</code></pre>" re.sub(pattern, repl, text, flags=re.DOTALL)
После того, как ИИ сформирует ответ необходимо прописать проверку на код if "```" in cleaned: и потом использовать функцию.
Добавил отдельную категорию с переводческими моделями. Пока доступна только TranslateGemma. С ней удобно получать переводы фраз на десятки языков.
Попытался встроить Qwen3-VL, но что-то не пошло. Хотел сделать обработку фото и текста. Чтобы модель могла описывать фото и работать с изображениями.
В группе теперь фото обрабатывается только если в описании к фото упомянуть бота.
Из-за форматирования кода используется HTML, а Markdown не учитывается. Добавил функцию перевода из Markdown в HTML.
text = fix_latex(text) text = re.sub(r'### (.+?)(?:\n|$)', r'<b>\1</b>\n', text) text = re.sub(r'## (.+?)(?:\n|$)', r'<b>\1</b>\n', text) text = re.sub(r'# (.+?)(?:\n|$)', r'<b>\1</b>\n', text) text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text, flags=re.DOTALL) text = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', text, flags=re.DOTALL) text = re.sub(r'^- ', '• ', text, flags=re.MULTILINE)
Поменял Ministral 7B на Ministral 3:14B. Но она выдает большие текста, превыщая ограничение телеграмма на 1 сообщение (4096 символов).
Модели становились всё больше и умнее, и однажды ответ перестал влезать в лимит Telegram (4096 символов). Пришлось реализовать разбиение длинных сообщений на части по 4000 символов, не разрывая при этом стриминг.
Добавил модель qwen2.5vl:7b, которая работает с текстом и фото. Обьединил оброботку фото и текста в одну функцию, чтобы у них была общая история. Для glm-ocr прописал отдельное формирование ответа. Если используется модель работающая с фото, то в запросе к модели должен быть пункт: "images" = images_base64. Поэтому настройки для запроса формируем в отдельную ячейку и если есть фото, то добавляем к нему "images".
Из-за разрыва текста на несколько сообщений иногда режется разметка HTML. Поэтому их нужно дозакрыть.
tags = ["b", "i", "pre", "code"] for tag in tags: opens = text.count(f"<{tag}>") closes = text.count(f"</{tag}>") if opens > closes: text += f"</{tag}>" * (opens - closes) elif closes > opens: text = text.replace(f"</{tag}>", "", closes - opens)
С историей для визуальных моделей пришлось помучатся. Она изначально вообще не сохранялась, а потом сохранялась без связи с текстовыми запросами. Пришлось менять всю структуру хранения истории. Теперь в историю сохраняется "фото" и модель понимает, что текст относился к фото. current_user_content = f"[Фото] {prompt}" if is_photo else prompt
Добавлена мини-игра "Детектив". Идея: ИИ генерирует уникальный сценарий преступления, а пользователь его расследует.
В ИИ подается промт "создай json". Она генерирует событие, улики, подозраваемых и отдельный пункт, где указывает, кто является виновным. За генерацию отвечает qwen2.5:7b-instruct.
Для игры сделана отдельная таблица в истории, которая хранит топ игроков и настройки игры.
При вызове команды формируется JSON через запрос к модели. Важно учесть, что модель может вернуть "плохой ответ" и проверить корректность её ответа. Далее извлекаем JSON (это можно сделать по "[" "]"). Далее сценарий сохраняем и работаем с ним.
У игрока есть 15 вопросов за которые он должен определить виновного. По истечению вопров или по желакию игрока нужно ввести команду и имя виновного. Далее проверяется имя на частичную схожесть с имеющимися (пользователь может ввести не правильно, в этом случае просим попробовать снова), если имя совпадает с каким-то из подозреваемых, то выдаем прав или не прав пользователь. Прибвляем в таблицу топа 1 дело, если обвинил верно.
Игрок может напрямую у модели спрашивать мотив или алиби подозреваемого. В другом случае выдаем, что такой информации нету. Так же генерируем для полноты описание места преступления и хронологию.
У игрока есть выбор способностей, которые дают бонус (одна за игру, если использовали, то записываем в таблицу). Криминалист раскрывает рандомную улику не тратя ход, журналист берет интервью у выбраного вами подозреваемого (генерируется интервью с строгим промтом), а психолог дает психологический портрет (характер подозреваемых генерируется при старте игры).
Также есть допрос свидетелей, которые генерируются в начале. Они выбираются рандомно. Изучение улик тратит ход. Улики указывают на человека, но они не всегда указывают на виновного (не зря они подозреваемые) и открываются случайно.
Игра не требовательная, но интересна тем, что сюжеты всегда разные.
Бот живет дома, интернет может упасть, Ollama — зависнуть. Чтобы он не умирал навсегда, добавил:
Логирование — чтобы знать, где упало.
Бесконечный цикл с перезапуском при падении polling'а.
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO
)
if name == "main":
while True:
try:
logging.info("Запуск бота...")
asyncio.run(run_bot_polling(app))
except Exception as e:
logging.error(f"Polling упал с ошибкой: {e}")
traceback.print_exc()
logging.info("Перезапуск через 5 секунд...")
time_module.sleep(5)
Также добавлены: обработчик ошибок, безопасные функции отправки текста и кнопок (они ловят ошибки связанные с отправкой), множество блоков try (особенно при запросах к Ollama), чтобы выводить все ошибки и контролировать ситуацию.
Добавлена выгрузка моделей через время, чтобы не переполнять память хоста. Если при ответе модель долго думает, то удаляем её из кеша.
Кеширование ответов, которое облегчает работу (если запрос повторяется, то ответ берется из кеша), но необходимо учитывать историю, чтобы не было ситуаций: модель на "порекомендуй еще 10 фильмов" выдала 10 комедий, хотя до этого речь шла о ужасах. И переодически чистить ответы, чтобы не загружать кеш.
В мини-игре добавлены уровни сложности. Для сложностей я создал отедльный список с множителем очков и описанием для промта. В топе теперь учитывается количество очков, которые считаются по формуле: points = (max_q - used_q + 1) * 50 * settings['complexity_factor'], где max_q - общее количество вопросов; used_q - количество использованых вопросов; + 1 т.к. первый вопрос обязательно нужно использовать (это проверяется про вводе команды обвинения); 50 - свободный множитель; settings['complexity_factor'] - множитель за уровень сложности из списка. if game['used_ability'] == 0: points = int(points * 1.2) - проверяем использовалась ли способность и если нет, то множим еще на 1.2.
Модель qwen2.5:7b-instruct заменена на qwen2.5:14b-instruct, которая более точно генерирует различные ситуации. Также при генерации она генерирует обьяснение почему подозреваемый виновен\невиновен и в конце оно выводится как обьяснение победы или проигрыша.
Исправлено множество проблем с вводом имен (например Петр и Пётр считает за разные имена).
Так же создан список, где собрано более сотни преступлений, которые поступают в промт и модель по ним генерирует ситуацию.
Обрабатываются почти все ошибки и исправлен баг с фото в группе (если бот не был упомянут, то мог выдать ошибку на фото)
К тексту добавилась картинка! Интегрировал AUTOMATIC1111. Доступные модели для генерации: sd_xl_base_1.0, sd3_medium, dreamshaper_8, v1-5-pruned-emaonly, sd_xl_refiner_1.0. Они хорошо работают в системе с 5070. Выбор модели доступен там же, где и выбор моделей Ollama. Сама же генерация осуществляется через специальную ссылку:
payload = { "prompt": "cat", "steps": 1, "width": 64, "height": 64, "override_settings": {"sd_model_checkpoint": model_name}}
async with session.post(f"{A1111_URL}/sdapi/v1/txt2img", json=payload, timeout=30) as resp: return resp.status == 200
Пока работает на английском, но можно осуществить перевод через модели ollama. Генерация осуществляется с помощью команды и описания изображения (/draw red cat). Бот отправляет запрос в WebUI, генерирует изображение и отправляет его пользователю.
Небольшой вывод:
Создать своего Telegram-бота на базе локальных нейросетей — задача вполне реальная. Это не требует огромных вложений (кроме, разве что, видеокарты), но открывает безграничный простор для творчества. Мой бот прошел путь от простого «ответчика» до помощника, который может поддержать беседу, распознать текст на фото, сгенерировать картинку. Так же с помощью генерации можно осуществить множество игр, нужна лишь ваша фантазия.
Источник


