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

от автора

В прошлой статье (https://habr.com/ru/articles/1071580/) я показал, как простой дебаунс на 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 Routerconst 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, а в персональную очередь. Первое сообщение ставит флаг обработки, повторные во время окна молча игнорируются:

```bashRPUSH 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:

```bashSET 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 символов.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1074734/