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