javascript

Self‑hosted вместо подписок: асинхронная очередь для ИИ‑агента на n8n и Redis

  • четверг, 27 августа 2026 г. в 00:00:06
https://habr.com/ru/articles/1074734/

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

Дисклеймер: в статье упоминается Meta: организация признана террористической и запрещена в РФ)

Схема, код и цифры взяты из продакшена; название проекта и префиксы ключей изменены. Статья не является рекламой/самопиаром и тому подобное.

Сегодня я поделюсь уровнем ниже. Дебаунс защищается от пользователя (точнее от его сообщений написанных в разнобой), а тут защищаться приходится от платформы: Meta Graph API не гарантирует доставку каждого события, а штатные интеграции живут своей жизнью. Разберу две проблемы, которые этот зазор создаёт: потерю вебхуков и race condition на общем ключе, а также механизм, которым мы их закрыли: изоляция Redis‑ключей, буфер с локом и связка «чат = сделка» с TTL 30 дней.

Стек: self‑hosted n8n + Redis 7.x на одном небольшом VPS, LLM — DeepSeek 4.0 Pro, через OpenRouter (fallback LLM — qwen3.7-plus). Воркфлоу из нескольких десятков нод. Бот принимает заявки в ателье дизайнерских украшений каждый день вне рабочее время менеджеров (с 18:00 до 10:00).

 Общий вид воркфлоу ещё на стадии завершения разработки
Общий вид воркфлоу ещё на стадии завершения разработки

Проблема 1. Вебхуки иногда просто не доходят

Сначала я грешил на свой сервер, а также подумывал на свою криворукость. История такая: ночью, когда крутится таргет, часть входящих сообщений в Direct не превращалась в запуск воркфлоу. Я сидел в логах n8n и считал вручную: из 15 сообщений подряд 2–3 не оставляли никакого следа — ни успешных executions, ни ошибок, ничего. Запрос до моего VPS не долетал.

Это не баг n8n, и как оказалось, не моя криворукость. Meta документирует ретраи вебхуков с экспоненциальным backoff, но если все попытки исчерпаны — события просто нет. Никакого HTTP 500, никакой ошибки в дашборде. Тишина в логах.

Да, есть одна неприятная деталь: штатная интеграция «amoCRM + нельзяgram» в те же минуты свой трафик получала. Сделка в CRM появлялась — сообщение от юзера попадало в диалоговое окно тоже, а мой воркфлоу о сообщении не знал. Такую асимметрию я начал наблюдать с первых дней тестового внедрения в продакшен. Как расставлены приоритеты между приложениями у Meta — стало главной задачей: всё описано в разделе Conversation Routing в Meta for Developers.

 Тот самый раздел настроек, где логика распределения диалогов жестко привязывается к одному приложению.
Тот самый раздел настроек, где логика распределения диалогов жестко привязывается к одному приложению.

Изучив документацию детальнее, оказалось, что при использовании штатного Handover Protocol в Meta возникает конфликт приоритетов: если назначить главным приемником n8n, то ИИ бот заберет себе весь трафик, лишив дневных менеджеров в amoCRM доступа к абсолютно всем лидам (та самая потеря нескольких вебхуков что я описал выше). Мета не позволяет гибко переключать роли приложений по расписанию, из‑за чего вебхуки уходят только в одну систему.

Проблема 2. Race condition на глобальном ключе

Архитектурная вводная: сделку в amoCRM создаёт штатная интеграция, а не мой воркфлоу. Она делает это по собственному расписанию — обычно за несколько секунд, но в пиковые вечера задержка доходит до 3–5 секунд. И её вебхук в n8n прилетает позже самого сообщения, причём несёт только ID новой сделки — ни имени, ни chatId.

Отсюда задача: где‑то держать состояние «чей клиент сейчас идёт по воронке», чтобы в момент, когда сделка наконец создана, связать её ID с правильным чатом. Первая версия была наивной (хотя и рабочей) — один временный ключ на всю систему:

```bash
# последний активный отправитель, ключ один на всю систему
SET last_insta_sender '{"id":"17841409269978502","ts":"1780267635866"}'
# TTL = 60 секунд
```

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

Время

Что происходит

00:00

Клиент А кликает по рекламе. Instagram шлёт вебхук, n8n пишет его ID в глобальный ключ

00:02

Клиент Б кликает по тому же объявлению. Его вебхук перезаписывает тот же ключ своим ID

00:17

Ветка синхронизации для Клиента А проснулась, читает ключ и видит там Клиента Б

Самое противное (другого слова не могу подобрать) дальше! Ни одной ошибки: нода Merge Sync Key and Lead ID отрабатывает со статусом Success, и сделка Клиента А аккуратно привязывается к чату Клиента Б. Потеря обновления (lost update) на распределённых вебхуках двух незнакомых людей. В логах — идеальная чистота.

