- BrainTools - https://www.braintools.ru -
В понедельник в 13:24 человек позвонил в небольшую производственную компанию, не дождался ответа и попал на голосового бота. Поговорил 27 секунд и положил трубку. Через пару минут перезвонил и на этот раз договорил до конца: рассказал, что нужно и в какой город, бот попрощался, а владельцу компании улетела SMS с карточкой заявки.
Про эти два звонка я знаю всё. Какая модель отвечала и с какой версией промпта. Через сколько миллисекунд человек услышал первый звук. Сколько токенов сгорело и какая доля пришлась на кэш. Откуда взялся телефон (из сети, человек его не диктовал). Почему разговор завершился. И во сколько мне обошёлся каждый: 6,8 и 8,4 рубля.
Месяц назад я не знал о своих звонках почти ничего. После разговора оставалась расшифровка, и всё. Между этими двумя состояниями примерно неделя работы, три неприятных открытия и одна грабля, на которую хочу отдельно позвать питонистов.
У клиента один телефон на всё: заказы, поставщики, личное. Когда он за рулём или на объекте, звонки пропадают, а пропущенный звонок в малом бизнесе это обычно навсегда, перезванивают единицы. Договорились об эксперименте: если трубку не взяли, оператор переадресует вызов на отдельный номер, и там отвечает бот. Портить нечего: эти звонки и так были потеряны.
Внутри так. Телефония Voximplant принимает вызов и открывает вебсокет на мой сервер. Сервер сидит мостом между телефонией и Yandex Realtime (модель speech-realtime-250923), которая слушает и говорит сама, без промежуточного текста. Сам движок я выбирал раньше, писал об этом [1], тогда это было веб-демо на сайте. В конце звонка расшифровка уходит в Claude, тот собирает карточку заявки: имя, задача, размеры, город. Карточка уезжает владельцу по SMS, потому что телеграм у него через VPN и не всегда под рукой.
Первая версия телефонной части была другой: распознавание у телефонии, текст в Claude, синтез обратно у телефонии. На живом звонке эта цепочка выдала паузу 4,08 секунды после каждой реплики человека, из них 3,84 занимал мой же запрос к модели. Пауза в трубке ощущается совсем не так, как спиннер на сайте: на четвёртой секунде тишины человек решает, что связь умерла. С мостом первый звук идёт через полсекунды: медиана 554 мс, 95-й перцентиль 804 мс.
Бот работал, заявки приходили. А на вопрос «сколько стоит один звонок» я отвечал что-то вроде «рубля три-четыре, кажется». Пока это эксперимент, сойдёт. Но я собираюсь выставлять за это счёт, а без себестоимости не видно маржи, а клиенту хочется показывать не ощущения, а цифры. Сколько звонков перехвачено, сколько из них целевых, во что они обошлись.
Недавно на Хабре писали [2], что классический мониторинг агентские сбои не ловит: аптайм зелёный, задержки в норме, а агент третий день уверенно несёт чушь. Подпишусь под каждым словом. Но там речь о платформах наблюдаемости, OpenTelemetry, LLM-судьях. У меня один агент, десятки звонков в месяц и бюджет на всё это «нисколько». Получилась версия для бедных: те же идеи, только целиком в одном файле SQLite.
Теперь каждый звонок оставляет в журнале 27 полей. Самое полезное из них:
модель и версия промпта, чтобы любое «стало хуже» привязывалось к конкретному изменению;
время до первого звука;
токены: вход, выход, сколько пришло из кэша;
откуда телефон: определился из сети, продиктован голосом, не получен вовсе;
чем кончился разговор: попрощались, бросил трубку, обрыв, таймаут;
флаг тестового звонка;
вместо самого номера хэш с солью, чтобы видеть повторные звонки, не храня лишний раз персональные данные;
и стоимость по статьям: голос Яндекса, токены Яндекса, Claude за карточку, минуты телефонии, SMS.
Три решения выглядят занудством, но именно они потом окупились.
Тарифы взяты не из прайса, а из собственных списаний. Прайс телефонии говорит «$0.003 за минуту». Транзакции по API говорят точнее: минута входящего стоит 0,290447 рубля и списывается поминутно с округлением вверх, звонок на 61 секунду тарифицируется как две минуты. SMS: 1,5508 рубля за фрагмент, а длинная карточка заявки спокойно разъезжается на три фрагмента. Прайс таких деталей не расскажет, а вот из детализации счёта они достаются за один вечер.
Неизвестный тариф пишется как NULL с причиной, а не как ноль. Соблазн был: чего не знаем, считаем нулём, сумма всё равно получится. Получится, только врать будет увереннее: ноль выглядит как посчитанная величина, и дыры в ней не видно. У старой телефонной цепочки один компонент стоимости так и остался честным «неизвестно», и это видно в отчёте, а не размазано нулями по итогу.
Тестовые звонки помечаются флагом, а не удаляются. Признак: номер звонящего из списка тестовых, SMS клиенту при этом не уходит. Пока фильтра не было, мои прогоны сидели в статистике вперемешку с живыми звонками, и вычищать их задним числом пришлось руками.
Метрики отвечают на вопрос «что было». Ещё хочется знать, что будет, если завтра тронуть промпт или сменить модель. Для этого рядом с метриками живёт экзамен: 13 сценариев трудных звонков, у каждого проверяемый исход, заявка существует, телефон правильный, запись в журнале есть.
Билеты собраны не из головы, а из настоящих звонков: номер диктуют словами («девять один шесть, три два один…»), номер с нулями внутри, человек перебивает бота, молчит в трубку, бросает трубку сразу после номера, рекламный обзвон, обрыв связи с провайдером посреди фразы, разрыв сокета на середине звонка.
Режима два. Мок: вместо Яндекса и Claude отвечают заглушки, гоняется бесплатно и за секунды, проверяет мою логику [3]. Live: те же сценарии против настоящих нейросетей, стоит денег, зато проверяет систему целиком. На live снят базовый замер: во всех сценариях с телефоном номер записан верно, первый звук около полсекунды, потерянных заявок ноль. Теперь любая правка промпта сравнивается с этим замером, а не с «вроде не хуже стало».
Перед выкаткой мок показывал 13 из 13. Красиво, зелено, спокойно. Первый же живой прогон и первые дни настоящих метрик объяснили, чего этот покой стоил.
Сюрприз первый: главная цифра для клиента была завышена. Личные и рекламные звонки записывались в журнал как целевые. Мок этого не ловил, сценарии проверяли механику доставки заявки, а не разметку. Приди я через месяц к клиенту с «перехвачено N целевых звонков», N было бы враньём. Классификацию поправил, старые записи пересмотрел руками.
Сюрприз второй, обещанный питонистам. Запись журнала и метрик жила в finally вебсокет-обработчика, и перед ней стоял await. Отмена корутины доставляется именно в точках await, причём CancelledError наследует BaseException, так что дежурный except Exception её не ловит. Итог: при обрыве сокета в неудачный момент звонок мог исчезнуть из журнала целиком и молча. Самое коварное в другом. В проде uvicorn корутину при разрыве не отменяет, а тестовый клиент starlette отменяет. То есть баг может годами тихо жить в проде и выстрелить при смене рантайма, когда его никто не ждёт. Лечение скучное: всё критичное в finally пишется строго синхронно (да, SQLite на пару миллисекунд заблокирует event loop, это дешевле потерянного звонка), а хвост с await, вроде карточки от Claude, идёт после и по принципу «получится, хорошо». В экзамене теперь есть билет ровно про это: клиент рвёт сокет посреди звонка, запись обязана существовать.
Сюрприз третий тянет на отдельное правило, о нём ниже.
Номер звонящего мост знает ещё до снятия трубки, сеть отдаёт его сама. В промпте прямо написано: телефон известен, не спрашивай. Модель всё равно периодически просит продиктовать номер. Инструкция на месте, модель её читала, статистика берёт своё. Заявка не теряется, страховка отправляет её с номером из сети, но правило из этого я вынес такое: всё, что можно забрать у модели и отдать детерминированному коду, надо забрать и отдать.
Телефон из речи, например, разбирает обычный парсер, и его результат перебивает модель. Путь к этой скучной схеме был живописный. Сначала выяснилось, что Realtime присылает каждую реплику дважды, частичную и уточнённую: при склейке номер превращался в 15 цифр вместо 11. Потом парсер потерял ноль. Номер с двумя нулями в середине превращался из одиннадцати цифр в девять и выбрасывался как мусор, потому что накопитель цифр проверялся на истинность, а ноль в Python ложен. Одна строчка if cur, и любой номер с нулём внутри молча пропадал. Вишенка: инструмент record_lead, через который модель должна была сохранять заявку, она не вызвала ни разу. Зато однажды прочитала его имя вслух, человеку в трубку: «рекорд подчёркивание лид». Инструмент я убрал совсем.
На эту граблю я наступил ещё до всякого счётчика, но она из той же серии. Выбирал модель для телефонного диалога и гонял бенчмарк: диктуешь номер голосом, смотришь, появилась ли заявка. Бенчмарк показывал 4 из 4 на обеих моделях-кандидатах. Потом я открыл сами заявки. Дешёвая модель записала продиктованный номер верно в 3 случаях из 10, в остальных дублировала цифры: из десяти продиктованных в заявке оказывалось тринадцать. Бенчмарк проверял наличие телефона, bool(phone), а не правильность. Упасть он не мог в принципе.
Хуже цифр было другое: вслух модель повторяла номер правильно. Звонящий слышит корректное подтверждение и вешает трубку спокойным, а в заявке лежит номер, по которому никогда не дозвонятся. Снаружи такая потеря не видна вообще никак, пока не сверишь заявку с расшифровкой. С тех пор про каждую проверку я первым делом спрашиваю: а умеет ли она краснеть? Проверка, которая не может упасть, хуже отсутствия проверки, потому что успокаивает.
Теперь цифры из счётчика, ради которых всё затевалось.
Целевой звонок с заявкой обходится в 6,8-8,4 рубля. Броски по 2-8 секунд (обзвонщики, ошиблись номером) копеечные. Контрольный живой звонок против стендового: 5,39 против 3,66 рубля, живой почти в полтора раза дороже. Настоящие разговоры длиннее сценарных: люди отвлекаются, переспрашивают, думают вслух.
Две трети стоимости это не голос, а токены. Realtime прогоняет весь промпт заново при каждом ответе бота: в счёте Яндекса за месяц токены заняли 66%, аудио 23%. Спасает кэш, попадание около 80%, без него вышло бы втрое дороже. Отсюда следствие, которое я не угадал бы заранее: длина системного промпта влияет на счёт голосового агента сильнее, чем длительность разговоров.
Для масштаба, день самых плотных тестов: 24 звонка, 14 минут разговора, 137,64 рубля Яндексу и 7,30 рубля телефонии. Минута мозга [4] стоит примерно в двадцать раз дороже минуты трубы. А SMS с карточкой (1,55 рубля за фрагмент, длинная карточка тянет на три) выходит сравнимой с коротким звонком.
Есть у Яндекса и модель поновее, звонок на ней стоит 1,55 рубля против 3,66 на одинаковых сценариях. Проверил: инструкции она держит хуже, просит телефон, который уже знает, повторяет вопросы, пропускает шаги. Экономии на моих объёмах рублей шестьдесят в месяц. Оставил дорогую.
Если пересчитать на заявку с учётом аренды номера, получается расчётная вилка: при 30 звонках в месяц заявка выходит около 59 рублей, при 100 уже около 28. Номер стоит одинаково при любой нагрузке, поэтому звонок с ростом дешевеет.
Напоследок я проверил сам счётчик: сверил его с детализацией в консоли Яндекса. За два дня, когда с Яндексом разговаривал только телефонный бот, журнал насчитал 10,80 и 8,10 рубля, биллинг показал 10,86 и 8,21. Расхождение 6 и 11 копеек, порядка процента. Откуда именно набегают копейки, пока не раскопал, подозреваю округления на стороне биллинга. Жить с этим можно: решения, которые я принимаю по этим цифрам, от копеек не зависят.
Автор недавней статьи про ИИ-агента на прод-логах [5] рассказывал в комментариях, что у него себестоимость прогона около нуля: подписка с фиксированным лимитом плюс кэш префикса. Путь рабочий, и для внутренней задачи я бы выбрал его же. Но когда услугу перепродаёшь, счётчик по API честнее подписки: видно, из чего складывается цена, где в ней запас и что случится с маржой, если звонков станет втрое больше.
Весь этот код писался с перекрёстным ревью: пишет одна модель, проверяет другая. Выкатку заворачивали два раза, и оба по делу: среди находок было то, что метрики пишет только одна из двух веток кода, и та самая финализация в finally. На третий заход ревью дало добро, деплой прошёл с бэкапом базы и смоук-звонком.
А через два дня случился тот самый понедельник из начала статьи: два живых целевых звонка, обе заявки доехали до клиента, у обеих правильный телефон из сети и полный ценник в журнале.
Главное, что поменялось, не в рублях. Через месяц у меня разговор с клиентом о том, продолжаем ли платно. Раньше я пришёл бы на него с «кажется, полезно». Теперь приду с журналом: столько перехвачено, столько из них целевых, вот себестоимость, вот что бот сказал в каждом звонке. Соглашаться или нет, решать клиенту, но решать он будет по цифрам, а не по моим ощущениям.
Я делаю сайты, рекламу и ИИ-агентов для малого бизнеса под брендом «Первый ИИ». Такие разборы по мере набивания шишек выкладываю в телеграм-канале @pervyyii [6].
Автор: eignatiev
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/33969
URLs in this post:
[1] писал об этом: https://habr.com/ru/articles/1057176/
[2] писали: https://habr.com/ru/companies/cloud_ru/articles/1063380/
[3] логику: http://www.braintools.ru/article/7640
[4] мозга: http://www.braintools.ru/parts-of-the-brain
[5] про ИИ-агента на прод-логах: https://habr.com/ru/articles/1065610/
[6] @pervyyii: https://t.me/pervyyii
[7] Источник: https://habr.com/ru/articles/1066792/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1066792
Нажмите здесь для печати.