- BrainTools - https://www.braintools.ru -

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

Дисклеймер: в статье упоминается Meta — организация, признанная экстремистской и запрещённая на территории РФ.

Все ключи, телефоны и названия в примерах изменены; код, схема, логика [1] и скрины‑ из рабочего проекта заказчика, и строго с его разрешения! Статья не является рекламой/самопиаром и тому подобное.

В прошлой статье [2] я рассказывал о скрытом баге: ИИ‑агент в проде заказчика тратит токены, генерация закрывается со статусом «успех», а клиенту уезжает пустая строка. Тогда я обещал разобраться и починить. Обещание выполнено: причина найдена, правки встали в воркфлоу, счётчик пустых ответов уже 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, память [3], диск, сеть = чисто. 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».

та же генерация глазами 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` | и это НЕ модерация |

Математика [4] сразу в один взгляд: reasoning / completion = 88/88 = 100%. Модель «говорила» только с собой, ни одного токена не дошло до поля content, и она сама закрыла генерацию со статусом «всё хорошо».

Сверяю случаи. Этот лог, 88 токенов — из того самого диалога из начала статьи: клиентка с «вылазкой и вдохновением». Случай на скринах выше — 95 completion‑токенов — это другой диалог, более ранний. Цифры разные, промптовые тоже, но сигнатура один в один: text пуст, генерация «успешна». Начинаю убеждаться что разовый глюк превратился в воспроизводимый паттерн — баг:‑(

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

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

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

Теперь соберём пазл. Системный промпт (его структура — в статье [5]) построен на детерминизме: «детерминистическая карта тегов», Ступени 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/ [5]) я писал: «если модель забыла теги то сработают дефолты, отказ безопасен по построению». Так вот, уточняю границы того обещания: дефолты страхуют отказ типа «теги потерялись» но не защищает от случая когда «ответа нет вообще». Пустая строка проходит мимо всех дефолтов насквозь, до самой ноды отправки. И чем строже промпт, тем больше сценариев, где молчание = формально безопасный ход: детерминизм, который я внедрил как фичу, здесь сыграл против меня. Это не баг конкретной версии — это свойство класса 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, репозиторий открыт). К моменту разговора мои три слоя уже были набросаны в воркфлоу, однако мой интерес [6] был другой: как ту же задачу решает движок, построенный с нуля, без n8n? Ответ совпал послойно: платформа считает пустой ответ при потраченных токенах неуспешным завершением (FAILED + автоматический повтор), а в графе декларативно описывается узел‑обработчик пустого ответа. Два движка, написанных независимо друг от друга: n8n+Redis и Go+Rust… В данной задаче сошлись на одной и той же схеме. Видимо, это и есть своего рода канон.

Из того же разговора забрал в бэклог идею, которой в моём плане не было: если content пуст, а reasoning содержательный то НЕ выбрасывать рассуждение, а попробовать вытащить из него полезную часть и переупаковать в ответ. В бой пока не ставил, и даже не приступал в качестве бэклога пробовать. Есть принцип: «в проде живёт только проверенное»… И его пока не отменяли. Хотя как ещё один слой перед заглушкой менеджера вариант выглядит рабочим.

Почему автотесты это не поймают баг

Логичный вопрос возникает: «а почему не покрыть тестами!?». Вкратце отвечаю тремя аргументами:

  1. Семантика. Клиентка написала не просто «спасибо» (это покрыто скриптом), а живую рефлексию про видеоблог и вылазку. Тест‑кейсы под бесконечные варианты человеческой речи не пишутся, особенно на этапе, когда проект уже в работе давновато, и логично что в директ пишут люди метафорично/ эмоционально/ образно и тому подобное

  2. Комбинаторика. Решение принимается на пересечении: системный промпт на 6.4k токенов + история диалога из Redis + стадия сделки + конкретные слова против триггеров тулов. Баг воспроизводится только на уникальной комбинации = даже миллионы вариантов в стенд не занесёшь.

  3. Стохастика. Даже на идентичном вводе LLM при ненулевой температуре может девять раз ответить идеально, а на десятый уйти в ветку молчания. Своеобразный чёрный ящик, меняющий поведение [7] от запуска к запуску, не даёт стопроцентной гарантии.

И вывод, который мне нравится больше )как в принципе и заказчикам), чем «мы всё протестировали»: такой баг не покрывается тестами — он измеряется в проде. Метрика здесь не требует никакого учёта: каждая вспышка бага = упавшая нода отправки в executions с ошибкой [8] "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 без номера, хотя клиентка прислала его минутой раньше

И вот здесь надо заметить, что НЕ произошло. Агент мог «услужливо» вытянуть номер из чего угодно: из обрывков контекста, из случайных цифр рядом. Ровно об этом была третья статья раньше [5]: распознавание телефона нельзя доверять модели на слово. Вместо этого в карточке честное «не указан», диалог продолжается. Молчание бывает разное: в главной части статьи молчание модели = баг, а здесь молчание поля = фича. Протокол честности не дал системе наврать там, где нужен точный факт.

Дальше = то, ради чего всё строилось. Повторный запрос доехал, и в 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‑страницу тоже ставим на «ИИ‑смену». Один мозг [9], три входа: НЕЛЬЗЯgram Direct, Facebook Direct и виджет на сайте. Как я уложу это в один воркфлоу (или несколько суб‑воркфлоу) (и почему именно так решил с виджетом), и в принципе как у меня получится (все баги и ошибки), и с чего начну первым — в следующих статьях поделюсь с вами.

Всем добра! И спасибо за внимание [10]:‑)

Автор: gotham_engineer

Источник [11]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35449

URLs in this post:

[1] логика: http://www.braintools.ru/article/7640

[2] прошлой статье: https://habr.com/ru/articles/1076084/

[3] память: http://www.braintools.ru/article/4140

[4] Математика: http://www.braintools.ru/article/7620

[5] в статье: https://habr.com/ru/articles/1076198/

[6] интерес: http://www.braintools.ru/article/4220

[7] поведение: http://www.braintools.ru/article/9372

[8] ошибкой: http://www.braintools.ru/article/4192

[9] мозг: http://www.braintools.ru/parts-of-the-brain

[10] внимание: http://www.braintools.ru/article/7595

[11] Источник: https://habr.com/ru/articles/1081780/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1081780

www.BrainTools.ru

Rambler's Top100