Один STT-сервис дал 60-70% точности на специфической лексике (топонимы, названия улиц, профессиональные термины). Два сервиса параллельно + взвешенное голосование + AI-fusion для спорных случаев дали 95%+ точности. Время обработки 5-8 секунд, стоимость $70-130/месяц при 1000 сообщений в день. В статье — полный разбор архитектуры, алгоритмы scoring, примеры кода и расчёт экономики.
Содержание
Почему один STT оказалось недостаточно
Эволюция решения: от 60% к 95%
Архитектура Multi-API Ensemble
Взвешенное голосование: математика выбора
AI-fusion: когда голосования недостаточно
Постобработка: ловим систематические ошибки
Промпты для STT-сервисов
Мониторинг и graceful degradation
Результаты и метрики
Экономика решения
Выводы
1. Проблема: почему один STT недостаточно
Я разрабатывал систему транскрипции голосовых оповещений в ЧС для Telegram-канала. Задача казалась простой — Speech-to-Text API существуют больше десяти лет, технология зрелая.
Первая версия использовала один STT-сервис (SaluteSpeech от Сбера, он бесплатный для небольших объёмов). Запустили, протестировали на реальных данных. Результат: всего 60% точности.
Каждое второе сообщение содержало критические ошибки.
Анатомия ошибки
Типичное голосовое сообщение длиной 15 секунд:
«Внимание, оповещение для жителей Масычево, Илёк-Пеньковки и Мокрой Орловки. Просьба соблюдать осторожность на улице Заречной.»
А вот что возвращали разные STT-сервисы:
Сервис | Результат |
|---|---|
SaluteSpeech | «Внимание, оповещение для жителей масло чего, и лёгкая пеньковка и мокрой орлов. Просьба соблюдать осторожность на улице зеречной.» |
Yandex SpeechKit | «Внимание, оповещение для жителей мамы чего, илёгкая пеньковка и мокрой орловке. Просьба соблюдать осторожность на улице заречной.» |
Google Speech-to-Text | «Внимание, оповещение для жителей мосычево, илёк пеньковки и мокрой орловки. Просьба соблюдать осторожность на улице заречной.» |
Обратите внимание: каждый сервис ошибается по-разному. SaluteSpeech превращает «Масычево» в «масло чего», Yandex - в «мамы чего», Google - в «мосычево». При этом Google правильно распознаёт «мокрой орловки», а первые два - нет.
Почему модели ошибаются на топонимах
Причина фундаментальная: STT-модели обучены на общей лексике. Датасеты для обучения содержат миллионы часов подкастов, аудиокниг, телефонных разговоров, YouTube-видео и редко названия деревень с населением 200 человек.
Как работает распознавание речи на высоком уровне:
Акустическая модель преобразует звуковой сигнал в вероятности фонем
Языковая модель выбирает наиболее вероятную последовательность слов
Проблема в языковой модели. Она обучена на текстах, где «масло» встречается миллионы раз, а «Масычево» ни разу. Когда акустическая модель даёт неоднозначный сигнал (а она всегда даёт неоднозначный), языковая модель выбирает знакомое слово.
Это классическая проблема OOV (Out-Of-Vocabulary). STT-системы плохо справляются со словами, которых не было в обучающих данных.
Проблема усугубляется для:
Составных топонимов: «Илёк-Пеньковка» - это один населённый пункт, но модель не знает этого. Она слышит «илёк» и думает: «странное слово, наверное ослышалась». Потом слышит «пеньковка» - тоже незнакомо. В итоге выдаёт «и лёгкая пеньковка», потому что «лёгкая» и «пеньковка» реальные слова.
Диалектных произношений: Местные жители произносят топонимы иначе, чем диктор на радио. «Козинка» может звучать как «Казинка» с редуцированным «о». Модель, обученная на стандартном произношении, теряется.
Редких слов в окружении частых: Контекст влияет на распознавание. «Улица Центральная» распознаётся лучше, чем «улица Заречная», потому что «центральная» частое слово, а «заречная» нет. Языковая модель «подтягивает» редкое слово к частому.
Аббревиатур и терминов: «FPV» произносится как «эф-пи-ви» или «фэ-пэ-вэ». Модель не знает, что это аббревиатура, и пытается найти похожие слова. Результат — «в пиве» или «и видео».
Цифр в контексте: «Дом 47» может стать «дом сорок семь» или «дом 4 7» или «дом 47». Модели нестабильны в форматировании чисел.
Пробовали (и почему не сработало)
Увеличить prompt/hints в Whisper:
Whisper от OpenAI поддерживает текстовый prompt до 224 токенов. Мы передавали список топонимов:
<source lang="python"> prompt = "Населённые пункты: Масычево, Илёк-Пеньковка, Мокрая Орловка, Козинка, Головчино..." </source>
Помогло частично — точность выросла с 60% до 70%. Но 224 токена — это ~50 слов. В нашем регионе 42 населённых пункта, плюс улицы, плюс термины. Всё не влезает.
Google Cloud Speech Adaptation:
Google предлагает механизм speech adaptation, можно загрузить список фраз с boost-коэффициентами. Потратил два дня. Результат: 72% точности, но стоимость $0.016/минута против $0.006 у Whisper. Прирост не оправдывает 2.5x цену.
Fine-tuning открытой модели:
Теоретически можно дообучить Whisper на своих данных. Практически это требует:
100+ часов размеченного аудио (у нас было 50 часов, но неразмеченных)
GPU-инфраструктуру для обучения
Время на эксперименты (недели)
В реальности денег на это нет и Fine-tuning оправдан при миллионах минут аудио в месяц.
2. Эволюция: от 60% к 95%
Ключевое открытие пришло из анализа ошибок: разные сервисы ошибаются на разных словах. Если SaluteSpeech не знает «Масычево», возможно Whisper или Gemini распознают его правильно.
Собрал статистику по 1000 сообщений: какой сервис на каком слове ошибся. Выяснилось, что пересечение ошибок всего 15%. То есть в 85% случаев хотя бы один сервис распознавал слово правильно.
Идея: запускаю несколько STT параллельно, сравниваем результаты, выбираем лучший.
Версия 2.0: четыре сервиса параллельно
Первая реализация была наивной: подключили все доступные API и запускали их одновременно.
Подключённые сервисы:
SaluteSpeech (Сбер) - бесплатный до 5000 минут/месяц, поддерживает smart hints
Yandex SpeechKit - хорошее качество на русском языке
OpenAI Whisper - мультиязычный, стабильный
Google Gemini - multimodal, понимает контекст, оплата по токенам
Я ввел понятие «консенсус»: когда все четыре сервиса возвращают одинаковый текст. На консенсусных фрагментах точность была 98%.
Но консенсус достигался только в 25% случаев. В остальных 75% разброс мнений.
Проблема 1: Latency
Каждый сервис отвечает за 2-5 секунд. При параллельном запуске через asyncio.gather общее время определяется самым медленным сервисом. SaluteSpeech периодически «задумывался» на 8-10 секунд. Yandex иногда отвечал за 12 секунд.
Итого: 14-20 секунд на одно сообщение. Для системы оповещений это неприемлемо. Пока система «думает», люди ждут критическую информацию.
Проблема 2: Стоимость
Четыре API-вызова на каждое сообщение = примерно 4x стоимость базового варианта.
При 1000 сообщениях в день:
Whisper: $60/мес
Yandex: $80/мес
Gemini: $40/мес
SaluteSpeech: бесплатно, но лимит быстро кончается
Прирост точности с 60% до 80% не оправдывал 4x увеличение стоимости.
Проблема 3: Сложность выбора
Когда сервисы расходятся во мнениях (а это 75% случаев), непонятно кому верить.
Простое голосование «3 из 4»: не работает, часто расклад 2:2 или все четыре варианта разные
Выбор самого длинного ответа: иногда модели галлюцинируют и добавляют несуществующие слова
Выбор самого короткого: теряем информацию
Случайный выбор: плохо
Версия 2.5: два лучших сервиса + ROVER
После нескольких недель сбора статистики я проанализировал, какие сервисы меньше ошибаются.
Методика анализа:
Собрал 500 размеченных примеров
Для каждого сервиса (с помощью Claude, дав ему контекст и данные) посчитал WER (Word Error Rate) по категориям: топонимы, термины, общая лексика
Построили матрицу корреляции ошибок: если сервис A ошибся, ошибся ли сервис B?
Результаты:
Сервис | WER общий | WER топонимы | WER термины | Корреляция с Whisper |
|---|---|---|---|---|
Whisper | 12% | 35% | 8% | - |
Gemini | 14% | 22% | 15% | 0.31 |
Yandex | 18% | 38% | 12% | 0.67 |
SaluteSpeech | 22% | 45% | 18% | 0.58 |
Корреляция ошибок Gemini и Whisper самая низкая (0.31). Это значит, что они ошибаются на разных словах и дополняют друг друга.
Yandex и SaluteSpeech сильно коррелируют с Whisper (0.67 и 0.58). Они не добавляют новой информации, если Whisper ошибся, скорее всего и они ошибутся.
Оставил только Gemini и Whisper. Два сервиса вместо четырёх.
Latency упала до 10-14 секунд (max из двух, а не четырёх). Стоимость вдвое ниже.
ROVER для объединения:
Для объединения результатов внедрил ROVER (Recognizer Output Voting Error Reduction) — классический алгоритм из speech recognition, разработанный в NIST в 1997 году.
ROVER работает так:
Выравнивает две транскрипции по словам (alignment)
Для каждой позиции голосует за вариант
При равенстве голосов выбирает первый вариант
Точность выросла до 90%. Но ROVER плохо справлялся со случаями, когда гипотезы сильно отличаются. Пример:
Gemini: «Внимание, оповещение для Масычево»
Whisper: «Внимание, оповещение для масло чего»
ROVER не понимает, что «Масычево» и «масло чего» - это одно и то же слово с ошибкой. Он видит разные слова и не может выбрать.
Версия 3.0: AI-fusion + постобработка
Финальная архитектура добавила два ключевых компонента:
1. AI-fusion: когда сервисы сильно расходятся (agreement < 70%), отправляем обе гипотезы в LLM. LLM получает контекст (список топонимов) и понимает, что «Масычево» - населённый пункт из списка, а «масло чего» - бессмыслица. LLM выбирает правильный вариант.
2. Постобработка: регулярные выражения для исправления систематических ошибок. База паттернов пополняется из логов. Если мы 10 раз видели ошибку «и лёгкая пеньковка» → «Илёк-Пеньковка», добавляем правило.
Также оптимизировал Gemini: перешли с Pro на Flash без потери качества, но с 3x ускорением.
Результат: 95%+ точности, 5-8 секунд latency, $70-130/месяц.
Версия | Сервисы | Время | Точность | Стоимость |
|---|---|---|---|---|
v1.0 | 1 (SaluteSpeech) | 3-5 сек | ~60% | ~$20/мес |
v2.0 | 4 (все) | 14-20 сек | ~80% | ~$350/мес |
v2.5 | 2 (Gemini + Whisper) | 10-14 сек | ~90% | ~$100/мес |
v3.0 | 2 + AI-fusion + постобработка | 5-8 сек | ~95% | ~$100/мес |
3. Архитектура Multi-API Ensemble
Общая схема

