title background

Статьи / Локальный ASR сервер для звонков со смешанной речью

Sep 27, 2026 · Sanatel Consulting

О чём это

Мы собрали речевую аналитику телефонных разговоров, которая целиком работает на одном сервере внутри контура заказчика: ни аудио, ни расшифровка наружу не уходят. Разговоры идут на смеси русского с казахским или узбекским, записи приходят с АТС в телефонном качестве.

Готовых решений под такую комбинацию нет. Whisper и локальные LLM — это библиотеки, а не сервис: они обрабатывают один файл по запросу. Чтобы получилась система, поверх них пришлось построить отдельный слой: очередь заданий, ансамбль языковых проходов, фильтрацию галлюцинаций и формулу выбора лучшего результата.

Статья — про этот слой. Все цифры и код взяты из работающей системы, включая те места, где первое очевидное решение оказалось неверным.

Задача: почему это не обычное распознавание

Вход — стерео-запись разговора с Asterisk: менеджер в одном канале, клиент в другом. Это единственное, что играет нам на руку: роли известны точно, диаризация не нужна.

Всё остальное против нас:

  • Смешанная речь. В одном разговоре встречаются русский и казахский или русский и узбекский. Чаще всего переключение происходит один раз, сразу после приветствия, но бывает и в середине.
  • Разговорная лексика. Клиенты говорят живым языком с заимствованиями. Модели, дообученные на литературных и новостных корпусах, на этом заметно проседают.
  • Телефонное качество. Узкая полоса 8 кГц, mp3 64 кбит/с, joint stereo, менеджер говорит в трубку, а не в гарнитуру. Это худший вход, на котором Whisper вообще применим.

Облачные провайдеры дают на русском приемлемый результат, на казахском и узбекском — заметно хуже. Плюс сам факт передачи записей наружу, о чём дальше.

Почему только в контуре заказчика

Причин три, и юридическая — не самая частая в переговорах, хотя и самая жёсткая.

Право. В Казахстане Закон № 94-V «О персональных данных и их защите» требует, чтобы хранение персональных данных велось в базе на территории республики (п. 2 ст. 12); трансграничная передача регулируется отдельно и требует своих оснований. Для банков поверх этого работает режим банковской тайны: сведения о клиенте раскрываются третьим лицам только с его письменного согласия.

В Узбекистане картина изменилась в 2026 году. Закон № ЗРУ-1125 от 26 марта 2026 года изложил ст. 27¹ Закона № ЗРУ-547 в новой редакции: обязательная локализация теперь распространяется на закрытый перечень — биометрические и генетические данные, данные пользователей услуг операторов телекоммуникаций. Для остальных категорий обработка за пределами страны допускается при выполнении условий закона, но подзаконных актов по ним на момент написания нет.

То есть аргумент «закон прямо запрещает облако» в Узбекистане больше не универсален. Зато появился другой: условия трансграничной передачи пока не раскрыты, и локальное решение просто выводит компанию из этой неопределённости.

Доверие. Независимо от права, средний и крупный бизнес формулирует требование прямо: записи не должны покидать периметр. Типовой запрос на пресейле — выделенный сервер в помещении компании либо собственная стойка в чужом ЦОД. Разделяемое облако, как правило, не рассматривается вообще.

Стоимость владения. Облачный ASR тарифицируется за минуту. На потоке в тысячи разговоров в сутки своя видеокарта окупается быстро и даёт предсказуемый бюджет.

Архитектура: слой поверх двух движков

На сервере живут три процесса: Python/FastAPI с faster-whisper, Ollama с Qwen2.5 и наш ASR Service на TypeScript (Node.js, Fastify). Последний — это и есть система; первые два без него просто библиотеки.

