Как я написал AI-бота на aiogram 3 с помощью нейросетей, выжил при 2500+ пользователей и почему SQLite "всё ещё торт"
Постмортем разработки Telegram-бота Щёлк-ГДЗ. Как я боролся с ошибками, оптимизировал SQLite и делал стриминг от ИИ на дешевом VPS. Честный технический опыт соло-разработчика.
На дворе 2026 год. Очередной постмортем микро-SaaS'а в Телеге.
Без маркетинга и успешного успеха. Только боль, костыли и суровый прод! Мой пет-проект - бот «Щёлк-ГДЗ». Это ИИ-помощник, который решает школьные задачки по фоткам, видео, гс, тг-кружкам и PDF. Под капотом Python 3.12.3, aiogram 3, aiosqlite, апи OpenRouter (модель Gemini 3.0 Flash) и Робокасса. Крутится всё это на дешёвом VPS в Нидерландах. Держит 2500+ пользователей и не падает.
В этой статье расскажу, как за 9 месяцев построил логичную архитектуру, победил ошибку FloodWait при стриминге ответа ИИ и почему в условиях 1 гига RAM обычная SQLite - отличное решение.
Нейронка вместо джуна и Уроборос багов
Сразу признаюсь... С нуля я это не писал :) Синтаксис мне генерили LLM-ки. Начинал с Gemini 2.5 Pro, затем перешел на 3.0 Pro, а сейчас использую 3.1 Pro.
Многие думают, что нейронка сама напишет проект "под ключ", но это миф. Я никогда не доверял ИИ проектирование архитектуры и использовал его как продвинутый StackOverflow (скармливал конкретную задачу (например, написать SQL-миграцию) и получал кусок кода).
ИИ — это не архитектор, а джун на спидах.
Как только логика усложнялась - гемини ловил «Уроборос багов». Кидаешь баг А - он его фиксит, но появляется ошибка Б. Скармливаешь и её - фиксит, но возвращается баг А. Цикл замкнулся. Лечилось только созданием новых чатов, в которые я кидал код и писал запросы типа "Найди критические ошибки, логические дыры и баги".
Про продуктовую логику нейронка вообще не слышала. В первой версии рефералки был баг, где любой(если его аккаунта ещё нет в БД) мог написать в конце ссылки что любые 9 цифр (?start=1234567890) и получить бонусы.
Также были ошибки с гонкой состояний при оплатах и активациях промокодов, которые тоже фиксил запросами в новые чаты с ИИ. Так я исправил около 20 архитектурных дыр.
Немного про архитектуру.
Чтобы код не превратился в нечитаемую лапшу на 5000 строк, я жестко разбил всё на модули. Архитектура бота выглядит так:
main.py - это точка входа с настройкой логгеров, коннетами aiohttp и запуском APScheduler
database.py - вся работа с БД
neiro.py - логика общения с апи OpenRouter (стриминг, сжатие фоток через Pillow, JSON-контекст)
states.py - классы состояний aiogram.fsm.state (например, PaymentProcess, GdzMode)
utils.py - легковесные утилиты. Например лок пользователей (защита от спама запросами):
Хэндлеры вынесены в отдельную папку handlers/:
handlers/common_handlers.py - главное меню с обработкой /start (рефералки, utm-метки).
handlers/gdz_handlers.py - сам процесс ИИ-решения (вход в GdzMode, прием фото, видео, кружочков).
handlers/pay_handlers.py - это логика платежей (Robokassa) и "Умная корзина".
handlers/tasks_handlers.py - квесты (выдача премиума за подписку на каналы спонсоров).
Сборку интерфейсов вынес в all_def.py. Не люблю, когда в хэндлерах генерится полотно текста с кнопками. Там же лежат функции склонения слов (1 запрос, 2 запроса, 5 запросов). А в settings.py лежат списки с рандомными ответами бота, чтоб казался живым.
Так же в settings.py я сделал кэширование картинок, тоесть при первом запуске бот грузит фото меню как BufferedInputFile, сохраняет file_id от Телеграма и дальше шлет картинки моментально по ID. Сервак говорит спасибо за сэкономленный трафик)
В handlers/common_handlers.py находится первичная маршрутизация. Вот так обрабатываются рефералки, переходы с сайта, с рекламы и другое при старте:
Диета по токенам
Хранить бесконечную историю диалогов дорого и бессмысленно. В бесплатной версии храню 10 последних сообщений (5 пар вопрос-ответ), а в преме — 30.
Но фотки весят большое кол-во токенов. Если премиум-юзер закинет 30 фоток, OpenRouter выставит мне огромный счет. В итоге я прикрутил ограничение: Из 30 сообщений ИИ видит только 10 последних картинок.
Старые фотки тупо вырезаю из JSON. Подменяю на системный промпт:
С довольно неплохой моделью (gemini 3.0 flash) это работает как часы (она честно признается, что забыла картинку, а не выдумывает что-то из воздуха).
Стриминг, FloodWait и защита баланса
Чтобы бот не выглядел тормозом, я сделал стриминг ответа от ИИ в neiro.py. Я обновляю сообщение в Телеграме чанками по мере получения их от ОпенРоутера.
Но если делать message.edit_text слишком часто, ловишь FloodWait. В итоге я выставил интервал в 0.7 секунд и обернул всё в жесткий try/except:
Стрим при этом не прерывается. Поспали и погнали дальше :) Если на этапе обработки файла или стриминга падает критическая ошибка - честно возвращаю юзеру запрос на баланс.
В handlers/gdz_handlers.py это выглядит так:
SQLite тащит
Почему не Postgre? Потому что для микро-SaaS с 2,5к пользователей SQLite хватает за глаза. Но в асинхронной среде она любит кидать ошибку database is locked.
Чтобы этого избежать, я включил WAL-режим при инициализации пула, разделил коннекты на db_writer и db_reader, а сложные операции доверил самому SQL.
Например, 00:00 запускается крон-таска, которая собирает огромную аналитику, начисляет всем активным юзерам +1 ежедневный запрос и сбрасывает просроченные подписки. И это всё это работает атомарно внутри database.py:
Умная корзина на APScheduler
Когда дело дошло до монетизации, всплыли две проблемы:
Первая: юзер оплатил, но забыл нажать кнопку «✅ Я оплатил» в боте. Бот ждет, юзер ждет, товар не выдается, поддержка кипит.
Вторая: юзер сформировал счёт и передумал ("брошенная корзина").
Вместе с LLM я с нуля изучил apscheduler и убил двух зайцев фоновыми задачами. Теперь в handlers/pay_handlers.py при генерации ссылки на оплату я создаю две отложенные таски:
Как это работает?
Через 5 минут срабатывает async def auto_check_payment(), которая тихо стучится в робокассу. Если статус success - бот сам начисляет запросы на баланс и радует клиента. Если статус pending - таска умирает, и в дело вступает 30-минутная таска.
Но она не шлёт спам вслепую, а лезит в БД и проверяет 3 бизнес-правила:
1. await db.has_successful_payment_recently - Не купил ли он другой товар за последние 3 часа?
2. await db.get_latest_invoice_id - А это точно самый последний сгенерированный им счет?
3. await db.can_send_agitation - Не присылали ли мы ему агитацию недавно?
Если проверки пройдены, то юзер получает сообщение: "⏳ Домашка сама себя не решит! Ты начал оформлять покупку, но оплата так и не прошла...". Это поднимает конверсию оплат.
Финал
Почему я не использую Redis для стейтов? Ответ банален: мой дешевый VPS имеет всего 1 гб оперативки. Пул aiohttp, In-Memory стейты и асинхронные таски и так жрут 70% RAM. Редис тупо не влезет.
Как говорится, работает — не трогай.
Сейчас проект обзавелся сайтом-витриной и продолжает развиваться. В планах - искать и чинить новые баги. Перееду на более мощное железо, когда сервак начнет физически задыхаться.
Готов ответить на вопросы по архитектуре и послушать советы в комментариях \(^^)/
P.s. вот ссылка на первую статью о моём проекте:
P.s. Если кто-то хочет потестить вживую(не реклама), то юз бота в тг @gdzshchelk_bot