Почему параллельно, а не последовательно?
Latency критична. При последовательных запросах получаем сумму времени: 3-5 секунд Gemini + 3-5 секунд Whisper = 6-10 секунд. При параллельных максимум из двух: max(3-5, 3-5) = 3-5 секунд, плюс ~0.5 секунды на ensemble.
Обработка частичных отказов
Что если один из сервисов недоступен? Система должна работать с одним результатом:
# Фильтруем None (упавшие сервисы) valid_results = { k: v for k, v in results.items() if v is not None } if len(valid_results) == 0: raise TranscriptionError("All STT services failed") if len(valid_results) == 1: # Один сервис — используем его результат single_result = list(valid_results.values())[0] logger.info(f"Single service mode: {list(valid_results.keys())[0]}") return self.postprocess(single_result.text) # Два сервиса — полный ensemble pipeline return await self.full_ensemble(valid_results)
4. Взвешенное голосование: математика выбора
Простое сравнение строк не работает. Даже на тривиальных фразах сервисы редко дают идентичный текст: разная пунктуация, регистр, пробелы.
Нужна метрика «качества» каждой транскрипции. Называем её confidence score.
Формула confidence score
# Загружаем справочные данные self.toponyms = self._load_toponyms() self.domain_terms = self._load_domain_terms() self.emergency_patterns = self._compile_emergency_patterns() self.illogical_patterns = self._compile_illogical_patterns() def calculate_confidence_score( self, transcript: str, service: str, raw_confidence: float = 1.0 ) -> float: """ Рассчитывает confidence score для транскрипции. Учитывает: - Базовый вес сервиса - Количество распознанных топонимов (+2% за каждый) - Количество доменных терминов (+10% за каждый) - Экстренные фразы (+15% за каждую) - Нелогичные конструкции (-30% за каждую) Returns: float: score от 0.0 до 2.0 """ # Базовый score score = raw_confidence * self.service_weights.get(service, 1.0) # Бонус за топонимы toponym_count = self._count_toponyms(transcript) if toponym_count > 0: # +2% за каждый найденный топоним score *= (1 + 0.02 * toponym_count) # Бонус за доменные термины term_count = self._count_domain_terms(transcript) if term_count > 0: # +10% за каждый термин score *= (1 + 0.10 * term_count) # Бонус за экстренные фразы emergency_count = self._count_emergency_patterns(transcript) if emergency_count > 0: # +15% за каждую фразу score *= (1 + 0.15 * emergency_count) # Штраф за нелогичные конструкции illogical_count = self._count_illogical_patterns(transcript) if illogical_count > 0: # -30% за каждую (мультипликативно) score *= (0.7 ** illogical_count) # Потолок 2.0 чтобы один фактор не доминировал return min(score, 2.0)
Почему такие коэффициенты?
Коэффициенты подбирались эмпирически на размеченных данных (~500 примеров):
+2% за топоним - низкий бонус, потому что топонимы могут быть ложными срабатываниями. «Козинка» в контексте «корзинка» не должна сильно влиять на score.
+10% за доменный термин - выше, потому что термины редко появляются случайно. Если модель распознала «FPV» или «ПВО», скорее всего это правильно.
+15% за экстренную фразу - ещё выше, потому что устойчивые конструкции («отбой опасности», «просьба соблюдать осторожность») почти никогда не галлюцинируются.
-30% за нелогичную конструкцию - жёсткий штраф. «Масло чего» или «на двое суток опасности» — верный признак ошибки распознавания.
Справочные данные
def _load_toponyms(self) -> set[str]: """42 топонима региона""" return { "Козинка", "Казинка", # варианты написания "Головчино", "Борисовка", "Масычево", "Мокрая Орловка", "Дорогощь", "Подол", "Гора-Подол", "Замостье", "Глотово", "Доброивановка", "Теребрено", "Безымено", "Некрасово", "Серетино", "Нехотеевка", "Журавлёвка", "Сподарюшино", "Новостроевка", "Илёк-Пеньковка", "Лёвкино", "Левкино", "Пеньково", # ... остальные } def _load_domain_terms(self) -> set[str]: """Профессиональные термины и аббревиатуры""" return { "FPV", "БПЛА", "ПВО", "РЭБ", "РСЗО", "БТР", "БМП", "САУ", "ЗРК", # ... остальные } def _compile_emergency_patterns(self) -> list[re.Pattern]: """Паттерны экстренных фраз""" patterns = [ r'\bотбой\s+(опасности|тревоги|угрозы)\b', r'\bвнимание[,!]?\s+оповещение\b', r'\bпросьба\s+соблюдать\s+осторожность\b', r'\bопасность\s+(атаки|угрозы)\b', r'\bприближается\b', r'\bобнаружен[аы]?\b', ] return [re.compile(p, re.IGNORECASE) for p in patterns] def _compile_illogical_patterns(self) -> list[re.Pattern]: """Паттерны бессмысленных конструкций (признак ошибки)""" patterns = [ r'\bна\s+двое\s+суток\s+опасности\b', # «отбой» → «на двое суток» r'\bотбоя\s+от\s+опасности\b', # грамматически неверно r'\bлёгкая\s+пеньковка\b', # разбитый топоним r'\bи\s+лёгкая\s+пеньковка\b', # ещё вариант r'\bмасло\s+чего\b', # «Масычево» → бред r'\bмамы\s+чего\b', # ещё вариант r'\bмокрой\s+орлов\b', # разбитый топоним ] return [re.compile(p, re.IGNORECASE) for p in patterns]
Методы подсчёта
def _count_toponyms(self, text: str) -> int: """Считает количество топонимов в тексте""" text_lower = text.lower() count = 0 for toponym in self.toponyms: if toponym.lower() in text_lower: count += 1 return count def _count_domain_terms(self, text: str) -> int: """Считает количество доменных терминов""" text_upper = text.upper() count = 0 for term in self.domain_terms: if term.upper() in text_upper: count += 1 return count def _count_emergency_patterns(self, text: str) -> int: """Считает количество экстренных фраз""" return sum(1 for p in self.emergency_patterns if p.search(text)) def _count_illogical_patterns(self, text: str) -> int: """Считает количество нелогичных конструкций""" return sum(1 for p in self.illogical_patterns if p.search(text))
Алгоритм выбора победителя
def weighted_voting( self, results: dict[str, TranscriptionResult] ) -> Optional[str]: """ Выбирает лучшую транскрипцию на основе confidence scores. Returns: str: текст победителя, если есть явный лидер None: если нужен AI-fusion """ scored = [] for service, result in results.items(): score = self.calculate_confidence_score( result.text, service, result.confidence ) scored.append({ 'service': service, 'text': result.text, 'score': score, 'raw_confidence': result.confidence, }) logger.debug(f"{service}: score={score:.3f}, text={result.text[:50]}...") # Сортируем по убыванию score scored.sort(key=lambda x: x['score'], reverse=True) if len(scored) < 2: return scored[0]['text'] if scored else None top = scored[0] second = scored[1] # Clear winner: лидер на 15%+ выше второго if top['score'] > second['score'] * 1.15: logger.info( f"Clear winner: {top['service']} " f"(score={top['score']:.3f} vs {second['score']:.3f})" ) return top['text'] # Нет явного победителя — нужен AI-fusion logger.info( f"No clear winner: {top['service']}={top['score']:.3f}, " f"{second['service']}={second['score']:.3f}" ) return None
5. AI-fusion: когда голосования недостаточно
В 30-40% случаев оба сервиса уверены в своих результатах, но результаты разные. Пример:
Gemini | Whisper |
|---|---|
«Козинка, отбой опасности» | «Казинка, отбой опасности» |
score = 1.42 | score = 1.38 |
Разница 3% меньше порога в 15%.
Идея AI-fusion
Отправляем обе гипотезы в LLM вместе с контекстом (списком топонимов). LLM понимает:
«Козинка» есть в списке топонимов
«Казинка» — нет (хотя фонетически похоже)
Следовательно, правильный вариант — «Козинка»
Реализация
python
class GeminiCombiner: """ AI-fusion через Gemini для объединения спорных транскрипций. """ def __init__(self, api_key: str): genai.configure(api_key=api_key) self.model = genai.GenerativeModel('gemini-2.5-flash') self.toponyms_context = self._load_toponyms_context() async def combine_transcriptions( self, transcripts: list[str] ) -> str: """ Объединяет несколько транскрипций в одну. Args: transcripts: список транскрипций от разных сервисов Returns: str: объединённая транскрипция """ prompt = self._build_prompt(transcripts) generation_config = { "temperature": 0.1, # Низкая для детерминизма "top_p": 0.95, "max_output_tokens": 1024, } try: response = await self.model.generate_content_async( prompt, generation_config=generation_config, ) return self._parse_response(response.text) except Exception as e: logger.error(f"Gemini Combiner failed: {e}") # Fallback на ROVER return self.rover_fallback(transcripts) def _build_prompt(self, transcripts: list[str]) -> str: transcript_list = "\n".join( f"ВАРИАНТ {i+1}: {t}" for i, t in enumerate(transcripts) ) return f"""Ты эксперт по транскрипции аудио для службы экстренных оповещений. Даны несколько вариантов транскрипции одного аудиофрагмента от разных сервисов: {transcript_list} КОНТЕКСТ — известные топонимы региона: {self.toponyms_context} ЗАДАЧА: 1. Сравни все варианты транскрипции слово за словом 2. Для каждого расхождения выбери наиболее вероятный вариант 3. Учитывай список топонимов — если слово есть в списке, предпочитай его 4. Составные топонимы (через дефис) не разбивай: "Илёк-Пеньковка" — одно слово ПРАВИЛА: - НЕ добавляй слова, которых нет ни в одном варианте - НЕ исправляй грамматику, только выбирай между вариантами - Если все варианты одинаковы — просто верни этот текст - "Козинка" вероятнее чем "Казинка" (есть в базе топонимов) - "Масычево" вероятнее чем "масло чего" (есть в базе) Верни ТОЛЬКО итоговую транскрипцию, без объяснений:""" def _parse_response(self, response: str) -> str: """Извлекает текст из ответа, убирая возможные артефакты""" # Убираем markdown-форматирование если есть text = response.strip() text = re.sub(r'^```.*?\n', '', text) text = re.sub(r'\n```$', '', text) text = re.sub(r'^\*\*', '', text) text = re.sub(r'\*\*$', '', text) return text.strip() def rover_fallback(self, transcripts: list[str]) -> str: """ ROVER алгоритм как fallback если Gemini недоступен. Простое word-level голосование. """ # Токенизация token_lists = [t.split() for t in transcripts] # Находим максимальную длину max_len = max(len(tokens) for tokens in token_lists) # Паддинг до одинаковой длины padded = [ tokens + [''] * (max_len - len(tokens)) for tokens in token_lists ] # Голосование по позициям result = [] for i in range(max_len): candidates = [tokens[i] for tokens in padded if tokens[i]] if candidates: # Берём самый частый вариант winner = max(set(candidates), key=candidates.count) result.append(winner) return ' '.join(result)
Когда вызывается AI-fusion
async def full_ensemble( self, results: dict[str, TranscriptionResult] ) -> str: """ Полный ensemble pipeline для двух результатов. """ # Шаг 1: Пробуем weighted voting winner = self.weighted_voting(results) if winner is not None: # Есть явный победитель return self.postprocess(winner) # Шаг 2: Проверяем agreement texts = [r.text for r in results.values()] agreement = self._calculate_agreement(texts[0], texts[1]) logger.info(f"Agreement: {agreement:.1%}") if agreement >= 0.7: # Высокий agreement — берём результат с лучшим score best = max(results.values(), key=lambda r: r.confidence) return self.postprocess(best.text) # Шаг 3: Низкий agreement — AI-fusion logger.info("Low agreement, using AI-fusion") combined = await self.combiner.combine_transcriptions(texts) return self.postprocess(combined) def _calculate_agreement(self, text1: str, text2: str) -> float: """ Рассчитывает степень совпадения двух текстов. Использует коэффициент Жаккара на уровне слов. """ words1 = set(text1.lower().split()) words2 = set(text2.lower().split()) if not words1 and not words2: return 1.0 intersection = len(words1 & words2) union = len(words1 | words2) return intersection / union if union > 0 else 0.0
6. Постобработка: ловим систематические ошибки
Даже после AI-fusion остаются повторяющиеся ошибки. Постобработка исправляет их детерминированно — без вызовов API, за микросекунды.
Три типа постобработки
class PostProcessor: """ Постобработка транскрипций. Исправляет систематические ошибки без вызовов API. """ def __init__(self): self.toponym_fixes = self._load_toponym_fixes() self.term_normalizations = self._load_term_normalizations() self.phonetic_fixes = self._load_phonetic_fixes() def process(self, text: str) -> str: """Применяет все исправления последовательно""" text = self._fix_split_toponyms(text) text = self._normalize_terms(text) text = self._fix_phonetic_errors(text) text = self._fix_punctuation(text) return text # === 1. Склейка разбитых топонимов === def _load_toponym_fixes(self) -> dict[str, str]: return { r'\bлёгкая\s+пеньковка\b': 'Илёк-Пеньковка', r'\bи\s+лёгкая\s+пеньковка\b': 'Илёк-Пеньковка', r'\bилёк\s+пеньковка\b': 'Илёк-Пеньковка', r'\bмокрой\s+орлов\b': 'Мокрая Орловка', r'\bмокрая\s+орлов\b': 'Мокрая Орловка', r'\bгора\s+подол\b': 'Гора-Подол', } def _fix_split_toponyms(self, text: str) -> str: for pattern, replacement in self.toponym_fixes.items(): text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) return text # === 2. Нормализация терминов === def _load_term_normalizations(self) -> dict[str, str]: return { # Аббревиатуры произнесённые по буквам r'\bэф\s*пи\s*ви\b': 'FPV', r'\bэфпиви\b': 'FPV', r'\bф\s*п\s*в\b': 'FPV', r'\bбэ\s*пэ\s*эл\s*а\b': 'БПЛА', r'\bбпэла\b': 'БПЛА', r'\bпэ\s*вэ\s*о\b': 'ПВО', r'\bрэ\s*б\b': 'РЭБ', r'\bэр\s*эс\s*зэ\s*о\b': 'РСЗО', # Транслитерация r'\bхимарс\b': 'HIMARS', r'\bхаймарс\b': 'HIMARS', } def _normalize_terms(self, text: str) -> str: for pattern, replacement in self.term_normalizations.items(): text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) return text # === 3. Исправление фонетических ошибок === def _load_phonetic_fixes(self) -> dict[str, str]: return { # Частые фонетические ошибки r'\bна\s+двое\s+суток\s+опасности\b': 'отбой опасности', r'\bотбоя\s+от\s+опасности\b': 'отбой опасности', r'\bотбоя\s+опасности\b': 'отбой опасности', # Искажения топонимов r'\bмасло\s+чего\b': 'Масычево', r'\bмамы\s+чего\b': 'Масычево', r'\bБезыменно\b': 'Безымено', r'\bКазинки\b': 'Козинка', } def _fix_phonetic_errors(self, text: str) -> str: for pattern, replacement in self.phonetic_fixes.items(): text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) return text # === 4. Пунктуация === def _fix_punctuation(self, text: str) -> str: # Убираем двойные пробелы text = re.sub(r'\s+', ' ', text) # Убираем пробелы перед запятыми text = re.sub(r'\s+,', ',', text) # Добавляем пробел после запятой если нет text = re.sub(r',(?=\S)', ', ', text) return text.strip()
Пополнение словаря исправлений
Словари исправлений пополняются из лога ошибок. Каждое ручное исправление логируется:
def log_correction( original: str, corrected: str, correction_type: str ): """Логирует исправление для последующего анализа""" entry = { 'timestamp': datetime.utcnow().isoformat(), 'original': original, 'corrected': corrected, 'type': correction_type, } with open('corrections_log.jsonl', 'a') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n')
За 4 месяца накопилось 331 запись. Пример:
Оригинал | Исправление | Количество |
|---|---|---|
«Козинки» | «Козинка» | 32 |
«fpv» | «FPV» | 24 |
«бпла» | «БПЛА» | 19 |
«и лёгкая пеньковка» | «Илёк-Пеньковка» | 15 |
«масло чего» | «Масычево» | 12 |
7. Промпты для STT-сервисов
Gemini STT
Gemini поддерживает multimodal input аудио и текст в одном запросе. Это позволяет передать развёрнутый контекст.
class GeminiSTTClient: def __init__(self, api_key: str): genai.configure(api_key=api_key) self.model = genai.GenerativeModel('gemini-2.5-flash') self.toponyms = self._load_toponyms() async def transcribe_with_context( self, audio_path: str ) -> TranscriptionResult: """ Транскрибирует аудио с контекстным промптом. """ # Читаем аудио with open(audio_path, 'rb') as f: audio_content = f.read() # Определяем формат audio_format = self._detect_format(audio_path) # Создаём multimodal prompt prompt = self._build_prompt() audio_part = { "inline_data": { "mime_type": f"audio/{audio_format}", "data": base64.b64encode(audio_content).decode('utf-8') } } generation_config = { "temperature": 0.1, "top_p": 0.95, "top_k": 40, "max_output_tokens": 8192, } response = await self.model.generate_content_async( [prompt, audio_part], generation_config=generation_config, ) return self._parse_response(response.text) def _build_prompt(self) -> str: toponym_list = ', '.join(sorted(self.toponyms)[:30]) return f"""Вы профессиональный транскрибатор для службы экстренных оповещений. Транскрибируйте это аудио максимально точно, уделяя особое вниман��е: 1. НАЗВАНИЯМ НАСЕЛЁННЫХ ПУНКТОВ (см. список ниже) 2. Профессиональным терминам и аббревиатурам 3. Числам и направлениям 4. Названиям улиц ВАЖНЫЕ НАСЕЛЁННЫЕ ПУНКТЫ РЕГИОНА: {toponym_list} ВАЖНО: - Составные названия пишите через дефис: Илёк-Пеньковка, Гора-Подол, Мокрая Орловка - Аббревиатуры пишите заглавными: FPV, БПЛА, ПВО, РЭБ - Если слово похоже на топоним из списка — используйте написание из списка Верните результат в формате JSON: {{ "transcript": "точная транскрипция аудио", "toponyms_found": ["список", "найденных", "топонимов"], "confidence": "high/medium/low", "unclear_parts": ["неразборчивые фрагменты если есть"] }}""" def _parse_response(self, response: str) -> TranscriptionResult: """Парсит JSON-ответ от Gemini""" try: # Убираем markdown если есть clean = re.sub(r'^```json\s*', '', response) clean = re.sub(r'\s*```$', '', clean) data = json.loads(clean) confidence_map = {'high': 0.95, 'medium': 0.75, 'low': 0.5} return TranscriptionResult( text=data.get('transcript', ''), confidence=confidence_map.get(data.get('confidence', 'medium'), 0.75), service='gemini', raw_response=data, ) except json.JSONDecodeError: # Если не JSON — берём как plain text return TranscriptionResult( text=response.strip(), confidence=0.6, service='gemini', raw_response={'raw': response}, )
Whisper
Whisper принимает prompt до 224 токенов. Используем его для подсказок:
class WhisperSTTClient: def __init__(self, api_key: str): self.client = OpenAI(api_key=api_key) self.toponyms = self._load_toponyms() async def transcribe(self, audio_path: str) -> TranscriptionResult: """ Транскрибирует аудио через Whisper API. """ # Формируем prompt (до 224 токенов) toponym_sample = ', '.join(list(self.toponyms)[:15]) prompt = ( f"Экстренное оповещение для населения. " f"Могут упоминаться населённые пункты: {toponym_sample}. " f"Термины: FPV, БПЛА, ПВО, РЭБ. " f"Составные названия: Илёк-Пеньковка, Мокрая Орловка." ) # Whisper API синхронный, оборачиваем в executor loop = asyncio.get_event_loop() response = await loop.run_in_executor( None, lambda: self._sync_transcribe(audio_path, prompt) ) return response def _sync_transcribe( self, audio_path: str, prompt: str ) -> TranscriptionResult: with open(audio_path, 'rb') as audio_file: response = self.client.audio.transcriptions.create( model="whisper-1", file=audio_file, language="ru", prompt=prompt, response_format="verbose_json", ) return TranscriptionResult( text=response.text, confidence=self._estimate_confidence(response), service='whisper', raw_response=response.model_dump(), ) def _estimate_confidence(self, response) -> float: """ Оценивает уверенность на основе метаданных ответа. Whisper не возвращает confidence напрямую, но можно оценить по наличию no_speech_prob и avg_logprob в сегментах. """ if not hasattr(response, 'segments') or not response.segments: return 0.7 # Дефолт # Средний avg_logprob по сегментам logprobs = [ s.get('avg_logprob', -0.5) for s in response.segments if 'avg_logprob' in s ] if not logprobs: return 0.7 avg_logprob = sum(logprobs) / len(logprobs) # Преобразуем logprob в confidence [0, 1] # avg_logprob обычно от -1.0 (плохо) до 0 (идеально) confidence = max(0.0, min(1.0, 1.0 + avg_logprob)) return confidence
8. Мониторинг и graceful degradation
Логирование
Каждый этап pipeline логируется для отладки:
import logging import json from datetime import datetime # Настройка логгера logger = logging.getLogger('voice_transcription') logger.setLevel(logging.DEBUG) # Форматтер с JSON для структурированных логов class JsonFormatter(logging.Formatter): def format(self, record): log_data = { 'timestamp': datetime.utcnow().isoformat(), 'level': record.levelname, 'message': record.getMessage(), 'module': record.module, 'function': record.funcName, } if hasattr(record, 'extra_data'): log_data.update(record.extra_data) return json.dumps(log_data, ensure_ascii=False) # Пример использования def log_transcription_result( message_id: str, results: dict, winner: str, final_text: str, processing_time: float ): logger.info( "Transcription completed", extra={'extra_data': { 'message_id': message_id, 'services': list(results.keys()), 'winner': winner, 'text_length': len(final_text), 'processing_time_ms': int(processing_time * 1000), 'gemini_score': results.get('gemini', {}).get('score'), 'whisper_score': results.get('whisper', {}).get('score'), }} )
Метрики для мониторинга
python
from dataclasses import dataclass, field from collections import defaultdict import time @dataclass class TranscriptionMetrics: """Метрики для мониторинга качества транскрипции""" total_requests: int = 0 successful_requests: int = 0 failed_requests: int = 0 # По сервисам service_calls: dict = field(default_factory=lambda: defaultdict(int)) service_failures: dict = field(default_factory=lambda: defaultdict(int)) service_latencies: dict = field(default_factory=lambda: defaultdict(list)) # Ensemble статистика clear_winner_count: int = 0 ai_fusion_count: int = 0 rover_fallback_count: int = 0 # Timing total_processing_time: float = 0.0 def record_request( self, success: bool, services_used: list[str], services_failed: list[str], latencies: dict[str, float], used_ai_fusion: bool, used_rover: bool, processing_time: float ): self.total_requests += 1 if success: self.successful_requests += 1 else: self.failed_requests += 1 for service in services_used: self.service_calls[service] += 1 if service in latencies: self.service_latencies[service].append(latencies[service]) for service in services_failed: self.service_failures[service] += 1 if used_ai_fusion: self.ai_fusion_count += 1 elif used_rover: self.rover_fallback_count += 1 else: self.clear_winner_count += 1 self.total_processing_time += processing_time def get_summary(self) -> dict: avg_time = ( self.total_processing_time / self.total_requests if self.total_requests > 0 else 0 ) return { 'total_requests': self.total_requests, 'success_rate': self.successful_requests / max(self.total_requests, 1), 'avg_processing_time_ms': int(avg_time * 1000), 'clear_winner_rate': self.clear_winner_count / max(self.total_requests, 1), 'ai_fusion_rate': self.ai_fusion_count / max(self.total_requests, 1), 'service_failure_rates': { service: self.service_failures[service] / max(self.service_calls[service], 1) for service in self.service_calls }, 'avg_latencies_ms': { service: int(sum(lats) / len(lats) * 1000) if lats else 0 for service, lats in self.service_latencies.items() }, }
Graceful degradation
class ResilientEnsemble: """ Ensemble с graceful degradation при отказах. """ def __init__(self, gemini_client, whisper_client, combiner): self.gemini = gemini_client self.whisper = whisper_client self.combiner = combiner self.metrics = TranscriptionMetrics() # Circuit breaker state self.circuit_state = { 'gemini': {'failures': 0, 'last_failure': None, 'open': False}, 'whisper': {'failures': 0, 'last_failure': None, 'open': False}, } self.failure_threshold = 5 self.recovery_timeout = 60 # секунд def _is_circuit_open(self, service: str) -> bool: """Проверяет, открыт ли circuit breaker для сервиса""" state = self.circuit_state[service] if not state['open']: return False # Проверяем timeout для восстановления if state['last_failure']: elapsed = time.time() - state['last_failure'] if elapsed > self.recovery_timeout: # Пробуем восстановить state['open'] = False state['failures'] = 0 logger.info(f"Circuit breaker closed for {service}") return False return True def _record_failure(self, service: str): """Записывает отказ сервиса""" state = self.circuit_state[service] state['failures'] += 1 state['last_failure'] = time.time() if state['failures'] >= self.failure_threshold: state['open'] = True logger.warning(f"Circuit breaker opened for {service}") def _record_success(self, service: str): """Записывает успех — сбрасывает счётчик отказов""" self.circuit_state[service]['failures'] = 0 async def transcribe(self, audio_path: str) -> str: """ Транскрибирует с учётом состояния circuit breakers. """ start_time = time.time() services_to_use = [] # Определяем доступные сервисы if not self._is_circuit_open('gemini'): services_to_use.append(('gemini', self.gemini.transcribe_with_context)) if not self._is_circuit_open('whisper'): services_to_use.append(('whisper', self.whisper.transcribe)) if not services_to_use: raise TranscriptionError("All services unavailable (circuit breakers open)") # Запускаем доступные сервисы tasks = [ asyncio.create_task(func(audio_path), name=name) for name, func in services_to_use ] results_raw = await asyncio.gather(*tasks, return_exceptions=True) # Обрабатываем результаты results = {} latencies = {} services_failed = [] for task, result in zip(tasks, results_raw): service = task.get_name() if isinstance(result, Exception): logger.error(f"{service} failed: {result}") self._record_failure(service) services_failed.append(service) else: self._record_success(service) results[service] = result # Latency примерная (общее время / кол-во сервисов) latencies[service] = time.time() - start_time # ... остальная логика ensemble ... processing_time = time.time() - start_time # Записываем метрики self.metrics.record_request( success=len(results) > 0, services_used=[s for s, _ in services_to_use], services_failed=services_failed, latencies=latencies, used_ai_fusion=used_ai_fusion, used_rover=used_rover, processing_time=processing_time, ) return final_text
9. Результаты и метрики
Методология измерения
Для оценки качества мы использовал 500 размеченных примеров: аудио + ручная транскрипция. Примеры собирал в течение месяца, которые покрывали разные условия:
Разное качество микрофона (телефон, гарнитура)
Разная длина сообщений (от 5 до 30 секунд)
Разный фоновый шум (тихо, улица, помещение с эхом)
Разные дикторы (мужские/женские голоса, разный темп речи)
Основная метрика: WER (Word Error Rate)
WER = (S + I + D) / N где: S = количество замен (слово распознано неправильно) I = количество вставок (лишнее слово) D = количество удалений (слово пропущено) N = общее количество слов в ground truth
WER 5% означает, что в среднем 5 слов из 100 распознаны неправильно.
Сравнение точности по категориям
Категория | Один сервис (Whisper) | Ensemble v3.0 | Улучшение |
|---|---|---|---|
Общая лексика | 90% | 98% | +8% |
Топонимы (простые) | 65% | 95% | +30% |
Топонимы (составные) | 20% | 90% | +70% |
Аббревиатуры | 70% | 98% | +28% |
Числа | 85% | 97% | +12% |
Экстренные фразы | 75% | 99% | +24% |
Детальные примеры распознавания
Пример 1: Топоним «Масычево»
Этап | Результат | WER | Комментарий |
|---|---|---|---|
SaluteSpeech | «масло чего» | 100% | Полная замена |
Yandex | «мамы чего» | 100% | Другая ошибка |
Whisper | «Масечево» | 50% | Близко, но неточно |
Gemini | «Масычево» | 0% | Правильно |
Ensemble | «Масычево» | 0% | Gemini победил по score |
Пример 2: Составной топоним «Илёк-Пеньковка»
Этап | Результат | WER | Комментарий |
|---|---|---|---|
SaluteSpeech | «и лёгкая пеньковка» | 200% | Разбил + ошибка |
Whisper | «Илек Пеньковка» | 50% | Без дефиса |
Gemini | «Илёк-Пеньковка» | 0% | Правильно |
PostProcess | Исправит «Илек Пеньковка» → «Илёк-Пеньковка» | - | Страховка |
Ensemble | «Илёк-Пеньковка» | 0% | - |
Пример 3: Фраза «отбой опасности» (сложный случай)
Эта фраза интересна тем, что SaluteSpeech систематически ошибался одинаково:
Этап | Результат | Комментарий |
|---|---|---|
SaluteSpeech | «на двое суток опасности» | Классическая фонетическая ошибка |
Whisper | «отбой опасности» | Правильно |
Gemini | «отбой опасности» | Правильно |
Ensemble | «отбой опасности» | Консенсус 2 из 3 |
Даже если бы Whisper и Gemini оба ошиблись, постобработка содержит правило:
r'\bна\s+двое\s+суток\s+опасности\b': 'отбой опасности'
Пример 4: Аббревиатура в контексте
Исходное аудио: «Обнаружен FPV дрон в районе Козинки»
Этап | Результат |
|---|---|
Whisper | «Обнаружен эф пи ви дрон в районе Козинки» |
Gemini | «Обнаружен FPV дрон в районе Козинки» |
Ensemble | «Обнаружен FPV дрон в районе Козинки» |
Gemini понял из контекста, что «эф пи ви» - это аббревиатура FPV. Whisper распознал по буквам, но постобработка всё равно исправила бы.
Метрики production-системы (4 месяца работы)
Метрика | Значение | Комментарий |
|---|---|---|
Обработано сообщений | 48,000+ | ~400/день в среднем |
Среднее время обработки | 6.2 сек | Включая все этапы |
Медианное время | 5.4 сек | 50-й перцентиль |
95-й перцентиль | 9.1 сек | Редкие медленные запросы |
99-й перцентиль | 12.3 сек | Очень редкие случаи |
Agreement между сервисами | 73% | Совпадение ≥70% слов |
Clear winner (без AI-fusion) | 64% | Один сервис явно лучше |
AI-fusion вызовов | 31% | Нужно объединение |
ROVER fallback | 5% | AI-fusion недоступен |
Ошибок, требующих ручной правки (было, уже нет) | 4.2% | ~17 из 400 в день |
Uptime системы | 99.7% | 2.6 часа downtime за 4 мес |
Динамика по месяцам:
Месяц | Точность | Avg latency | Комментарий |
|---|---|---|---|
1 | 91% | 8.1 сек | Начальная версия |
2 | 93% | 6.8 сек | Оптимизация промптов |
3 | 94% | 6.4 сек | Расширение постобработки |
4 | 95% | 6.2 сек | Тонкая настройка весов |
Точность растёт, latency падает система «учится» на своих ошибках через пополнение словарей постобработки.
10. Экономика решения
Расчёт для разных нагрузок
Нагрузка | Whisper | Gemini STT | AI-fusion | Итого/месяц |
|---|---|---|---|---|
500 msg/день | $30 | $20 | $3 | ~$55 |
1000 msg/день | $60 | $40 | $6 | ~$110 |
2000 msg/день | $120 | $80 | $12 | ~$215 |
Примечание: расчёт для среднего сообщения 15-20 секунд. Для более длинных аудио стоимость выше.
Сравнение с альтернативами
Подход | Стоимость/мес | Точность | Время | Комментарий |
|---|---|---|---|---|
1 STT (Whisper) | ~$60 | 60-70% | 3-5 сек | Дёшево, много ошибок |
4 STT параллельно | ~$350 | 80% | 14-20 сек | Дорого, медленно |
Fine-tuned модель | $5000+ upfront | 95%+ | 3-5 сек | Требует данных |
Наш ensemble | ~$110 | 95% | 5-8 сек | Оптимальный баланс |
ROI (если использовать для коммерции)
Ручная расшифровка одного сообщения занимает 1-2 минуты у оператора. При зарплате оператора 60,000₽/месяц и 1000 сообщениях в день:
Время на ручную работу: 1000 × 1.5 мин × 30 дней = 750 часов/месяц
Требуется операторов: 750 / 160 = ~4.7
Стоимость: ~280,000₽/месяц
С этой системой:
Автоматизировано: 95%
Ручная работа: 50 сообщений/день × 1.5 мин = 37.5 часов/месяц
Требуется операторов: 0.25
Стоимость системы: ~$110 ≈ 11,000₽
Стоимость оператора: ~15,000₽/месяц
Итого: ~26,000₽/месяц vs 280,000₽/месяц
Экономия: 90% или ~250,000₽/месяц.
FAQ:
Q: Почему не использовать только Whisper с большим prompt?
A: Whisper ограничен 224 токенами в prompt - это ~50 слов. Если у вас 40+ топонимов, 20+ терминов и примеры фраз - не влезет. Кроме того, prompt в Whisper - это подсказка, а не инструкция. Модель может проигнорировать его.
Gemini позволяет передать полный контекст (тысячи токенов) и даёт структурированный ответ с confidence. Комбинация двух подходов работает лучше, чем любой из них отдельно.
Q: Сколько топонимов можно передать в контекст?
A: Для Gemini - практически без ограничений. Мы передаём 42 топонима + 15 терминов + примеры фраз. Это ~500 токенов, стоимость минимальна.
Для Whisper лучше ограничиться 15-20 самыми важными (частыми) словами.
Q: Что если один сервис постоянно недоступен?
A: Система работает в режиме graceful degradation. Если Gemini недоступен, используем только Whisper (точность падает до ~75%). Если Whisper недоступен - только Gemini (~80%). Circuit breaker автоматически отключает нестабильный сервис и пробует восстановить через минуту.
Q: Как часто нужно обновлять словарь топонимов?
A: Зависит от домена. Для географии — почти никогда (населённые пункты не появляются каждый день). Для продуктовой поддержки — чаще (новые фичи, продукты).
Q: Можно ли использовать подход для других языков?
A: Да. Whisper поддерживает 100+ языков. Gemini — основные мировые языки. Принцип ensemble работает независимо от языка.
Качество STT на разных языках разное. Для английского базовая точность выше (95%+), ensemble даст меньший прирост. Для редких языков — ensemble критичен.
Q: Как масштабируется решение?
A: Линейно. 10x сообщений = 10x стоимость.
При очень высоких нагрузках (10,000+ msg/день) имеет смысл посмотреть на self-hosted Whisper и что-то еще на GPU.
Q: Почему не fine-tuning?
A: Fine-tuning требует:
100+ часов размеченного аудио
GPU-инфраструктуру
Время на эксперименты
Поддержку модели (ретрейнинг при изменении данных)
Денег и времени на. это нет. Ensemble даёт сравнимое качество за $100/месяц без DevOps.
Q: Как измерять качество?
A: WER (Word Error Rate) — стандартная метрика. Формула: (Substitutions + Insertions + Deletions) / Total Words.
WER отдельно по категориям:
Общий WER
WER на топонимах
WER на терминах
WER на числах
Это позволяет видеть, где система слабая.
Q: Что делать с очень длинными аудио (5+ минут)?
A: Разбивать на чанки по 30-60 секунд. Whisper имеет лимит 25MB на файл. Gemini 20MB.
Для разбиения использовать VAD (Voice Activity Detection) режем по паузам, чтобы не разрывать слова.
Ошибки при внедрении
Ошибка 1: Слепое доверие одному сервису
Whisper обучен на общих данных. Он не знает ваших топонимов, терминов, специфики. Всегда измеряйте качество на своих данных.
Ошибка 2: Слишком много сервисов
Больше сервисов = больше latency + стоимость + сложность выбора. Анализируйте корреляцию ошибок.
Ошибка 3: Игнорирование постобработки
«ML должен всё решить, regex — это прошлый век.»
ML-модели делают систематические ошибки. Если вы видите одну и ту же ошибку 10 раз, проще добавить правило замены, чем переобучать модель.
Ошибка 4: Отсутствие логирования
Без логов вы не узнаете о проблемах, пока пользователи не пожалуются. Логируйте: исходный текст, результат каждого сервиса, финальный результат, время обработки.
Ошибка 5: Одинаковые промпты для разных сервисов
«Скопирую промпт из Gemini в Whisper.»
Сервисы по-разному интерпретируют промпты. Whisper ожидает короткую подсказку (224 токена). Gemini развёрнутую инструкцию.
Ошибка 6: Фиксированные пороги
«Agreement > 70% — берём консенсус, иначе AI-fusion.»
Оптимальные пороги зависят от данных. 70% работает для нас, для вас может быть 60% или 80%. A/B тестируйте.
Ошибка 7: Игнорирование стоимости
«Gemini Pro точнее, будем использовать его.»
Gemini Pro в 4 раза дороже Flash при сопоставимом качестве для STT. Всегда считайте ROI.
Ретроспектива: что бы сделал иначе
1. Начать бы с анализа ошибок, а не с подключения сервисов.
Потратил две недели на интеграцию четырёх STT, а потом выяснили, что два из них бесполезны. Если бы начал с 500 размеченных примеров и анализа WER, сэкономили бы время.
Сначала данные, потом архитектура.
2. Раньше внедрили бы AI-fusion.
ROVER — хороший алгоритм, но он не понимает семантику. Неделю пытался его тюнить, добавлял веса, эвристики. В итоге один промпт в Gemini решил проблему лучше, чем все эвристики.
Урок: Не бойтесь использовать LLM для «склейки».
3. Раньше перешли бы на Gemini Flash.
Первые три недели использовал Gemini Pro. Он медленнее в 3 раза и дороже в 4 раза. Качество транскрипции. Думал, что «Pro лучше», но это не так для задачи STT.
Тестируйте младшие модели. Часто они достаточны.
Что дальше можно сделать
Снижение стоимости:
Gemini 2.5 Flash-Lite ($0.10 input, $0.40 output) - потенциальная экономия 50-60%. Нужно протестировать качество на наших данных.
Prompt caching - Gemini поддерживает кэширование промптов. Наш промпт с топонимами одинаковый для всех запросов. Кэширование сэкономит ~40% на input токенах.
Батчинг для не-срочных — если сообщение не требует мгновенного ответа, можно накапливать и отправлять пачками. Некоторые API дают скидку на batch.
Улучшение качества:
Автоматическое пополнение словаря - если транскрипция содержит новое слово, похожее на топоним (заглавная буква, суффикс -ово/-ино/-ка), добавлять в кандидаты для ручной проверки.
A/B тестирование весов - автоматизировать подбор коэффициентов в scoring-формуле на основе размеченных данных.
GPT-4o Transcribe - OpenAI выпустили новую модель с diarization. Стоит сравнить с Whisper.
Мониторинг:
Real-time дашборд - график latency, success rate, agreement rate. Алерт если метрики падают.
Автоматическое обнаружение новых ошибок - кластеризация транскрипций, которые пользователи исправляют вручную.
Применимость подхода
Описанный подход работает не только для топонимов. Multi-API Ensemble полезен везде, где есть специфическая лексика:
Медицина: названия препаратов, диагнозы, латинские термины
Юриспруденция: статьи законов, юридические термины
Техническая поддержка: названия продуктов, артикулы, SKU
Логистика: адреса, названия складов, номера заказов
Финансы: тикеры акций, названия фондов, финансовые термины
Если статья была полезна поставьте плюс, это помогает другим найти материал.
