Публичной статистики о том, как продавцы маркетплейсов реально пользуются нейросетями, почти нет: обзоры пересказывают друг друга, а цифры остаются внутри сервисов. Мы делаем banan.wtf — сервис, который из одного фото товара собирает карточку для Wildberries и Ozon, — и решили показать и цифры, и то, что за ними: код, который эти цифры переживает.

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

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

Откуда данные и что мы считали

Всё, что ниже, — обезличенные агрегаты сервиса за декабрь 2025 — июль 2026: суммарно более 7 500 генераций изображений. «Задачей» мы называем цепочку «исходное фото + все его правки»; статистику итераций и времени суток считали по последним 90 дням — это около 1 300 задач.

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

Часть 1. Что в данных

Миф об идеальном промпте

Самая устойчивая находка — то, как на самом деле выглядит работа с генератором.

Как закрываются задачи генерации: 71% — первой генерацией, 29% — правками
Как закрываются задачи генерации: 71% — первой генерацией, 29% — правками

71% задач закрываются первой же генерацией: пользователь загрузил фото, выбрал шаблон или описал задачу — и результат сразу ушёл в карточку. Оставшиеся 29% — самое интересное. Это не «неудачные» генерации, а нормальный рабочий процесс: человек доводит результат серией коротких правок, в среднем 3,4 генерации на такую цепочку.

Реальные правки выглядят не как переписанный промпт, а как императивы: «замени фон», «убери тень», «сделай текст крупнее». Пользователь мыслит диффами к результату, а не описаниями с нуля. Никто не «инжинирит промпты» — люди разговаривают с картинкой.

Из этого следуют три вещи, и все три дальше материализуются в код:

  • Продуктовая. Правки — first-class операция. UX в стиле «не понравилось? напиши новый промпт» заставляет начинать с нуля и проигрывает по времени.

  • Экономическая. Одна карточка — это в среднем ≈1,7 генерации, если считать по всем задачам. Этот множитель надо закладывать и в мощности, и в юнит-экономику, и в лимиты тарифов.

  • Инженерная. Правка — это продолжение диалога с моделью. Значит, контекст задачи — исходник, версии, история — должен жить на сервере как единая сущность, и историю этого диалога нужно уметь воспроизводить точно. Ниже расскажу, как ровно это требование привело нас к самому странному багу за полгода (спойлер: поле thought_signature, о котором вы, скорее всего, не слышали).

Когда селлеры делают карточки

Распределение генераций по времени суток: пик 11:00–19:00, 13% после 22:00
Распределение генераций по времени суток: пик 11:00–19:00, 13% после 22:00

Распределение по часам двугорбое: первый пик — 15:00, второй — 19:00 по Москве. Рабочий интервал 11:00–19:00 даёт чуть больше половины объёма, но каждая восьмая генерация происходит после 22:00, а ~5% — глубокой ночью.

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

Практических следствия два. Пиковая нагрузка приходится не на «офисные» часы, на которые по умолчанию калибруют мониторинг и дежурства, а на вечер — алерты пришлось передвинуть. И лучшее окно для рискованных деплоев — не вечер и не ночь, а утро буднего дня: трафик минимален, команда в форме.

Спрос, который вырос в разы за один месяц

Абсолютные объёмы мы не раскрываем, но динамику показать можем: после спокойной весны объём генераций вырос в несколько раз за месяц — июнь 2026 стал рекордным за историю сервиса, и июль идёт с опережением. Всплеск совпал с волной интереса к image-моделям нового поколения (то самое семейство, которое в рунете прозвали «нано-банана») и с подготовкой селлеров к осеннему сезону.

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

Часть 2. Конвейер

Схема пайплайна: фото → анализ → параллельные генерации → комплект; прогресс через Redis pub/sub и WebSocket
Схема пайплайна: фото → анализ → параллельные генерации → комплект; прогресс через Redis pub/sub и WebSocket

