О проекте
Клиент — сеть клубов единоборств в Петербурге: 6 филиалов, 8 направлений (бокс, ММА, самбо, дзюдо, кикбоксинг, тайский бокс, каратэ, самооборона), взрослые и детские группы. Вход для нового клиента всегда один — мессенджер. До нас заявки обрабатывали администраторы руками: отвечали в Telegram и VK, вели учёт в Google Sheets, перебивали лидов в CRM. На пиках (вечер, выходные) отклик уходил за 30–60 минут, часть клиентов не дожидалась.
Задача — сделать бота, который ведёт клиента от первого сообщения до записи на пробное занятие: выясняет, что человек хочет, куда ему удобно ехать, когда он может прийти, и сам записывает в CRM. Без оператора. Круглосуточно.
Сейчас этот бот в продакшене, ведёт сотни диалогов в неделю. Расскажу, как он устроен внутри, с решениями, которые мы принимали и переписывали.
Почему не «просто LLM с промптом»
Первое, что обычно пробуют в похожих задачах, — один большой системный промпт, в котором описан весь сценарий, и дальше модель сама ведёт разговор. В такой постановке это не работает по трём причинам:
- Каскадные ошибки. На первых ходах максимум неоднозначности («мне бы что-то поспокойнее» — это про уровень? про возраст? про мини-группу?), и одна ошибка разбора ломает все последующие шаги.
- Галлюцинации про расписание и тренеров. Клуб — это конкретные филиалы, конкретные группы в конкретные дни, конкретные тренеры. Модель, видя общий контекст, начинает придумывать и тренеров, и время занятий.
- Интеграции. CRM, Google Sheets и пост-аналитика требуют строгих полей в строгих форматах: имя, возраст, опыт, филиал, направление, дата пробного, канал подтверждения. Никакой «свободный чат» этого не даст.
Решение — машина состояний из 13 шагов, в которой каждый шаг имеет свою инструкцию и свою цель. LLM используется как «умный слой» внутри шага, но порядок и переходы между шагами управляются кодом.
13 шагов: от «здрасте» до «жду вас в среду»

| # | Шаг | Что собираем | Модель |
|---|---|---|---|
| 1 | Направление | бокс, ММА, самбо… | Haiku |
| 2 | Аудитория | для себя / для ребёнка | Haiku |
| 3 | Имя | имя клиента | Haiku |
| 4 | Возраст и опыт | обязательно оба поля | Haiku |
| 5 | Перерыв | длительность у опытных | Haiku |
| 6 | Филиал | 1 из 6 | Haiku |
| 7 | Презентация программы | нарратив под клиента | Sonnet |
| 8 | Формат группы | мини 30+ или общая | Sonnet |
| 9 | Даты пробного | две ближайшие даты | Sonnet |
| 10 | ФИО для записи | фамилия и отчество | Haiku |
| 11 | Подтверждение записи | финальная сверка | Sonnet |
| 12 | Канал подтверждения | телефон / WhatsApp / Telegram | Haiku |
| 13 | Финализация | адрес, резюме, прощание | Sonnet |
Плюс два особых режима. Быстрый трек: клиент сразу оставил телефон и сказал «запишите», без выяснения направления. Свободный чат: человек задаёт вопрос вне воронки — цены, парковка, раздевалки.
Haiku или Sonnet — это не просто «дешевле или дороже»
Выбор модели по шагу — одно из решений, которые дали больше всего экономики. Первая версия крутилась целиком на Sonnet. После месяца работы посмотрели статистику ошибок по шагам и увидели: на простых квалификационных шагах (имя, филиал, направление) Haiku 4.5 промахивается не чаще Sonnet, а стоит в десять раз дешевле. На сложных шагах — презентация программы, предложение дат, финализация — Sonnet по-прежнему нужен: там важен тон, склонение, нюансы.
Отдельное правило: на ходах 1–2 диалога разбор сообщения всегда идёт через Sonnet. Даже если шаг сам по себе простой, на первых двух сообщениях максимум неоднозначности, и экономия $0,004 за диалог не окупает каскадную ошибку.
Стоимость одного диалога (среднее по логам, ₽)
Только Sonnet ███████████████████████ 2.81 ₽
Haiku + Sonnet по шагам █████ 0.57 ₽
Только Haiku (эксперимент) ██ 0.22 ₽ (больно по качеству)
RAG: почему в той же БД, а не в отдельном векторном сервисе

