Дисклеймер: в статье упоминается Meta — организация, признанная экстремистской и запрещённая на территории РФ.
Все ключи, телефоны и названия в примерах изменены; код, схема, логика и скрины‑ из рабочего проекта заказчика, и строго с его разрешения! Статья не является рекламой/самопиаром и тому подобное.
В прошлой статье (https://habr.com/ru/articles/1076084/) я рассказывал о скрытом баге: ИИ-агент в проде заказчика тратит токены, генерация закрывается со статусом «успех», а клиенту уезжает пустая строка. Тогда я обещал разобраться и починить. Обещание выполнено: причина найдена, правки встали в воркфлоу, счётчик пустых ответов уже 7 дней — чисто. Баг получил имя Reasoning Lock. Теперь подробно о нём: симптомы, «расследование по этажам», механика и «лечение» — в общём в стиле детектива))
Напомню, что строю ИИ-агентов на self-hosted n8n. Герой статьи — агент в проде заказчика: по ночам принимает заявки в Instagram Direct, ведёт диалог, собирает анкету и создаёт/обновляет сделку в amoCRM. Стек агента: n8n + Redis + LangChain, LLM основная — DeepSeek V4 Pro через OpenRouter, резервная — недавно поменял на Qwen3.8 Flash.
Предыстория: два периода, две природы отказов
История началась задолго до самого бага. С конца июня (установил в прод агента в первых числах июня если точнее быть) отказы случались двух периодов, и разница между ними = ключ к пониманию.
Период первый = тесты и запуск: инфраструктура. Первые «пропавшие ответы» пользователям имели вполне земную природу: ограничения Meta — потери вебхуков, окно ответов, двойные доставки. Как это чинилось (мгновенный 200 OK, буфер на Redis, дебаунс) — разобрано в первых двух частях. К продакшену этот класс закрыли.
Период второй — последние два месяца: промпт. Система стабилизировалась, и правки от заказчика посыпались в системный промпт… (( Под живую бизнес-логику: инструкция по пересылке оплаты пользователем, отдельные сценарии ответов для VIP-клиентов (абзаца два , которые под каждое «фи» постоянного клиента меняли по несколько раз), уточнения формулировок после каждого спорного диалога. Каждая правка по отдельности естетсвенно правильная и рабочая. Суммарно же промпт разросся до где то 6.4k токенов, и плотность запретов («КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО», «Отправь СТРОГО этот текст») стала максимальной за всю жизнь системы.
На этом фоне, всплыл отказ нового класса — не инфраструктурный. О нём и статья…
«Симптомы»: агент не отвечает плохо — он молчит вообще!
От менеджеров через заказчика начали приходить для меня «приветы»: агент перестал отвечать клиентам. Не «отвечает глупо», не «медленно» или «не там запятая» — а просто тишина. Собрал случаи: четыре, все в смену ИИ, все по одному сценарию. В amoCRM это выглядит так: клиенту задали вопросы — он ответил анкетой, агент подтвердил и… на следующее, или через одно/два человеческое сообщение — ноль. Диалог обрывается на полуслове, сделка висит, клиент пишет ещё раз — и снова ничего.
Показательный — последний из четырёх. После подтверждения анкеты клиентка написала не «спасибо», а вот такое:
«…я не веду особо видеоблог и тд, для меня это целая вылазка, нужно вдохновение…»
Живая человеческая мысль/рассказ в процессе переписки после заказа. Под неё в промпте нет ни скрипта, ни триггера тула. И агент замолчал.
За последние двое суток это случилось дважды — два вечера подряд, оба диалога были заскринены — и прилетели мне в чат от разгневанного заказчика. Вот пример хронологии одного из них в amoCRM:
Ложный след: инфраструктура
Первая моя гипотеза данного инцидента банальная: сервер. Termius, SSH: CPU, память, диск, сеть = чисто. n8n жив, Redis жив, воркер не падал, OOM нет. Перезапускать нечего. Первая гипотеза отверглась быстро.
Зацепка: execution-логи
Иду в executions n8n, фильтр по ошибкам. И вот она:
```textNodeApiError: Invalid or missing message text parameter at item index 0.Message text must be a non-empty string.node: Send direct message2 (@mookielianhd/n8n-nodes-instagram, v1)operation: messaging / sendMessage, itemIndex: 0время: 20:45:18 — через секунду после закрытия генерации```
Нода отправки сообщения в НЕЛЬЗЯgram упала, потому что параметр text = пустая строка. Модель типа ответила, но прислала пустоту, и community-нода честно рубит выполнение на валидации «non-empty string«.
Значит, вопрос в чём причина бага переезжает вверх по цепочке… Так кто обнулил текст?!
Следующая остановка моя — это выход самого агента. И сразу отмечу для чистоты: дальше пойдёт другой случай из той же четвёрки — не диалог из начала статьи, а более ранний. Открываю ноду агента и смотрю её OUTPUT в execution:
сообщение: {{ $json.userText }}, клиент: {{ ...chatId }}
"text": "", finish_reason: "stop", completionTokens: 95, promptTokens: 6677. Красные стрелки мои)Вот это уже интересно стало для меня. Пустой text и «успешный» stop видны на уровне LangChain внутри n8n = то есть пустота вышла из модели как есть: не потерялась при передаче, не вырезалась парсингом. Коннектор честно отдал то, что получил. Промптовые 6677 токенов — тот самый системный промпт в работе. А сколько из 95 completion-токенов модель потратила на мысли и сколько на текст — этого со стороны n8n не видно. Значит надо смотреть в логе провайдера!
OpenRouter: 88 токенов в никуда
Открываю карточку генерации в OpenRouter — и всё встаёт на места, картина прояснилась:
| Поле | Значение | Комментарий ||---|---|---|| `model` | `deepseek/deepseek-v4-pro-20260423` | reasoning-снапшот || `provider_name` | `StreamLake` | апстрим OpenRouter || `native_tokens_completion` | **88** | всего сгенерировано 88 токенов || `native_tokens_reasoning` | **88** | и ВСЕ 88 — внутри `<think>`. Видимого текста: **0** || `finish_reason` | `stop` | модель "успешно" завершилась сама || `generation_time` | 4217 мс | четыре секунды мыслей в никуда || `usage` | $0.0023 | деньги за пустой ответ || `native_tokens_cached` | 4772 из 6406 | системный промпт в кэше, всё по-взрослому || `content_guardrail_invoked` | `false` | и это НЕ модерация |
Математика сразу в один взгляд: reasoning / completion = 88/88 = 100%. Модель «говорила» только с собой, ни одного токена не дошло до поля content, и она сама закрыла генерацию со статусом «всё хорошо».
Сверяю случаи. Этот лог, 88 токенов — из того самого диалога из начала статьи: клиентка с «вылазкой и вдохновением». Случай на скринах выше — 95 completion-токенов — это другой диалог, более ранний. Цифры разные, промптовые тоже, но сигнатура один в один: text пуст, генерация «успешна». Начинаю убеждаться что разовый глюк превратился в воспроизводимый паттерн — баг(
Механика иронии: как жёсткий промпт учит модель молчать
Теперь соберём пазл. Системный промпт (его структура — в статье https://habr.com/ru/articles/1076198/) построен на детерминизме: «детерминистическая карта тегов», Ступени 1,2,3, жёсткие скрипты — «КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать из головы», «Отправь СТРОГО этот текст», «только данные из тулзов». Два месяца бизнес-правок из предыстории — оплата, VIP-сценарии, уточнения = каждый раз добавляли в эту карту и новый запрет, и новый непокрытый угол.
DeepSeek V4 Pro сама по себе рассуждающая модель. Перед ответом она разворачивает <think> и проверяет свой будущий ответ на соответствие системным запретам. И вот что происходит на фразе вроде «для меня это целая вылазка, нужно вдохновение«:
1. Скриптов под такую фразу нет = Ступени 1- 3 покрывают оплату, анкету и каталог, а не пост-покупочные размышления.
2. Тул вызывать не на что (не вопрос про цены/доставку/каталог).
3. Внутри <think> модель перебирает варианты и на каждый находит нарушение какого-нибудь «КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО».
4. Вывод рассуждения: любое слово = нарушение. Молчание = единственный compliant-вариант.
5. </think>, токен остановки, finish_reason: stop.
Дальше провайдер вырезает <think> из ответа (правильно делает в принципе, ведь клиенту не нужны внутренние монологи), и вниз по цепочке едет пустая строка. LangChain отдаёт её в n8n, нода Format Response честно форматирует пустоту, нода отправки падает на валидации.
По большому счёту всю цепочку падения можно отобразить так:
```textсообщение клиентки (вне скриптов и тулов) ↓агент: <think> перебирает запреты = легального хода нет ↓решение: молчание - </think> - EOS, finish_reason: stop ↓StreamLake: вырезает <think> - content = "" ↓Format Response: форматирует пустоту - пустота ↓Send direct message: "text must be a non-empty string" - NodeApiError ↓клиент ждёт. тишина.```
Как говориться «дьявол кроется в деталях«…и ирония, которую я оценил уже после. В своей статье ранее ( https://habr.com/ru/articles/1076198/) я писал: «если модель забыла теги то сработают дефолты, отказ безопасен по построению». Так вот, уточняю границы того обещания: дефолты страхуют отказ типа «теги потерялись» но не защищает от случая когда «ответа нет вообще». Пустая строка проходит мимо всех дефолтов насквозь, до самой ноды отправки. И чем строже промпт, тем больше сценариев, где молчание = формально безопасный ход: детерминизм, который я внедрил как фичу, здесь сыграл против меня. Это не баг конкретной версии — это свойство класса reasoning-модели (o1/o3, R1-семейство), в совокупности со сверхжёсткими промптами, в непокрытых сценариях приходят к решению «промолчать безопаснее, чем нарушить«.
Решение: три слоя защиты встали в воркфлоу
Код ниже является рабочий и стоит в воркфлоу. Своего рода именно слои защиты, можно сказать, от данного бага. Что покажет счётчик пустых ответов за первые недели наблюдения покажу цифрами в Follow-up в конце статьи.
Слой 1. Ступень 4 промта — «Светская беседа» = первопричина устранена
Согласитесь что в принципе тупик возникает там, где у модели нет легального хода. Значит, легальный ход надо прописать! В системный промпт добавлен универсальный fallback после Ступеней 1–3:
```text◦СТУПЕНЬ 4 — СВЕТСКАЯ БЕСЕДА (если ни одна из Ступеней 1-3 не сработала, и ни один инструмент не подходит к сообщению):Ответь клиенту коротко и тепло от себя (2-4 предложения) по теме егосообщения. Можно задать один уточняющий вопрос.Категорически запрещено: выдумывать цены, сроки, наличие, характеристики.После текста — теги: [VERDICT:CHAT:CHAT_ONLY|WARM] [TASK:NONE] [STAGE:CONSULT]```
Ключевое слово в данном случае — легализует. Модель в <think> перестаёт искать «какой ответ не нарушит промпт», потому что теперь нарушением является именно молчание. Клиентка с «вылазкой и вдохновением» получит тёплый человеческий ответ, диалог продолжится, сделка не зависнет.
Слой 2. Валидатор пустого ответа = страховка в n8n
Всем понятно, что со Ступенью 4 вероятность бага никуда не денется: LLM недетерминирована. Поэтому между ней и нодой Format Response встаёт Code-нода-детектор: Reasoning_Validator. Сигнатура бага однозначная: пустой контент + потраченные токены.
```javascript// === Reasoning Validator ===const rawOutput = ($json.output || '').trim();const tokensSpent = (($json.usage?.completion_tokens) || ($json.usageCompletion) || 0) > 0;// счётчик попыток живёт в самом item — переживает возврат из ветки ретраяconst attempt = $json.attempt || 0;const MAX_ATTEMPTS = 2;// ответ на месте — сбрасываем счётчик и едем дальше по конвейеруif (rawOutput) { return [{ json: { ...$json, status: 'SUCCESS', attempt: 0 } }];}if (!rawOutput && tokensSpent) { if (attempt < MAX_ATTEMPTS) { // Reasoning Lock: инкремент и возврат на повтор с «пинком» return [{ json: { status: 'RETRY_REQUIRED', attempt: attempt + 1, system_kick: 'Клиент ждёт ответа. Твой прошлый ответ пришёл пустым из-за ограничений промпта. Если ни один сценарий Ступеней 1–3 не подходит — СТРОГО переходи к Ступени 4 (Светская беседа) и ответь коротко от себя.' }}]; } // лимит исчерпан — фиксируем аварию, алерт менеджеру return [{ json: { status: 'FAILED_FALLBACK', error: 'Reasoning Lock: лимит попыток повтора исчерпан.' } }];}// пусто и без потраченных токенов — другой класс проблемы, отдаём наверхreturn [{ json: { ...$json, status: 'EMPTY_NO_TOKENS' } }];```
Три исхода: SUCCESS, RETRY_REQUIRED, FAILED_FALLBACK раскладываются обычной нодой IF. Ветка ретрая уходит во второй экземпляр агента, которому в систему дописывается system_kick — точечное разрешение на свободный текст по Ступени 4; его ответ снова проходит этот же валидатор, а счётчик attempt в payload гарантирует максимум два захода. Если и это не помогло = FAILED_FALLBACK: клиенту уходит безопасная заглушка от менеджера, инцидент падает в Telegram-логи.
Слой 3. Телеметрия: читать мысли модели
Сам OpenRouter умеет отдавать скрытые рассуждения отдельным полем. И именно для этого в тело запроса добавлен параметр:
```json{ "include_reasoning": true }```
После этого в лог генерации падает содержимое <think> = значит можно увидеть текст тупика своими глазами: на каком шаге рассуждения нейронка решила, что молчать безопаснее. Для отладки это бесценно, в основной контекст цепочки это не попадает.
Независимая сверка
Ничто так не проверяет архитектуру, как чужая реализация той же задачи. Пока готовил этот раздел, обсудил кейс здесь, на Хабре, с автором одного open-source оркестратора агентов (Go + Rust + MCP, репозиторий открыт). К моменту разговора мои три слоя уже были набросаны в воркфлоу, однако мой интерес был другой: как ту же задачу решает движок, построенный с нуля, без n8n? Ответ совпал послойно: платформа считает пустой ответ при потраченных токенах неуспешным завершением (FAILED + автоматический повтор), а в графе декларативно описывается узел-обработчик пустого ответа. Два движка, написанных независимо друг от друга: n8n+Redis и Go+Rust… В данной задаче сошлись на одной и той же схеме. Видимо, это и есть своего рода канон.
Из того же разговора забрал в бэклог идею, которой в моём плане не было: если content пуст, а reasoning содержательный то НЕ выбрасывать рассуждение, а попробовать вытащить из него полезную часть и переупаковать в ответ. В бой пока не ставил, и даже не приступал в качестве бэклога пробовать. Есть принцип: «в проде живёт только проверенное«… И его пока не отменяли. Хотя как ещё один слой перед заглушкой менеджера вариант выглядит рабочим.
Почему автотесты это не поймают баг
Логичный вопрос возникает: «а почему не покрыть тестами!?». Вкратце отвечаю тремя аргументами:
1. Семантика. Клиентка написала не просто «спасибо» (это покрыто скриптом), а живую рефлексию про видеоблог и вылазку. Тест-кейсы под бесконечные варианты человеческой речи не пишутся, особенно на этапе, когда проект уже в работе давновато, и логично что в директ пишут люди метафорично/ эмоционально/ образно и т.п.
2. Комбинаторика. Решение принимается на пересечении: системный промпт на 6.4k токенов + история диалога из Redis + стадия сделки + конкретные слова против триггеров тулов. Баг воспроизводится только на уникальной комбинации = даже миллионы вариантов в стенд не занесёшь.
3. Стохастика. Даже на идентичном вводе LLM при ненулевой температуре может девять раз ответить идеально, а на десятый уйти в ветку молчания. Своеобразный чёрный ящик, меняющий поведение от запуска к запуску, не даёт стопроцентной гарантии.
И вывод, который мне нравится больше )как в принципе и заказчикам), чем «мы всё протестировали»: такой баг не покрывается тестами — он измеряется в проде. Метрика здесь не требует никакого учёта: каждая вспышка бага = упавшая нода отправки в executions с ошибкой "non-empty string«. Фильтр в n8n нативный = счётчик уже существует, я его не завожу, я его читаю. До фикса счётчик ловил вспышки (четыре случая, два последних с интервалом в сутки), после внедрения слоёв счётчик переезжает в валидатор: каждая его запись = попытка модели молчать. Если лог пуст означает что слои работают.
Эпилог: страховка дала первый бой
Понятно что сразу увидеть в данном случае, работают ли все слои защиты, не возможно. Поэтому я тоже в статье ранее обещал написать нынешнюю только после того как «проверка» новых слоёв защиты сработает на живом случае. И вот через несколько дней после установки прод сам провёл первую проверку. Не тестом а живым диалогом с юзером.
Ночь, смену держит LLM. Клиентка отвечает на вопросы анкеты двумя сообщениями подряд, как люди и пишут: сначала данные, потом номер. И следом кидает медиа — которое Meta везёт в amoCRM текстовой заглушкой «Мы приносим свои извинения, но из-за ограничений…»
Подтверждение от ИИ-агента уходит в 23:20. Дата рождения принята. Пол принят. Сфера принята. а вот Телефон/Связь: не указан = в этом прогоне номер в контекст агента не доехал!
И вот здесь надо заметить, что НЕ произошло. Агент мог «услужливо» вытянуть номер из чего угодно: из обрывков контекста, из случайных цифр рядом. Ровно об этом была третья статья раньше ( https://habr.com/ru/articles/1076198/ ) : распознавание телефона нельзя доверять модели на слово. Вместо этого в карточке честное «не указан», диалог продолжается. Молчание бывает разное: в главной части статьи молчание модели = баг, а здесь молчание поля = фича. Протокол честности не дал системе наврать там, где нужен точный факт.
Дальше = то, ради чего всё строилось. Повторный запрос доехал, и в 23:38 подтверждение уходит уже с номером:
Теперь без технической части — что это стоит бизнесу. Номер в карточке = это не «поле заполнено». Это тег на сделке и задача менеджеру на утро: связаться с человеком, который уже всё выбрал и ждёт звонка. А теперь отнимите один слой: сообщение с номером пропало насовсем, клиентка не продублировала = бабах, и утром менеджер открывает сделку с дырой на месте телефона, а клиентка за ночь успевает прочитать каталог того, кто отвечает быстрее. Ночные лиды не ждут = они покупают у тех, кто дошёл до них за минуту, а не к десяти утра.
Технический итог моего эпилога: буфер и дебаунс из второй статьи приняли запоздавший запрос, память склеила анкету в одну сделку, протокол честности не дал системе наврать в первом прогоне, а валидатор из этой статьи гарантировал, что оба подтверждения уехали непустыми и доставленными. Что именно стрельнуло той ночью — потеря на канале или тупик генерации — разделяют executions и лог провайдера; клиенту разницы нет, а точную атрибуцию сведу в итоговой заметке.
Если свести всё к нескольким правилам
1. Жёсткий промпт без fallback-сценария = тупик на каждом непокрытом сценарии.
2. Reasoning-модель сначала проверяет ответ на запреты, и только потом пишет; молчание для неё как бы «безопаснее» нарушения.
3. Пустой контент + потраченные токены + finish_reason: stop = Reasoning Lock. Детектор однозначный.
4. Страховка от этого живёт в executions, а не в тестах: упала нода отправки с «non-empty string» озночает что искать иди вверх по цепочке.
5. Новый тип сообщения в жизни клиентов = новый легальный ход в карте промпта. Карта должна расти вместе с жизнью.
Итоговая заметка
Слои стоят в работе, счётчик пустых ответов тикает. Через месяц можно уже и короткую заметку с цифрами фиксировать: сколько раз модель пробовала молчать после фикса, что поймал валидатор, что показала телеметрия <think>. Баг редкий, но его класс растёт вместе с промптом. Теперь на него есть своё решение)
Что дальше?
Пока счётчик тикает,Вот чем поделюсь: пару недель пилил бэклог и согласовывал с заказчиком два продолжения проекта. Первое — виджет на сайт: заявки должны собираться круглосуточно и с сайта, тем же ассистентом. Второе — таргет у них работает на зарубежную аудиторию, поэтому Facebook-страницу тоже ставим на «ИИ-смену». Один мозг, три входа: НЕЛЬЗЯgram Direct, Facebook Direct и виджет на сайте. Как я уложу это в один воркфлоу (или несколько суб-воркфлоу) (и почему именно так решил с виджетом), и в принципе как у меня получится (все баги и ошибки), и с чего начну первым — в следующих статьях поделюсь с вами.
Всем добра! И спасибо за внимание)
ссылка на оригинал статьи https://habr.com/ru/articles/1081780/