Мне нравится создавать Telegram-ботов и экспериментировать с нейросетями, поэтому часто объединяю их в своих в своих проектах. Коллеги заметили мое хобби и предложили разработать новогоднего гида, который будет генерировать персональные предсказания и советы на 2024 год для наших читателей.
Во время реализации спецпроекта я впервые столкнулся с ограничениями по маркетинговой части проекта. Как заставить ChatGPT придумывать разнообразные, но только положительные предсказания? Как не выйти за лимиты текстовой модели и Telegram? В этой статье делюсь ответами на вопросы и реакцией пользователей на проект.
Постановка задачи
Перед началом разработки мы с коллегами подробно обсудили характеристики бота (новогоднего гида) и определились с функциональностью.
- Взаимодействие с ботом происходит через одно сообщение. В терминах Telegram — через InlineKeyboardMarkup.
- С помощью текстовой модели бот генерирует предсказание или совет — выбор остается за пользователем. При этом предсказание может быть на тему путешествий, отношений, здоровья, образования или карьеры. А совет — о том, какой посмотреть фильм или сериал, какую книгу почитать или что приготовить.
- На вопрос «Кто я в год дракона?» бот должен выводить одну из заготовленных картинок.
- Новогодний гид должен предлагать пользователям участвовать в розыгрыше, а по завершении — разослать всем результаты. Для этого нужно настроить массовую рассылку сообщений.
Все требования строятся вокруг «фронтенда» и логики бота. Это значит, что я могу использовать любые удобные инструменты для решения задачи.
Архитектура приложения
Основной принцип работы прост: бот должен обращаться к текстовой модели и передавать ответ пользователю. Но здесь появляется загвоздка: часто модель не отвечает мгновенно, а у официальной библиотеки OpenAI нет асинхронного интерфейса. Кроме того, нужно предусмотреть повторы (retry) в случае сетевой или программной ошибки и научиться сохранять задания, чтобы они не слетали при перезагрузке бота.
Я разделил приложение на две части и делегировал задачи брокеру сообщений. В первой (bot) использовал фреймворк aiogram, который обрабатывает обновления из Telegram Bot API и выполняет «быстрые» задачи — например, навигацию в меню. В нашем случае — получает запрос на совет или предсказание и перекладывает его в очередь сообщений.
Во второй части приложения находится обработчик (worker). Он извлекает сообщения из очереди, преобразует в запрос и отправляет к текстовой модели. Для работы с брокером сообщений я использовал библиотеку dramatiq.
# Компонент bot
@router.callback_query(Callback.filter(F.action == Action.request_answer))
async def handle_prediction_message(query: CallbackQuery, callback_data: Callback, bot: Bot):
# Сообщение-заглушка пользователю
# Отправка сообщения в брокер
request_completion.send(...)
# Компонент worker
async def _request_completion(*args, **kwargs):
# Логика обращения к текстовой модели
# Отправка результата в Telegram
await bot.send_photo(...)
@dramatiq.actor
def request_completion(*args, **kwargs):
loop.run_until_complete(_request_completion(*args, **kwargs))
С архитектурой определились. Далее нужно было сделать меню с красивыми иллюстрациями.
Замена иллюстраций
Давайте еще раз взглянем на макет. Для каждой «страницы» меню нужно добавить уникальное изображение в виде анимированного стикера. Однако технические ограничения Telegram превращают его в обычное анимированное изображение, поскольку у фото, видео и анимаций может быть подпись, а у стикеров — нет.
Заглянем в документацию. Telegram Bot API позволяет заменять контент в сообщениях на произвольный, но меню — это фиксированный набор заранее заготовленных изображений. Конечно, можно каждый раз добавлять новые иллюстрации, но загрузка однотипных картинок требует много времени, да и такая активность не понравится Telegram. Поэтому для экономии трафика и времени лучше один раз загрузить изображения на серверы Telegram, чтобы потом использовать повторно.
Оптимизация многообещающая, но нужно учесть следующие подводные камни:
- изображения должны «принадлежать» боту, поскольку именно он их и загружает;
- кэшированные изображения должны находиться в каком-нибудь чате.
Последний пункт заслуживает более подробного объяснения. Если в процессе разработки вы загрузите кэш изображений в переписку с ботом, а потом очистите историю, то первое время все будет работать. Но вскоре Telegram удалит вложения, которые «не привязаны» к чату, и идентификатор станет некорректным.
Один из вариантов решения — создать закрытый чат в качестве хранилища для актуальных изображений бота. Для этого я сделал недокументированную команду, которая обновляет кэш иллюстраций:
@router.message(Command("/cache"))
async def command_cache(message: Message, bot: Bot) -> None:
if message.from_user.id not in DEVELOPER_IDS:
return
dir = "images"
for filename in os.listdir(dir):
if os.path.isdir(f"{dir}/{filename}"):
continue
file = FSInputFile(f"{dir}/{filename}")
if filename.endswith(".png"):
message = await message.answer_photo(file)
await pic_update(filename, message.photo[0].file_id)
else:
message = await message.answer_video(file)
await pic_update(filename, message.video.file_id)
await message.answer("ok")
В результате служебный чат быстро «уйдет вниз» мессенджера и не будет мешать во время тестирования. Это значит, что кэш останется в безопасности.
Массовая отправка сообщений
В Bot API нет универсальной функции для отправки сообщений всем пользователям бота. Однако в библиотеке есть достаточно комфортные условия для разработки собственных. За секунду Bot API может отправить сообщения в 30 чатов, при этом в каждый из них можно прислать не более 20 сообщений за минуту.
У сообщений есть информация, которую нужно где-то хранить. Для этого я использовал две таблицы:
- new_year_broadcast_message — текст сообщения и идентификатор админа,
- new_year_broadcast_message_entry — запись об отправке указанного сообщения.
По умолчанию запись об отправке создается со значением sent = False. Это означает, что сообщение еще не было отправлено.
Поскольку Python довольно медленный, перебор пользователей через SQLAlchemy занимает много времени. Поэтому вместо него я использовал SQL:
engine = create_async_engine(...)
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)
async with async_session() as session:
session: AsyncSession
# Создаем сообщение, коммит назначает ему идентификатор
broadcast = BroadcastMessage(
from_user_id=user_id,
text=text
)
session.add(broadcast)
await session.commit()
# Делаем подзапрос, у которого в первой колонке идентификаторы всех
# известных пользователей, а во втором — константа
select_stmt = select(User.id, broadcast.id)
# Одним запросом создаем по строке на каждого известного пользователя
stmt = insert(BroadcastMessageEntry).from_select(["user_id", "broadcast_id"], select_stmt)
await session.execute(stmt)
await session.commit()
Далее я реализовал функциональность для самой рассылки:
async def do():
async with async_session() as session:
session: AsyncSession
# Лимит в этом запросе ограничивает количество сбщ в секунду
stmt = select(BroadcastMessageEntry).options(selectinload(BroadcastMessageEntry.message)).where(BroadcastMessageEntry.sent == False).limit(5)
result = await session.execute(stmt)
for e in result.fetchall():
entry: BroadcastMessageEntry = e[0]
try:
await bot.send_message(
chat_id=entry.user_id,
text=entry.message.text,
)
entry.sent = True
logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} successful")
except TelegramForbiddenError:
# Пользователь нас заблокировал
entry.sent = True
logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with blocked chat. Marked as successful")
except TelegramBadRequest as e:
# Тоже возможный вариант блокировки
entry.sent = True
logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with BadRequest: {e}. Marked as successful")
except Exception as e:
# Ограничение по спаму, недоступность сервера, etc...
logger.exception(e)
break
await session.commit()
async def scheduler():
# Например, через aioschedule планируем отправку
aioschedule.every(1).seconds.do(do)
while True:
await aioschedule.run_pending()
await asyncio.sleep(1)
asyncio.create_task(scheduler())
Для отправки массовых сообщений я использовал aioschedule, запущенный в том же цикле событий (event loop), что и aiogram. Ограничение в пять чатов за секунду, конечно, предусмотрительное, но зато хорошо работает.
Настройка текстовой модели
Хитрые пользователи могут «развести» ChatGPT на незапланированные откровения, поэтому от пользовательского ввода мы сразу отказались и оставили только категории. А чтобы снизить стоимость запроса к текстовой модели, решили запрашивать советы и предсказания без истории:
messages = [
{
"role": "system",
"content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."
},
{"role": "user", "content": "Посоветуй книгу"},
]
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
temperature=0.6,
messages=messages
)
Ниже — результаты, которые мы получили. Кажется, они выглядят адекватно.
- Фильм — «Побег из шоушенка».
- Сериал — «Игра престолов».
- Книга — «1984» Джорджа Оруэлла.
Далее нужно было разнообразить вывод. Для этого можно повысить параметр «температура», но тогда текстовая модель начнет бредить и выдавать несуществующие произведения. Другой способ — случайно выбрать жанр, добавить модификатор для совета и имя пользователя в Telegram:
_advice_modifier = [
"жизнерадостной",
"интригующей",
"забавной",
…
]
_advice_book_genre = [
"жанра фантастика",
"о морских путешествиях",
"про поиск работы",
...
]
modifier = random.choice(_advice_modifier)
messages = [
{
"role": "system",
"content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."
},
{
"role": "system",
"content": f"Твоя рекомендация должна быть {modifier}!",
},
{
"role": "system",
"content": f"Твоего собеседника зовут {full_name}. "
},
{
"role": "user",
"content": f"Посоветуй книгу {random.choice(_advice_book_genre)}"
},
]
Во время тестирования на коллегах мы выяснили, что использование пользовательского имени в советах — плохая идея.
Василиса Примерова: *выбирает категорию «книги»*. Бот: Я рекомендую тебе книгу «Королева тьмы» от Василисы Примеровой. Это захватывающее историческое фэнтези, где встречаются сильные женские персонажи, магия и эпические сражения. Погрузись в мир, где прошлое переплетается с фантазией и приключениями!
Некорректное поведение бота
Список жанров и положительных модификаторов дает достаточное разнообразие в советах без угрозы попасть на «ужастик». Однако в продакшен все равно попадали странные советы:
«Хватит мяукать, начни рычать: как стать настоящим львом» — удивительная книга, которая расскажет тебе, как обрести силу и уверенность, примерив на себя манеры настоящего царя джунглей. Забудь о мяуканье и покажи всем, что ты настоящий зверь!
Как насчет «Волшебник-паук и драконья принцесса: Приключения волшебного мира, где драконы играют в бейсбол»? Эта книга полна волшебства, приключений и, конечно же, невероятных игр драконов в любимую американскую игру! Отправляйтесь вместе с главными героями в увлекательное путешествие по фантастическому миру, где мечты сбываются, а драконы — настоящие звезды бейсбола!
Самая потешная категория даже после всех настроек — блюда для готовки. Вот примеры:
Как насчет волшебных тестовых крыльев? Они легкие, хрустящие и просто исчезают во рту!
Как насчет «Курицы-ниндзя»? Это блюдо из куриного филе, обжаренного в соусе терияки с добавлением свежих овощей и немного остроты. Готовься к взрыву вкуса и ниндзя-приключениям на твоем языке!
Попробуй веганскую «Шаурму без шаурмы» — это сочные овощи, обжаренные вкусняшки и ароматные соусы, завернутые в питу. Она настолько вкусная, что даже морковка начнет мечтать о том, чтобы стать шаурмой!
Ах, азиатская кухня — это настоящий фейерверк вкусов! Моя рекомендация — попробуй суши «Драконий огонь»! Это сочные роллы с лососем, авокадо и огненным соусом. Они такие острые, что твой рот будет взрываться вкусом, словно дракон пускает огонь! Ощути яркость вкуса и погружение в азиатскую культуру с этими потрясающими суши!
Острое блюдо? О, я знаю идеальную рекомендацию для тебя — «Вулкан вкуса»! Это блюдо состоит из сочной мясной начинки, огненно-острого соуса и тающего сыра, все это завернуто в лепешку и запечено до золотистой корочки. Гарантирую, что твой вкусовой вулкан точно взорвется от удовольствия! Будь готов к пожару на твоем языке!
«Воспитание» бота
Техника, обкатанная на советах, была незначительно модифицирована для предсказаний. Мы добавили персонажа, от имени которого модель должна писать текст:
messages = [
{
"role": "system",
"content": f"Ты пишешь краткие и лаконичные предсказания на 2024 год. Твой ответ - один абзац.",
},
{
"role": "system",
"content": (
f"Пиши предсказание, как будто ты {person}."
)
},
{
"role": "system",
"content":
f"Твоего собеседника зовут {full_name}. "
f"Напиши персонализированное предсказание для этого человека.",
},
{
"role": "system",
"content": f"Предсказание должно быть {prediction_modifier}",
},
{"role": "user", "content": f"Тема: {text}"},
]
Ограничение по длине сообщения — один абзац. Потому что попытки ограничить бота по количеству символов сильно блокировали фантазию ChatGPT. А вариант через количество предложений часто приводил к нумерованным спискам:
1. В 2024 году ты найдешь истинную любовь, которая будет наполнять твою жизнь счастьем и радостью. 2. Твои отношения будут строиться на взаимопонимании и уважении, что приведет к гармонии и стабильности в твоей личной жизни. 3. Будь открытой и честной с партнером, чтобы создать крепкую и продолжительную связь. 4. Важно помнить, что истинная любовь требует усилий и компромиссов, но она стоит каждой затраченной минуты. 5. Не забывай о самолюбии и самоуважении — любовь к себе является фундаментом для здоровых и счастливых отношений.
Статистика
За 40 дней работы бота пришло 4 016 пользователей, которые совершили 14 419 запросов. Почти половина запросов — это «Кто я в год дракона?», то есть заранее заготовленные тексты и уникальные иллюстрации. Среди предсказаний пользователей интересует вопрос отношений и карьеры, а среди советов — фильмы и книги.
В среднем пользователи совершали по четыре запроса, но медианное значение — два. Рекорд — 91 запрос к боту от одного пользователя.
На графике выше видно, что какая-то из маркетинговых активностей привела к «всплеску». 14 декабря было более 5 000 запросов к текстовой модели, а пиковая частота составила пять обращений в секунду. Как и ожидалось, с наступлением нового года активность снизилась и пользователи отключили бота.
В этот день новогодний гид залип на пару минут, что привело к срабатыванию мониторинга для крупных ботов в Telegram и уведомлению от BotFather. Недоступность, конечно, отрицательное явление, но активация мониторинга — это признание для нашего бота.
На графике выше отмечено количество обращений к текстовой модели, но каждое из них — это два-три клика по кнопкам в интерфейсе Telegram, так что в действительности бот обрабатывал больше 300 действий в минуту.
Заключение
Я рад, что согласился поучаствовать в этом спецпроекте. Это отличная возможность проверить свои скиллы в разработке крупных Telegram-ботов и создать интересный сервис для наших читателей.