Стек прозаичный: Node.js, MongoDB, очередь задач и пул воркеров отдельными процессами от API, Redis pub/sub для событий, WebSocket до браузера. Интереснее не «что», а три решения, которые определили всё остальное.

Комплект — это fan-out с частичными успехами. «Комплект карточки» — 5–7 изображений (главное фото 3:4, инфографика, lifestyle-слайды) плюс видеообложка из одной пользовательской загрузки. Серверный анализ товара порождает набор подзадач с готовыми промптами; подзадачи уходят в очередь и генерируются параллельно как независимые задания. Частичный успех — норма, а не исключение: если из семи изображений упало одно, перегенерировать нужно ровно его, не трогая остальные. Промпты собираются на сервере, а не в клиенте — это единый стиль комплекта, возможность молча улучшать шаблоны для всех сразу и защита от «сломал сам себе шаблон».

Append-only везде, где можно. Оригиналы не перезаписываются никогда: «перегенерируй третий слайд» добавляет версию в отдельный массив, и у пользователя остаётся вся история. Из схемы комплекта, как есть:

// Post-settlement single-card regenerations (chat-native, append-only). Each
// is rendered as its own assistant message; originals in cardResults are kept.
regenerations: { type: [regenerationSchema], default: [] },

Это дёшево и снимает целый класс багов вида «пользователь откатился, а откатываться некуда».

Атомарные claim-флаги вместо «кто первый встал». Видеообложка ставится в очередь после того, как готовы изображения, — и «понять, что пора» могут два процесса одновременно: хук воркера и поллинг со стороны API. Если оба поставят видео-джобу — получится дубль и двойное списание. Лечится атомарным флагом: кто первым перевёл false → true через findOneAndUpdate, тот и ставит задачу.

// Cross-process atomic claim flag: the writer that flips this false->true wins
// the right to enqueue the deferred video, so the worker hook and a web-api
// GET poll cannot both create a duplicate video child. MUST be a declared
// path — otherwise Mongoose strict mode silently strips the $set and the
// guard never persists.
videoEnqueued: { type: Boolean, default: false },

Обратите внимание на вторую половину комментария — это грабля внутри грабли: если путь не объявлен в схеме, Mongoose в strict-режиме молча выкидывает $set по нему. Гард выглядит написанным, ревью проходит, а в базе ничего не меняется.

Часть 3. Пять историй

История 1. Соединение длиной в полторы минуты

Генерация изображения занимает от 20 до 90 секунд. Самый простой интеграционный контракт — синхронный HTTP: отправил запрос, держишь соединение, получил картинку. Так делают почти все первые версии, и наша не была исключением.

Что с этим не так — по нарастающей:

  1. у каждого посредника по пути (балансировщики, прокси, рантаймы) свои таймауты на idle-соединения, и вы не контролируете минимальный из них;

  2. обрыв соединения неоднозначен: генерация на той стороне могла успеть завершиться — деньги за вызов модели списаны, картинки нет;

  3. наивный ретрай после обрыва — это вторая генерация и второе списание;

  4. рестарт воркера (деплой!) теряет все ин-флайт запросы разом.

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

Код поллера, сокращённый, но реальный:

async generateImage(payload, { idempotencyKey } = {}) {
  const startedAt = Date.now();
  const submitted = await this._fetch('/v1/images/generations?async=true', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey || crypto.randomUUID() },
    body: JSON.stringify(payload)
  }).then((r) => r.json());

  // Ответ на постановку может быть сразу терминальным — идемпотентный повтор.
  if (submitted.status === 'done') return this._resolveJobResult(submitted);
  return this._pollJobUntilDone(submitted.id, startedAt);
}

