Что случилось: два лимита, которые не работали с самого запуска
В Octery, боте, который отвечает лидам в чатах amoCRM и передаёт сделку менеджеру, не сработал ни один из двух лимитов на длину диалога. 30 сентября команда обнаружила, что не работает ни Max Messages, ни Hard Stop Threshold. По оценке автора разбора, они не работали с самого запуска (разбор кейса на Habr).
Max Messages задаёт, сколько ответов бот даёт, прежде чем позвать живого специалиста. Hard Stop Threshold ограничивает серию бессмысленных сообщений подряд: «ааа», «тест», «???» или второй бот на той стороне канала. Обе настройки есть в админке у каждой компании и ведут к одному действию: передать диалог менеджеру.
Причин две, и они прятали друг друга. В условии передачи стояло И вместо ИЛИ, поэтому бот звал менеджера только при одновременном исчерпании обоих лимитов. Ветка передачи переписывала ответ на «передаю менеджеру» и меняла намерение диалога на manager, но записывала результат в поля, у которых вместо имён стояли выражения. Отправка ответа и логирование при этом читали данные из более раннего разбора ответа модели, а не из ветки передачи.
Починили так: решение вынесли в отдельную чистую функцию в API. Она получает ответ модели и два счётчика из состояния диалога, сама достаёт лимиты из настроек компании и возвращает одно итоговое решение. Логику покрыли 16 тестами по сценариям из спецификации. Дальше разбираем механику обеих ошибок, почему они не всплывали, что даёт чистая функция и какие грабли ждут на выкате.
Как устроены лимиты в Octery: Max Messages и Hard Stop Threshold
Max Messages считает ответы бота. Клиент задал уже двадцатый вопрос, значит по замыслу ему пора к живому специалисту: бот своё дело сделал, дальше разговор тянет время и портит конверсию.
Hard Stop Threshold считает сообщения, на которые отвечать бессмысленно. Каждое такое сообщение стоит вызова модели, и упираться в них бесконечно незачем: после заданной серии подряд диалог тоже уходит менеджеру. Разница между настройками в том, что одна считает накопленное за диалог, а другая реагирует на локальный всплеск.
Решение принималось после ответа модели, в пайплайне обработки сообщения. Условие собиралось из трёх проверок, соединённых между собой.
- Клиент пишет сообщение в чат amoCRM.
- Модель генерирует ответ.
- Пайплайн разбирает ответ модели на поля: текст, намерение и служебные данные.
- Отдельная ветка проверяет лимиты и решает, передавать ли диалог менеджеру.
- Отправка ответа в чат и логирование.
Снаружи всё выглядело рабочим: настройка сохранялась, в интерфейсе стояли цифры, бот отвечал как обычно. Именно поэтому никто не замечал проблемы месяцами. Логи ошибок не содержали, потому что ошибки не было с точки зрения платформы: ветка просто не запускалась.
Ошибка первая: И вместо ИЛИ в условии передачи
Условие передачи собрали из трёх проверок через И. Логика получилась такой: бот зовёт менеджера, только когда клиент исчерпал лимит сообщений и одновременно последние несколько раз подряд писал ерунду. Такого на практике не случается. Один из двух сценариев гасит другой: клиент, который задаёт много осмысленных вопросов, никогда не наберёт серию бессмысленных сообщений, а тот, кто пишет «???» и «тест», редко доходит до лимита ответов.
// иллюстрация механики, не дословный код проекта
// было: передача только при выполнении всех условий сразу
handoff = (replies_count >= max_messages)
&& (nonsense_streak >= hard_stop)
// третья проверка в источнике не раскрыта
// стало: достаточно любого из лимитов
handoff = (replies_count >= max_messages)
|| (nonsense_streak >= hard_stop)
Цена такой замены символа огромна: с И оба лимита фактически превращаются в один, причём с крайне редким триггером. С ИЛИ каждая настройка работает независимо и делает то, что обещает в интерфейсе.
Эта ошибка сама по себе обнаружилась бы сразу. Достаточно в тестовом диалоге дойти до лимита и увидеть, что бот продолжает отвечать, хотя должен был позвать человека.
Ошибка вторая: результат писался в поля с выражениями вместо имён
Ветка передачи делала две вещи: подменяла ответ на фразу «передаю менеджеру» и меняла намерение диалога на manager. Но записывала она это в поля, у которых вместо имён стояли выражения. Значение вставили в поле имени, и оно там осталось лежать.
Само по себе это уже поломка, но фатальной её сделала вторая половина проблемы. Следующие шаги, отправка ответа и логирование, читали ответ и намерение не из ветки передачи, а напрямую из разбора ответа модели, который выполнялся раньше. Даже если бы кто-то исправил И на ИЛИ, клиент получил бы обычный ответ бота, а в логах не осталось бы следа о срабатывании лимита.
Аналогия: значение записали в переменную, но имя переменной собрали выражением, и обращаться к ней по ожидаемому имени стало нечем. Читатель ниже по потоку спокойно берёт старое значение и не подозревает, что рядом кто-то пытался записать новое.
Проверка «в чате появилась фраза про менеджера» тоже ничего не показала бы: фраза лежала в поле, которое никто не читал.
Почему две ошибки по отдельности нашлись бы сразу, а вместе - нет
Каждую из ошибок по отдельности нашёл бы первый же ручной прогон до лимита. Вместе они делали лимиты невидимыми: настройка сохранялась, в интерфейсе стояли цифры, бот работал как обычно.
| Что сломано | Что увидел бы ручной прогон |
|---|---|
| Только И вместо ИЛИ | Дошли до лимита, передача не срабатывает, бот отвечает дальше. Причина на виду. |
| Только запись в поля с выражениями | Передача срабатывает, но клиент получает обычный текст, а в логах нет ни слова про менеджера. Расхождение на виду. |
| Обе ошибки вместе | Условие не выполняется, ветка не запускается, расхождение не возникает. Симптомов ноль, вопросов ноль. |
Вторая ошибка просто ни разу не получила шанса проявиться: до неё не доходило исполнение. Поэтому и «исправление» одной строки в условии не дало бы эффекта, и это лучший маркер того, что копать нужно глубже.
Проверка требовала дойти в живом диалоге до лимита и посмотреть, что будет. Доводить тестовый диалог до нужного счётчика руками долго, и при обычных проверках до него не доходили.
Корень проблемы: логика жила в конфигурации пайплайна, а не в коде
Логика лимитов жила в конфигурации пайплайна. У такого места есть предсказуемый набор ограничений:
- юнит-тестов на конфигурацию не напишешь;
- в CI её не прогнать, поэтому регресс ловится только руками;
- на ревью изменение выглядит как «поправил условие», без диффа, который можно прочитать и обсудить;
- проверить результат можно единственным способом: дойти в живом диалоге до лимита.
Проблема не в конкретном человеке, который поставил И вместо ИЛИ. У логики не было исполняемого контракта и автоматической проверки. Конфигурация хранит состояние, но не описывает поведение так, чтобы его можно было проверить без запуска продукта.
Показательно, что снаружи всё сходилось: цифры в админке на месте, сохранение работает, бот отвечает. Разрыв между «настройка есть» и «настройка действует» не виден ни в интерфейсе, ни в логах, если логи не пишут причину решения.
Решение: чистая функция API, которая сама берёт лимиты из настроек компании
Решение по сообщению переехало в API отдельной чистой функцией. Контракт такой:
- на входе: ответ модели и два счётчика из состояния диалога;
- внутри: функция сама достаёт лимиты из настроек компании;
- на выходе: одно итоговое решение о передаче менеджеру.
Лимиты берутся внутри намеренно: тот, кто вызывает функцию, не может подсунуть ей другие значения. Иначе через полгода найдётся второй вызывающий, который передаст свои цифры, и логика снова начнёт расходиться с настройками в админке.
Чистая функция даёт три практических эффекта. Поведение детерминировано: одинаковые входные данные всегда дают одинаковый результат, поэтому баг воспроизводится без живого диалога. Нет побочных эффектов: функцию можно гонять пачками в тестах, не поднимая чат и не вызывая модель. Появляется единая точка решения: обе исходные ошибки закрываются по построению, потому что проверки больше не разбросаны по веткам конфигурации, а имена полей заданы кодом, а не выражением.
Ограничение честное: функция решает только вопрос передачи менеджеру. Остальная часть пайплайна, генерация ответа, разбор, отправка и логирование, осталась прежней. Перенос одной ветки не чинит архитектуру целиком, он убирает конкретный класс ошибок в конкретном месте.
Формулы с двумя ловушками на единицу в счётчиках
В опубликованном фрагменте разбора конкретные формулы не приведены, поэтому ниже общие правила, которые стоит зафиксировать в спецификации до написания кода. Счётчики почти всегда ломаются на границе, а не в середине.
Ловушка первая: момент инкремента относительно проверки. Пусть Max Messages = 3. Бот даёт три ответа и передаёт диалог на четвёртом сообщении клиента или передаёт сразу после третьего ответа? Оба варианта рабочие, но выбран должен быть ровно один и записан словами. Если счётчик увеличивается до проверки, сравнение replies >= max сработает на третьем ответе. Если после, тот же код сработает на четвёртом. Одна и та же формула даёт разное поведение в зависимости от порядка двух строк.
Ловушка вторая: строгое или нестрогое сравнение плюс правило сброса серии. Для Hard Stop Threshold мало выбрать streak > hard_stop или streak >= hard_stop. Нужно решить, входит ли текущее сообщение в серию и что считается осмысленным сообщением, обнуляющим счётчик. Если сброс не описан, серия «ерунда, ерунда, нормальный вопрос, ерунда» будет считаться по-разному в двух реализациях одной и той же идеи.
Обе ловушки закрываются тестами на границах: значение на единицу меньше лимита, ровно на лимите, на единицу больше. Проверять «на глаз» бессмысленно, потому что разница в поведении ровно в один ответ и есть предмет спора.
16 тестов по сценариям из спецификации: что именно покрыли
Тесты писались по сценариям из спецификации, а не по коду. Разница принципиальная: тест, восстановленный из реализации, подтверждает, что код делает то, что делает, а тест из спецификации проверяет, что код делает то, что обещано клиенту.
Набор такого вида должен покрывать как минимум следующие группы сценариев:
- диалог ниже обоих лимитов, передачи нет;
- значение ровно на границе Max Messages;
- значение ровно на границе Hard Stop Threshold;
- обе границы достигаются одновременно;
- серия бессмысленных сообщений прерывается осмысленным и счётчик сбрасывается;
- у компании не заданы настройки, поведение по умолчанию определено.
Отдельно сохранили тест на старый AND-случай. Он фиксирует, что комбинация «исчерпан лимит сообщений и одновременно идёт серия бессмысленных» больше не считается единственным триггером передачи. Такой тест выглядит избыточным ровно до момента, когда кто-то снова «оптимизирует» условие и вернёт прежнюю логику. Тогда он падает первым и объясняет, почему так делать нельзя.
Приём с тестом, который падает на старом коде, разбирается и в другом материале AI-Manual: как 30 зелёных тестов ничего не доказывали и почему тест, падающий на старой версии, меняет приёмку результата. Логика та же: тест ценен, когда он способен отличить правильное поведение от неправильного, а не когда он просто зелёный.
Прогон в CI закрывает то, чего не хватало изначально. Конфигурацию пайплайна в CI не проверишь, а функцию на TypeScript или любом другом языке с тестовым раннером проверишь за секунды на каждом пуше.
Риск выката: компании с маленькими лимитами впервые увидят передачу менеджеру
После исправления компании с маленькими лимитами впервые увидят передачу менеджеру. Для них это выглядит как новая функция: раньше бот отвечал бесконечно, теперь на третьем сообщении зовёт человека. Формально ничего не добавили, только починили, но пользователь оценивает продукт по поведению, а не по changelog.
Что стоит сделать до релиза:
- посмотреть, какие значения Max Messages и Hard Stop Threshold реально стоят у компаний: маленькие цифры дадут самый заметный сдвиг поведения;
- предупредить клиентов заранее и объяснить, что настройки начали действовать, а не появились;
- дать возможность пересмотреть значения перед включением, чтобы никто не получил сюрприз в разгар рекламной кампании;
- подготовить ответ поддержки на типовой вопрос «почему бот стал передавать диалоги».
Это типичный риск при починке давно сломанной логики: чем дольше баг живёт, тем сильнее фактическое поведение воспринимается как норма и тем дороже стоит его изменение.
Что забрать в свою автоматизацию: короткие выводы
Чек-лист, который переносится на любой чат-бот с лимитами:
- решения о передаче, эскалации и блокировке держать в коде, а не в конфигурации;
- оформить их чистой функцией с явным контрактом: вход, выход, отсутствие побочных эффектов;
- настройки читать внутри функции, чтобы вызывающий не мог подменить лимиты;
- писать тесты по спецификации, а не по реализации;
- обязательно покрывать границы счётчиков и порядок инкремента;
- сохранять тест на старый багованный случай как защиту от возврата логики;
- прогонять набор в CI на каждом изменении.
Полезно заранее определить, какой компонент вообще способен вызвать внешнее последствие и что стоит непосредственно перед этим действием. Похожий разбор на границе исполнения есть в материале про агента с правами read-only, который всё равно изменил CRM: там тоже помогала единая детерминированная точка принятия решения.
Ограничение тоже стоит держать в голове. Перенос логики в код занимает время, требует нормального контракта и меняет архитектуру. Для бота из двух условий это может показаться избыточным. Как только в логике появляются лимиты, счётчики и несколько режимов, цена отсутствия тестов становится выше цены рефакторинга. Практический первый шаг: выпишите все места, где бот решает передать диалог человеку, и проверьте, что каждое из них покрыто тестом на границе.