На Хабре мы часто читаем о вещах практичных: код, архитектура, базы, очереди, деплой - всё, что помогает системам жить и не падать. А вот про то, как инженерные навыки могут улучшать досуг, текстов заметно меньше.

Я люблю ходить в музеи и почти всегда беру аудиогид: в музее для меня каждая картина — своего рода дверь, а хороший рассказ помогает её открыть через эпоху, детали, биографию художника, символы. Не так давно попал на выставку Карла Брюллова в Третьяковской Галерее. Взял аудиогид — это такая трубка, похожая на телефонную, где нужно вводить номер картины и слушать заранее записанную дорожку. Вешаешь себе на шею — и ходишь, слушаешь. А ведь у этого решения куча недостатков: оно говорит со всеми одинаковым голосом, одинаковой длительностью и с одинаковым фокусом, а если нужной записи просто нет, то и рассказа нет. Одноразовые наушники могут не всем подходить. Музеям приходится такие устройства заряжать, обновлять прошивки, записывать и заливать аудиодорожки, держать штат для выдачи и учёта аппаратуры. Словом — морока.

У каждого в кармане уже есть телефон — с нормальными наушниками, привычным интерфейсом и связью.Почему бы не убрать отдельное «железо» и не сделать аудиогид, который живёт там, где он действительно удобен? А если уж делать аудиогид на телефоне, то почему он должен быть одинаковым для всех и жёстко привязанным к фиксированному набору записей? Почему бы не сделать его персональным — таким, где можно задать тон, длительность, фокус и даже «настроение» рассказа, а потом получить аудио прямо по запросу.

Так родилась идея проекта: персональный аудиогид, который по запросу строит рассказ о картине и озвучивает его. Дальше в статье я покажу, как устроен этот путь от запроса до готового аудиофайла, какую архитектуру я выбрал для пайплайна LLM + TTS и какие инженерные решения нужны, чтобы это работало.

И да, самый очевидный вопрос: «А почему бы просто не спросить у Алисы про эту картину?»

Если нужен быстрый общий ответ — это вполне рабочий вариант. Но как только хочется не «справку», а рассказ в определённой подаче, вылезает несколько неудобств, которые в музее особенно чувствуются. Во‑первых, все пожелания к формату приходится каждый раз проговаривать заново: «коротко», «попроще», «как для ребёнка», «с акцентом на символы», «в спокойном тоне», «на две минуты» — и так для каждой новой картины. В итоге ты меньше смотришь на полотно и больше диктуешь длинный запрос, пытаясь вспомнить правильные формулировки.

Во‑вторых, у голосового ассистента ты практически не управляешь «актёрской» частью. Голос — один и тот же (привычный голос Алисы), и ты не можешь выбрать другой тембр или стиль озвучки под настроение, сменить «диктора», сделать подачу более нейтральной или, наоборот, более выразительной. А в аудиогиде голос — это половина впечатления: иногда хочется спокойного экскурсовода, иногда — более живой интонации, иногда — максимально ровного голоса, который не перетягивает внимание на себя.

Поэтому мне хотелось сценария, где в музее ты делаешь одно простое действие — вводишь картину и автора — а всё остальное «режиссируется» без лишних диктовок и без привязки к одному‑единственному голосу.

Архитектура: эволюция решения

Telegram-бот + LLM + TTS: “работает, но долго”

Максимально простая схема: пользователь пишет название картины и автора, бот вызывает LLM для генерации текста и затем TTS для озвучки, после чего отправляет голосовое в Telegram. Это хороший прототип, но в реальном музее почти сразу чувствуется главный минус — суммарная задержка. LLM и TTS вместе могут дать десятки секунд и даже минуты ожидания, а в условиях нестабильной связи это превращается в лотерею. Плюс такой подход не оставляет “следа”: если захотелось переслушать или вернуться к рассказу позже, всё снова упирается в повторную генерацию.

Добавляем базу данных: “появляется память”

Дальше нужна простая способность помнить: что уже запрашивали и что уже сделали. Здесь появляется база данных, но не как хранилище аудио, а как место для метаданных: нормализованный ключ запроса, статус, версия промпта, язык (если делаем мультиязычную версию), и ссылка на готовый результат. Как только система умеет отвечать “у меня уже есть готовая озвучка”, пользователь перестаёт ждать каждый раз, а генерация перестаёт происходить по кругу.

Выносим аудио в S3-совместимое хранилище: “файлы отдельно, данные отдельно”