async _pollJobUntilDone(jobId, startedAt) {
  let consecutiveFailures = 0;
  for (;;) {
    // Дедлайн — на джобу целиком, а не на отдельные HTTP-вызовы.
    if (Date.now() - startedAt >= this.requestTimeoutMs) throw new JobTimeoutError(jobId);
    await sleep(this.pollIntervalMs);
    let body;
    try {
      body = await this._fetch(`/v1/jobs/${jobId}`).then((r) => r.json());
      consecutiveFailures = 0;
    } catch (error) {
      // 401/402 ожиданием не лечатся; остальное — бюджет из трёх подряд.
      if (error.httpStatus === 401 || error.httpStatus === 402
        || ++consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) throw error;
      continue;
    }
    if (body.status === 'failed') throw toTypedError(body.error, jobId);
    if (body.status === 'done') return this._resolveJobResult(body, jobId);
    // 'queued' / 'running' → ждём дальше.
  }
}
Синхронный вызов против асинхронной джобы: где рвётся длинное соединение и как поллинг с идемпотентной постановкой это чинит
Синхронный вызов против асинхронной джобы: где рвётся длинное соединение и как поллинг с идемпотентной постановкой это чинит

Здесь три места, которые легко упустить:

  • Idempotency-Key на постановке. Ретрай сабмита (сеть моргнула после отправки, до ответа) не создаёт вторую джобу и второе списание. Идемпотентность постановки — не опция, а часть контракта.

  • Ответ на постановку бывает терминальным. Тот самый идемпотентный повтор: джоба уже завершилась, и провайдер возвращает результат сразу. Без этой ветки — вечный поллинг несуществующей «текущей» джобы.

  • Бюджет ошибок поллинга различает классы. 429/5xx/сетевые — транзиент, три подряд терпим. 401/402 — проблемы доступа, ожиданием они не лечатся — фейлим сразу. А общий дедлайн стоит на всей джобе, иначе «почти успешный» поллинг может длиться вечно.

Вывод переносимый: любую операцию дольше пары десятков секунд оформляйте как ресурс с id — даже если провайдер предлагает удобный синхронный вызов. Синхронный контракт для долгих операций — это долг, который вернётся с процентами в самый неудобный момент.

История 2. Чужой API — недоверенный ввод

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

По контракту одного из провайдеров результат можно получить как base64 в теле (b64_json) или как URL. Мы, естественно, взяли base64 — на один запрос меньше. Выяснилось, что содержимое поля иногда приходит невалидным — при том что снаружи всё идеально: HTTP 200, status: done. До пользователей это не дошло — ошибка автоматически уводила запрос к резервному провайдеру, — но в журнале такие случаи копились, и доверие к маршруту таяло.

Починили сменой формата: просим response_format: url и скачиваем изображение сами — обрыв и повтор скачивания полностью под нашим контролем, а «это действительно картинка» проверяется на нашей стороне.

Но локальный фикс — не главное. Главное — режим доверия, к которому мы в итоге пришли для любого стороннего API в пайплайне:

  1. Классификация ошибок + один фолбэк. Любая ошибка внешнего вызова — типизированная, и запрос один раз уходит на резервного провайдера. Инвариант: пользователь видит ровно то, что видел бы, если бы сбойного провайдера вовсе не было.

  2. Circuit breaker. Три ошибки подряд — пауза на две минуты (нет смысла стоять в очереди за ошибкой). Ошибки доступа — сразу длинная пауза и событие админам: они ожиданием не лечатся.

  3. Рубильник без деплоя + журнал каждого вызова. Флаг маршрутизации читается на каждую генерацию — выключение мгновенно. Каждый вызов пишется в append-only лог (primary / fallback / skipped с причиной), поверх — дашборд с error rate и задержками p50/p95 по маршрутам.

Вывод: сторонний API — это недоверенный ввод, как форма на сайте. Валидируйте содержимое, а не статус-коды; типизируйте ошибки; и держите рубильник, который не требует деплоя, — на проде он окупается в первый же инцидент.

История 3. Каскад моделей: фолбэк — это семантика, а не failover

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

  1. Фолбэк — только на инфраструктурных и safety-ошибках. Никогда — на «результат не понравился». Иначе стиль комплекта становится непредсказуемым (пользователь выбирал модель как инструмент), а расходы — неконтролируемыми.

  2. Явный контент-отказ не спасаем. Провайдер остаётся авторитетом по допустимости содержимого. Каскад, который «дожимает» отклонённый промпт через другие модели, — это уже не отказоустойчивость, а обход модерации.

  3. При safety-ошибке пропускаем кандидатов того же провайдера. Тот же классификатор безопасности откажет так же — потратим попытку впустую.

