За три месяца наш голосовой ИИ-агент успел сделать три вещи, за которые живого администратора уволили бы в первый же день:
-
соврал клиенту, что переводит его на сотрудника, которого не существует;
-
какое-то время принимал всех звонящих за одного и того же человека;
-
выдавал «клонированный голос», который на самом деле был обычным синтезатором — клонирование молча не работало.
Ни одна из этих трёх проблем не лечится «хорошим промптом». Все три лечатся структурой кода. Об этом и статья — и ещё о том, что всё это в итоге поехало на полностью российском стеке (Yandex SpeechKit + YandexGPT, данные не покидают РФ), где почти каждая задача оказывалась не проще, а сложнее.
Сразу про роли — это объяснит, почему дальше я везде звоню агенту сам. В этом проекте я проектировщик, тимлид и тестировщик: придумывал архитектуру и флоу, ставил задачи, принимал работу и лично гонял агента по звонкам. Саму реализацию вела разработка. Поэтому «клиент», который звонит агенту во всех историях ниже, — это я с софтфоном в роли клиента, и каждый баг я ловил собственным ухом на собственном же тестовом звонке.
Что это вообще
AIRA — ИИ-ресепшионист для сервисного бизнеса. Один диалоговый «мозг» ведёт разговор во всех каналах — голос, Telegram, MAX, — записывает клиентов, отвечает на вопросы из базы знаний и при необходимости передаёт человеку. Подопытный арендатор — барбершоп «Здоровый Лось».
Текстовые каналы — это решённая задача: чат прощает почти всё. А вот голос оказался отдельным адом, и именно про него весь дальнейший рассказ.
Почему голос — это отдельный ад
В чате у тебя есть всё, чтобы спрятать несовершенство модели. Видно «печатает…». Можно переспросить, не показавшись идиотом. Пауза в две секунды никем не замечается. Сообщение можно отредактировать.
На телефонном звонке нет ничего из этого.
Пауза — это не «думает», это мёртвый эфир, в который человек говорит «алё? вы тут?». Перебить нельзя. Сказанное не отмотать. И всё происходит в реальном времени, без права на «секундочку, я перечитаю». Голос не добавляет новых проблем — он берёт каждую слабость текстового агента и умножает на десять.
Поэтому статья про голос. Текстовый продукт у нас работает; голос — то, на чём мы три месяца набивали шишки.
Архитектура — на один экран
Чтобы дальнейшее было понятно, нужно ровно столько:
Три принципа, которые потом всё определяют:
-
Один мозг на все каналы. Голос, Telegram и MAX зовут один и тот же
aira-coreпо HTTP (POST /v1/agent/turn). Голос — это просто ещё один канал без своей бизнес-логики. -
Ports-and-adapters. CRM, LLM, STT/TTS — это сменные адаптеры за интерфейсами. Что именно стоит за портом, выбирается в админке, а не в коде.
-
Движок stateless. Состояние диалога (текущая стадия) сериализуется в JSON и ездит туда-обратно. В памяти между ходами ничего не висит.
-
Сервисы разнесены по контейнерам. Gateway, мозг (
aira-core), админка и Postgres — отдельные Docker-сервисы под docker-compose, общающиеся по HTTP. Реалтайм-обработку звука держим в отдельном сервисе от мозга: тяжёлый аудио-тракт изолирован, а сервисы деплоятся и перезапускаются независимо.
И ключевое следствие, к которому мы вернёмся в самом конце: brain.py понятия не имеет, барбершоп это или стоматология. Вертикаль живёт в конфиге.
Главное предательство: бот соврал, что зовёт администратора
Вот первое, что агент сделал на живом звонке.
Я набираю 7000 со своего софтфона, дожидаюсь приветствия и говорю как обычный клиент:
— Здравствуйте, хочу записаться на стрижку завтра вечером. — Секунду, соединяю с администратором.
И тишина. Администратора нет и быть не может — это демо, никакого живого оператора на линии не стоит. Я сижу один в мёртвом эфире, который сам же и спроектировал.
Мысль вслух: в этот момент я был уверен, что где-то криво настроен перевод звонка. Полез искать несуществующий баг в телефонии. Зря.
Открываю логи мозга в соседнем терминале и звоню ещё раз:
bash
$ ssh mikl@192.168.200.184$ docker compose logs -f core | grep -E "goto|tool_round|action"tool_round=1 goto(stage=confirm)tool_round=2 goto(stage=confirm)tool_round=3 goto(stage=confirm)...tool_round=9 goto(stage=confirm)MAX_TOOL_ROUNDS reached → action=transfer
Вот оно. Девять раз подряд модель пыталась перейти в одну и ту же стадию диалога.
Механизм такой. Мозг работает через function calling: сначала зовёт инструменты (goto, free_slots, book), и только потом пишет текст. На количество tool-вызовов за один ход стоит лимит — MAX_TOOL_ROUNDS=10. Модель (тогда мозгом был gpt-4o-mini) проигнорировала enum в JSON-схеме инструмента и зациклилась: goto(confirm) снова и снова, пока не упёрлась в лимит. А исчерпание лимита система интерпретировала как «всё, мозг сдался — переводим на оператора».
Модель не упала. Она сделала ровно то, на что заточена: выдала правдоподобный текст. Беда в том, что правдоподобный текст был ложью.
Как мы это чинили — и чем именно НЕ чинили
Очевидное лечение — дописать в промпт «не зацикливайся, не вызывай goto в ту же стадию». Мы это пробовали. Помогает примерно как просьба «не думай о белом медведе».
Сработало другое — структурный guard. На уровне кода: goto в ту же стадию, где ты уже находишься, — это no-op с ошибкой self_loop. Не «модель попросили не делать», а «модель физически не может это сделать, движок просто не даст».
И это превратилось в главный принцип всего проекта:
Машинно-форсированный инвариант сильнее любого обещания в прозе.
Обещание в промпте — это надежда. Инвариант в коде — это гарантия. Дальше мы прогнали этот принцип ещё в двух местах:
-
Тест «torch не должен импортироваться». Голосовой gateway в продовом режиме работает на облаке и не должен тянуть
torch(это полтора гигабайта и куча боли с CUDA). Мы не «договорились этого не делать» — мы написали тестtest_importing_registry_does_not_import_torch, который красный, еслиtorchпросочился в импорты. Нельзя случайно сломать то, что охраняет тест. -
AST-проверка хардкода стадий. Линтер, который ругается, если кто-то захардкодил литерал имени стадии там, где должно быть data-driven.
И вся семья guard’ов диалога — это то же самое мышление, размноженное на разные способы сорвать разговор: off-topic (посторонние сообщения не загрязняют историю), no-progress (детектор застревания), farewell (про него ниже), re-ask (про пустой ввод — тоже ниже). Каждый guard — это не «модель, веди себя хорошо», а структурная стенка, об которую плохое поведение разбивается.
Самое приятное в структурном guard’е: когда мы позже переехали со стека на YandexGPT, этот инвариант не пришлось трогать вообще. Он про граф диалога, а не про конкретную модель. Меняешь мозг — стенки остаются на месте.
Кладбище живых багов
Каждый из них найден не тестом, а ухом на реальном звонке. Это, кажется, и есть главный урок про голос: юнит-тесты ловят логику, но не ловят «звучит как робот в колодце».
Все звонящие — один человек. Какое-то время агент был свято уверен, что ему звонит один и тот же клиент. Всегда. Я звоню, спрашиваю про стрижку — он помнит «прошлый» разговор, которого я не вёл. Звоню с другого номера — та же сессия, тот же контекст. Причина — одна строка: UUID звонка 11111111-1111-... был захардкожен в диалплане Asterisk. Один на всех. Все звонки сливались в одну бесконечную сессию «клиента, который никак не определится». Лечение — uuid4() на каждый звонок.
Бот думает, что я молчу, пока я говорю. Поставили Silero VAD (определяет речь/тишину). На звонке — я говорю в трубку, агент не реагирует:
vad_score=0.01 speech? novad_score=0.00 speech? novad_score=0.00 speech? no
Скоры около нуля при живой речи. Оказалось, Silero v5 требует префикс контекста в 64 сэмпла — без него VAD тупо не «разогревается» и скорит всё как тишину. Добавили префикс — ожило.
Длинные фразы обрывались на полуслове. Агент проговаривал начало ответа и замолкал. Я полчаса грешил на TTS. На деле — длина пакета в протоколе AudioSocket это uint16, то есть максимум 65535 байт на чанк. Длинный синтез просто не влезал. Лечение — резать TTS-аудио на куски ≤32000 байт.
Прощание с асимметричной ценой ошибки. Нужно понимать, когда человек закончил, и класть трубку. Но тут ошибки стоят по-разному. Ложное «не услышал прощание» (false-negative) ловится idle-таймаутом — подождали 20 секунд тишины, положили. А вот ложное «услышал прощание» (false-positive) обрывает живого звонящего навсегда — это катастрофа. Поэтому детект двухуровневый: безусловный (явное «до свидания/пока») плюс контекстный («спасибо, всё» — но только если задача реально закрыта). Фразу «не надо» из триггеров убрали вообще — слишком часто это было «не надо вот это, давайте другое», а не «до свидания».
Бота нельзя перебить — и это осознанно. Чтобы не было эха и наложений, голос работает в half-duplex: пока бот говорит, входящие фреймы отбрасываются. Цена — перебить агента нельзя, даже на приветствии. Мы это приняли осознанно: приветствие короткое, а альтернатива (полноценный barge-in с эхоподавлением) — это отдельный большой кусок, который мы отложили. Честный размен, а не недосмотр.
Большой разворот: как мы выкинули GPU
А теперь — про самую честную часть, потому что на Хабре её обычно прячут.
Изначально весь голосовой стек был локальным, на отдельной Windows-машине с GPU на 12 ГБ. faster-whisper large-v3 для распознавания, Silero и Piper для синтеза, XTTS для клонирования голоса. План был красивый: всё своё, ничего наружу, фирменный клонированный голос бренда.
Реальность по пунктам:
-
«Клон» никогда не был клоном. XTTS инициализировался с неверным id модели (
v2.0.3вместо реального пути), тихо падал и откатывался на Piper. То есть наш «фирменный клонированный голос» де-факто месяц был стандартным синтезатором, и мы этого не замечали. Мысль вслух: вот этот момент, когда понимаешь, что демонстрировал заказчику «клон», который никогда не включался, — отрезвляет лучше любого код-ревью. -
Silero на 8 кГц звучал грубо. Телефонная частота дискретизации + не самый удачный голос = «робот из колодца».
-
Холодный старт large-v3 — около 70 секунд. Если модель не прогрета, первый звонящий за день ловит тишину, пока она грузится.
-
Один STT-инстанс на GPU сериализует звонки. Для одного клиента ок, но это потолок, в который упираешься сразу же.
Сложив это, мы развернулись и переехали на облако: Yandex SpeechKit (STT + TTS) и YandexGPT в качестве мозга. Что мы на этом получили и чем заплатили — честно:
Получили: контур 152-ФЗ как факт, а не как обещание — данные не уходят из РФ. Ноль возни с GPU и CUDA на проде. Предсказуемость: нет холодного старта на 70 секунд, нет сериализации звонков на одной видеокарте. Минус целая машина из инфраструктуры. И неожиданный бонус — голос: синтез Yandex SpeechKit звучит заметно живее локального Silero на 8 кГц, тот самый «робот из колодца» исчез сам собой.
Заплатили: зависимостью от облака и латентностью сетевого вызова (об этом — следующий раздел). И отказом от идеи фирменного клон-голоса в ближайшей версии — хотя, как выяснилось, мы и так им не пользовались.
Месседж, который я бы хотел донести: мы сначала построили не то, посмотрели правде в глаза и сменили курс. Это не провал проекта — это нормальный инженерный цикл. Хуже было бы тащить мёртвый «клон» в прод, потому что его жалко выкидывать.
Латенси как системная задача
После переезда на облако замерили задержку на тёплом ходе диалога — около 3.5 секунды:
STT (Yandex REST) ~0.9 cLLM (YandexGPT) ~1.9 cTTS (Yandex REST) ~0.8 c
Проблема не в каком-то одном медленном куске. Проблема в том, что это всё происходит последовательно: дослушали реплику целиком → распознали целиком → подумали целиком → синтезировали целиком → начали говорить. Каждый этап ждёт полного завершения предыдущего.
Решение — сломать эту последовательность стримингом, и мы начали его подключать. Обе оптимизации идут под тумблером в админке, с откатом на старый последовательный путь:
-
gRPC-стриминг Yandex SpeechKit — распознаёт по ходу речи (partial + final) и обрабатывает аудио на лету, а не копит всю реплику целиком. Снимает почти весь этап накопления.
-
LLM-стриминг в TTS пайплайном — начинать озвучивать первое предложение, пока модель пишет второе. Первый звук уходит человеку гораздо раньше.
Тонкость, которая делает это нетривиальным: стримить можно только финальную текстовую реплику. Нельзя начинать озвучку, пока мозг ещё дёргает инструменты (goto, free_slots) — tool-раунды должны завершиться раньше, иначе мы озвучим то, что ещё не решено. То есть стриминг и tool-петля живут в одном ходе и не должны друг друга ломать.
Награда за всю строгость: вертикаль = конфиг
Теперь — зачем была вся эта дисциплина с guard’ами, инвариантами и чистыми слоями.
Помните, в начале я сказал, что brain.py не знает, барбершоп это или стоматология? Вот отдача.
Стоматологическая клиника как вертикаль отличается от барбершопа ровно одной строкой во флоу — текстом стадии перевода на оператора («оператором барбершопа» → «оператором клиники»). Все рёбра графа, все переходы, все инструменты — идентичны. Ноль изменений в brain.py, tools.py, flow_validator.py.
И это не «мы посмотрели и вроде совпадает» — это закреплено тестом:
def test_dental_flow_only_transfer_stage_differs(): # стоматология и барбершоп различаются # строго одной стадией, всё остальное идентично ...
Новую нишу заводишь данными — шаблон (flow + персона + база знаний + услуги), а не правкой движка. Применил шаблон — это барбершоп. Применил другой — это клиника. Кнопкой.

