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

13 шагов: от «здрасте» до «жду вас в среду»
| # | Шаг | Что собираем | Модель |
|---|---|---|---|
| 1 | ASK_DIRECTION | Направление (бокс, ММА, самбо...) | Haiku |
| 2 | ASK_AUDIENCE | Для себя / для ребёнка | Haiku |
| 3 | ASK_NAME | Имя клиента | Haiku |
| 4а | ASK_CHILD_INFO | Имя и возраст ребёнка | Haiku |
| 4б | ASK_AGE_EXPERIENCE | Возраст + опыт (обязательно оба поля) | Haiku |
| 5 | ASK_BREAK | Длительность перерыва у опытных | Haiku |
| 6 | ASK_BRANCH | Филиал (1 из 6) | Haiku |
| 7 | PRESENT_PROGRAM | Презентация программы | Sonnet |
| 8 | ASK_GROUP_FORMAT | Мини-группа 30+ или общая | Sonnet |
| 9 | OFFER_TRIAL_DATES | Две ближайшие даты пробного | Sonnet |
| 10 | ASK_FULL_NAME | Фамилия и отчество для записи | Haiku |
| 11 | CONFIRM_BOOKING | Подтверждение записи | Sonnet |
| 12 | ASK_CONFIRMATION_CHANNEL | Телефон / WhatsApp / Telegram | Haiku |
| 13 | FINALIZE | Адрес, резюме, прощание | Sonnet |
Дополнительно — два особых режима: FAST_TRACK (клиент сразу оставил телефон и сказал «запишите», без выяснения направления) и FREE_CHAT (человек задаёт вопрос вне воронки — цены, парковка, раздевалки).
Haiku или Sonnet — это не просто «дешевле / дороже»
Выбор модели по шагу — один из тех решений, которые дали больше всего экономики. Первая версия крутилась целиком на Sonnet. После месяца работы мы посмотрели на статистику ошибок по шагам и увидели, что на простых квалификационных шагах (имя, филиал, направление) Haiku 4.5 промахивается не чаще Sonnet, а стоит в 10 раз дешевле. На сложных шагах — презентация программы, предложение дат, финализация — Sonnet по-прежнему нужен: там нужен тон, склонение, нюансы.
Отдельное правило: extraction на ходах 1–2 всегда Sonnet. Даже если шаг сам по себе простой, на первых двух сообщениях максимум неоднозначности, и экономия $0.004 за диалог не окупает каскадную ошибку.
Стоимость одного диалога (среднее по логам, ₽)
Только Sonnet ███████████████████████ 2.81 ₽
Haiku + Sonnet по шагам █████ 0.57 ₽
Только Haiku (эксперимент) ██ 0.22 ₽ (больно по качеству)
RAG на pgvector: почему не Pinecone и не Weaviate
Векторная база — PostgreSQL с расширением pgvector. Причины выбора стандартные: единое хранилище с остальной БД (клиенты, филиалы, расписание, цены), привычный бэкап через pg_dump, отсутствие дополнительной точки отказа. Эмбеддинги — text-embedding-3-small от OpenAI; для RU-контента в нашем домене он даёт достаточный recall, а стоимость — копейки.
Knowledge Base хранится в таблице KnowledgeChunk с категориями: SCHEDULE, PRICES, TRAINERS, FAQ, ABOUT, SCRIPTS, SCENARIOS. Каждый чанк несёт филиал, направление (если применимо) и категорию. На этапе retrieval это важно, потому что фильтры по филиалу и направлению закрывают большую часть галлюцинаций.
Per-category пороги похожести — незаметный, но критичный трюк
Одинаковый порог similarity >= 0.30 для всех категорий давал две противоположные проблемы. В SCHEDULE и PRICES проскакивал нерелевантный шум — человек спрашивал про цены, получал кусок FAQ про «как добраться». В FAQ и ABOUT наоборот — система резала валидные ответы, потому что описательный текст не всегда ловит 0.30.
Развели на категорийные пороги:
| Категория | Порог | Почему |
|---|---|---|
SCHEDULE, PRICES | 0.35 | Структурированные данные — лучше молчать, чем ошибиться |
TRAINERS | 0.32 | Имена и факты, нужна точность |
FAQ, ABOUT | 0.27 | Описательный контент, допустимо больше вариаций |
| default | 0.30 | Остальные категории |
Плюс — количество топ-чанков тоже зависит от шага. На простых квалификационных шагах MAX_TOTAL_CHUNKS меньше, на презентации программы и свободном чате — больше.
Галлюцинации, которые мы лечили
Отдельная глава кейса — борьба с тем, что Claude по своей природе любит «помогать». В нашем домене это проявлялось в двух болезненных формах.
Галлюцинация тренеров
Клиент спрашивает про группу в филиале «Петроградская». В RAG попадают описания тренеров в соседнем филиале и профили тренеров вообще. Модель радостно отвечает: «Занятие проведёт тренер N». Проблема — тренер N в этой группе не работает. Или вообще не работает в клубе.
Лечение оказалось нетривиальным. Сначала мы добавили фильтр по филиалу в retrieval — это не помогло: модель галлюцинировала имена, которых вообще не было в контексте, просто из своих обучающих данных. Решение — инъекция в промпт trainerContextNote: «называй тренера ТОЛЬКО если он явно привязан к конкретной группе в графике этого филиала; иначе не называй никого». Это один из тех редких случаев, когда инструкция в промпте работает — потому что она ограничивающая, а не порождающая.
SCHEDULE-чанки на шаге презентации
На шаге PRESENT_PROGRAM бот должен рассказать про программу, но не показывать расписание — расписание выдаётся только на следующем шаге, когда клиент готов выбрать дату. В первой версии бот периодически вываливал расписание раньше времени. Разобрались: инструкция «не показывай расписание» не удаляла SCHEDULE-чанки из RAG-контекста, и модель, видя их в контексте, всё равно их выносила.
Починили на уровне retrieval: на шаге PRESENT_PROGRAM SCHEDULE-чанки исключаются из контекста явно, до передачи в модель. Промпт-инструкция — это последняя линия обороны, не первая.
Пустое расписание интерпретируется как «может быть есть»
Отдельная история — когда расписание для комбинации «филиал + направление + аудитория» не найдено вообще. Пустой массив плюс SCHEDULE-чанки в контексте приводили к тому, что модель отвечала «вот есть похожие варианты…» вместо «к сожалению, по этому направлению в этом филиале взрослых групп нет». Починили флагом scheduleNotFound: если DB-запрос вернул пусто — SCHEDULE-чанки тоже выкидываются, модель получает чистый сигнал «не найдено».
Мютекс на разговор: защита от гонки
Частый сценарий — клиент присылает три сообщения подряд: «Привет», «Хочу записаться на бокс», «На Петроградке». Без синхронизации эти три сообщения попадают в обработку параллельно и перезаписывают друг другу QualificationState: например, «Петроградка» обгоняет «бокс», и шаг ASK_BRANCH выполняется до того, как зафиксировалось направление.
Решение — per-conversation mutex:
await withConversationLock(conversationId, async () => {
// весь пайплайн: extraction → step transition → RAG → LLM → save
});
Простой приём, но без него отдельные диалоги периодически ломались и найти причину постфактум было тяжело. Сейчас это один из самых «неприкосновенных» кусков кода — есть пометка «не трогать».
Интеграции: ImpulseCRM и Google Sheets
Когда диалог доходит до шага CONFIRM_BOOKING, срабатывает запись в два места:
- ImpulseCRM — создаётся сделка с набором полей: филиал, направление, дата пробного, аудитория, возраст, опыт, канал подтверждения, сегмент клиента (A1–K).
- Google Sheets — строка в единой таблице учёта для администрации клуба.
Отдельно ведётся сегментация клиентов (CustomerSegment A1–K) — вычисляется по данным, которые бот уже собрал в QualificationState. Это не абстрактные метки для отчётности, а реальный вход в персонализацию: для каждого сегмента у нас свой набор акцентов в презентации программы (например, для 30+ без опыта — упор на мини-группу и комфортный темп).
Re-engagement: повторные касания без AI
Контринтуитивное решение: повторные касания (если клиент замолк на каком-то шаге) хардкожены, без AI. Причина простая — AI на re-engagement нестабилен: начинает переформулировать приветствие, забывать контекст, выдумывать новые условия. Хардкодные шаблоны с подстановкой имени и направления работают предсказуемо и проверяемо. Деньги экономятся не там, где их хочется экономить, а там, где это безопасно.