Сам каскад — один проход по конфигу, без рекурсии, изолированный так, чтобы не мочь сломать основной поток задания:

async attemptCrossModelFallback({ usedModel, prompt, quality, isSafety, ...ctx }) {
  const usedProvider = getImageProviderForModel(usedModel);
  const tried = [];
  for (const candidate of IMAGE_FALLBACK_MODEL_ORDER) {
    if (candidate === usedModel) continue;
    if (isSafety && getImageProviderForModel(candidate) === usedProvider) continue;
    tried.push(candidate);
    try {
      // Кандидат может не поддерживать исходное качество
      // (у лайт-модели только один тир 1K) — нормализуем.
      const candidateQuality = normalizeImageQualityForModel(candidate, quality);
      const result = await this.createGenerator({ modelName: candidate })
        .generate(prompt, candidateQuality, ctx);
      if (result?.success) return { result, fallbackModel: candidate, tried };
    } catch (e) { /* лог и следующий кандидат */ }
  }
  return { result: null, fallbackModel: null, tried };
}

Порядок кандидатов — продуктовое решение, а не техническое: последним стоит быстрая резервная модель, потому что отдать картинку в базовом разрешении лучше, чем показать ошибку. А каждый сработавший каскад фиксируется в метаданных генерации (from, to, tried) — без этого невозможно ни отладить, ни честно ответить пользователю, почему результат выглядит иначе.

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

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

История 4. thought_signature: скрытое поле контракта

Помните инженерное следствие из части 1 — «правки — это диалог, и историю диалога нужно воспроизводить точно»? Вот как оно нас укусило.

Ассистент комплекта — tool-using агент с двумя движками: основной — Claude, резервный — Gemini (на случай, когда основной провайдер лежит целиком). На основном пути всё работало. На резервном мультиходовые сессии ломались, стоило диалогу продолжиться после вызова инструмента.

Расследование было муторным, потому что по отдельности работало всё: один ход — ок, диалог без инструментов — ок, тот же диалог на Claude — ок. Ломалась только комбинация «Gemini + инструменты + следующий ход».

Причина: Gemini 3.x подписывает свои functionCall-парты служебным полем thought_signature и требует, чтобы в истории следующего запроса эти парты вернулись в точности как были. А мы, как приличные люди со стримингом, собирали историю ассистентского хода из потоковых дельт — и реконструированная часть теряла подпись. Для API это выглядело как подделка его собственного прошлого хода.

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

// Non-streaming on purpose: Gemini 3.x requires replaying the model's exact
// `functionCall` parts (they carry a `thought_signature`) on the next request,
// which a delta-reconstructed part loses. generateContent gives us the intact
// parts; we re-emit the text as chunked deltas so the SSE path is unchanged.
// (Gemini is the fallback engine — Claude is the primary and truly streams.)
const response = await this.getClient().models.generateContent({ ... });
// ...
yield {
  type: 'message_end',
  content,
  stopReason: functionCalls.length ? 'tool_use' : 'end_turn',
  native: parts  // реплеим на следующем ходу байт-в-байт
};

То есть на резервном пути мы отказались от честного стриминга: берём цельный ответ, текст ре-эмитим псевдо-дельтами (SSE-контракт с фронтендом не меняется), а сырые парты модели сохраняем и на следующем ходу возвращаем как есть.

Вывод из этой истории я бы повесил на стену: у LLM-API появились stateful-поля, и история диалога — больше не «ваши сообщения в вашем формате», а «парты модели как есть». Храните сырой ответ модели рядом со своим нормализованным представлением. Место дешёвое, а альтернатива — дебаг вслепую против чёрного ящика, который просто отвечает отказом.

История 5. Список с двумя хозяевами