Векторный индекс — рядом с остальной БД: клиенты, филиалы, расписание, цены. Причины стандартные: единое хранилище, привычный бэкап, одна точка отказа вместо двух. Эмбеддинги — стандартные, ничего экзотического; для русскоязычного контента в этом домене полнота достаточная, а стоимость копеечная.
База знаний разбита на категории: расписание, цены, тренеры, FAQ, общие описания, скрипты, сценарии. Каждый чанк несёт филиал, направление (если применимо) и категорию. На этапе поиска это критично — фильтры по филиалу и направлению закрывают большую часть галлюцинаций.
Категорийные пороги похожести — незаметный, но критичный трюк
Одинаковый порог похожести >= 0,30 для всех категорий давал две противоположные проблемы. В расписании и ценах проскакивал нерелевантный шум: человек спрашивает про цены, получает кусок FAQ про «как добраться». В FAQ и общих описаниях наоборот — система резала валидные ответы, потому что описательный текст не всегда ловит 0,30.
Развели на категорийные пороги:
| Категория | Порог | Почему |
|---|---|---|
| Расписание, цены | 0,35 | Структурированные данные, лучше молчать, чем ошибиться |
| Тренеры | 0,32 | Имена и факты, нужна точность |
| FAQ, общие описания | 0,27 | Описательный контент, допустимо больше вариаций |
| Остальное | 0,30 | По умолчанию |
Плюс размер выдачи в чанках тоже зависит от шага. На простых квалификационных в контекст попадает минимум. На презентации программы и свободном чате — больше.
Галлюцинации, которые мы лечили
Отдельная глава — борьба с тем, что LLM по своей природе любит «помогать». В этом домене это проявлялось в двух болезненных формах.
Галлюцинация тренеров
Клиент спрашивает про группу в одном филиале. В выдачу попадают описания тренеров соседнего филиала и общие профили. Модель радостно отвечает: «занятие проведёт тренер N». Проблема — тренер N в этой группе не работает. Или вообще не работает в клубе.
Лечение оказалось нетривиальным. Сначала добавили фильтр по филиалу при поиске — не помогло: модель галлюцинировала имена, которых в контексте вообще не было, просто из обучающих данных. Решение — ограничивающая инструкция в промпте: «называй тренера, только если он явно привязан к конкретной группе в графике этого филиала; иначе не называй никого». Один из редких случаев, когда инструкция в промпте действительно работает — потому что она запрещающая, а не порождающая.
Расписание на шаге презентации
На шаге презентации программы бот должен рассказать про программу, но не показывать расписание — расписание выдаётся только на следующем шаге, когда клиент готов выбрать дату. В первой версии бот периодически вываливал расписание раньше времени. Разобрались: инструкция «не показывай расписание» не удаляла соответствующие чанки из контекста, и модель, видя их там, всё равно их выносила.
Починили на уровне поиска: на шаге презентации чанки расписания исключаются из контекста явно, до передачи в модель. Промпт-инструкция — это последняя линия обороны, не первая.
Пустое расписание интерпретируется как «вдруг есть»
Ещё одна история — когда расписание для комбинации «филиал + направление + аудитория» не найдено вообще. Пустой массив плюс чанки расписания в контексте приводили к тому, что модель отвечала «вот есть похожие варианты», вместо «к сожалению, по этому направлению в этом филиале взрослых групп нет». Починили отдельным флагом: если запрос к базе вернул пусто, чанки расписания тоже выкидываются, модель получает чистый сигнал «не найдено».
Мютекс на разговор: защита от гонки
Частый сценарий — клиент присылает три сообщения подряд: «Привет», «Хочу записаться на бокс», «На Петроградке». Без синхронизации эти три сообщения попадают в обработку параллельно и перезаписывают друг другу состояние разговора. Например, «Петроградка» обгоняет «бокс», и шаг «филиал» выполняется до того, как зафиксировалось направление.
Решение — замок на разговор:
для каждого входящего сообщения:
захватить замок по ID разговора
прогнать пайплайн: разбор → переход шага → RAG → LLM → сохранение
отпустить замок
Простой приём, но без него отдельные диалоги периодически ломались, а найти причину постфактум было тяжело. Сейчас это один из самых «неприкосновенных» кусков кода. Есть пометка «не трогать».
Интеграции: CRM и Google Sheets
Когда диалог доходит до подтверждения записи, срабатывает запись в два места:
- CRM — создаётся сделка с набором полей: филиал, направление, дата пробного, аудитория, возраст, опыт, канал подтверждения, сегмент клиента.
- Google Sheets — строка в единой таблице учёта для администрации клуба.
Отдельно ведётся сегментация клиентов — вычисляется по данным, которые бот уже собрал. Это не абстрактные метки для отчётности, а вход в персонализацию: для каждого сегмента свой набор акцентов в презентации программы. Например, для 30+ без опыта — упор на мини-группу и комфортный темп.
Повторные касания — без AI
Контринтуитивное решение: повторные касания (если клиент замолк на каком-то шаге) хардкожены, без LLM. Причина простая — на повторном касании модель нестабильна: начинает переформулировать приветствие, забывать контекст, выдумывать новые условия. Хардкодные шаблоны с подстановкой имени и направления работают предсказуемо и проверяемо. Деньги экономятся не там, где их хочется экономить, а там, где это безопасно.
Два процесса, одна база
Архитектурное решение, которое я бы назвал лучшим в проекте:
[веб-панель :3000] [бот-сервис :3001]
│ │
└──────── общая БД ─────────┘
(HTTP между ними нет)
Веб-панель (где редактируется база знаний, мониторятся диалоги, смотрится аналитика) и бот-сервис — два отдельных процесса, общающихся только через общую БД. Нет HTTP между ними, нет внутренних API, нет зависимости «если панель лежит, бот тоже лежит». Немного дисциплины на общей схеме и миграциях, но в остальном чистое упрощение.
Результаты

