В понедельник в 13:24 человек позвонил в небольшую производственную компанию, не дождался ответа и попал на голосового бота. Поговорил 27 секунд и положил трубку. Через пару минут перезвонил и на этот раз договорил до конца: рассказал, что нужно и в какой город, бот попрощался, а владельцу компании улетела SMS с карточкой заявки.
Про эти два звонка я знаю всё. Какая модель отвечала и с какой версией промпта. Через сколько миллисекунд человек услышал первый звук. Сколько токенов сгорело и какая доля пришлась на кэш. Откуда взялся телефон (из сети, человек его не диктовал). Почему разговор завершился. И во сколько мне обошёлся каждый: 6,8 и 8,4 рубля.
Месяц назад я не знал о своих звонках почти ничего. После разговора оставалась расшифровка, и всё. Между этими двумя состояниями примерно неделя работы, три неприятных открытия и одна грабля, на которую хочу отдельно позвать питонистов.
Откуда тут вообще бот
У клиента один телефон на всё: заказы, поставщики, личное. Когда он за рулём или на объекте, звонки пропадают, а пропущенный звонок в малом бизнесе это обычно навсегда, перезванивают единицы. Договорились об эксперименте: если трубку не взяли, оператор переадресует вызов на отдельный номер, и там отвечает бот. Портить нечего: эти звонки и так были потеряны.
Внутри так. Телефония Voximplant принимает вызов и открывает вебсокет на мой сервер. Сервер сидит мостом между телефонией и Yandex Realtime (модель speech-realtime-250923), которая слушает и говорит сама, без промежуточного текста. Сам движок я выбирал раньше, писал об этом, тогда это было веб-демо на сайте. В конце звонка расшифровка уходит в Claude, тот собирает карточку заявки: имя, задача, размеры, город. Карточка уезжает владельцу по SMS, потому что телеграм у него через VPN и не всегда под рукой.
Первая версия телефонной части была другой: распознавание у телефонии, текст в Claude, синтез обратно у телефонии. На живом звонке эта цепочка выдала паузу 4,08 секунды после каждой реплики человека, из них 3,84 занимал мой же запрос к модели. Пауза в трубке ощущается совсем не так, как спиннер на сайте: на четвёртой секунде тишины человек решает, что связь умерла. С мостом первый звук идёт через полсекунды: медиана 554 мс, 95-й перцентиль 804 мс.
«Рубля три-четыре, наверное»
Бот работал, заявки приходили. А на вопрос «сколько стоит один звонок» я отвечал что-то вроде «рубля три-четыре, кажется». Пока это эксперимент, сойдёт. Но я собираюсь выставлять за это счёт, а без себестоимости не видно маржи, а клиенту хочется показывать не ощущения, а цифры. Сколько звонков перехвачено, сколько из них целевых, во что они обошлись.
Недавно на Хабре писали, что классический мониторинг агентские сбои не ловит: аптайм зелёный, задержки в норме, а агент третий день уверенно несёт чушь. Подпишусь под каждым словом. Но там речь о платформах наблюдаемости, OpenTelemetry, LLM-судьях. У меня один агент, десятки звонков в месяц и бюджет на всё это «нисколько». Получилась версия для бедных: те же идеи, только целиком в одном файле SQLite.
Техпаспорт звонка
Теперь каждый звонок оставляет в журнале 27 полей. Самое полезное из них:
-
модель и версия промпта, чтобы любое «стало хуже» привязывалось к конкретному изменению;
-
время до первого звука;
-
токены: вход, выход, сколько пришло из кэша;
-
откуда телефон: определился из сети, продиктован голосом, не получен вовсе;
-
чем кончился разговор: попрощались, бросил трубку, обрыв, таймаут;
-
флаг тестового звонка;
-
вместо самого номера хэш с солью, чтобы видеть повторные звонки, не храня лишний раз персональные данные;
-
и стоимость по статьям: голос Яндекса, токены Яндекса, Claude за карточку, минуты телефонии, SMS.
Три решения выглядят занудством, но именно они потом окупились.
Тарифы взяты не из прайса, а из собственных списаний. Прайс телефонии говорит «$0.003 за минуту». Транзакции по API говорят точнее: минута входящего стоит 0,290447 рубля и списывается поминутно с округлением вверх, звонок на 61 секунду тарифицируется как две минуты. SMS: 1,5508 рубля за фрагмент, а длинная карточка заявки спокойно разъезжается на три фрагмента. Прайс таких деталей не расскажет, а вот из детализации счёта они достаются за один вечер.
Неизвестный тариф пишется как NULL с причиной, а не как ноль. Соблазн был: чего не знаем, считаем нулём, сумма всё равно получится. Получится, только врать будет увереннее: ноль выглядит как посчитанная величина, и дыры в ней не видно. У старой телефонной цепочки один компонент стоимости так и остался честным «неизвестно», и это видно в отчёте, а не размазано нулями по итогу.
Тестовые звонки помечаются флагом, а не удаляются. Признак: номер звонящего из списка тестовых, SMS клиенту при этом не уходит. Пока фильтра не было, мои прогоны сидели в статистике вперемешку с живыми звонками, и вычищать их задним числом пришлось руками.
Экзамен из 13 билетов
Метрики отвечают на вопрос «что было». Ещё хочется знать, что будет, если завтра тронуть промпт или сменить модель. Для этого рядом с метриками живёт экзамен: 13 сценариев трудных звонков, у каждого проверяемый исход, заявка существует, телефон правильный, запись в журнале есть.
Билеты собраны не из головы, а из настоящих звонков: номер диктуют словами («девять один шесть, три два один…»), номер с нулями внутри, человек перебивает бота, молчит в трубку, бросает трубку сразу после номера, рекламный обзвон, обрыв связи с провайдером посреди фразы, разрыв сокета на середине звонка.
Режима два. Мок: вместо Яндекса и Claude отвечают заглушки, гоняется бесплатно и за секунды, проверяет мою логику. 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 рубля телефонии. Минута мозга стоит примерно в двадцать раз дороже минуты трубы. А SMS с карточкой (1,55 рубля за фрагмент, длинная карточка тянет на три) выходит сравнимой с коротким звонком.
Есть у Яндекса и модель поновее, звонок на ней стоит 1,55 рубля против 3,66 на одинаковых сценариях. Проверил: инструкции она держит хуже, просит телефон, который уже знает, повторяет вопросы, пропускает шаги. Экономии на моих объёмах рублей шестьдесят в месяц. Оставил дорогую.
Если пересчитать на заявку с учётом аренды номера, получается расчётная вилка: при 30 звонках в месяц заявка выходит около 59 рублей, при 100 уже около 28. Номер стоит одинаково при любой нагрузке, поэтому звонок с ростом дешевеет.
Напоследок я проверил сам счётчик: сверил его с детализацией в консоли Яндекса. За два дня, когда с Яндексом разговаривал только телефонный бот, журнал насчитал 10,80 и 8,10 рубля, биллинг показал 10,86 и 8,21. Расхождение 6 и 11 копеек, порядка процента. Откуда именно набегают копейки, пока не раскопал, подозреваю округления на стороне биллинга. Жить с этим можно: решения, которые я принимаю по этим цифрам, от копеек не зависят.
Автор недавней статьи про ИИ-агента на прод-логах рассказывал в комментариях, что у него себестоимость прогона около нуля: подписка с фиксированным лимитом плюс кэш префикса. Путь рабочий, и для внутренней задачи я бы выбрал его же. Но когда услугу перепродаёшь, счётчик по API честнее подписки: видно, из чего складывается цена, где в ней запас и что случится с маржой, если звонков станет втрое больше.
Что в итоге
Весь этот код писался с перекрёстным ревью: пишет одна модель, проверяет другая. Выкатку заворачивали два раза, и оба по делу: среди находок было то, что метрики пишет только одна из двух веток кода, и та самая финализация в finally. На третий заход ревью дало добро, деплой прошёл с бэкапом базы и смоук-звонком.
А через два дня случился тот самый понедельник из начала статьи: два живых целевых звонка, обе заявки доехали до клиента, у обеих правильный телефон из сети и полный ценник в журнале.
Главное, что поменялось, не в рублях. Через месяц у меня разговор с клиентом о том, продолжаем ли платно. Раньше я пришёл бы на него с «кажется, полезно». Теперь приду с журналом: столько перехвачено, столько из них целевых, вот себестоимость, вот что бот сказал в каждом звонке. Соглашаться или нет, решать клиенту, но решать он будет по цифрам, а не по моим ощущениям.
Я делаю сайты, рекламу и ИИ-агентов для малого бизнеса под брендом «Первый ИИ». Такие разборы по мере набивания шишек выкладываю в телеграм-канале @pervyyii.
Автор: eignatiev


