История простая и неприятная. Пользователь в Картаре записал голосовое, ждал что приложение его поймёт, а ему на экран вылезла сырая техническая ошибка, прямо как она есть, со всеми внутренностями. Я когда это увидел, первое что вырвалось было «Пиздец, так мы её ещё и показываем юзеру». А причина оказалась глубже одного бага, потому что распознавание речи стучалось на эндпоинт, которого вообще не существует, и весь флоу был мёртвый и на dev, и на проде. Починил я это так: поднял faster-whisper локально на CPU, чтобы распознавание речи работало своими силами без облака, и заодно прошёлся по всему приложению и завернул сырые ошибки в человеко-читаемый враппер в двенадцати местах. Дальше расскажу по порядку что именно сломалось и почему я выбрал self-host, а не очередной внешний сервис.
Где была дыра
Вы когда пользуетесь приложением, даже не представляете на сколько там внутри всё может быть на соплях, и вот это как раз тот случай. У нас в проекте есть переменная, которая указывает куда ходить за нейросетями, и она вела на OpenRouter, потому что через него идёт основной трафик к моделям. А код транскрипции, ничтоже сумняшеся, дописывал к этому адресу путь к транскрибации и ждал что там кто-то ответит. Только вот у OpenRouter такого эндпоинта нет и никогда не было, он маршрутизирует чаты к моделям, а не крутит распознавание речи. Ровно на эти же грабли я уже наступал в SceneX, где OpenRouter не проксирует ни Whisper, ни эмбеддинги и тоже пришлось ставить всё локально. То есть запрос уходил в никуда, возвращалась ошибка, и эта ошибка показывалась прямо пользователю.
Самое мерзкое тут даже не то что не работало распознавание, мало ли, фича отвалилась, бывает. Мерзко то, что сломано оно было одновременно и на dev, и на проде, и никто этого не ловил, потому что голосовой ввод штука редкая, мало кто им пользуется в первые дни. Я сам наткнулся случайно когда решил потыкать как живой юзер. И вот тут вылез второй слой проблемы, потому что «Надо ещё поискать где такая хуйня может юзеру всплыть». Раз в одном месте сырая ошибка утекла наружу, значит и в других может.
Почему self-host, а не другой облачный API
Вариант «найти ещё один сервис, который умеет в распознавание речи, и подключить его» я рассматривал ровно секунд десять. Потому что это снова внешняя зависимость, снова ключи, снова деньги за каждую секунду аудио, снова чужой аптайм, от которого зависит моя фича. А у меня уже был рабочий паттерн под боком, ведь эмбеддинги в Картаре я давно гоняю своим ML на CPU, без облака, и это себя оправдало. Так что решение напрашивалось само: «По транскрипту давай селф хост как эмбединги. Только надо как то пиздато сделать чтобы без косяков обрабатывалось и быстро».
Вот это «быстро и без косяков» и было главным ограничением. У меня нет видеокарты под это дело, всё крутится на обычном CPU, и денег на инфраструктуру копейки. Поэтому Claude Code, которым я оркестрировал всю эту работу, поднял faster-whisper в варианте small на движке CTranslate2 с квантизацией int8, и это как раз тот компромисс, который тянет распознавание речи на процессоре без дикого ожидания и без облачного счёта в конце месяца. Модель весит примерно 483 мегабайта, и я её вшил прямо в образ, рядом с приложением, и поставил флаг local_files_only=True, то есть приложение даже не пытается лезть в интернет за весами, всё лежит на месте. Подняли контейнер, и оно работает само, без внешнего API, без ключей, без ежемесячной абонентки за чужой движок.
Это уже не первый раз, когда я ухожу от облака к своему. Кстати про то, как у меня вообще проекты переезжали между провайдерами и почему я перестал доверять единственному внешнему API, я отдельно писал в истории про миграцию с OpenAI, там та же логика, только про деньги и про то как два проекта встали разом. Тут логика та же, просто доведённая до конца: если фичу можно держать у себя на CPU за копейки и она не упрётся ни в чужой тариф, ни в отсутствующий эндпоинт, то её надо держать у себя. Так и сложился единый паттерн «своё ML на CPU, без облака», сначала эмбеддинги, теперь распознавание речи, и обе штуки крутятся на одном принципе.
Сырые ошибки наружу — отдельная боль
Теперь про вторую половину работы, которая по-хорошему была важнее первой. Починить один сломанный флоу это локально, а вот сделать так чтобы технический текст вообще никогда не утёк юзеру, это уже системно. Потому что давайте честно, ошибка с url, со статус-кодом, с куском трейсбэка ничего не говорит обычному человеку, она его только пугает и создаёт ощущение что приложение сделано на коленке. А оно и сделано на коленке, чего уж там, но показывать это пользователю необязательно.
Поэтому я завёл функцию userFacingError(), которая работает по простому принципу и показывает текст ошибки пользователю только если этот текст реально человеко-читаемый. Внутри сидит регулярка, которая отсекает всё подозрительное, url, traceback, статус-коды и прочую техническую требуху. Если сообщение прошло проверку и выглядит как нормальная фраза, показываем его. Если нет, показываем заранее заготовленный понятный фолбэк, что-то в духе «не получилось, попробуйте ещё раз», а сырое улетает в консоль, где ему и место, чтобы я мог это отладить. То есть разработчик видит всё, а пользователь только то, что ему понятно.
Этот враппер я прогнал по всему фронту и применил в двенадцати местах. Не в одном, где словил баг, а во всех, где сырая ошибка теоретически могла всплыть юзеру. Двенадцать точек это вообще-то много, и это хорошо показывает, насколько легко такая дрянь расползается по приложению, когда ты пилишь фичи быстро и не закладываешь обработку ошибок системно с самого начала. Каждое место, где ты просто пробрасываешь сообщение от сервера на экран как есть, это потенциальная утечка технического мусора прямо в лицо человеку.
Что я из этого вынес
Первое про дальние углы. Голосовой ввод был сломан и на dev, и на проде, и держалось это ровно потому что фичей редко пользуются. Если у вас есть кусок продукта, в который пользователи заходят нечасто, считайте что он сломан, пока вы лично не проверили его как живой юзер. Зелёные тесты тут не спасают, у меня они были зелёные, а эндпоинта, в который ходил код, не существовало в природе, просто потому что переменная окружения указывала не туда.
Второе про обработку ошибок. Её надо закладывать как слой, а не латать по факту. Двенадцать мест, где сырьё могло утечь юзеру, появились не за один день, они накопились, потому что обработка ошибок не была единым правилом. Один враппер с проверкой на человеко-читаемость закрыл всю поверхность разом, и теперь любое новое место автоматически попадает под то же правило, и это куда дешевле, чем ловить каждую утечку поодиночке.
Третье и для меня главное про self-host. Каждый раз, когда фичу можно вытащить из облака к себе на CPU за копейки, это стоит сделать, особенно когда ты соло и считаешь каждый рубль на инфраструктуру. Распознавание речи на faster-whisper в int8 крутится у меня локально, модель вшита в образ, в интернет не лезет, и я больше не завишу от того, есть ли у внешнего провайдера нужный эндпоинт или нет его вовсе. Меньше внешних зависимостей, меньше мест, где всё развалится в самый неподходящий момент. Вот и делайте выводы.