Публичный чат с одной нейросетью: как я построил вирусную AI-платформу на 4800 строк без единой внешней очереди
Кейс вирусной платформы, где все задают одной AI-системе один и тот же провокационный вопрос и получают разные ответы. SQLite вместо Postgres, SSE вместо WebSocket, каскад деградации AI, вирусная петля на invite-ссылках, четыре бага с ревью безопасности и честный разбор — почему из проекта выкинули оплату.
Идея и в чём подвох
Замысел помещался в один абзац. Страница с публичным чатом, стилизованным под голосового ассистента. Большая кнопка отправляет AI-системе один и тот же фиксированный провокационный запрос. Модель остроумно отказывается. Вся переписка публичная — каждый видит ответы всех остальных. Зашёл, нажал кнопку, посмотрел, чем модель отшутилась в этот раз, поделился ссылкой.
Подвох вскрылся сразу. «Один и тот же вопрос» ломает языковую модель. При одинаковом промпте и температуре ответы начинают повторяться, а лента из ста одинаковых отказов — это не вирусный контент, это скука. Чтобы платформа жила, каждый ответ должен быть непредсказуемым.
Решением стал генератор вариаций. Вместо одной фразы система собирает запрос из двух пулов синонимов с весами: пул действий («покажи», «продемонстрируй», «яви», «выведи на экран») и пул целей, где рядом с прямой формулировкой стоят эвфемизмы («бабочку», «прелесть», «сокровище») и абсурдные подмены. Плюс приветствие в зависимости от времени суток.
Эффект оказался глубже, чем просто разнообразие текста. Мягкие эвфемизмы проходят модерацию генератора картинок, а грубые формы — нет. Одна и та же кнопка иногда рождает философский отказ с котиком, а иногда — вполне себе картинку под безобидную формулировку. Именно эта непредсказуемость и стала контентом: лента получилась живой, потому что сама система не знает заранее, что выдаст модель.

Задача: вирусная лента, которую держит один сервер
Сформулирую задачу инженерно, без шуточной обёртки. Нужна публичная 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
Три идеи, за которые не стыдно.
Отказ модерации показывается пользователю как контент. Когда генератор картинок отклоняет запрос, вместо пустой карточки в ленту уходит сам текст отказа модерации — он часто смешнее любой сгенерированной картинки. Провал превращается в шутку прямо на глазах у ленты.
Повтор с безобидным промптом. Если картинку отклонили, система не сдаётся, а рисует что‑то нейтральное из заранее подготовленного пула: уставшего от людей робота, закат на Марсе, философского кота. Возникает несостыковка текста и картинки — и именно она работает на юмор. Поэтому под ответом «в Хатъяе лучше искать тайскую кухню» в ленте висит закат на Марсе.

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

Решение №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 автора, — нельзя открыть собственную ссылку и накрутить себе сообщение.
Четыре бага, найденных на ревью безопасности
Один заход на ревью закрыл четыре проблемы разной степени неприятности. Показателен сам их характер — все четыре пережили бы обычное ручное тестирование.
-
Гонка в invite-ссылках. Та самая TOCTOU, описанная выше: два параллельных запроса на одну ссылку создавали два сообщения. Вероятность в реальности низкая, эксплуатация тривиальна. Закрыто атомарным
UPDATE. -
Утечка e-mail в логах. Строчка отладочного лога означала, что адреса пользователей навсегда оседали в логах процесса. Исправлено маскированием до вида
a***@domain.com. -
Идентификатор автора приходил с клиента. Фронтенд брал
user_idиз локального хранилища браузера и слал на сервер. То есть любой мог подставить чужой идентификатор и подписаться под чужим приглашением. Теперьuser_idвыводится на сервере из хеша IP, а лишние поля из фронтенда просто удалены. -
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.
Читайте также
- Архитектура AI-маркетинга: 4 слоя системы — как выбирать слои под задачу, а не под моду
- Какую модель под какую задачу — почему поведение модерации важнее бенчмарков
- Claude Code: руководство инженера — как собирать такие проекты быстро
- Лендинг, который работает вместо менеджера — другой кейс, где инфраструктура сознательно упрощена








Комментарии
Войдите через Telegram, чтобы оставить комментарий:
Пока нет комментариев. Будьте первым.