Что он делает:

  • Отдаёт единый HTTP API с авторизацией по токену и валидацией запроса по JSON-схеме. К нему обращаются разные внутренние системы компании, а не один скрипт.
  • Держит очередь заданий в памяти и прогоняет каждое через фиксированную машину состояний.
  • Готовит аудио: проверяет, доступны ли исходные моно WAV файлы, или нужно использовать стерео MP3. ffmpeg разбивает стерео на два моно-канала и ресемплит в 16 кГц, файлы валидируются по размеру и длительности.
  • Собирает результат, выбирает победителя, пишет журнал и отдаёт страницу состояния: задания по состояниям, время распознавания, температура GPU, оценки вариантов, покрытие речи.
  • Чистит за собой: временные каналы удаляются сразу после обработки, задания живут сутки.

Состояния задания:

draft → new → wave_ready → winner → refine → done → error

Переходами занимаются три независимых цикла-свитчера — по одному на подготовку аудио, распознавание и обращения к LLM. Каждый крутится на своём интервале и защищён флагом от повторного входа:

let gIntervalRunning = false; async function asrStateSwitcher(): Promise<void> { if (gIntervalRunning) return; // запускать только один экземпляр gIntervalRunning = true; // refine -> done, winner -> refine, wave_ready -> winner // каждый блок берёт самое старое задание в своём состоянии gIntervalRunning = false; }

Это и есть весь параллелизм в однопоточном Node: пока цикл ASR ждёт ответа от Python по сети, цикл ffmpeg успевает подготовить каналы для следующего разговора. Никаких воркеров и очередей на брокере — для нагрузки в тысячи разговоров в сутки это избыточно.

Сам вызов распознавания идёт строго последовательно: Python-сервис синхронный и отдаёт 429, если занят. Внутри одного задания проходы выполняются один за другим, параллелить их на одной видеокарте смысла нет.

Три прохода вместо одного

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

Поэтому каждый канал распознаётся трижды: автоматический проход по паре языков и два форсированных. Языковая пара — параметр задания, конвейер от неё не зависит:

/** 'uz,ru' -> ['uz,ru', 'uz', 'ru']; 'kk,ru' -> ['kk,ru', 'kk', 'ru'] */ export function getLanguageCandidateSettings(languages: string): string[] { const langs = languages.split(',').map(s => s.trim()).filter(Boolean); return [languages, ...langs]; }

Первый элемент — строка целиком: на стороне Python это означает «язык не форсируем». Остальные — форсированные проходы. Для Узбекистана получается uz,ru → авто + uz + ru, для Казахстана kk,ru → авто + kk + ru.

Итого шесть обращений к ASR на один разговор: три прохода × два канала. Это осознанная плата — на смешанной речи выигрыш в качестве больше, чем потеря во времени.

Важный побочный эффект, на который стоит обратить внимание: при форсированном языке Whisper не запускает детекцию вообще. Поле language в ответе равно тому, что вы передали, а language_probability всегда равна 1.0. Осмысленная детекция есть только у авто-прохода, и строить выбор победителя на «уверенности в языке» нельзя.

Это не академический SOTA. Исследовательский путь для code-switching — архитектурные доработки самой модели: энкодер-рефайнер для внутрифразового переключения, языково-осознанные адаптеры декодера. Наш вариант — инженерный компромисс: приемлемое качество без дообучения и без ресёрч-команды.

Выбор моделей: одна базовая, одна специализированная

Базовая модель — Whisper large-v3 через faster-whisper. Она отвечает за авто-проход и за русский язык, и на русском её качество нас устраивает. На узбекском — нет.

Критерии отбора специализированной модели были простые: архитектура Whisper либо возможность конвертации в CTranslate2 (иначе она не встанет в наш движок), и качество на наших собственных записях, а не на публичных бенчмарках.

Мы прогнали три модели на одних и тех же реальных разговорах, форсированный узбекский проход. Вот один из них — тот, где large-v3 практически развалился:

Метрика / large-v3 / Модель A / Модель B
Сегментов: 8 / 4 / 5
Покрытие речи, с: 13,0 / 34,7 / 110,7
Доля речи от длительности: 0,095 / 0,255 / 0,814
Доля низкой уверенности: 0,875 / 0,000 / 0,000
Взвешенная уверенность: 57,3 / 71,9 / 94,3

Модель B восстановила 81 % разговора как связную речь там, где базовая модель дала 9,5 %. Текст читаемый: возврат билета, номер брони, объяснение про стороннее агентство.

По взвешенной уверенности на трёх разговорах:

Разговор / large-v3 / Модель A / Модель B
№1 (138,5 с): 69,5 / 78,9 / 90,3
№2 (136,0 с): 57,3 / 71,9 / 94,3
№3 (239,9 с): 66,8 / 77,2 / 93,8

Модель A — дообученная под узбекский версия large-v3-turbo, модель B — дообученная Whisper-medium, переведённая в CTranslate2. Разрыв между ними устойчивый, 12–23 пункта на каждом разговоре. Отдельно показательно, что у модели B наши фильтры галлюцинаций не отбраковали ни одного сегмента на всех трёх записях — а у двух других отбраковывали регулярно.

Один нюанс, который стоит знать: speechCoverageRatio больше единицы — не ошибка. Покрытие суммируется по двум каналам, а делится на длительность разговора; когда оба собеседника говорят помногу, сумма превышает длину записи.

Обе модели одновременно в 16 ГБ видеопамяти вместе с LLM не помещаются, поэтому специализированная модель не крутится вторым сервером, а подменяется в том же процессе между запросами. Измеренное время подмены на тёплом кэше — около 0,9 секунды, то есть примерно 5 % от времени обработки одного разговора.

Галлюцинации: шесть классов, которые пришлось ловить руками

На тишине, шуме и обрывках речи Whisper выдаёт правдоподобный несуществующий текст. Без отдельного слоя очистки он попадает в диалог, накручивает метрики и ломает выбор победителя. Все шесть классов ниже найдены на реальных записях, а не взяты из документации.

1. Эхо initial_prompt. Модель дословно повторяет служебную подсказку как реплику собеседника. Составной промпт авто-прохода эхается частями, поэтому сверяем и по отдельным предложениям.

2. Известные фразы-артефакты. Следы загрязнения обучающих данных субтитрами: «Субтитры создавал DimaTorzok», «Продолжение следует», «Подписывайтесь на канал», «Thank you for watching», турецкое «Altyazı». Матчим по устойчивой части, а не по фразе целиком: вариант с другим глаголом («субтитры сделал») иначе проскакивает.

3. Чужая система письма. На шуме декодер уходит в случайный скрипт. Кириллица и латиница покрывают все наши языки; если доля прочих знаков превышает 30 %, сегмент отбраковывается. Фильтр общепроектный: кириллица сама по себе не отличает ru от kk и uz.

4. Межсегментная петля. Самый частый класс: одна фраза повторена 13–15 раз подряд. Детектируется только по каналу целиком, поэтому фильтрация принимает на вход весь массив сегментов, а не по одному. Пороги разные для длинных и коротких фраз — менеджер может дважды переспросить одно и то же, а «да», «хоп», «рахмат» повторяются постоянно.

5. Повтор внутри сегмента. Мусор вида qaqqaqqaqqaq… и «Қаза, Қаза, Қаза, …» ловится по сжимаемости текста. Whisper считает то же самое сам, но у него это завязано на retry-цикл по температурам, который не работает, когда температура задана одним числом. Дублируем независимо:

const COMPRESSION_RATIO_THRESHOLD = 2.4; function isCompressionAnomaly(text: string): boolean { const buf = Buffer.from(text, 'utf-8'); if (buf.length === 0) return false; return buf.length / deflateSync(buf).length > COMPRESSION_RATIO_THRESHOLD; }

6. Недостоверная длительность. Это не фильтр, а обрезка. Whisper регулярно приписывает короткой реплике интервал в десятки секунд — в одном случае «Говорит на русском языке.» заняло 114 секунд. Текст может быть настоящим, фейковая только длительность, поэтому сегмент сохраняем, но для метрик берём правдоподобное время произнесения из расчёта 12 символов в секунду.

Важный принцип: сырой результат распознавания при этом не изменяется. Фильтрация влияет только на собираемый диалог и на метрики, поэтому разбор любого спорного случая возможен по исходным данным.

Один класс мы пока не ловим и знаем об этом: грамматически правильная фраза-заглушка на чужом языке, не повторяющаяся («thank you so much for joining us today» на тишине). Не петля, не блоклист, обычная латиница. Дешёвое решение напрашивается: у неё было 40+ символов при длительности 0,18 с — то есть текст физически не мог быть произнесён. Симметричная проверка «длительность слишком мала для такого текста» закрывает этот класс, не трогая остальное.

Математика выбора победителя

Это место, на котором мы потратили больше всего времени, и первое очевидное решение здесь неверное.

Очевидное решение — взять вариант с наибольшей средней уверенностью модели. Оно не работает по двум причинам. Первая: avg_logprob у галлюцинаций регулярно выше, чем у настоящей речи — выдуманный текст модель декодирует увереннее, чем плохо слышимую реплику. Вторая: вариант, распознавший четверть разговора и потерявший остальное, по средней уверенности выглядит отлично.

У нас был случай, где выбор по средней уверенности предпочёл вариант, потерявший около 40 % содержания разговора. После него формула приобрела нынешний вид:

score = weightedConfidence × (coverage / coverage_max) × (segments / segments_max)

По множителям:

  • Взвешенная уверенность. avg_logprob каждого сегмента приводится к шкале 0–100 и усредняется с весом по длительности речи, а не по числу сегментов. Иначе десяток коротких мусорных реплик перевешивает одну длинную настоящую.
  • Относительное покрытие речи. Сколько секунд речи вариант распознал, нормировано на лучший вариант того же разговора. Это и есть защита от потери содержания.
  • Относительное число сегментов. Поправка на дробность: вариант, слепивший разговор в несколько длинных блоков, не получает преимущества перед детальным.
function computeScores(variants: AsrAnalyzedVariant[]): Record<string, number> { const maxCoverage = Math.max(...variants.map(v => v.coverageSeconds), 1e-9); const maxSegmentCount = Math.max(...variants.map(v => v.segmentCount), 1); const scores: Record<string, number> = {}; for (const v of variants) { const coverageRatio = v.coverageSeconds / maxCoverage; const segmentCountRatio = v.segmentCount / maxSegmentCount; scores[v.languageSetting] = Number( (v.weightedConfidence * coverageRatio * segmentCountRatio).toFixed(4), ); } return scores; }

И главное ограничение, которое стоит понимать с самого начала: это функция ранжирования, а не абсолютная оценка качества. Оба относительных множителя считаются относительно лучшего варианта той же джобы, поэтому у победителя они почти всегда равны 1.0, и score схлопывается обратно в среднюю уверенность — со всеми её проблемами. Использовать итоговый score как порог «распозналось или нет» нельзя. Для этого есть отдельная метрика.

Сборка диалога и порог отбраковки

Победивший вариант превращается в диалог: очищенные сегменты обоих каналов сводятся в общую временную шкалу и сортируются по началу. Роль присваивается по каналу — какой из них менеджер, указано в задании. Диаризация не нужна вообще.

Временные метки отдаются исходными. Обрезка длительности из фильтра №6 касается только метрик: если поправить ещё и таймстампы, разъедется разметка диалога.

Диалог собирается только для победителя. У проигравших вариантов остаются метрики — это материал для разбора спорных случаев и для последующей настройки формулы.

Внешний порог качества — отдельный и простой:

if (job.analysis.winnerSpeechCoverageRatio >= 0.28) { result.state = 'done'; result.dialogue = job.analysis.winnerDialogue; } else { result.state = 'error'; result.comment = 'job bad: low Coverage Ratio!'; }

Если победитель покрыл речью меньше 28 % длительности записи, задание помечается как нераспознанное. Такой разговор не уходит в смысловой анализ и не попадает в отчётность. Лучше честно отдать «распознать не удалось», чем передать обрывки, на основании которых кто-то будет делать выводы о работе менеджера.

Смысловой анализ на локальной LLM

Расшифровка сама по себе не отвечает на вопросы бизнеса. Содержательные выводы делает Qwen2.5 через Ollama на том же сервере: краткое содержание и результат разговора, проверка по чек-листу (приветствие, выявление потребности, работа с возражением, договорённость о следующем шаге), потребности и причины отказа, оценка менеджера по единым критериям.

Qwen выбрана за качество работы с русским, казахским и узбекским. В эксплуатации версия на 7B, 14B — альтернатива при большем объёме видеопамяти.

Ответ запрашивается по JSON-схеме через format в Ollama, то есть результат приходит пригодным для загрузки в аналитику без разбора текста регулярками.

Одно решение, которое мы приняли и откатывать не собираемся: LLM-вычитку расшифровки мы не делаем. Идея звучит логично — попросить модель поправить очевидные опечатки ASR. На практике контекст локальной модели не тянет диалог целиком в JSON, а на частях она начинает переписывать формулировки и схлопывать реплики. Валидация «длина массива до и после» отсекает половину таких ответов, но остаётся риск незаметной подмены смысла. Мы вложились в качество распознавания вместо косметики поверх него.

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

Железо и пропускная способность

Текущий стенд: NVIDIA RTX 5060 Ti 16 ГБ, Ubuntu 24.04, KVM. 16 ГБ видеопамяти — практический минимум, и вот почему: на карте одновременно живут модель распознавания и LLM для смыслового анализа. Если памяти меньше, выбор невелик — либо LLM меньшего размера с потерей качества выводов, либо выгрузка одной модели перед загрузкой другой с задержкой на каждом разговоре.

Теперь про производительность, и здесь есть ловушка, в которую легко попасть при оценке.

Легко предположить, что раз ASR и LLM — это два отдельных процесса, они работают параллельно и суточная пропускная способность считается по большему из двух времён. Это не так. Без MPS или MIG два CUDA-контекста на одной карте не выполняются одновременно: драйвер переключает их по времени. Оба процесса живы и обрабатывают свои очереди, но вычислительный ресурс делится, а не складывается.

Поэтому считать нужно по сумме:

диалогов в сутки = 86400 / (t_ASR + t_LLM)

У нас измерено: около 30 секунд на разговор длительностью 2–3 минуты — это все три прохода по обоим каналам плюс подмена движка. Время LLM-анализа зависит от размера чек-листа и объёма генерации; при 30 секундах на диалог получается ориентир 2500 - 3000 разговоров в сутки при круглосуточной загрузке.

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

Для большего объёма конфигурация масштабируется второй картой или вторым сервером — очередь распределяется между ними без изменений в архитектуре.

Что дальше и чего мы сознательно не делаем

Ближайшее, по убыванию отношения результата к затратам:

  • Исходные WAV вместо MP3. Сейчас на вход идёт то, что отдаёт АТС по умолчанию. Получить исходные файлы — вопрос настройки на стороне Asterisk. Самый дешёвый прирост точности из всех доступных (к моменту написания статьи уже реализовано).
  • Отраслевой словарь заказчика в initial_prompt. Названия продуктов, модели, типовые имена. Дни работы, никакого обучения.
  • Специализированная модель для казахского. Подход воспроизводится без изменений архитектуры — меняется движок на форсированном проходе. Отбор по тем же критериям.
  • Симметричная проверка длительности для класса галлюцинаций, описанного выше.

Про доменный fine-tune стоит сказать отдельно, потому что его принято считать неподъёмным для небольшой команды. Это не так. Для заметного выигрыша в домене достаточно порядка 10–30 часов размеченного аудио — это 250–750 разговоров по 2–3 минуты. Разметка не с нуля: прогоняете своим же пайплайном и правите руками, примерно 5–10× реального времени аудио. Выходит человеко-месяц работы, а не исследовательский проект.

Почему мы за это пока не взялись: окупается такое только под конкретного заказчика с постоянным потоком, на его данных и в его контуре. Для пилота — нет. Это не «нам не по силам», это «не сейчас и не для всех».

Чего мы не будем делать точно — LLM-вычитки расшифровок (причины выше) и погони за академическим SOTA в code-switching. Многопроходный ансамбль с форсированием языка — прагматичный инженерный компромисс, и мы его таким и называем.