Продуктовые цифры после внедрения:
| Метрика | До | После |
|---|---|---|
| Среднее время первого ответа | 35 мин | < 5 сек |
| Доля заявок без ответа в течение 2 часов | 22% | 0% |
| Конверсия «первое сообщение → запись на пробное» | 18% | 34% |
| Ручных записей в CRM администратором | 100% | < 10% (только быстрый трек и сложные случаи) |
| Стоимость диалога (токены LLM + эмбеддинги) | — | 0,57 ₽ |
Отдельный качественный эффект — администраторы больше не сидят в мессенджерах, а работают с уже квалифицированными лидами в CRM: имя, возраст, опыт, филиал, дата пробного, канал подтверждения — всё заполнено ботом.
Главный вывод
Единственное, что я бы хотел, чтобы вы забрали из этого кейса: умный бот — это на 20% модель и на 80% инженерия вокруг неё. Модель — самый дешёвый компонент стека. Самые дорогие вещи — отладка галлюцинаций, правильный порядок шагов, замок на разговор, категорийные пороги в поиске по базе знаний и сто других мелких решений, каждое из которых «очевидно» задним числом.
Именно поэтому «прикрутить AI за вечер» и «собрать рабочий AI-консультант для бизнеса» — это две разные задачи, различающиеся в работе раз в пятьдесят.