Поведение — в данных. Надёжность — в структуре. Движок — один. Вот ради чего всё это.
Где мы сейчас и что дальше
Честный статус, без приукрашивания. Начинали мы намеренно как single-tenant — под первого клиента, чтобы не утонуть в мультиарендности раньше времени. Но архитектура с самого начала была к ней готова: tenant_id заложен в модель данных, а вертикали — это данные, а не ветвления кода. В итоге мы дотащили систему до мультитенанта: несколько арендаторов, у каждого свои шаблоны и свой флоу. Тот самый принцип «вертикаль = конфиг» из предыдущего раздела и оказался фундаментом, на котором мультиарендность встала без переписывания движка. Текстовый продукт — рабочий end-to-end. Голос — функциональный прототип в шаге от демо-качества: цикл «звонок → распознавание → мозг → синтез» работает вживую, дальше — стриминг латенси и финальный выбор продового голоса.
Если коротко, три месяца голоса научили меня одной вещи. Качество голосового ИИ-агента — это не качество модели. Это сумма стенок, которые ты построил вокруг модели, чтобы её правдоподобная болтовня не превращалась в ложь, мёртвый эфир и оборванные звонки.
Если вы строите что-то похожее — или вам нужен голосовой агент, который не врёт про несуществующих администраторов, — пишите, обсудим.
ссылка на оригинал статьи https://habr.com/ru/articles/1053502/