Симптом со стороны пользователя: список задач в истории иногда «вспыхивал» — комплект на мгновение рассыпался на отдельные треды-карточки и собирался обратно. А иногда не собирался.

У списка оказалось два продюсера. REST-выдача истории собирается по правилам: служебные записи отфильтрованы (карточки комплекта помечены hiddenFromHistory и наружу торчат одной агрегированной записью комплекта). А realtime-канал, который оживляет список при изменениях, слал снапшот, собранный по другим правилам — без фильтра и без агрегации. Клиент честно перезаписывал состояние тем, что пришло последним. Push обгонял REST — и список превращался в кашу из внутренних записей.

Классическая ошибка вида «данные одни, а правил видимости два». Фикс скучный, и это комплимент — комментарий из кода:

// Mirror the REST listThreads visibility rules so a realtime push can't
// desync the history list. Two rules matter: (1) bundle fan-out children are
// hiddenFromHistory and must not surface as standalone threads, and (2) the
// unified bundle entries must be included — otherwise overwriting the client
// list with this payload clobbers bundles out of history and splits them
// into leaked card threads.
const visibleFilter = { userId, hiddenFromHistory: { $ne: true } };
Два продюсера одного списка: REST с фильтрами и агрегацией против realtime-снапшота без них; фикс — единые правила видимости
Два продюсера одного списка: REST с фильтрами и агрегацией против realtime-снапшота без них; фикс — единые правила видимости

…плюс тест на паритет, который гоняет оба пути и сравнивает выдачи. Без теста «одинаковые правила» разъедутся на первом же рефакторинге — проверено.

Второе правило реалтайма, к которому мы пришли той же ценой: событие несёт готовый DTO, а не только id. Событие вида «сходи перечитай» отправляет клиента в REST — и начинается гонка «пуш пришёл раньше, чем запись стала видна читающей реплике». Собирает DTO тот, кто в момент события знает больше всех, — воркер.

Вывод: если у данных два пути доставки — REST и push, — у них должен быть один код видимости. Не «одинаковый», а физически один. И тест на паритет сверху.

Кириллица на инфографике

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

Что сработало у нас: модели нового поколения (у которых с текстом стало радикально лучше) плюс жёсткие серверные правила в промптах — количество слов на слайд, требования к контрасту и кеглю, запрет декоративных шрифтов. И третье, неочевидное: дешёвые правки. Если «сделай текст крупнее» стоит одно сообщение и полминуты, редкие огрехи перестают быть проблемой.

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

Чеклист: что забрать с собой

  1. Итеративность — норма, а не сбой. Правки — first-class операция; экономику считайте из ≈1,7 генерации на результат, а не из одной.

  2. Ваши пики — не ваши рабочие часы. У аудитории малого бизнеса нагрузка живёт вечером; калибруйте мониторинг и окна деплоя по данным, а не по привычке.

  3. Долгая операция — это ресурс с id, а не HTTP-запрос. Асинхронная постановка, поллинг с дедлайном на джобу целиком, идемпотентность сабмита.

  4. Сторонний API — недоверенный ввод. Валидируйте содержимое, а не статус-коды; типизируйте ошибки; circuit breaker и рубильник без деплоя.

  5. Фолбэк в генеративных системах — семантика. Только технические и safety-причины, никогда «по вкусу»; контент-отказы не спасать; каждое срабатывание — в метаданные.

  6. История диалога с LLM — парты модели как есть. Stateful-поля вроде thought_signature уже реальность; храните native-ответ рядом с нормализованным.

  7. REST и realtime делят один код видимости. Физически один, плюс тест на паритет; событие несёт готовый DTO, а не приглашение перечитать.

  8. Append-only и атомарные claim-флаги. Версии вместо перезаписи; false → true через атомарный апдейт вместо «кто первый встал»; и помните, что Mongoose strict молча выкидывает необъявленные пути.

Что дальше

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

Вопросы по методологии, данным и инженерным деталям — в комментарии, отвечу.