Как я собрал офлайн-пайплайн для телефонных звонков на одной игровой карте — и зачем там вообще LLM, если «кто что сказал» нас почти не интересует.
Написано с помощью ИИ
Репозиторий: antirek/calls-pipeline
Пересмотрел запись вебинара про речевую аналитику. Вспомнил, что после конференции год назад хотел собрать такой пайплайн сам — потому что на вопрос из зала докладчик ответил примерно: «Все бы мы хотели…».
Хорошо, что сразу не стал. За это время появились модели, на которых такое реально крутится дома: не в облаке и не на «реакторе», а на условном чайнике. У меня ориентир — относительно бюджетная RTX 5060 Ti 16 GB (да, иногда приходится одалживать видюху у сына с игрового компа). В пайплайне сейчас три слоя: диаризатор → транскрайбер → LLM. Без Whisper и без pyannote из того доклада — другие модели, но задача та же.
Зачем это вообще
Мне не нужен стенографический протокол «со всеми придыханиями». Нужно другое:
-
вытащить проблемные звонки — не сходится баланс, сменить тариф, конфликт, агент отработал плохо;
-
найти обещания и намерения — «отправлю договор», «подключу услугу» — и хотя бы мысленно поставить задачу-напоминание в CRM;
-
по пачке звонков за день понять, о чём был день — например, «часто звонили по аварии на линии».
Имена и бренды из текста — не всегда и не критично. Обычно звонок уже привязан к номеру, а номер — к организации или физлицу в CRM. Ещё бы посчитать WER… но об этом честно ниже.
Облачный GigaChat Max на тех же записях пишет так, будто товарищ майор каждое слово занёс в тетрадочку. Сравнивать домашний стек с этим, наверное, вообще некорректно. Но для эскалаций и обещаний локальный уровень уже рабочий.
Как устроен пайплайн
моно mp3/wav
→ ffmpeg: 16 kHz mono + highpass
→ Sortformer (diar) → сегменты спикеров
→ нарезка чанков (паузы diar, max ~20 с)
→ GigaAM v3_e2e_rnnt (ASR) → transcript.txt
→ [если перевод оператора] transfer-split → re-diar head/tail → снова ASR
→ GigaChat: extract → summarize-call → escalation
→ summarize-batch (день)
→ MongoDB + Web UI
Всё в Docker (diar / asr / llm + llamacpp), оркестратор на хосте — тонкий recognize.py. Контейнеры diar и asr гоняются по очереди на одной карте: вместе с LLM не уживутся.
1. Диаризация — Sortformer
nvidia/diar_streaming_sortformer_4spk-v2.1: ~117M, ~0.47 GB на диске, ~0.5–1.5 GB VRAM. Делит моно на «Спикер 1 / Спикер 2 / …» (до 4).
Отдельная боль телефонии — перевод на другого оператора: в одной записи вдруг три голоса, и «глобальная» diar путает роли. Для этого сделан transfer-split:
-
в черновом транскрипте ищем cue (
перевед,переключ,соедин…); -
после cue смотрим audio hold (тишина / гудки ожидания по энергии wav) или длинную паузу diar;
-
режем файл, отдельно диаризуем голову и хвост, склеиваем спикеров, снова гоним ASR.
Без этого кусок звонков с переводами разваливается в кашу.
2. ASR — GigaAM, не Whisper и не Vosk
GigaAM v3_e2e_rnnt: ~220–240M, ~0.45 GB, на GPU ~1–2 GB VRAM. Берёт уже нарезанные сегменты, отдаёт текст с пунктуацией.
Whisper / pyannote из доклада сознательно не тащил: для русских телефонных mono Sortformer + GigaAM оказались достаточны. Vosk смотрел как CPU-замену — на чистой речи бывает близко, на шуме и телефонии обычно хуже; а ошибки ASR сразу бьют по LLM (номера, имена, «маркет»). Оставил GigaAM.
Diар + ASR на батче ведут себя спокойно: примерно 1:3 — три минуты разговора обрабатываются около минуты wall-clock (RTF ≈ 3×). Модели «тихонькие»; греется уже следующий слой.
3. LLM — зачем она, если диалог уже есть
Кто что сказал построчно нас в итоге мало волнует. Важнее:
-
надо ли эскалировать звонок руководителю;
-
обещал ли менеджер что-то сделать;
-
какой смысл у разговора и что было за день.
Для этого локально крутится GigaChat3.1-10B-A1.8B: MoE 10B total / ~1.8B active, квант q6_K (~8.8 GB файл, в рантайме ~10 GB VRAM), контекст у нас -c 16384, full offload в llama.cpp. Целая 10B в fp16 на 16 GB не влезла бы — поэтому квант; зато active маленький, и остаётся место под контекст.
По каждому transcript.txt пайплайн делает:
-
extract — телефоны, адреса, суммы, commitments; телефоны ещё groundим по цифрам в тексте (иначе LLM охотно выдумывает);
-
summarize-call — intent, issues, actions (нарратив, не ярлык
technical/telephony); -
escalation — отдельный проход:
needed, severity, reasons, цитаты для супервайзера; IVR/«операторы заняты» режем pre-filter’ом, чтобы не эскалировать очередь; -
summarize-batch — дневной отчёт; если звонков много — map-reduce чанками (в 16k все summary дня не влезают).
Перебирал Gemma, Qwen, T-lite, GigaChat — всё, что реалистично на этой карте. Gemma «получше» советовали — мне не зашла. T-lite по саммари был нормальный; GigaChat оказался менее прожорливым, и это важнее маркетинга «27B»: на тех же 16 GB можно держать контекст пошире. Да, маленькие модели галлюцинируют все — поэтому не слепо верим JSON, а режем правилами и калибруем промпты.
LLM уже не «тихонькая»: комп прогревается до 70–80 °C, охота прикрутить ещё пару кулеров на вытяжку чайника. GigaChat Audio / VibeVoice на этой карте не завёл — кому повезло больше, интересно услышать.
На диске три основные веса суммарно ≈ 9.7 GB. Карта одна: либо recognize (diar/asr ephemeral), либо llamacpp в VRAM — не оба сразу.
Цифры с реальных прогонов
Один из дней (входящие + исходящие, ANSWERED, длительность > 30 с):
|
Этап |
Wall-clock (ориентир) |
|---|---|
|
Recognize (Sortformer → GigaAM), ~87 звонков / ~3 ч аудио |
~53 мин (median ~27 с/звонок; много уходит в cold-start Docker) |
|
summarize-call (summary + escalation) |
~12 мин на ~82 usable |
|
extract (отдельный проход в том прогоне) |
~5 мин |
|
summarize-batch |
~1 мин |
|
import в Mongo / UI |
секунды |
Пустые/почти пустые transcript (тишина, обрыв) LLM пропускает. Эскалаций за день — порядка десятка–двух после калибровки (без ложных «посидел в IVR»).
Узкое место recognize сейчас не «тяжёлый GigaAM», а docker-compose run --rm на каждый звонок (модель грузится снова и снова). Следующий логичный шаг — тёплые воркеры, а не пять параллельных холодных контейнеров на 16 GB.
Куда смотреть глазами
После прогона: MongoDB + простой Web UI (список звонков, фильтры, эскалации, модалки с диалогом / фактами extract / JSON / саммари, вкладка «саммари за день»). Чтобы не жить только в out/*/call_summary.json.
Есть регрессия из пяти зафиксированных звонков — чтобы после правок transfer-split или промптов не сломать базовые кейсы. Это consistency, не WER.
Чего честно нет
WER по всем боевым записям посчитать нельзя: нет ручной расшифровки-эталона. Golden в репозитории — заморозка выхода пайплайна, не «истина от человека». Имеет смысл когда-нибудь разметить маленькую выборку — иначе метрика будет врать красиво.
Natasha и GLiNER для сущностей пробовал — в основной путь не пошли. Хватило LLM-extract + phone grounding.
На выходе
Получился не «заменитель облачного гиганта», а домашний контур под конкретные вопросы:
запись → кто говорил → что сказали → надо ли бить тревогу / что обещали → что было за день.
Свежие модели Сбера и NVIDIA + Docker + одна 16 GB карта. Работает достойно на уровне чайника — и до «реакторов» ему далеко, и сравнивать с ними, наверное, незачем. Для эскалаций и обещаний из реальных телефонных диалогов — уже можно пользоваться.
Автор: antirek