Замечу: сценарий не ограничен одним таргетом! Те же «грабли» — два человека, написавшие ИИ‑боту в Direct с разницей в пару секунд, неважно, по клику по объявлению или напрямую. При фоновых 20–30 лидах за ночь шанс поймать такой рассинхрон копеечный, меньше процента. Но один масштабный таргет — и разъезд данных становится вопросом времени, а не случайности.

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

  • 15 секунд — окно дебаунса: сколько ждём перед склейкой сообщений (описано в конце первой статьи);

  • 17 секунд — пауза ветки, которая ждёт, пока штатная интеграция amoCRM создаст сделку и её вебхук долетит до n8n;

  • 60 секунд — TTL глобального ключа из примера выше. Он жил дольше, чем длилось ожидание, поэтому чужая перезапись успевала случиться.

После очередного разъезда я пересобрал контур так, чтобы корректность не зависела ни от скорости amoCRM, ни от таймингов Meta. Получилось три уровня:

Уровень 1. Изоляция ключей

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

```javascript
// Нода Scenario Router
const input = $input.first().json;
const userId = input.chatId;
const ch = input.transport; // 'ig'

return [{
  json: {
    ...input,
    redisKeys: {
      buffer:    `app:buf:${ch}:${userId}`,  // очередь сообщений
      lock:      `app:lock:${ch}:${userId}`, // флаг "идёт обработка"
      aggregate: `app:agg:${ch}:${userId}`,  // склеенный текст
      memory:    `app:mem:${ch}:${userId}`   // память диалога для ИИ
    }
  }
}];
```

Префикс с chatId означает ровно одно: Клиент Б физически не имеет доступа к состоянию Клиента А, перезаписать чужие данные невозможно — писать некуда. Весь класс race condition из прошлого раздела умирает на уровне схемы именования.

 Здесь происходит буферизация входящих событий и проверка дебаунса
Здесь происходит буферизация входящих событий и проверка дебаунса

Уровень 2. Буфер с локом вместо «повезёт = не повезёт»

Сообщения летят не в LLM, а в персональную очередь. Первое сообщение ставит флаг обработки, повторные во время окна молча игнорируются:

```bash
RPUSH app:buf:ig:12345 '{"text":"хочу купить","ts":"1780267"}'

# проверяем, не идёт ли уже обработка
GET app:lock:ig:12345          # -> nil, значит я первый
SET app:lock:ig:12345 1 EX 15  # флаг на 15 секунд
```

Процесс запускается с задержкой в 15 секунд (то самое окно дебаунса), после чего нода Pop from Buffer циклом выгребает очередь LPOP‑ом до пустого ответа.

В черновике я расписал лок недостаточно, исправляюсь. Полная механика такая:

  • Как проверяется. Перед установкой флага — GET того же ключа. Пустой ответ значит «я первый», а непустой ответ значит — «обработка уже идёт», и выполнение просто завершается без записи дубля в буфер.

  • Как снимается. Отдельного DEL нет: флаг самоуничтожается по TTL через 15 секунд. Этого достаточно — окно закончилось, очередь очищена, следующее сообщение клиента снова может стать «первым».

  • Что при коллизии. Между GET и SET есть микросекундное окно, и два параллельных исполнения теоретически могут оба решить, что они первые. На практике это не страшно: LPOP атомарен, каждое сообщение уйдёт ровно в один поток, а второй исполнитель получит пустой ответ и тихо выйдет через IF Has Messages. Худший случай — повторный запуск‑ цепочки, который схлопывает дедупликация ниже. Если будете повторять это у себя — берите сразу SET NX EX 15: одна команда вместо пары GET+SET, и окна нет совсем.

Дедупликация: откуда берутся prev и line

Каждый вытащенный элемент проходит ноду склейки. Чтобы код читался: line — текст сообщения, которое только что достали из буфера; prev — накопленный текст диалога из ключа aggregate. Предыдущая нода (Redis Get Accumulated) достаёт prev из Redis, дальше Merge складывает его с новым сообщением по позиции, и в Code‑ноду они приходят парой:

```javascript
// Нода Combine All Text
// полный дубль — ретрай мессенджера
if (prev === line) return [{ json: { combined: prev } }];
// дубль прицепился в конец — Meta прислала хук дважды
if (prev && prev.endsWith(line)) return [{ json: { combined: prev } }];
// частичный дубль в начале — берём свежую версию
if (line.startsWith(prev)) return [{ json: { combined: line } }];
// нормальное продолжение диалога
return [{ json: { combined: `${prev} ${line}`.trim() } }];
```