Следующий вопрос — где хранить сами аудиофайлы. Держать их рядом с сервисом на диске быстро становится неудобно: ограничение объёма, сложные бэкапы, миграции, проблемы при масштабировании. Хранить бинарники в базе тоже можно, но это плохо для БД: растут объёмы и бэкапы, ухудшается эксплуатация. Поэтому аудио логично вынести в S3-совместимое хранилище, а в БД оставить только s3_path и метаданные. При этом пользователь получает аудио не “по ссылке из S3”, а прямо в Telegram: бэкенд скачивает файл из S3 и загружает его в Telegram как voice/audio.

Кэш для идемпотентности: “повторы не должны создавать дублей”

В музейном сценарии повторы — норма: Telegram может прислать апдейт повторно, пользователь может нажать “отправить” ещё раз, сеть может оборваться. Если это не контролировать, появляются дубль-задачи и дубль-аудио. Поэтому вводится идемпотентность входящих апдейтов через быстрый ключ в Redis с TTL: если апдейт уже видели — не обрабатываем его повторно.

Кэш результата: “не генерировать одно и то же дважды”

Даже при корректной обработке апдейтов остаются повторные смысловые запросы: одна и та же картина, тот же автор, та же версия подачи. Здесь нужен кеш результата: если аудио уже есть, система должна мгновенно отдавать его без вызовов LLM/TTS. Это одновременно ускоряет UX и резко снижает стоимость, потому что LLM и TTS — самые дорогие шаги.

Итоговая схема: “быстро для пользователя, экономно и устойчиво внутри”

В финальной версии пользователь по-прежнему делает минимум — вводит название картины и автора. Дальше бэкенд проверяет идемпотентность и кэш, при попадании сразу достаёт s3_path, скачивает аудио из S3 и отправляет его в Telegram. При промахе запускается генерация (LLM → TTS), результат сохраняется в S3 и фиксируется в БД/кэше, после чего пользователь получает готовое голосовое. Это и есть актуальная архитектура: минимальный ввод, возможность переслушивать без повторной генерации, устойчивость к плохой сети и повторным апдейтам, и нормальная эксплуатация с разделением данных и файлов.

Пайплайн: от запроса до аудио (LLM → TTS)

Снаружи всё просто: пользователь пишет боту название картины и автора — и получает голосовое сообщение. Внутри пайплайн делится на две ветки: быструю (если результат уже есть) и длинную (если нужно генерировать заново).

Сначала бэкенд нормализует ввод, чтобы одинаковые запросы приводились к одному виду и хорошо попадали в кеш. Затем проверяет: есть ли уже готовое аудио для этой картины в текущей версии «подачи». Если есть, система берёт s3_path, скачивает файл из S3-совместимого хранилища и загружает его в Telegram как voice/audio — пользователь получает ответ почти мгновенно.

Если готового результата нет, запускается длинная ветка. Запрос ставится в очередь, воркер вызывает LLM и получает текст рассказа (пользователь вводит только название и автора, а стиль/длина/уровень и прочая «режиссура» задаются на бэкенде). Затем этот текст отправляется в TTS, на выходе получается аудио, которое сохраняется в S3. После этого бэкенд фиксирует метаданные и путь к файлу в базе и обновляет кэш, чтобы следующий такой же запрос не запускал генерацию снова. Финальный шаг — доставка: аудио скачивается из S3 и отправляется пользователю через Telegram Bot API.

В результате получается понятная логика: один раз подождал — дальше переслушиваешь и получаешь ответ без повторной генерации.

Выбор LLM и TTS

С базовыми кирпичиками вроде PostgreSQL и S3 обычно всё понятно, а вот с генеративными сервисами так не работает: качество, стабильность и “поведение” моделей напрямую определяют качество контента: от содержания до подачи.

С LLM я перебрал несколько вариантов и в итоге остановился на OpenAI API gp. Он мне показался наиболее “красноречивым”: текст звучит связно, ритмично и хорошо ложится в озвучку. Он лучше справлялся с промтами в стиле: «Рассказывай как Паола Волкова»(обожаю её лекции). Для музейного формата это критично — сухой или рваный текст даже идеальным голосом будет восприниматься хуже, чем живой и хорошо структурированный.

С TTS я начал с популярного сервиса ElevenLabs. Когда я искал варианты, этот сервис попадался чаще всего, и это легко понять: в сервисе доступно много голосов, они приятные, ровные, есть выбор, включая русскоязычные. Первые прогоны действительно выглядели впечатляюще, но довольно быстро обнаружилась проблема, которая для аудиогида по искусству неожиданно оказалась “блокером”: русские голоса часто ошибались с ударениями, особенно в фамилиях художников. Например, Борис Кустодиев (Кусто́диева) превращался в “Кустоди́ев”, а Карл Брюллов (Брюлло́в) — в “Брю́ллова”. В обычном тексте это можно пропустить, а в аудио такие вещи режут слух и снижают доверие: кажется, что рассказ “не настоящий”. Еще одним минусом оказалась невозможность явно обозначить ударения в тексте.

