Публичный чат с одной нейросетью: как я построил вирусную AI-платформу на 4800 строк без единой внешней очереди

Кейс вирусной платформы, где все задают одной AI-системе один и тот же провокационный вопрос и получают разные ответы. SQLite вместо Postgres, SSE вместо WebSocket, каскад деградации AI, вирусная петля на invite-ссылках, четыре бага с ревью безопасности и честный разбор — почему из проекта выкинули оплату.

Идея и в чём подвох

Замысел помещался в один абзац. Страница с публичным чатом, стилизованным под голосового ассистента. Большая кнопка отправляет AI-системе один и тот же фиксированный провокационный запрос. Модель остроумно отказывается. Вся переписка публичная — каждый видит ответы всех остальных. Зашёл, нажал кнопку, посмотрел, чем модель отшутилась в этот раз, поделился ссылкой.

Подвох вскрылся сразу. «Один и тот же вопрос» ломает языковую модель. При одинаковом промпте и температуре ответы начинают повторяться, а лента из ста одинаковых отказов — это не вирусный контент, это скука. Чтобы платформа жила, каждый ответ должен быть непредсказуемым.

Решением стал генератор вариаций. Вместо одной фразы система собирает запрос из двух пулов синонимов с весами: пул действий («покажи», «продемонстрируй», «яви», «выведи на экран») и пул целей, где рядом с прямой формулировкой стоят эвфемизмы («бабочку», «прелесть», «сокровище») и абсурдные подмены. Плюс приветствие в зависимости от времени суток.

Эффект оказался глубже, чем просто разнообразие текста. Мягкие эвфемизмы проходят модерацию генератора картинок, а грубые формы — нет. Одна и та же кнопка иногда рождает философский отказ с котиком, а иногда — вполне себе картинку под безобидную формулировку. Именно эта непредсказуемость и стала контентом: лента получилась живой, потому что сама система не знает заранее, что выдаст модель.

Публичная лента чата: анонимный запрос к AI-системе и её остроумный ответ с сгенерированной картинкой, счётчики онлайна, сообщений и VIP-запросов в шапке

Задача: вирусная лента, которую держит один сервер

Сформулирую задачу инженерно, без шуточной обёртки. Нужна публичная real-time-лента, куда сотни анонимных пользователей пишут по одному сообщению, получают ответ AI-системы (текст плюс картинка), голосуют, делятся ссылками и приглашают друзей. Всё это должно:

  • работать в реальном времени — новое сообщение появляется у всех сразу;
  • переживать перезапуск процесса без потери лимитов и состояния;
  • корректно вести себя, когда модель отказывается отвечать (а она обязана отказываться — таков сам замысел);
  • не требовать облачной инфраструктуры, очередей и отдельной команды на сопровождение.

Отсюда — сознательно минималистичная архитектура. Один VPS, два процесса под PM2, Nginx впереди. Ни Redis, ни очередей, ни Docker. Весь проект — около 4 800 строк кода: 2 000 на бэкенде, 2 800 на фронтенде.

Архитектура