Как понимаю сразу возникает вопрос «Что это даёт на практике?!». Во‑первых, ретраи НЕЛЬЗЯgram схлопываются в ноль — повторный хук превращается в «полный дубль» и выбрасывается. Во‑вторых, пять сообщений подряд склеиваются в один структурированный промпт: один вызов LLM вместо пяти. Деньги на токенах — приятная часть, но важнее другое — агент отвечает на весь контекст разом, а не выдаёт три дёрганых реплики подряд.

 Фрагмент сценария, отвечающий за удержание паузы и предотвращение конкурентных запросов
Фрагмент сценария, отвечающий за удержание паузы и предотвращение конкурентных запросов

Уровень 3. Связка «чат = сделка» как запись с TTL

Главный сдвиг: привязка сессии к ID сделки — это больше не угадывание по временному ключу, а запись в Redis с длинным TTL:

```bash
SET app:union:ig:17841409269978502 '{"leadId":"1739374997051656"}' EX 2592000
```

Ключ живёт 30 дней. Теперь любая асинхронная ветка перед походом в тяжёлый API amoCRM делает один быстрый GET: если связка есть — точечно обновляем существующую карточку, и системе физически неоткуда взять команду на создание дубля. Клиент может вернуться через неделю — контекст и привязка на месте.

Отдельная засада, которую поймали уже в проде: при обновлении сделки штатная интеграция amoCRM иногда перетягивала поле "Ответственный" на своего технического пользователя. Выглядело так: ИИ — бот отработал, а задача утром висит не на менеджере, а на техническом пользователе интеграции. Лечится жёстким прописыванием responsible_user_id в ноде Amo: Capture & Update Lead — теперь каждый ответ агента возвращает сделку нужному человеку. Мелочь, но без неё утренняя очередь задач превращалась в «кашу».

Смена ответственного внутри воркфлоу
Смена ответственного внутри воркфлоу
Смена ответственного в самой црм
Смена ответственного в самой црм

Что в итоге в цифрах?

Отдельный контур статистики после каждой сессии пишет в первый лист Google Sheets: время, вердикт агента, ID сделки.

В данной ноде два листа: первый лист заполнен сырыми логами (ID сделки, время, вердикт)
В данной ноде два листа: первый лист заполнен сырыми логами (ID сделки, время, вердикт)

А на втором листе Google Sheets стандартными формулами превращается массив из листа 1 в наглядный экран аналитики с конверсиями и распределением трафика по часам: \

Готовая статистика заказчику о работе ИИ с 20:00-10:00
Готовая статистика заказчику о работе ИИ с 20:00–10:00

После всех отладок, которые я перечислил в этой статье, первая неделя тестового периода в продакшене была такая:

  • N = 179 диалогов:

  • диалог = сделка в amoCRM: 155 из 179, конверсия 75.4%;

  • полный цикл ответа: в среднем 30.2 секунды (минимум 18, максимум 54). Да, не мгновенно, играет фактор что ИИ — агент «ходит» в инструменты и пишет развёрнуто, контекстное коно переписки 10 сообщений; для ночной квалификации лидов эта скорость нормальна;

  • 74% обращений приходятся на окно 20:00–10:00 по Минску, когда менеджеров физически нет — раньше эти заявки висели до утра;

  • за ту же неделю 16% запросов к API amoCRM упали с ошибкой. Интеграция не идеальна, но ретраи спасают. Говорить что в статье что заказчику мол «всё работает идеально» было бы враньём.

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

За первый месяц через систему прошло около 4.5 тысячи обращений от 2.3 тысячи уникальных клиентов — весь этот объём крутится на VPS за 20 у.е..

Про готовые сервисы — без рекламы и самопиара

Напрашивается вопрос: зачем городить своё, если есть готовые прослойки между мессенджерами и CRM? Ответ трезвый, не предвзятый, и сугубо со своего опыта и мнения. Надёжность там решается тем же способом, к которому приходит любой, кто упёрся в ненадёжность вебхуков: к вебхукам добавляется периодический опрос API по расписанию. Не дошло хуком = придёт следующим циклом опроса.

Это рабочий компромисс,и у него две цены:

  • задержка (ответ никогда не быстрее интервала опроса);

  • подписка, растущая с трафиком.

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

Что дальше...

В следующей статье я поделюсь разбором контура генерации ответа: как ИИ‑агент извлекает имя и телефон гибридно (LLM‑tools с regex на подстраховке), зачем нужен машиночитаемый вердикт из тегов и почему ответы режутся на абзацы короче 180 символов.

Спасибо за внимание! Отдельное спасибо тем, кто дочитал до конца. Буду рад, если мой опыт окажется полезным и поможет вам решить аналогичные задачи в своих проектах. Всем добра!)