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:
Ложный след: инфраструктура
Первая моя гипотеза данного инцидента банальная: сервер. Termius, SSH: CPU, память, диск, сеть = чисто. n8n жив, Redis жив, воркер не падал, OOM нет. Перезапускать нечего. Первая гипотеза отверглась быстро.
Зацепка: execution-логи
Иду в executions n8n, фильтр по ошибкам. И вот она:
```text
NodeApiError: 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 и виджет на сайте. Как я уложу это в один воркфлоу (или несколько суб-воркфлоу) (и почему именно так решил с виджетом), и в принципе как у меня получится (все баги и ошибки), и с чего начну первым - в следующих статьях поделюсь с вами.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.