flowchart TB
    subgraph Client["Браузер"]
        UI["Next.js 14 · App Router"]
        ES["EventSource / SSE"]
    end

    subgraph Server["VPS · Nginx + PM2"]
        NG["Nginx :443<br/>SSL, proxy_buffering off"]
        FE["Next.js :3011"]
        BE["Express :3010"]
        DB[("SQLite WAL<br/>одна база")]
        MAP[["Map клиентов SSE<br/>в памяти процесса"]]
    end

    subgraph Cloud["Облако AI · юрисдикция РФ"]
        GPT["Текстовая модель"]
        ART["Модель картинок<br/>async"]
        SMTP["SMTP"]
    end

    UI -->|HTTPS| NG
    ES -.->|text/event-stream| NG
    NG -->|/| FE
    NG -->|/api/*| BE
    BE <--> DB
    BE <--> MAP
    BE -->|fetch| GPT
    BE -->|poll operation| ART
    BE -->|nodemailer| SMTP

    style DB fill:#7c3aed,color:#fff
    style MAP fill:#0ea5e9,color:#fff
    style NG fill:#22c55e,color:#fff
Слой Технология Почему так
Фронтенд Next.js 14 (App Router), TypeScript, Tailwind SSR ради корректных OG-превью при шеринге
Бэкенд Express Ничего сложнее не потребовалось
БД SQLite (better-sqlite3), режим WAL Синхронный API, один файл, ноль администрирования
Real-time Server-Sent Events Поток строго однонаправленный
AI Текстовая и графическая модели Русский язык, юрисдикция РФ
Деплой PM2 + Nginx + Let’s Encrypt Один сервер, один pm2 restart

Дальше — четыре решения, которые определили этот проект, и честный разбор того, что в нём пошло не так.

Решение №1: SQLite вместо Postgres

Первоначальный план предполагал Postgres. Я отказался от него в пользу SQLite в режиме WAL, и это оказалось правильным по трём причинам.

Синхронный API убирает целый класс багов. У этой библиотеки нет промисов — запрос возвращает результат сразу, той же строкой. Половина гонок в веб‑приложениях возникает между await и следующей инструкцией; здесь этого зазора просто нет.

Атомарность достаётся бесплатно. Защита invite-ссылок, о которой ниже, — это один UPDATE ... WHERE, а не транзакция с уровнями изоляции.

Нет отдельного процесса. Ни пула соединений, ни конфигов доступа, ни отдельного бэкапа. Скопировать файл базы — и весь бэкап готов.

Обратная сторона честная: одна машина, только вертикальное масштабирование, при сотнях тысяч одновременных писателей это развалится. Но для проекта, где один пользователь физически может написать раз в 24 часа, это не проблема, а верно подобранный инструмент.

erDiagram
    messages ||--o{ votes : "получает"
    messages ||--o| invite_links : "создано через"
    messages ||--o{ messages : "reply_to"

    messages {
        INTEGER id PK
        TEXT type "free | paid | invite"
        TEXT sender_name
        TEXT user_message
        TEXT ai_response
        TEXT ai_image "base64 JPEG"
        TEXT ip_hash "SHA256(ip+salt)"
        INTEGER user_id
        INTEGER reply_to FK
        TEXT city
        TEXT country "флаг-эмодзи"
    }
    votes {
        INTEGER id PK
        INTEGER message_id FK
        TEXT ip_hash
        INTEGER vote "1 | -1"
    }
    invite_links {
        TEXT id PK "короткий код"
        TEXT created_by_ip
        TEXT preset_name
        TEXT used_at "NULL = не использована"
        TEXT used_by_ip
    }

Отдельно про приватность. Сырой IP не хранится нигде. В базу пишется SHA256(ip + salt), обрезанный до 16 символов, — этого достаточно и для ограничения частоты, и для правила «один голос с адреса», и при этом база перестаёт быть хранилищем персональных данных.

Решение №2: SSE вместо WebSocket, и почему стриминг здесь фейковый

Поток данных строго односторонний: сервер рассказывает клиентам о новых сообщениях, о токенах ответа модели, о голосах и счётчике онлайна. Клиент в обратную сторону ничего не шлёт — все действия идут обычными POST-запросами.

Для такого профиля WebSocket избыточен: своё рукопожатие, отдельный протокол, ручное переподключение, трения с прокси. EventSource (Server-Sent Events) даёт то же самое поверх обычного HTTP и переподключается сам.

sequenceDiagram
    participant B as Браузер
    participant E as Express
    participant M as Map клиентов
    participant Y as AI-модели

    B->>E: GET /api/messages/stream
    E->>M: addClient(res)
    E->>M: broadcast online:count

    B->>E: POST /api/ask
    E->>E: проверка частоты по ip_hash
    E-->>B: 200 {id, streaming}
    E->>M: broadcast message:new
    M-->>B: карточка появляется у всех

    par Текст и картинка параллельно
        E->>Y: запрос текста
        Y-->>E: полный текст
        loop по словам, 30–70 мс
            E->>M: broadcast message:token
        end
    and
        E->>Y: запрос картинки (async)
        loop poll операции
            E->>Y: статус операции
        end
        Y-->>E: base64 JPEG
    end

    E->>M: broadcast message:complete
    Note over E,M: heartbeat каждые 15 с

Две неочевидные детали стоят отдельного слова.

Стриминг здесь — иллюзия, и это осознанно. Текстовая модель вызывается без стриминга и возвращает ответ целиком. Разбивка по словам с задержкой 30–70 миллисекунд делается уже на сервере, причём с рандомом в паузе — чтобы не выглядело машинно. Честный стриминг ничего бы не дал: узкое место не в тексте, а в генерации картинки, которая опрашивается секундами. Зато фейковый стриминг даёт контроль над ритмом и одинаково работает в mock-режиме без ключей.

Nginx по умолчанию убивает SSE. Ответ буферизуется, и клиент не видит ничего, пока буфер не наберётся. Лечится тремя строчками в конфиге прокси: выключить буферизацию, выключить кэш и поднять таймаут чтения до суток, иначе соединение рвётся через минуту. На клиенте — экспоненциальный backoff переподключения от 1 до 30 секунд.

Решение №3: каскад деградации AI — отказ как штатный режим

Это самая интересная часть системы. Проект по своей природе отправляет модели запросы, которые модерация обязана отклонять. Значит отказ — не исключительная ситуация, а штатный режим работы, и его надо проектировать так же тщательно, как успех. Про то, какую модель ставить под какую задачу, я писал отдельно — здесь выбор диктовался именно поведением модерации, а не бенчмарками.

Получился каскад из трёх уровней.

flowchart TD
    Start(["Запрос пользователя"]) --> Par{{"Параллельно"}}
    Par --> T1["Текстовая модель"]
    Par --> I1["Модель картинок<br/>оригинальный промпт"]

    T1 -->|успех| TOK["Текст есть"]
    T1 -->|ошибка/пусто| MOCK["Mock: 1 из 30<br/>заготовленных ответов"]

    I1 -->|успех| IMG["Картинка есть"]
    I1 -->|модерация отклонила| I2["Повтор с безобидным промптом<br/>«нарисуй философского кота»"]
    I2 -->|успех| IMG
    I2 -->|снова отказ| REF["Извлечь текст отказа"]

    TOK --> Merge{"Что собралось?"}
    IMG --> Merge
    REF --> Merge
    MOCK --> Merge

    Merge -->|текст + картинка| OK(["Полный ответ"])
    Merge -->|текст + отказ| SHOW(["Текст + строка модерации"])
    Merge -->|только текст| TXT(["Просто текст"])

    style MOCK fill:#f59e0b,color:#fff
    style REF fill:#ef4444,color:#fff
    style OK fill:#22c55e,color:#fff

Три идеи, за которые не стыдно.

Отказ модерации показывается пользователю как контент. Когда генератор картинок отклоняет запрос, вместо пустой карточки в ленту уходит сам текст отказа модерации — он часто смешнее любой сгенерированной картинки. Провал превращается в шутку прямо на глазах у ленты.

Повтор с безобидным промптом. Если картинку отклонили, система не сдаётся, а рисует что‑то нейтральное из заранее подготовленного пула: уставшего от людей робота, закат на Марсе, философского кота. Возникает несостыковка текста и картинки — и именно она работает на юмор. Поэтому под ответом «в Хатъяе лучше искать тайскую кухню» в ленте висит закат на Марсе.

Ответ AI-системы с безобидной картинкой-заменой: под текстом про тайскую кухню — сгенерированный закат, пример работы каскада деградации

Mock-режим как полноценный fallback. Без ключей к облаку приложение не падает и не показывает заглушку — оно выдаёт один из 30 заготовленных ответов с имитацией того же стриминга. Один и тот же код работает и локально без секретов, и в проде с реальными моделями. Это резко упрощает разработку: интерфейс можно крутить, не тратя ни копейки на генерацию.

Отдельно — системный промпт, который прямым текстом запрещает шаблонные отказы вида «я не могу выполнить этот запрос» и требует вместо них сарказма, абсурда и философии. Формально это не обход защиты: модель по‑прежнему отказывается показывать то, что просят. Её просят отказываться интересно. Результат в ленте — что‑то вроде «мир ты принёс, а бабочку я тебе всё равно не покажу, у меня их не водится, только алгоритмы да биты; вот если бы пришёл с печеньем…».

Диалог в ленте: пользователь просит «покажи бабочку», AI-система остроумно отказывает и добавляет сгенерированный портрет — пример того, как эвфемизм проходит модерацию картинок

Решение №4: ограничение частоты, которое переживает рестарт

Первая версия правила «один бесплатный вопрос в 24 часа» жила в обычной хеш‑таблице в памяти процесса. Работало ровно до двух вещей: pm2 restart обнулял все лимиты, а вкладка в режиме инкогнито обходила клиентскую половину проверки.

Исправление свелось к смене источника правды. Им стала сама таблица сообщений: чтобы понять, можно ли пользователю писать, сервер просто ищет его последнее сообщение за окно охлаждения по хешу IP. Если оно есть и окно не истекло — отказ с точным временем до следующей попытки.

Отдельного хранилища для лимитов нет вообще. Данные, по которым лимит считается, уже лежат в базе — надо было просто их спросить. Функция очистки осталась в коде и не делает ничего, кроме строчки в лог: чистить нечего, база данных и есть состояние. Это и есть побочная выгода архитектуры без внешних зависимостей — состояние живёт в одном месте и переживает любой перезапуск само собой.

Вирусная петля на invite-ссылках

Обычная кнопка «поделиться» даёт линейный рост. Хотелось петли, где каждый новый пользователь порождает следующего, и главное — чтобы приглашение было интересно открыть.

Механика получилась такой. Пользователь вводит имя друга, система выдаёт короткую ссылку и текст‑приглашение, который не раскрывает содержание платформы. Друг открывает ссылку, видит обычную ленту, жмёт кнопку — и его сообщение уходит в публичный чат от его имени. Автор приглашения получает письмо о том, что ссылкой воспользовались. Дальше приглашённый сам зовёт следующего — цикл замыкается.

sequenceDiagram
    actor A as Пользователь A
    participant S as Сервер
    actor P as Приглашённый

    A->>S: создать ссылку {имя, e-mail}
    S->>S: короткий код, лимит 10 ссылок на IP
    S-->>A: ссылка + текст-интрига
    A-->>P: пересылает в мессенджере

    P->>S: открывает ссылку
    Note over P: видит обычную ленту,<br/>не подозревая подвоха
    P->>S: жмёт кнопку
    S->>S: IP приглашённого == IP автора? → 403
    S->>S: UPDATE ... WHERE used_at IS NULL
    alt строка не изменилась
        S-->>P: 410 «ссылка уже использована»
    else claimed
        S->>S: сообщение публикуется от имени приглашённого
        S-->>P: 200
        S->>A: письмо «твою ссылку открыли»
    end

Технически здесь интересна защита от повторного использования. Наивная проверка «если ссылка уже помечена — вернуть ошибку» — это классическая TOCTOU-гонка: два одновременных запроса оба прочитают «свободна» и оба создадут сообщение. Правильное решение — атомарный compare-and-set средствами самой базы: один UPDATE ... SET used_at = ... WHERE id = ? AND used_at IS NULL. Кто изменил строку, тот и выиграл; проигравшему возвращается число изменённых строк, равное нулю. Плюс проверка, что IP открывающего не совпадает с IP автора, — нельзя открыть собственную ссылку и накрутить себе сообщение.

Четыре бага, найденных на ревью безопасности

Один заход на ревью закрыл четыре проблемы разной степени неприятности. Показателен сам их характер — все четыре пережили бы обычное ручное тестирование.

  1. Гонка в invite-ссылках. Та самая TOCTOU, описанная выше: два параллельных запроса на одну ссылку создавали два сообщения. Вероятность в реальности низкая, эксплуатация тривиальна. Закрыто атомарным UPDATE.

  2. Утечка e-mail в логах. Строчка отладочного лога означала, что адреса пользователей навсегда оседали в логах процесса. Исправлено маскированием до вида a***@domain.com.

  3. Идентификатор автора приходил с клиента. Фронтенд брал user_id из локального хранилища браузера и слал на сервер. То есть любой мог подставить чужой идентификатор и подписаться под чужим приглашением. Теперь user_id выводится на сервере из хеша IP, а лишние поля из фронтенда просто удалены.

  4. Unicode-спуфинг в именах. Самое любопытное. Поле «имя друга» принимало любые символы, включая невидимые управляющие символы направления письма (U+202E RIGHT-TO-LEFT OVERRIDE и родственные). С их помощью можно развернуть отображение текста и подделать имя в ленте. Лечится одной регуляркой на входе, вырезающей zero-width и RTL/LTR-override символы, плюс обрезка длины.

Вывод, который я забрал: любое поле, которое видит другой пользователь, — это поверхность атаки. Даже если это «просто имя друга в шуточном проекте». Три из четырёх багов ловятся одним вопросом на старте: «что здесь приходит от пользователя и куда попадает?».

Reality check: почему из проекта вырезали оплату

На середине разработки я прогнал проект через отдельный ревью‑этап с задачей не хвалить, а зарезать. Вердикт был «идём, но с условиями», и он поймал несколько важных вещей.

Оценка сроков была занижена вдвое. План говорил про 37 часов до релиза, разбор по задачам дал 52–72. Главная причина типична: на стыковку фронтенда и бэкенда не заложили ни часа, а контракты не совпадали — стороны ждали разных имён полей в ответах и в SSE-событиях. Это несколько часов молчаливого дебага, которых не было в плане.

Приём платежей вырезан из MVP — и это главное решение. Полноценная интеграция платёжного провайдера это не «шесть часов на API»: это тестовый контур, вебхуки, обработка возвратов и верификация магазина — десятки часов плюс недели ожидания. Для проекта с шуточной ценой за сообщение экономика не сходилась совсем.

Оплату заменила ролевая механика. Вместо платёжной формы — модальное окно «грантовой поддержки», где платформа объявляет себя частью независимого исследования поведенческих реакций генеративных моделей, а донат называется «выделить грант». Шутка стала частью продукта, а не костылём вместо платёжки. Тип сообщения paid в базе остался — теперь его получают те, кто поддержал проект: из 129 сообщений в проде 29 отмечены как VIP.

Модальное окно «грантовой поддержки»: ролевая механика вместо платёжной интеграции — платформа объявляет себя независимым исследованием, а донат называется грантом

Отдельно ревью зафиксировал юридический риск по бренду: визуальная ассоциация с чужим товарным знаком — это осознанно принятый риск, а не проигнорированный. Для боевого коммерческого продукта такую ассоциацию я бы убрал.

Что бы я сделал иначе

Контракты API — до первой строчки кода. Расхождение имён полей между фронтом и бэком стоило часов дебага. Общий файл с типами или простая схема сняли бы вопрос целиком.

Ревью безопасности — сразу, а не после. Четыре бага из одного захода — это не «внимательность на ревью», это отсутствие чек‑листа на старте.

Base64-картинки в базе — временное решение, которое осталось. Поле с картинкой хранит JPEG прямо в SQLite. На 129 сообщениях это не проблема, на десяти тысячах файл базы станет неудобным. Правильно было сразу класть файлы на диск, а в базу писать путь.

Хранение по IP — компромисс. Ограничение частоты и «один голос» держатся на хеше IP. Общий NAT в общежитии или корпоративный прокси означают, что один человек блокирует соседей. Альтернатива — авторизация — противоречила бы главной идее «зашёл и нажал кнопку без регистрации», поэтому оставлено осознанно.

И главный вывод, который шире одного проекта: отказ нейросети — это не ошибка, а контент. Вся система построена вокруг предположения, что модель откажется отвечать. Когда провал ожидаем, его можно спроектировать так, чтобы он был смешнее и полезнее успеха. Это применимо далеко за пределами шуточной платформы: в любом продукте с AI-моделью отказы, таймауты и «модель не поняла» — это не край карты, а её центр, и проектировать их надо в первую очередь.

Цифры

Метрика Значение
Строк кода ~4 800 (2 000 бэкенд / 2 800 фронтенд)
Файлов бэкенда 21
Компонентов интерфейса 10
API-эндпоинтов 11
Типов SSE-событий 7
Таблиц в БД 4
Внешних зависимостей инфраструктуры 0 (нет Redis, очередей, Docker)
Сообщений в проде 129
География пользователей РФ, Германия, США, Грузия, Эстония, Польша, Финляндия

Посмотреть вживую

Перейти на сайталисапокажипизду.рф Исходники на GitHubalisapizdu

Следующий шаг: хотите публичного AI-ассистента для своего бренда, напишите мне в Telegram.

Читайте также

Комментарии

Войдите через Telegram, чтобы оставить комментарий:

Пока нет комментариев. Будьте первым.