Диаризация — разделение записи на реплики по говорящим — это то место, где обычно ломается идея «прогоним звонки через нейросеть и получим отчёт». Начинается всё с боли руководителя: он слушает три звонка из двухсот, выбирает их сам, и по этой выборке делает выводы обо всём отделе — кто плохо отрабатывает возражения, кому пора на обучение, где скрипт не работает. Выборку сформировал тот же человек, который по ней судит; сплошная проверка руками невозможна — поток из сотни разговоров в день переслушивать некому и некогда.
Автоматизировать это мешает не распознавание речи, с ним давно всё в порядке. Мешает то, что расшифровка сама по себе бесполезна: в сплошном тексте не видно, кто говорил. Диаризация — отдельный шаг, отдельная модель и отдельный источник ошибок. Ниже разбор контура, который я собрал на LangGraph: запись превращается в диалог с ролями, роутер определяет тип разговора, и дальше в дело вступает нужный суб-агент — оценка работы менеджера или разбор негатива.
Собрать такой контур целиком, вместе с промптами оценки и калибровкой на людях, учимся на курсе «ИИ для 1С».
Почему одной транскрибации мало?
Whisper возвращает список сегментов вида «начало, конец, текст». Ни в одном поле нет говорящего. Для задачи «сделай саммари разговора» этого хватает. Для задачи «оцени работу менеджера» — нет, и вот почему.
Возьмите любую метрику из реального чек-листа отдела продаж: менеджер представился, менеджер спросил, каким софтом клиент пользуется сейчас, менеджер закрыл возражение, менеджер назначил встречу. Каждая формулировка начинается со слова «менеджер». Если модель не знает, какая реплика чья, она оценивает не работу сотрудника, а содержание разговора вообще. На практике это выглядит так: клиент сам сказал «давайте на следующей неделе созвонимся» — модель засчитывает менеджеру назначенную встречу. Формально в тексте встреча есть.
Посмотрите на поля, которые контур заполняет по каждому разговору: introduced_himself, software_already_used, customer_objections, objections_manager_close, date_meeting. Четыре из пяти отвечают на вопрос «что сделал менеджер», и ни одно из них нельзя заполнить по тексту, в котором роли не расставлены.
Отсюда конструкция: диаризация не украшение расшифровки, а условие, при котором отчёт вообще имеет смысл. Без ролей контур меряет разговор, а нужно мерять человека.
Что такое диаризация и почему её нет внутри Whisper?
Это две разные задачи, которые решают модели разных семейств. Whisper отвечает на вопрос «какие слова прозвучали» — это распознавание речи. Диаризация отвечает на вопрос «кто и когда говорил» — это сегментация записи на речевые отрезки, снятие голосового эмбеддинга с каждого отрезка и кластеризация этих эмбеддингов по говорящим. Текст в этой задаче не участвует вообще: на выходе получается список интервалов с метками SPEAKER_00, SPEAKER_01 и без единого слова.
Коробочные сервисы продают связку этих двух шагов под общим названием «речевая аналитика» — там она спрятана внутрь и настройке не поддаётся. Когда собираешь конвейер сам, оба шага приходится держать на виду по отдельности.
В проекте диаризация сделана на pyannote.audio, пайплайн — pyannote/speaker-diarization-community-1. Модель закрытая: перед первым запуском нужно зайти на её страницу в Hugging Face, принять лицензию и положить токен в конфиг, иначе загрузка молча не состоится.
def _diarize(self, waveform: torch.Tensor, sample_rate: int, num_speakers: int = None) -> list[dict]:
with torch.no_grad():
kwargs = {"waveform": waveform, "sample_rate": sample_rate}
if num_speakers is not None:
output = self.pipeline(kwargs, num_speakers=num_speakers)
else:
output = self.pipeline(kwargs)
result = []
for turn, speaker in output.speaker_diarization:
result.append({"speaker": speaker, "start": turn.start, "end": turn.end})
return result
Обратите внимание на num_speakers. Если вы точно знаете, что в записи двое — менеджер и клиент, — эту цифру надо передать: кластеризация перестаёт угадывать число говорящих и работает заметно устойчивее. Как только в разговор подключается третий (передали трубку коллеге, включили громкую связь), жёсткое ограничение начинает вредить, и параметр приходится отпускать в None. В контуре, который вызывается из бота, он как раз не задан — записи приходят разные.
Второй практический момент: транскрибация и диаризация одновременно борются за одну и ту же видеопамять. В сервисе диаризация вынесена на CPU, а после каждой обработки идёт torch.cuda.empty_cache() — иначе на длинной очереди файлов GPU заканчивается раньше, чем очередь.
Как из двух потоков собирается диалог
Итого после двух проходов по одному файлу есть два независимых списка интервалов. Границы в них не совпадают и совпадать не могут: Whisper режет запись по фразам и паузам, pyannote — по смене голоса. Сшивка идёт по максимальному перекрытию: для каждого сегмента текста ищем говорящего, чей интервал перекрывает его сильнее прочих.
def _find_speaker_for_segment(self, segment_start, segment_end, diarization_result) -> str:
max_overlap = 0
best_speaker = "UNKNOWN"
for speaker_info in diarization_result:
# Вычисляем перекрытие временных интервалов
overlap_start = max(segment_start, speaker_info["start"])
overlap_end = min(segment_end, speaker_info["end"])
overlap = max(0, overlap_end - overlap_start)
if overlap > max_overlap:
max_overlap = overlap
best_speaker = speaker_info["speaker"]
return best_speaker
Тут важна не столько формула, сколько ветка UNKNOWN. Сегмент, который не перекрылся ни с одним интервалом диаризации, не выбрасывается и не приписывается ближайшему говорящему наугад — он остаётся с явной меткой «не знаю». Дальше по конвейеру эта метка видна и человеку, и модели, и по количеству UNKNOWN на запись сразу понятно, что с исходником что-то не так.
Дальше всё сводится к одной строке — именно в таком виде диалог уходит в модель:
dialog_text = "\n".join([f"{item['speaker']}: {item['text'].strip()}" for item in dialog_data])
Никакого JSON и никаких таймингов в промпте: модели нужны роли и порядок реплик, тайминги ей нечего с ними делать. Кто из говорящих менеджер, а кто клиент, SPEAKER_00 и SPEAKER_01 не сообщают — это модель определяет из содержания диалога, и на коротких записях она в этом иногда ошибается. Надёжный способ — привязка ролей по каналу записи (в телефонии менеджер и клиент обычно пишутся в разные каналы), но это уже вопрос к источнику записей, а не к контуру.
Почему это граф с ветвлением, а не цепочка промптов?
Звонки бывают разного жанра, и оценивать их одним промптом — значит писать инструкцию на все случаи сразу. Разговор о продаже товара оценивается по одним критериям (представился, выявил потребность, закрыл возражения, назначил встречу), жалоба — по совершенно другим (насколько горячо, что именно сломалось, что с этим делать). Слепив их в один промпт, вы получаете модель, которая ищет возражения в жалобе и уровень негатива в спокойной продаже.
Поэтому маршрут выбирается явно: узел транскрибации, за ним роутер-классификатор, от роутера — условные рёбра в двух разных суб-агентов.
graph_builder = StateGraph(State)
graph_builder.add_node("transcribe", transcribe)
graph_builder.add_node("negative_agent", build_negative_agent_graph())
graph_builder.add_node("manager_job_evaluation_agent", build_manager_job_evaluation_agent_graph())
graph_builder.add_edge(START, "transcribe")
graph_builder.add_conditional_edges(
"transcribe",
router,
{
"SALE_OF_GOODS": "manager_job_evaluation_agent",
"NEGATIVE_FEEDBACK": "negative_agent",
},
)
graph_builder.add_edge("manager_job_evaluation_agent", END)
graph_builder.add_edge("negative_agent", END)
Ключевая деталь — вторая и третья строки. В узлы подставлены не функции, а скомпилированные графы: build_negative_agent_graph() возвращает готовый CompiledStateGraph, и для внешнего графа он неотличим от обычного узла. Каждый суб-агент разрабатывается, запускается и отлаживается отдельно (у каждого свой main(), свой рисунок графа, свой State), а в общий контур подключается одной строкой. Это же и способ ограничить состояние: у ветки негатива в схеме есть level, negative_aspects, main_reason, и внешний граф про эти поля ничего не знает.
Сам роутер — не разбор ответа регулярками, а структурированный вывод по схеме:
class DialogRoutes(BaseModel):
"""Информация о маршрутах движения диалога"""
routes: Literal["NEGATIVE_FEEDBACK", "SALE_OF_GOODS"] = Field(
default="SALE_OF_GOODS",
description="Тип диалога",
)
Literal из двух значений плюс дефолт — это гарантия того, что из роутера выйдет ровно один из ключей словаря условных рёбер, а не «Похоже, это скорее продажа». Ветка по умолчанию выбрана осознанно: продаж в потоке больше, и если модель сомневается, звонок уйдёт в более частый сценарий. Тот же приём — структурная схема вместо свободного текста — лежит в основе любого агентного контура; подробнее про устройство графов и состояния я разбирал в статье про LangGraph-агента для 1С, а про разделение работы между несколькими агентами — в разборе мультиагентных систем для бизнеса.
Как собирается такой контур целиком — от приёма файла до отчёта в Telegram — мы проходим на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов».
Что именно оценивает суб-агент по работе менеджера
Ветка продаж — два узла. Первый вытаскивает из диалога факты по жёсткой схеме, второй превращает факты в отчёт и числовую оценку. Разделение не косметическое: извлечение фактов и вынесение суждения — разные задачи, и смешивать их в одном вызове означает получить красивый отчёт, под которым ничего не лежит.
class MetricsDialogAnalysis(BaseModel):
"""Ключевые метрики анализа диалога"""
introduced_himself: bool = Field(..., description="Менеджер представился")
software_already_used: str = Field(
..., description="Какой софт уже используют. Если не спросил - ответить 'не спросил'"
)
customer_objections: List[str] = Field(..., description="Список возражений клиента которые прозвучали в диалоге")
objections_manager_close: List[str] = Field(
..., description="Список возражений которые менеджер закрыл, дал подробное объяснение"
)
date_meeting: datetime = Field(..., description="Дата встречи, если не назначил, оставь пустым")
Схема — это и есть чек-лист руководителя, переведённая в типы. Два списка вместо одного поля «работа с возражениями» дают то, ради чего всё затевалось: возражения клиента и закрытые возражения лежат отдельно, и разница между ними считается арифметикой, а не выпрашивается у модели. date_meeting со строгим типом datetime — самая хрупкая часть схемы: «созвонимся после праздников» в дату не превращается, и это надо либо разрешать отдельным правилом, либо принимать как ограничение.
Второй узел получает на вход только метрики — dialog_text из состояния перед этим вызовом выбрасывается. Модель пишет отчёт по фактам, а не перечитывает диалог заново и не находит в нём новых поводов для оценки. Оценка при этом намеренно грубая, три градации: 0 — плохо, 1 — средне, 2 — хорошо. Просить у модели балл от 1 до 100 бессмысленно — разницу между 63 и 71 она не воспроизведёт даже на одной и той же записи.
Ветка негатива устроена похоже, но раскладывается на четыре узла: уровень негатива (low/medium/high), список негативных моментов, главная причина, отчёт. Каждый узел — один узкий вопрос со своей схемой. Соблазн спросить всё одним промптом велик, но на длинной жалобе модель начинает смешивать «что не понравилось» и «из-за чего всё началось», а разделив вопросы, вы получаете от неё по одному ответу за раз.
Где такой контур врёт?
Список ниже — не оговорка в конце статьи, а то, с чем придётся работать на реальных записях.
Качество исходника. Телефонная линия режет частоты, гарнитура в open space тащит соседей, запись через громкую связь смазывает голоса. Диаризация страдает от этого сильнее, чем распознавание: кластеризация голосовых эмбеддингов требует, чтобы голоса вообще различались.
Перебивания и наложение речи. Ровно тот момент, ради которого руководитель и слушает звонок, — спор, где двое говорят одновременно, — для контура худший. Whisper выдаёт на этом участке один сегмент со смешанным текстом, диаризация — два перекрывающихся интервала, сшивка по максимальному перекрытию отдаёт всю фразу одному говорящему. Реплика оппонента просто исчезает.
Длинные паузы и «ага». Короткие поддакивания то попадают в сегменты, то теряются на фильтре тишины, а на паузе кластеризация иногда открывает лишнего говорящего. Отсюда записи, где вместо двух участников контур уверенно находит трёх.
Жаргон, названия и имена. Названия конфигураций, товаров, фамилии — то, что Whisper переписывает по-своему. Для отчёта о работе менеджера это чаще косметика, но если вы собираетесь считать по расшифровке упоминания конкретного продукта, готовьтесь к словарю замен.
И главное. Оценку работы человека нельзя выкатывать без калибровки на людях. Порядок такой: берёте выборку записей, руководитель оценивает их вручную, контур оценивает те же записи, дальше вы смотрите не на среднее, а на расхождения — где модель поставила 2, а человек 0, и наоборот. Разбор каждого такого случая обычно чинится не «моделью получше», а формулировкой в описании поля схемы. Пока расхождения не разобраны, цифра из контура — это повод послушать конкретный звонок, а не основание для разговора с сотрудником. О том, как вообще заводить такие вещи в рабочие процессы, я писал отдельно — внедрение ИИ-агентов в бизнес.
Что переносится на любой похожий конвейер
Несколько вещей, которые в проекте видны, но которые стоит держать в голове шире одного кейса.
Считайте вызовы модели на единицу работы, а не «стоимость токена». В этом контуре ветка продаж — три вызова на звонок (роутер, метрики, отчёт), ветка негатива — пять (роутер и четыре узла). Плюс GPU-время на транскрибацию и диаризацию, которое от длины записи зависит линейно, а от модели LLM не зависит вообще. Отсюда честная единица измерения — стоимость обработки часа записи, и в ней доля «железа» под Whisper и pyannote обычно выходит крупнее, чем доля вызовов модели. В проекте всё поднимается локально: whisper.cpp с large-v3 и VAD-моделью Silero на своём сервере, а LLM — Qwen/Qwen3-4B-Instruct-2507 на vLLM. Кто пишет отчёт, менять при этом легко: слой доступа к модели вынесен в фабрику, и локальная модель заменяется облачной одной константой.
Обе модели поднимаются локально и обычными командами, без обёрток:
# LLM: узлы графа ходят сюда по OpenAI-совместимому API
vllm serve Qwen/Qwen3-4B-Instruct-2507 --gpu-memory-utilization 0.6 --max-model-len 16384
# STT: whisper.cpp с моделью large-v3 и VAD Silero v6.2.0
./build/bin/whisper-server --host 0.0.0.0 --port 8888 -m models/ggml-large-v3.bin --language ru -vm models/ggml-silero-v6.2.0.bin --vad --beam-size 5
Здесь важны два параметра. --max-model-len 16384 задаёт потолок контекста: расшифровка часового разговора вместе с системным промптом и схемой ответа в него должна поместиться целиком, иначе диалог придётся резать и оценка поедет. --gpu-memory-utilization 0.6 оставляет часть видеопамяти под транскрибацию — на одной карте LLM и Whisper делят ресурс, и жадная настройка одного роняет другого.
Логируйте промежуточные артефакты, а не только результат. В сервисе на диск пишутся три файла на запись: чистая диаризация, чистая транскрипция и сшитый результат. Когда отчёт получается странным, это единственный способ за минуту понять, где сломалось: расшифровка мусорная, роли перепутаны или модель неверно прочитала нормальный диалог. Без этих трёх файлов вы будете спорить с моделью, хотя виноват микрофон.
Checkpoint нужен раньше, чем кажется. Обработка одного звонка — это минуты GPU-времени, и падение на последнем узле означает, что транскрибацию и диаризацию придётся гонять заново. LangGraph умеет сохранять состояние графа между узлами (langgraph-checkpoint-redis в этом проекте уже в зависимостях, а параметр checkpointer предусмотрен в сборке графов), и как только конвейер выходит из демо-режима в очередь из сотен файлов, подключать его приходится сразу. Заодно это даёт возможность возобновить прогон с середины и посмотреть состояние упавшей задачи, а не только строку в логе.
Точка входа — не главное. В проекте их две, и обе тонкие: HTTP-ручка POST /api/v1/analyze-audio, которая принимает файл и возвращает отчёт, и Telegram-бот на aiogram, куда руководитель просто пересылает голосовое или запись. Внутри обе делают одно и то же — graph.ainvoke({"file_path": ...}). Граф не знает, откуда пришёл файл, и это правильное разделение: интеграцию с телефонией потом добавляют третьей точкой входа, не трогая логику.
Где проходит граница
В статье — архитектура и работающий скелет: почему диаризация обязательна, как сшиваются два потока, зачем маршрут выбирается роутером, как выглядит схема оценки и где конструкция ломается. Этого достаточно, чтобы собрать такой конвейер под свои записи.
За кадром осталось то, что определяет, будет ли он давать осмысленный результат на вашем потоке: полные тексты промптов оценки и разбора негатива, подбор описаний полей в схемах (именно там, а не в системном сообщении, живёт большая часть качества), процедура калибровки на размеченной выборке, работа с записями плохого качества и сравнение локальных моделей разного размера на одной и той же задаче. Всё это мы разбираем на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — там контур собирается целиком, от приёма файла до отчёта, и прогоняется на реальных записях.