Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

Exit-интервью на LLM: как выяснить истинные причины отказа от оплаты, пока пользователь ещё на странице

Пошаговая интеграция LLM-бота для exit-интервью при закрытии пейволла: готовая схема данных, идемпотентный эндпоинт с защитой от гонок, промпты по методологии «

Коротко

Что будет в материале

  1. 01

    Почему пользователи уходят с пейволла: проблема, которую не решают метрики

  2. 02

    Архитектура решения: как мы встроили LLM-бота в момент закрытия пейволла

  3. 03

    Двухнедельный тест: 59 сессий, 34% ответивших и неожиданный инсайт

  4. 04

    Готовые шаблоны для внедрения в ваш проект

Почему пользователи уходят с пейволла: проблема, которую не решают метрики

Воронка конверсии на пейволле показывает 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% вы найдёте инсайт, который не дадут стандартные метрики. Код и промпты готовы, осталось адаптировать под свой стек.

Подписаться на канал