Daily MaverickDrought-hit Kenya should lift ban on GMO maize imports to prioritise its food securityESPN Deportes"Voy a necesitarlos": Ben Shelton hizo un serio pedido antes de enfrentar a Zverev en la finalESPN25 years after 9/11, Strahan, Testaverde, others relive the emotional memoriesThe Jerusalem Post'Death of a Democracy': Exploring the rise and fall of Weimar Germany - reviewוואלהחמישה קטינים נעצרו בחשד למעורבות באירוע דקירה בחולוןRTP DesportoEnric Mas confirma triunfo após 21.ª etapa vencida por Tobias Halland JohannessenInquirerRidon: AMLC report will confirm P6.7-B Duterte-Carpio transactionsVarietyCourteney Cox Remembers ‘Scream’ Co-Star Hayden Panettiere: ‘She’s a Really Wonderful Actor’Global NewsRCMP investigate Halifax-area arson, say fire linked to 2 others in past weekIl Fatto QuotidianoBen Gvir e il ministro della Cultura attaccano i registi di Naza: “Tradimento di Stato, andatevene”Wirtualna PolskaŁoś na torach w Warszawie. Poważne utrudnieniaCNN بالعربيةجدل حول صحة هدف فوز السيتي في ديربي مانشستر.. وهيئة التحكيم توضح
The Daily Newsstand · Free, Always
Sunday, September 13, 2026

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

Translate

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

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.