Запустить Whisper на одном файле — это уровень джуна. Пропустить через него десяток записей — уже мидл. А вот обрабатывать тысячи часов аудио ежедневно, чтобы потом аналитики ищем тренды, а юристы проверяют соблюдение скриптов — это совершенно другая лига.
Именно с такой задачей столкнулись в ЮMoney. Их сервис поддержки генерировал горы записей. Вручную их слушать — безумие. Нужен был промышленный конвейер, который не просто переводит звук в текст, а делает это точно, быстро и с пониманием, кто, когда и что сказал.
Они его построили. И я расскажу, как это работает изнутри, с такими деталями, от которых у классического DevOps потекут слезы умиления (или ужаса).
Проблема: не "если", а "когда" твой сервер сгорит
Первый соблазн — взять Whisper, обернуть его в Flask и радостно слать запросы. Сотня звонков в день? Легко. Потом приходит релиз новой функции, нагрузка вырастает в 50 раз, и твой красивый сервис падает под грузом 12-часовых аудиофайлов от клиентов.
Главная ошибка на старте: думать, что проблема — это точность транскрипции. Нет. Проблема — это предсказуемая задержка, управление памятью GPU и идемпотентность обработки одного и того же файла в сотне параллельных воркеров.
ЮMoney быстро это поняли. Их цели были конкретны:
- Обрабатывать >5000 часов аудио в сутки.
- Среднее время обработки (от загрузки до готового текста с метаданными) — не более 15 минут.
- Точность (WER) не хуже 5-7% для русской речи в условиях типового телефонного шума.
- Автоматически определять спикеров и склеивать их реплики в диалог.
- Все должно работать внутри корпоративного контура, без выхода в публичное облако.
Архитектура: от монолита к конвейеру из микросервисов
Они убили монолит. Вместо него появился асинхронный пайплайн, где каждый этап — отдельный сервис, который можно масштабировать горизонтально.
| Этап | Инструмент/Модель | Задача |
|---|---|---|
| Прием и валидация | FastAPI, Celery | Принять файл, проверить формат, создать таск в очереди. |
| Предобработка и чанкование | FFmpeg, PyTorch | Конвертация в wav, нормализация громкости, разбивка на сегменты по 30 сек. |
| Транскрибация | Whisper v3 Large-v3 (актуально на 20.03.2026) | Основное преобразование речи в текст для каждого чанка. |
| Диаризация | PyAnnotate 2.1 + собственные дообученные эмбеддинги | Определение границ реплик и кластеризация по спикерам. |
| Постобработка | Кастомные правила, ASR-постпроцессинг | Исправление доменных терминов ("юмани", "кошелек"), форматирование. |
| Сборка и сохранение | PostgreSQL, Redis, MinIO | Склейка текста, добавление меток времени и спикеров, сохранение в БД и object storage. |
Ключевое здесь — разделение ответственности. Сервис транскрибации не должен думать о форматах файлов. Сервис диаризации получает уже чистый аудиопоток и текст. Если один этап лег, остальные могут продолжать работать с накопленными задачами из очереди.
1 Чанкование: почему 30 секунд — это новый black
Whisper v3 Large — монстр. Он жрет до 10 ГБ VRAM на полную длину контекста. А звонки длятся и по часу. Прогнать такой файл целиком — гарантировать OutOfMemory даже на A100.
Решение — резать. Но не абы как. Прямые разрезы по времени убивают слова на стыках. Нужны умные границы по silence detection.
import whisper
from pydub import AudioSegment, silence
def chunk_audio_by_silence(file_path, chunk_duration_ms=30000, min_silence_len=500, silence_thresh=-40):
audio = AudioSegment.from_wav(file_path)
not_silence_ranges = silence.detect_nonsilent(audio, min_silence_len, silence_thresh, 1)
chunks = []
current_chunk = AudioSegment.empty()
for start, end in not_silence_ranges:
segment = audio[start:end]
if len(current_chunk) + len(segment) <= chunk_duration_ms:
current_chunk += segment
else:
if len(current_chunk) > 0:
chunks.append(current_chunk)
current_chunk = segment
if len(current_chunk) > 0:
chunks.append(current_chunk)
return chunks
Этот код режет аудио на сегменты до 30 секунд, но не разрывает речь в местах, где пауза меньше полусекунды. Результат — чанки, которые Whisper обрабатывает стабильно, без скачков потребления памяти.
2 Диаризация: кто сказал "алло"?
Текст есть. Но где начало реплики оператора, а где клиент перебивает? Это задача диаризации. ЮMoney отказались от простых энергетических методов (тише/громче) — они не работают при перекрытии речи.
Они используют комбинацию из двух моделей:
- Детектор смены спикера (Speaker Change Detection) — нейросеть, которая отмечает моменты, когда вероятно сменился говорящий.
- Голосовые эмбеддинги (Voice Embeddings) — модель преобразует короткий отрезок голоса в вектор. Векторы одного человека кучкуются рядом, векторы разных — далеко.
Для извлечения эмбеддингов они дообучили модель на внутренних данных — голосах своих операторов. Это дало сумасшедший прирост точности кластеризации для известных голосов.
# Упрощенный пример кластеризации спикеров
import numpy as np
from sklearn.cluster import DBSCAN
def cluster_speakers(audio_chunks, embedding_model):
"""
audio_chunks: список аудиосегментов после детекции смены спикера.
embedding_model: модель для получения голосового эмбеддинга (например, на базе ECAPA-TDNN).
"""
embeddings = []
for chunk in audio_chunks:
# Конвертируем аудио в numpy массив, нормализуем
audio_np = np.frombuffer(chunk.raw_data, dtype=np.int16)
# Получаем эмбеддинг (условный вызов)
emb = embedding_model.infer(audio_np)
embeddings.append(emb)
embeddings = np.array(embeddings)
# DBSCAN сам определяет количество кластеров, что удобно для неизвестного числа спикеров
clustering = DBSCAN(eps=0.3, min_samples=2, metric='cosine').fit(embeddings)
return clustering.labels_ # Метки кластера для каждого чанка
Получается разметка: "0-10 сек: спикер А, 10-25 сек: спикер Б". Ее потом накладывают на транскрибированный текст.
3 Масштабирование: очередь как спасательный круг
5000 часов аудио. Это примерно 208 дней непрерывной речи. Делать это синхронно — смешно. В основе всего пайплайна — RabbitMQ (хотя сейчас многие переходят на Kafka, но RabbitMQ проще в эксплуатации для таких пайплайнов).
Каждый этап — отдельный воркер, который берет задачу из своей очереди, выполняет и кладет результат в следующую очередь. Если транскрибатор падает, задачи накапливаются перед ним, но не теряются. Поднять еще три инстанса — и очередь быстро рассасывается.
Самая частая ошибка при настройке таких очередей — забыть про идемпотентность. Что, если воркер транскрибации выполнил задачу, но умер перед тем, как отметить ее выполненной? Задача уйдет другому воркеру, и тот же файл обработается дважды. Решение — сохранять результат в общее хранилище (например, MinIO) по уникальному ID задачи сразу после обработки. Перед началом обработки проверять, нет ли уже результата.
Железо и деньги: как не разориться на GPU
Whisper v3 Large на GPU в 10 раз быстрее, чем на CPU. Но одна карта A100 стоит как неплохая иномарка. ЮMoney пошли по пути гибридного кластера:
- Горячий пул: серверы с A100 или H100 для обработки в реальном времени (звонки, которые требуют быстрой транскрипции для live-аналитики).
- Холодный пул: инстансы с несколькими RTX 4090 для фоновой обработки архивных записей. Дешевле, но все еще с GPU.
- Резервный CPU-пул: на случай пиковых нагрузок или проблем с GPU. Используют оптимизированные реализации Whisper.cpp, которые работают на CPU, но медленнее.
Они написали свой шедулер, который распределяет задачи по пулам в зависимости от приоритета и наличия свободных ресурсов. Задача с пометкой "realtime" летит в горячий пул. Архивная обработка — в холодный.
Если своих мощностей не хватает, они используют облачные GPU инстансы от партнеров (партнерская ссылка) для кратковременного масштабирования. Но основная нагрузка — на своем железе.
Мониторинг: что пахнет горелым в дата-центре?
Тысячи задач в очередях. Десятки воркеров. Как понять, что все идет не так? Они собирают метрики по каждому этапу:
- Время обработки на чанк (перцентили 50, 95, 99). Внезапный рост p99 — кто-то подал 8-часовой файл без пауз, и чанкование сломалось.
- Загрузка VRAM на GPU. Если она постоянно выше 90% — скоро будет OOM.
- Длина каждой очереди. Растет очередь перед диаризацией? Значит, там бутылочное горлышко.
- Качество (WER) на отложенном тестовом наборе, который прогоняется раз в сутки.
Все это летит в Prometheus, дашборды в Grafana. Плюс кастомные алерты: "Если среднее время обработки превысило 20 минут — разбудить инженера" (да, даже ночью).
Финальный спринт: постпроцессинг, который знает бизнес
Whisper выдает "ю мани", а нужно "ЮMoney". Он пишет "кошелек", а в компании говорят "кошелек ЮMoney". Поэтому после всей магии нейросетей идет слой простых, но жизненно важных правил.
Они используют комбинацию словарей замен и небольшую языковую модель (типа MumbleFlow), которая исправляет опечатки в профессиональной лексике. Это дает еще 2-3% улучшения точности с точки зрения бизнес-пользователя, который ищет в тексте конкретные термины.
Итоговая схема пайплайна
Аудиофайл -> Приемный сервис (создание задачи с ID) -> Очередь "to_preprocess" -> Воркер предобработки (чанкование) -> Очередь "to_transcribe" -> Воркер транскрибации (Whisper v3) -> Очередь "to_diarize" -> Воркер диаризации и эмбеддингов -> Очередь "to_postprocess" -> Воркер постобработки -> Сохранение в БД и object storage -> Уведомление о завершении.
Каждый воркер пишет результат в общее хранилище (MinIO) под ID задачи. Если воркер умирает, задача забирается другим воркером, который сначала проверяет, нет ли уже результата в хранилище.
Что дальше? Не останавливайтесь на тексте
Текст — это только сырье. Следующий шаг, который уже делают в ЮMoney — запуск LLM (например, локально через Ollama) для суммаризации диалогов, определения интента клиента, выявления эмоциональной окраски. И все это в том же асинхронном пайплайне, где следующий этап берет текст и выдает анализ.
И последний совет, который не даст вам наступить на те же грабли: начинайте с мониторинга и логирования. Не с модели, не с кода. Сначала настройте сбор метрик и логов так, чтобы вы в любой момент видели, где застревают задачи, сколько памяти ест каждый воркер и какова точность на свежих данных. Без этого вы будете масштабировать вслепую, а это дорого и больно.