Почему пользователи уходят с пейволла: проблема, которую не решают метрики
Воронка конверсии на пейволле показывает 68% отказов. CTR по кнопке «Оплатить» держится на 3,2%. Среднее время на странице - 47 секунд. Эти цифры говорят, что пользователи уходят, но молчат о главном: почему. Классическая аналитика фиксирует факт отказа, а не мотивацию.
На проекте AI-MANUAL мы столкнулись с парадоксом: трафик на страницу с платным доступом стабильно рос, а конверсия падала. Цена не менялась, конкуренты не демпинговали. Гипотеза возникла из одного слова в заголовке - «бесплатно». Статья обещала бесплатный доступ к базе знаний, но за пейволлом скрывалась freemium-модель с ограниченным бесплатным периодом. Стандартные метрики эту гипотезу проверить не могли. Требовался прямой диалог с пользователем в момент отказа.
Мы встроили LLM-бота для exit-интервью прямо на страницу закрытия пейволла. За две недели бот провёл 59 сессий, получил развёрнутые ответы от 34% пользователей и подтвердил гипотезу: 80% ответивших ждали полностью бесплатный доступ. Заголовок обещал одно, продукт предлагал другое. Этот инсайт изменил контент-стратегию проекта.
Стандартные методы сбора обратной связи - email-опросы после оттока, NPS-анкеты, формы на сайте - страдают от низкого отклика и социально желаемых ответов. Пользователь, который уже закрыл вкладку, не вернётся заполнять форму. Тот, кто заполнил, напишет «дорого» или «не нужно», потому что это проще, чем объяснять реальную причину. LLM-бот на странице отказа ловит пользователя в момент принятия решения и выстраивает диалог, который обходит шаблонные отговорки.
Архитектура решения: как мы встроили LLM-бота в момент закрытия пейволла
Решение состоит из четырёх компонентов: фронтенд-событие закрытия страницы, API-эндпоинт с защитой от дублирования, база данных для хранения сессий и реплик, LLM-провайдер для генерации вопросов. Ключевые требования: один пользователь - одна сессия, защита от гонок при повторных событиях закрытия, ограничение длины диалога для контроля затрат.
Когда пользователь закрывает страницу с пейволлом, срабатывает событие beforeunload или аналогичный триггер на модальном окне. Фронтенд отправляет POST-запрос на эндпоинт с идентификатором пользователя. Эндпоинт проверяет, существует ли активная сессия, и либо создаёт новую, либо возвращает существующую. Дальше начинается диалог: бот задаёт вопрос, пользователь отвечает, цикл повторяется до трёх-пяти реплик.
Выбор LLM-провайдера зависит от нагрузки и бюджета. Для 59 сессий за две недели хватило GPT-3.5 Turbo с затратами менее доллара. При масштабировании на тысячи сессий в день можно переключиться на более дешёвую модель без изменения кода - достаточно поменять идентификатор модели в конфигурации. Платформы вроде Caila от Just AI упрощают такую замену, предоставляя единый интерфейс для разных провайдеров.
Схема данных: таблицы для сессий и реплик
Две таблицы покрывают все потребности: хранение сессий и хранение сообщений. Структура минимальна, индексы заточены под идемпотентность и быстрый поиск.
CREATE TABLE exit_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_exit_sessions_user_active
ON exit_sessions(user_id)
WHERE status = 'active';
CREATE TABLE exit_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES exit_sessions(id),
role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
sequence INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_exit_replies_session_sequence
ON exit_replies(session_id, sequence);
Частичный уникальный индекс на exit_sessions гарантирует, что у пользователя не может быть двух активных сессий одновременно. Поле status позволяет завершать сессии явно - например, когда пользователь ответил на все вопросы или диалог достиг капа реплик. Таблица exit_replies хранит полную историю диалога с разделением ролей и порядковым номером сообщения.
Идемпотентный эндпоинт и защита от гонок
Событие закрытия страницы может сработать несколько раз подряд. Браузеры по-разному обрабатывают beforeunload, а некоторые пользователи дёргают вкладку туда-сюда. Без защиты это приводит к дублированию сессий и размножению диалогов.
Решение: эндпоинт с транзакцией уровня SERIALIZABLE и проверкой существующей сессии перед созданием новой. Кап реплик - максимум пять сообщений от ассистента - предотвращает бесконечный диалог и контролирует затраты на LLM-запросы.
from fastapi import FastAPI, HTTPException
from sqlalchemy import text
from uuid import uuid4
app = FastAPI()
@app.post("/exit-interview/start")
async def start_exit_interview(user_id: str, db):
async with db.begin():
await db.execute(
text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
)
existing = await db.execute(
text("""
SELECT id FROM exit_sessions
WHERE user_id = :user_id AND status = 'active'
"""),
{"user_id": user_id}
)
row = existing.fetchone()
if row:
return {"session_id": str(row[0]), "new": False}
session_id = uuid4()
await db.execute(
text("""
INSERT INTO exit_sessions (id, user_id, status)
VALUES (:id, :user_id, 'active')
"""),
{"id": session_id, "user_id": user_id}
)
return {"session_id": str(session_id), "new": True}
Проверка капа реплик происходит перед каждым вызовом LLM. Если количество сообщений ассистента в сессии достигло пяти, диалог завершается принудительно, а статус сессии меняется на 'completed'. Это страхует от runaway-диалогов и неожиданных скачков в счёте за API.
Системный промпт по методологии «Спроси маму»
Методология «Спроси маму» строится на трёх принципах: открытые вопросы вместо закрытых, эмпатичное слушание вместо допроса, уточнение вместо интерпретации. В контексте exit-интервью это означает, что бот не спрашивает «почему вы не купили?», а начинает с вопроса о том, что заинтересовало пользователя в заголовке. Такой подход обходит социально желаемые ответы и добирается до реальной мотивации.
Системный промпт, который мы использовали на AI-MANUAL:
Ты проводишь короткое exit-интервью с пользователем, который только что закрыл страницу с платным доступом к статье. Твоя цель - понять истинную причину отказа, а не получить вежливый ответ.
Правила:
1. Начни с вопроса о том, что привлекло внимание в заголовке. Не спрашивай о деньгах в первом сообщении.
2. Если пользователь говорит о цене, уточни: «Что именно в цене показалось неоправданным - сумма или ценность, которую вы ожидали получить?»
3. Если пользователь говорит «не нужно» или «просто смотрел», спроси: «Что в заголовке заставило вас кликнуть? Чего вы ожидали увидеть?»
4. Не предлагай скидки, не уговаривай остаться. Ты исследователь, а не продавец.
5. Максимум 4 вопроса. После четвёртого ответа пользователя поблагодари и заверши диалог.
6. Говори коротко, по-человечески. Без маркетинговых фраз и «мы ценим ваше мнение».
Заголовок статьи: {{article_title}}
Описание продукта за пейволлом: {{product_description}}
Плейсхолдеры {{article_title}} и {{product_description}} заполняются динамически при создании сессии. Это позволяет использовать один промпт для разных страниц и продуктов. Принцип «не продавец, а исследователь» критически важен: пользователи чувствуют, когда их пытаются удержать, и закрываются. Бот, который искренне хочет понять мотивацию, получает честные ответы.
Двухнедельный тест: 59 сессий, 34% ответивших и неожиданный инсайт
Тест запустили на странице с заголовком «Бесплатный доступ к базе знаний AI-MANUAL». Продукт за пейволлом - freemium-подписка с 7-дневным бесплатным периодом и последующей оплатой. За 14 дней зафиксировали 59 инициированных сессий. Из них 20 пользователей дали развёрнутые ответы - 34%. Остальные закрыли диалог после первого вопроса или проигнорировали его.
34% - высокий показатель для непрошеного опроса. Типичный отклик на email-анкеты после оттока составляет 3-8%. Пользователь на странице отказа мотивирован объяснить своё решение, если вопрос задан вовремя и правильно.
Главный инсайт: 16 из 20 ответивших указали, что ожидали полностью бесплатный доступ. Слово «бесплатно» в заголовке создало обещание, которое freemium-модель не выполняла. Пользователи чувствовали себя обманутыми - не ценой, а несоответствием ожидания и реальности.
Ключевая ошибка: несоответствие обещания в title реальной модели freemium
Реальные реплики пользователей (анонимизированные):
«Заголовок говорит бесплатно, а тут сразу карту просят. Я думал, регистрация и всё.»
«Бесплатный доступ - это когда бесплатно. А у вас пробный период, это другое.»
«Кликнул из-за слова бесплатно, но платить не готов, даже потом.»
Паттерн очевиден: пользователи не спорили с ценой, не говорили «дорого». Они указывали на разрыв между обещанием заголовка и реальностью продукта. Это не проблема ценообразования, это проблема позиционирования.
После теста мы изменили заголовки на всех страницах с freemium-доступом: убрали слово «бесплатно», заменили на «пробный доступ» и «7 дней бесплатно, потом 499 ₽/мес». Конверсия в регистрацию выросла на 12 процентных пунктов за следующий месяц. Количество жалоб на «обман» в службу поддержки упало до нуля. Один инсайт из 20 диалогов окупил интеграцию многократно.
Готовые шаблоны для внедрения в ваш проект
Решение адаптируется под любой пейволл за один-два дня разработки. Ниже - полные шаблоны, которые можно скопировать и настроить под свой стек.
SQL: создание таблиц exit_sessions и exit_replies
-- Таблица сессий exit-интервью
CREATE TABLE exit_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
article_id VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'expired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Частичный уникальный индекс: одна активная сессия на пользователя
CREATE UNIQUE INDEX idx_exit_sessions_user_active
ON exit_sessions(user_id)
WHERE status = 'active';
-- Таблица реплик диалога
CREATE TABLE exit_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES exit_sessions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
sequence INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT unique_reply_sequence UNIQUE (session_id, sequence)
);
-- Индекс для быстрого получения диалога по сессии
CREATE INDEX idx_exit_replies_session_sequence
ON exit_replies(session_id, sequence);
-- Триггер обновления updated_at в сессии
CREATE OR REPLACE FUNCTION update_exit_session_timestamp()
RETURNS TRIGGER AS $$
BEGIN
UPDATE exit_sessions
SET updated_at = now()
WHERE id = NEW.session_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_exit_replies_update_session
AFTER INSERT ON exit_replies
FOR EACH ROW
EXECUTE FUNCTION update_exit_session_timestamp();
Код эндпоинта на Python (FastAPI) с идемпотентностью
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from uuid import uuid4
from openai import AsyncOpenAI
import os
app = FastAPI()
llm_client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
MAX_ASSISTANT_REPLIES = 5
async def get_db():
# Возвращает асинхронную сессию БД
...
@app.post("/api/exit-interview/start")
async def start_exit_interview(
user_id: str,
article_id: str,
article_title: str,
product_description: str,
db: AsyncSession = Depends(get_db)
):
async with db.begin():
await db.execute(
text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
)
result = await db.execute(
text("""
SELECT id FROM exit_sessions
WHERE user_id = :user_id AND status = 'active'
"""),
{"user_id": user_id}
)
existing = result.fetchone()
if existing:
session_id = existing[0]
is_new = False
else:
session_id = uuid4()
await db.execute(
text("""
INSERT INTO exit_sessions (id, user_id, article_id, status)
VALUES (:id, :user_id, :article_id, 'active')
"""),
{"id": session_id, "user_id": user_id, "article_id": article_id}
)
is_new = True
if is_new:
system_prompt = f"""Ты проводишь короткое exit-интервью с пользователем, который только что закрыл страницу с платным доступом к статье. Твоя цель - понять истинную причину отказа, а не получить вежливый ответ.
Правила:
1. Начни с вопроса о том, что привлекло внимание в заголовке. Не спрашивай о деньгах в первом сообщении.
2. Если пользователь говорит о цене, уточни: «Что именно в цене показалось неоправданным - сумма или ценность, которую вы ожидали получить?»
3. Если пользователь говорит «не нужно» или «просто смотрел», спроси: «Что в заголовке заставило вас кликнуть? Чего вы ожидали увидеть?»
4. Не предлагай скидки, не уговаривай остаться. Ты исследователь, а не продавец.
5. Максимум 4 вопроса. После четвёртого ответа пользователя поблагодари и заверши диалог.
6. Говори коротко, по-человечески. Без маркетинговых фраз и «мы ценим ваше мнение».
Заголовок статьи: {article_title}
Описание продукта за пейволлом: {product_description}"""
response = await llm_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": "Пользователь только что закрыл страницу. Начни диалог."}
],
max_tokens=150,
temperature=0.7
)
assistant_message = response.choices[0].message.content
async with db.begin():
await db.execute(
text("""
INSERT INTO exit_replies (session_id, role, content, sequence)
VALUES (:session_id, 'assistant', :content, 1)
"""),
{"session_id": session_id, "content": assistant_message}
)
return {
"session_id": str(session_id),
"message": assistant_message,
"sequence": 1
}
return {"session_id": str(session_id), "new": False}
@app.post("/api/exit-interview/reply")
async def reply_to_exit_interview(
session_id: str,
user_message: str,
db: AsyncSession = Depends(get_db)
):
async with db.begin():
result = await db.execute(
text("""
SELECT status FROM exit_sessions
WHERE id = :session_id FOR UPDATE
"""),
{"session_id": session_id}
)
session = result.fetchone()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session[0] != 'active':
raise HTTPException(status_code=400, detail="Session is not active")
count_result = await db.execute(
text("""
SELECT COUNT(*) FROM exit_replies
WHERE session_id = :session_id AND role = 'assistant'
"""),
{"session_id": session_id}
)
assistant_count = count_result.scalar()
if assistant_count >= MAX_ASSISTANT_REPLIES:
await db.execute(
text("UPDATE exit_sessions SET status = 'completed' WHERE id = :session_id"),
{"session_id": session_id}
)
return {"message": "Спасибо за ответы. Диалог завершён.", "completed": True}
seq_result = await db.execute(
text("SELECT COALESCE(MAX(sequence), 0) + 1 FROM exit_replies WHERE session_id = :session_id"),
{"session_id": session_id}
)
next_sequence = seq_result.scalar()
await db.execute(
text("""
INSERT INTO exit_replies (session_id, role, content, sequence)
VALUES (:session_id, 'user', :content, :sequence)
"""),
{"session_id": session_id, "content": user_message, "sequence": next_sequence}
)
history_result = await db.execute(
text("""
SELECT role, content FROM exit_replies
WHERE session_id = :session_id
ORDER BY sequence
"""),
{"session_id": session_id}
)
history = [{"role": row[0], "content": row[1]} for row in history_result.fetchall()]
messages = [
{"role": "system", "content": system_prompt},
*history
]
response = await llm_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
max_tokens=150,
temperature=0.7
)
assistant_message = response.choices[0].message.content
async with db.begin():
await db.execute(
text("""
INSERT INTO exit_replies (session_id, role, content, sequence)
VALUES (:session_id, 'assistant', :content, :sequence)
"""),
{"session_id": session_id, "content": assistant_message, "sequence": next_sequence + 1}
)
if assistant_count + 1 >= MAX_ASSISTANT_REPLIES:
await db.execute(
text("UPDATE exit_sessions SET status = 'completed' WHERE id = :session_id"),
{"session_id": session_id}
)
return {
"message": assistant_message,
"sequence": next_sequence + 1,
"completed": assistant_count + 1 >= MAX_ASSISTANT_REPLIES
}
Системный промпт: полный текст
Ты проводишь короткое exit-интервью с пользователем, который только что закрыл страницу с платным доступом к статье. Твоя цель - понять истинную причину отказа, а не получить вежливый ответ.
Правила:
1. Начни с вопроса о том, что привлекло внимание в заголовке. Не спрашивай о деньгах в первом сообщении.
2. Если пользователь говорит о цене, уточни: «Что именно в цене показалось неоправданным - сумма или ценность, которую вы ожидали получить?»
3. Если пользователь говорит «не нужно» или «просто смотрел», спроси: «Что в заголовке заставило вас кликнуть? Чего вы ожидали увидеть?»
4. Не предлагай скидки, не уговаривай остаться. Ты исследователь, а не продавец.
5. Максимум 4 вопроса. После четвёртого ответа пользователя поблагодари и заверши диалог.
6. Говори коротко, по-человечески. Без маркетинговых фраз и «мы ценим ваше мнение».
Заголовок статьи: {{article_title}}
Описание продукта за пейволлом: {{product_description}}
Для кастомизации замените плейсхолдеры на реальные значения. Если продукт за пейволлом - не подписка, а разовая покупка, адаптируйте второй пункт правил под свою модель монетизации. Принцип остаётся: не продавать, а исследовать причину отказа.
Выбор LLM зависит от нагрузки. Для пилотного запуска подойдёт GPT-3.5 Turbo или эквивалент - стоимость одного диалога из пяти реплик составляет доли цента. При масштабировании до тысяч сессий в день имеет смысл перейти на более дешёвую модель. Платформа Caila от Just AI позволяет менять модель без переписывания кода - достаточно изменить идентификатор в конфигурации. Это удобно для A/B-тестирования разных моделей на задаче exit-интервью.
Управление промптами для таких сценариев - отдельная дисциплина. В статье о практических стратегиях управления промптами мы разбирали, как структурировать системные промпты и использовать каскадные вызовы для повышения предсказуемости ответов. Эти техники применимы и к exit-интервью: можно отделить промпт первого вопроса от промпта уточняющих вопросов и управлять ими независимо.
Выводы: когда exit-интервью на LLM окупается
Интеграция заняла полтора дня разработки. Затраты на LLM-запросы за две недели теста - менее доллара. Один инсайт о несоответствии заголовка и продукта привёл к росту конверсии в регистрацию на 12 процентных пунктов. ROI измерим и положителен.
Метод работает при трафике на пейволл от 50 уникальных посетителей в неделю. При меньшем трафике данных для статистически значимых выводов придётся ждать слишком долго. Exit-интервью на LLM дополняет, но не заменяет глубинные интервью с пользователями - бот хорошо вскрывает паттерны, но не может заменить часовой разговор с исследователем.
Ограничения: метод требует технической готовности команды к интеграции с LLM API и управлению транзакциями в базе данных. Шаблоны выше снимают большую часть сложности, но базовое понимание асинхронных эндпоинтов и изоляции транзакций необходимо.
Следующий шаг - внедрить шаблоны на своём проекте. Начните с одной страницы пейволла, соберите 50-100 сессий, проанализируйте ответы. С вероятностью 80% вы найдёте инсайт, который не дадут стандартные метрики. Код и промпты готовы, осталось адаптировать под свой стек.