Каждый день в российском бизнесе происходят миллионы телефонных звонков. Колл-центры, клиники, юридические конторы, отделы продаж — везде, где есть телефон, есть поток неструктурированных данных, который никто не обрабатывает. Менеджер повесил трубку, записал в CRM «клиент интересовался» — и 80% информации из разговора потерялось.
Я потратил полгода на то, чтобы построить пайплайн, который берёт аудиозапись телефонного звонка и выдаёт структурированный JSON: кто звонил, чего хотел, какие суммы называл, что договорились делать дальше. В процессе набил достаточно шишек, чтобы написать эту статью.
Здесь не будет теории из документации. Будут конкретные решения, рабочий код на Python и грабли, на которые я наступил, чтобы вам не пришлось.
Архитектура: четыре этапа, один сюрприз
Пайплайн выглядит просто:
Аудио (PCM 16kHz) → ASR → Диаризация → LLM → JSON
На практике «просто» заканчивается после первого pip install. Каждый этап имеет свои подводные камни, а самый неожиданный — взаимодействие между этапами. Ошибка STT на 3% каскадно снижает точность LLM-извлечения на 10-15%.
Этап 1. Аудио: почему 8 кГц — это боль
Источник аудио — SIP-транк облачной АТС. Большинство провайдеров (Mango, Zadarma, UIS) отдают записи через API или webhook. Формат на входе — обычно PCM 16kHz mono или G.711 (8kHz).
И вот тут первые грабли.
G.711 (8 кГц). Телефонный стандарт, придуманный в 1972 году. Частотный диапазон — 300-3400 Гц. Человеческая речь содержит информативные компоненты до 8000 Гц. Итог: STT-модели, обученные на широкополосном аудио, на 8 кГц показывают WER на 5-8 процентных пунктов хуже.
Апсемплинг. Наивный librosa.resample(audio, orig_sr=8000, target_sr=16000) не добавляет информации — он просто интерполирует сэмплы. Но! Модели типа Whisper обучены на 16 кГц, и подача 8 кГц напрямую ломает их внутренние фильтры. Апсемплинг даёт +2-3% к точности просто за счёт корректного формата.
Моно vs стерео. Если АТС отдаёт стерео (левый канал = оператор, правый = клиент) — считайте, что вам повезло. Задача диаризации решена бесплатно. На практике 70% АТС отдают моно.
Минимальный код для приёма записи:
from fastapi import FastAPI, Request
import aiohttp
import aiofiles
app = FastAPI()
@app.post("/webhook/call-recording")
async def receive_recording(request: Request):
body = await request.json()
audio_url = body["recording_url"]
call_id = body["call_id"]
async with aiohttp.ClientSession() as session:
async with session.get(audio_url) as resp:
audio_bytes = await resp.read()
path = f"/data/calls/{call_id}.wav"
async with aiofiles.open(path, "wb") as f:
await f.write(audio_bytes)
await pipeline.enqueue(call_id, path)
return {"status": "accepted"}
Грабля, на которую я наступил: webhook может прийти раньше, чем запись дозаписалась на стороне АТС. Файл будет обрезан. Решение: retry с проверкой длительности через ffprobe — если аудио короче 5 секунд, ждём 10 секунд и скачиваем повторно.
Этап 2. Speech-to-Text: Whisper, SpeechKit и честные бенчмарки
Я протестировал четыре варианта на корпусе из 200 телефонных записей на русском языке (средняя длительность 3.5 минуты, качество — типичный мобильный звонок):
|
Модель |
WER (телефон, рус.) |
Latency (3 мин) |
Стоимость |
Streaming |
|---|---|---|---|---|
|
Whisper large-v3 (локально, A100) |
14.2% |
18 сек |
~$0 (GPU) |
Нет |
|
Whisper large-v3 (локально, RTX 4090) |
14.2% |
35 сек |
~$0 (GPU) |
Нет |
|
Yandex SpeechKit |
8.1% |
12 сек |
~₽1.2/мин |
Да |
|
Deepgram Nova-2 |
11.7% |
4 сек |
~$0.0043/мин |
Да |
Несколько неочевидных наблюдений:
Whisper врёт красиво. Когда Whisper не уверен, он не ставит [inaudible] — он генерирует правдоподобный, но неверный текст. Фраза «давайте встретимся в среду в три» может превратиться в «давайте встретимся в среду утри». Выглядит похоже, но LLM на следующем этапе не поймёт «утри» и потеряет время встречи.
SpeechKit выигрывает на русском. Это ожидаемо — модель дообучена именно на русской речи. Разница в WER (8.1% vs 14.2%) выглядит небольшой, но на практике это означает, что SpeechKit правильно распознаёт «двадцать третье» как дату, а Whisper — как «двадцать третья».
Deepgram — компромисс. Latency 4 секунды на трёхминутный звонок — это почти реалтайм. Для сценариев, где важна скорость (уведомления, алерты), Deepgram вне конкуренции.
Конфиг Whisper для телефонного аудио (не дефолтный!):
import whisper
model = whisper.load_model("large-v3", device="cuda")
def transcribe(audio_path: str) -> dict:
result = model.transcribe(
audio_path,
language="ru",
condition_on_previous_text=True,
# Для телефонного аудио — снижаем порог тишины,
# иначе модель «глотает» короткие реплики
no_speech_threshold=0.45,
# Повышаем порог сжатия — телефонный шум
# даёт ложные повторы
compression_ratio_threshold=2.8,
# beam search вместо greedy — +2% точности,
# но +40% времени
beam_size=5,
)
return result
Почему no_speech_threshold=0.45, а не дефолтные 0.6? На телефонных записях фоновый шум (улица, машина, кафе) создаёт высокий no_speech probability. С дефолтным порогом Whisper пропускает 15-20% реплик, считая их шумом. С 0.45 — пропускает 3-5%, но появляется 2-3% ложных распознаваний тишины. Трейдофф в пользу полноты.
Этап 3. Диаризация: кто это сказал?
Если аудио в моно — нужна speaker diarization. Я использую pyannote-audio 3.1:
from pyannote.audio import Pipeline
diarization_pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token="YOUR_HF_TOKEN"
)
def diarize(audio_path: str) -> list:
result = diarization_pipeline(audio_path, num_speakers=2)
segments = []
for turn, _, speaker in result.itertracks(yield_label=True):
segments.append({
"start": round(turn.start, 2),
"end": round(turn.end, 2),
"speaker": speaker
})
return segments
Проблема перехлёстов. Люди перебивают друг друга. pyannote честно размечает overlapping speech — но как совместить это с транскриптом, где Whisper выдаёт один поток текста?
Мой подход — привязка по средней точке сегмента:
def merge_transcript_speakers(
whisper_segments: list,
diarization: list
) -> str:
lines = []
for seg in whisper_segments:
mid = (seg["start"] + seg["end"]) / 2
speaker = "?"
for d in diarization:
if d["start"] <= mid <= d["end"]:
speaker = d["speaker"]
break
lines.append(f"[{speaker}]: {seg['text'].strip()}")
return "n".join(lines)
Это работает в 85% случаев. В оставшихся 15% — когда оба говорят одновременно — спикер определяется неверно. Я пробовал более сложные алгоритмы (взвешенное пересечение, voting по субсегментам), но выигрыш — 3-4%, а сложность кода растёт кратно. Для продакшена оставил простой вариант.
Грабля: pyannote иногда разбивает одного спикера на два, если человек меняет тон (начал спокойно, потом стал говорить громче). Решение — постобработка: если SPEAKER_00 и SPEAKER_02 никогда не пересекаются по времени, это скорее всего один человек.
def merge_fragmented_speakers(segments: list) -> list:
"""Объединяет спикеров, которые никогда не говорят одновременно."""
from itertools import combinations
speakers = set(s["speaker"] for s in segments)
merge_map = {}
for s1, s2 in combinations(speakers, 2):
s1_intervals = [(s["start"], s["end"]) for s in segments if s["speaker"] == s1]
s2_intervals = [(s["start"], s["end"]) for s in segments if s["speaker"] == s2]
has_overlap = any(
a_start < b_end and b_start < a_end
for a_start, a_end in s1_intervals
for b_start, b_end in s2_intervals
)
if not has_overlap:
# Объединяем менее частого спикера в более частого
if len(s1_intervals) >= len(s2_intervals):
merge_map[s2] = s1
else:
merge_map[s1] = s2
for seg in segments:
while seg["speaker"] in merge_map:
seg["speaker"] = merge_map[seg["speaker"]]
return segments
Этап 4. LLM-извлечение сущностей: prompt engineering на стероидах
Самая интересная часть. Берём размеченный транскрипт и просим LLM извлечь структурированные данные.
Промпт, который работает (после 40 итераций)
Первый промпт был наивный: «Извлеки из диалога контактные данные и суть обращения». LLM радостно галлюцинировал — додумывал имена, суммы и даты, которых в разговоре не было.
Вот версия, к которой я пришёл:
EXTRACTION_PROMPT = """Проанализируй транскрипт телефонного разговора.
Извлеки ТОЛЬКО данные, которые ЯВНО прозвучали.
ПРАВИЛА:
1. Если информация НЕ упоминалась — поле = null. НЕ додумывай.
2. Числа → цифры: "восемнадцать миллионов" → 18000000
3. Адреса → нормализуй: "Ленинский сто двадцать" → "Ленинский проспект, 120"
4. Если обсуждается несколько тем/запросов — отдельный объект на каждую
ФОРМАТ (JSON):
{
"contacts": [{"name": "str|null", "phone": "str|null", "role": "str"}],
"requests": [
{
"topic": "str — суть запроса в 1 предложении",
"details": {"ключ": "значение — только то, что прозвучало"},
"amounts": [{"value": number, "context": "str"}],
"confidence": 0.0-1.0
}
],
"action_items": [
{"action": "str", "who": "operator|client", "when": "ISO8601|null"}
],
"summary": "2-3 предложения"
}
ТРАНСКРИПТ:
{transcript}"""
Почему confidence — это спасение
Поле confidence — не декорация. Я использую его для автоматической фильтрации:
def validate_extraction(result: dict, transcript: str) -> dict:
"""Отсекаем данные с низкой уверенностью + проверяем по транскрипту."""
for req in result.get("requests", []):
if req.get("confidence", 0) < 0.6:
req["amounts"] = [] # Не доверяем суммам
req["_warning"] = "low_confidence"
# Проверяем, что суммы реально звучали в разговоре
for amount in req.get("amounts", []):
value = amount.get("value", 0)
if value and not _number_mentioned(value, transcript):
amount["_hallucinated"] = True
return result
def _number_mentioned(n: int, text: str) -> bool:
"""Проверяет, упоминалось ли число в тексте (цифрами или словами)."""
text_lower = text.lower()
# Прямое вхождение
if str(n) in text_lower:
return True
# Сокращения: "18 млн", "2.5 тыс"
millions = n / 1_000_000
if millions >= 1 and (
f"{int(millions)} млн" in text_lower
or f"{int(millions)} миллион" in text_lower
):
return True
return False
Выбор модели: не всегда нужен GPT-4
Тестировал на 500 размеченных звонках (ручная разметка — золотой стандарт):
|
Модель |
F1 (извлечение) |
Latency |
$/звонок |
|---|---|---|---|
|
GPT-4o |
0.91 |
2.8 сек |
$0.03 |
|
Claude 3.5 Sonnet |
0.90 |
2.1 сек |
$0.02 |
|
GPT-4o-mini |
0.84 |
0.9 сек |
$0.003 |
|
Llama 3.1 70B (vLLM) |
0.82 |
1.8 сек |
$0.001 |
GPT-4o-mini — мой выбор для продакшена. F1 0.84 — значит 84% сущностей извлечены верно. Оставшиеся 16% — это в основном неявные данные (клиент не назвал сумму прямо, но она вычисляется из контекста). Для таких случаев есть поле confidence < 0.6.
Llama 3.1 70B — если данные не должны покидать контур. Self-hosted на 2×A100 через vLLM. Точность чуть ниже, но нулевые затраты на API и полный контроль.
Собираем пайплайн
import asyncio
import time
from dataclasses import dataclass
@dataclass
class CallResult:
call_id: str
transcript: str
extracted: dict
duration_ms: int
async def process_call(call_id: str, audio_path: str) -> CallResult:
t0 = time.monotonic()
# STT и диаризация — параллельно (не зависят друг от друга)
transcript_fut = asyncio.to_thread(transcribe, audio_path)
diarize_fut = asyncio.to_thread(diarize, audio_path)
transcript_result, diarization = await asyncio.gather(
transcript_fut, diarize_fut
)
# Объединяем
diarization = merge_fragmented_speakers(diarization)
full_text = merge_transcript_speakers(
transcript_result["segments"], diarization
)
# LLM-извлечение
extracted = await call_llm(EXTRACTION_PROMPT.format(transcript=full_text))
validated = validate_extraction(extracted, full_text)
return CallResult(
call_id=call_id,
transcript=full_text,
extracted=validated,
duration_ms=int((time.monotonic() - t0) * 1000),
)
Оптимизация: с 40 секунд до 8
Первая версия обрабатывала 3-минутный звонок за 40 секунд. Вот что помогло:
Параллелизация STT + диаризация. Они независимы — запускаем одновременно. Whisper: 18 сек, pyannote: 12 сек. Параллельно: 18 сек (вместо 30). Экономия: 12 секунд.
Кэширование моделей в памяти. Whisper large-v3 загружается ~30 секунд. Держим модель в GPU memory, переиспользуем между запросами. То же для pyannote.
Оптимизация промпта. Первый промпт: 800 токенов. Финальный: 350 токенов. Меньше токенов → быстрее ответ LLM. Убрал многословные инструкции, оставил чёткие правила.
Chunking для длинных звонков. Звонки > 10 минут разбиваем на чанки по 5 минут с перекрытием 30 секунд. Каждый чанк обрабатывается отдельно, результаты мержатся. Без этого LLM начинает «забывать» начало разговора.
Итог: 8-12 секунд на 3-минутный звонок (RTX 4090 + GPT-4o-mini API).
Грабли, которые сэкономят вам время
1. LLM генерирует невалидный JSON. В ~3% случаев модель возвращает JSON с незакрытой скобкой или markdown-обёрткой ```json...```. Решение — не json.loads(), а парсер с fallback:
import json
import re
def safe_parse_json(text: str) -> dict:
# Убираем markdown-обёртку
text = re.sub(r'^```jsons*', '', text.strip())
text = re.sub(r's*```$', '', text.strip())
try:
return json.loads(text)
except json.JSONDecodeError:
# Пытаемся починить незакрытые скобки
for closer in ["}", "]}", "]}}", '"}']:
try:
return json.loads(text + closer)
except json.JSONDecodeError:
continue
raise
2. Whisper повторяет фразы. На тихих участках записи Whisper large-v3 иногда зацикливается: «да да да да да да да да». Это известный баг. Детектирую через compression_ratio:
def detect_repetition(segments: list) -> list:
"""Убираем сегменты с подозрительными повторами."""
cleaned = []
for seg in segments:
words = seg["text"].split()
if len(words) > 3:
unique_ratio = len(set(words)) / len(words)
if unique_ratio < 0.3: # Более 70% слов повторяются
continue
cleaned.append(seg)
return cleaned
3. Диаризация путает спикеров между звонками. pyannote присваивает SPEAKER_00 и SPEAKER_01 произвольно — в одном звонке оператор = SPEAKER_00, в другом = SPEAKER_01. Решение — эвристика: спикер, который говорит первым и произносит приветственную формулу («добрый день», «алло, компания…»), — оператор.
4. Кодировка номеров телефонов. «Плюс семь девятьсот пять триста двадцать один сорок два двенадцать» — STT может выдать как текст, а может как +79053214212. Нужен нормализатор, который обрабатывает оба варианта.
Мониторинг: как понять, что всё сломалось
Пайплайн без мониторинга — бомба замедленного действия.
import logging
from prometheus_client import Histogram, Counter
PROCESSING_TIME = Histogram(
'call_processing_seconds', 'Время обработки звонка',
buckets=[5, 10, 15, 20, 30, 60]
)
EXTRACTION_CONFIDENCE = Histogram(
'extraction_confidence', 'Средняя уверенность извлечения',
buckets=[0.3, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
)
ERRORS = Counter('call_processing_errors', 'Ошибки обработки', ['stage'])
async def monitored_process(call_id: str, path: str):
try:
with PROCESSING_TIME.time():
result = await process_call(call_id, path)
avg_conf = sum(
r.get("confidence", 0) for r in result.extracted.get("requests", [])
) / max(len(result.extracted.get("requests", [])), 1)
EXTRACTION_CONFIDENCE.observe(avg_conf)
return result
except Exception as e:
stage = "stt" if "whisper" in str(e).lower() else "llm"
ERRORS.labels(stage=stage).inc()
raise
Ключевые алерты:
-
avg_confidence < 0.6 за последний час → промпт деградировал или изменился формат звонков
-
processing_time > 30 сек → GPU под нагрузкой или API тормозит
-
error_rate > 5% → что-то сломалось, нужна ручная проверка
Раз в неделю — ручная выборка 20-30 звонков, сравнение с золотым стандартом. Если F1 падает ниже 0.80 — пересматриваем промпт или дообучаем STT.
Итого
Весь пайплайн — ~400 строк Python без учёта инфраструктуры. Ключевые решения:
-
STT: Whisper large-v3 для self-hosted, Yandex SpeechKit если нужна точность на русском, Deepgram если нужна скорость
-
Диаризация: pyannote-audio 3.1 — лучший open-source вариант
-
LLM: GPT-4o-mini для продакшена (цена/качество), GPT-4o для критичных данных
-
Валидация: обязательна — LLM галлюцинирует в ~5% случаев
Подход не привязан к домену. Замените JSON-схему в промпте — и пайплайн заработает для колл-центра, юридической консультации, медицинского приёма или любого бизнеса с телефонными переговорами.
Если есть вопросы по конкретному этапу — STT на русском, борьба с галлюцинациями, streaming-обработка — пишите в комментариях.
Автор: SmartAgent