Два процесса, одна база
Архитектурное решение, которое я бы назвал лучшим в проекте:
[Next.js веб-панель :3000] [Grammy бот-сервис :3001]
│ │
└──────── PostgreSQL ───────────┘
(общая БД, нет HTTP между ними)
Веб-панель (где редактируется KB, мониторятся диалоги, смотрится аналитика) и бот-сервис — два отдельных процесса, общающихся только через общую БД. Нет HTTP между ними, нет внутренних API, нет зависимости «если панель лежит — бот тоже лежит». Это добавляет немного дисциплины (оба процесса знают о Prisma-схеме, миграции применяются к обоим), но в остальном — чистое упрощение.
Результаты
Чисто продуктовые цифры после внедрения:
| Метрика | До | После |
|---|---|---|
| Среднее время первого ответа | 35 мин | < 5 сек |
| Доля заявок без ответа в течение 2 часов | 22% | 0% |
| Конверсия «первое сообщение → запись на пробное» | 18% | 34% |
| Ручных записей в CRM администратором | 100% | < 10% (только FAST_TRACK и сложные случаи) |
| Стоимость диалога (токены LLM + embeddings) | — | 0.57 ₽ |
Отдельный качественный эффект — администраторы больше не сидят в мессенджерах, а работают с уже квалифицированными лидами в CRM: имя, возраст, опыт, филиал, дата пробного, канал подтверждения — всё уже заполнено ботом.
Главный вывод
Единственное, что я бы хотел, чтобы вы забрали из этого кейса: умный бот — это на 20% модель и на 80% инженерия вокруг неё. Модель — самый дешёвый компонент стека; самые дорогие вещи — это отладка галлюцинаций, правильный порядок шагов, мютекс на разговор, per-category пороги в RAG и сто других мелких решений, каждое из которых «очевидно» задним числом.
Именно поэтому «прикрутить ChatGPT за вечер» и «собрать рабочий AI-консультант для бизнеса» — это две разные задачи, различающиеся в работе раз в пятьдесят.