В итоге для текущей версии проекта я остановился на TTS от Яндекс — Yandex SpeechKit. Главный плюс — заметно более аккуратная работа с русской фонетикой и ударениями, то есть аудио звучит естественнее именно в контексте русских имён и названий. Кроме того, этот сервис позволяет явно задавать ударения с помощью спец символов (может быт полезно для явного обозначения ударений тех слов с которыми сервис не справляется по-умолчанию). Минусы тоже есть: меньше поддерживаемых языков и меньше разнообразия голосов по сравнению с ElevenLabs. Пока это не критично, но в перспективе мультиязычность и многоголосность может быть важна — музейные аудиогиды почти всегда доступны на нескольких языках.

Промпт на бэкенде и качество выдачи LLM

Напомню, что пользователь должен вводить только название картины и автора. Вся «режиссура» рассказа живёт на бэкенде: я фиксирую структуру, тон, уровень сложности и ограничения так, чтобы текст стабильно хорошо звучал в аудио, а не превращался то в лекцию на 10 минут, то в два предложения.

Работая с OpenAI API, я не мог понять,почему модель уверенно путается в деталях: особенно на сюжетной живописи, где много персонажей, жестов, предметов, костюмов и мелких элементов. В таких случаях LLM могла «дорисовывать» несуществующие объекты, путать количество людей или даже смешивать картины между собой — и в аудиогиде это звучит плохо, потому что создаёт ощущение выдумки, а не рассказа.

Сильно помогло подключение web-search: когда модель может подтянуть внешние источники по конкретной картине, выдача становится заметно устойчивее — меньше случайных деталей и больше проверяемого контекста. В терминах API это именно встроенный инструмент web-search в Responses API: его можно включить в список tools, и модель при необходимости сама делает поиск перед тем, как сформировать ответ. Кому интересно можно почитать тут: https://platform.openai.com/docs/guides/tools-web-search

Итого: промпт на бэкенде отвечает за форму и “голос” рассказа, а web-search — за то, чтобы этот рассказ не расползался на уровне фактов и деталей, особенно когда картина сложная и насыщенная сценой.

Кеширование, доставка аудио и надёжность: чтобы это работало в музее

Когда используешь LLM и TTSв «полевом» сценарии, всё упирается в реальный мир: связь в здании музея может быть нестабильной, сообщения иногда отправляются повторно, Telegram может прислать один и тот же апдейт дважды, а внешние API время от времени отвечают медленно или с ошибкой. Поэтому здесь важны три вещи: не плодить дубли, уметь быстро отдавать уже готовое и не зависеть от длинных операций в момент запроса.

Идемпотентность: защита от повторов Telegram и “ой, нажал ещё раз”

Повторы — норма: пользователь может отправить сообщение дважды, а Telegram — повторить доставку апдейта. Если каждый повтор запускать заново, появляются дубли задач и лишние обращения к LLM/TTS. Поэтому на входе включена идемпотентность: повторный апдейт распознаётся и не обрабатывается второй раз.

Кеш результата: один раз сгенерировал — дальше отдаёшь мгновенно

В музее часто хочется переслушать: вернуться к картине, отправить другу, получить тот же рассказ ещё раз. Если каждый раз заново вызывать LLM и TTS, это и долго, и дорого. Поэтому результат кешируется: при повторном запросе система сначала проверяет, нет ли уже готового аудио, и если есть — сразу отвечает им, без повторной генерации.

Хранение аудио: S3 + метаданные в БД

Сами аудиофайлы хранятся в S3-совместимом хранилище. В базе данных остаются только метаданные и путь к объекту — так проще поддерживать объём файлов и не раздувать БД.

Очереди и ретраи: длинные операции — в фоне

Генерация текста и озвучка могут занимать заметное время, поэтому они вынесены в асинхронный контур. Для этого я использовал Celery, а в качестве брокера/хранилища служебных данных — Redis. Такой подход позволяет спокойно работать с таймаутами и ретраями и не заставляет пользователя пересылать запрос заново, если внешний сервис “моргнул”.

В итоге получается простая логика: один раз подождал, пока создалось — дальше слушаешь и переслушиваешь без повторной генерации, даже если связь в музее ведёт себя капризно.

Заключение

Это проект выходного дня. Я развернул бота на VPS и пользуюсь им когда посещаю музеи и выставки.

Репозиторий проекта

Мой публичный бот доступен по ссылке (есть ограничения)

Телеграмм-канал, в котором показываем картины с озвучкой (озвучка делается ботом) каждый день: https://t.me/artvoice\_ru