Reasoning Lock: ИИ-агент в проде тратит токены и молчит — живой разбор бага

от автора

Дисклеймер: в статье упоминается 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:

Хронология: 1 - клиент ведёт беседу; 2 - аи агент отвечает; 3 - ИМЕННО НА ЭТО СООБЩЕНИЯ LLM замолчал

Хронология: 1 — клиент ведёт беседу; 2 — аи агент отвечает; 3 — ИМЕННО НА ЭТО СООБЩЕНИЯ LLM замолчал

Ложный след: инфраструктура

Первая моя гипотеза данного инцидента банальная: сервер. 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«.

та же генерация глазами n8n - нода Send direct message2 с красным крестом, "Error in 19.917s"

та же генерация глазами n8n — нода Send direct message2 с красным крестом, «Error in 19.917s»
OUTPUT упавшей ноды

OUTPUT упавшей ноды

Значит, вопрос в чём причина бага переезжает вверх по цепочке… Так кто обнулил текст?!

Следующая остановка моя — это выход самого агента. И сразу отмечу для чистоты: дальше пойдёт другой случай из той же четвёрки — не диалог из начала статьи, а более ранний. Открываю ноду агента и смотрю её OUTPUT в execution:

 нода агента по этому второму случаю - обычный вход: сообщение: {{ $json.userText }}, клиент: {{ ...chatId }}

 нода агента по этому второму случаю — обычный вход: сообщение: {{ $json.userText }}, клиент: {{ ...chatId }}
OUTPUT агента по тому же второму случаю - "text": "", finish_reason: "stop", completionTokens: 95, promptTokens: 6677. Красные стрелки мои)

OUTPUT агента по тому же второму случаю — "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 пуст, генерация «успешна». Начинаю убеждаться что разовый глюк превратился в воспроизводимый паттерн — баг(

карточка генерации  "пустоты " в OpenRouter: "6 406 - 88", $0.00228, кэш активен.

карточка генерации «пустоты » в OpenRouter: «6 406 — 88», $0.00228, кэш активен.

Механика иронии: как жёсткий промпт учит модель молчать

Теперь соберём пазл. Системный промпт (его структура — в статье 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 текстовой заглушкой «Мы приносим свои извинения, но из-за ограничений…»

1: анкета текстом в 23:18; 2: номер в 23:19  и следом заглушка Meta за медиа

1: анкета текстом в 23:18; 2: номер в 23:19 и следом заглушка Meta за медиа

Подтверждение от ИИ-агента уходит в 23:20. Дата рождения принята. Пол принят. Сфера принята. а вот Телефон/Связь: не указан = в этом прогоне номер в контекст агента не доехал!

подтверждение в 23:20 без номера, хотя клиентка прислала его минутой раньше

подтверждение в 23:20 без номера, хотя клиентка прислала его минутой раньше

И вот здесь надо заметить, что НЕ произошло. Агент мог «услужливо» вытянуть номер из чего угодно: из обрывков контекста, из случайных цифр рядом. Ровно об этом была третья статья раньше ( https://habr.com/ru/articles/1076198/ ) : распознавание телефона нельзя доверять модели на слово. Вместо этого в карточке честное «не указан», диалог продолжается. Молчание бывает разное: в главной части статьи молчание модели = баг, а здесь молчание поля = фича. Протокол честности не дал системе наврать там, где нужен точный факт.

Дальше = то, ради чего всё строилось. Повторный запрос доехал, и в 23:38 подтверждение уходит уже с номером:

время уже 23:38 -  "Телефон/Связь: +375…". Анкета закрылась целиком, номер в CRM, тег на сделке, задача менеджеру создана.

время уже 23:38 — «Телефон/Связь: +375…». Анкета закрылась целиком, номер в CRM, тег на сделке, задача менеджеру создана.

Теперь без технической части — что это стоит бизнесу. Номер в карточке = это не «поле заполнено». Это тег на сделке и задача менеджеру на утро: связаться с человеком, который уже всё выбрал и ждёт звонка. А теперь отнимите один слой: сообщение с номером пропало насовсем, клиентка не продублировала = бабах, и утром менеджер открывает сделку с дырой на месте телефона, а клиентка за ночь успевает прочитать каталог того, кто отвечает быстрее. Ночные лиды не ждут = они покупают у тех, кто дошёл до них за минуту, а не к десяти утра.

Технический итог моего эпилога: буфер и дебаунс из второй статьи приняли запоздавший запрос, память склеила анкету в одну сделку, протокол честности не дал системе наврать в первом прогоне, а валидатор из этой статьи гарантировал, что оба подтверждения уехали непустыми и доставленными. Что именно стрельнуло той ночью — потеря на канале или тупик генерации — разделяют 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/