Self‑hosted вместо подписок: асинхронная очередь для ИИ‑агента на n8n и Redis
- четверг, 27 августа 2026 г. в 00:00:06
В прошлой статье я показал, как простой дебаунс на 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 сделки.

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

После всех отладок, которые я перечислил в этой статье, первая неделя тестового периода в продакшене была такая:
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 символов.
Спасибо за внимание! Отдельное спасибо тем, кто дочитал до конца. Буду рад, если мой опыт окажется полезным и поможет вам решить аналогичные задачи в своих проектах. Всем добра!)