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

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

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

```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, кэш активен.

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

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

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

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

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

Комментарии (10)


  1. bleedly
    13.09.2026 17:56

    Нормуль разбор, слои защиты.. Но меня зацепил Слой 2 (Валидатор). Ты возвращаешь payload на ретрай во второй экземпляр агента с system_kick. Получается, при каждом таком тупике заново скармливается модели весь контекст диалога + системный промпт на 6400 токенов ради короткого ответа?7... Не думал в ветке ретрая динамически подменять системный промпт на сильно урезанную версию (чисто под Ступень 4), чтобы не переплачивать за prompt-токены на повторных генерациях? Или контекстный кэш опенроутер здесь всё равно полностью нивелирует траты на повторный прогон?


  1. gotham_engineer Автор
    13.09.2026 17:56

    Спасибо за коммент) отличный вопрос - видно что прочитали внимательно статью)) Наверное могу сказать что прямо в корень зрите! Отвечу вкратце: я по началу думал про урезание промпта на ретрае, но тут как раз решает контекстный кэш оpenrouter, который я в таблице показывал. Поскольку базовые 6,4 к. токенов на 90% сидят в кэше, повторный прогон стоит копейки. Да и блин, если я начну на ходу подменять системный промпт на "лёгкую" версию, я просто собью кэш и заплачу за этот "лёгкий" промпт полную стоимость, что экономически невыгодно (хоть и не мои деньги то, баланс не к моим картам привязан - но заказчик их контролит знатно). да и в рамках n8n + LangChain передавать "пинок" через payload в system_kick банально проще архитектурно, чем городить ветвление с подменой текстовых шаблонов в системной ноде. Но опять же - выше сказал своё ведение и решение ) За мысль спасибо, честно))


    1. bleedly
      13.09.2026 17:56

      Логично, про сбивание кэша. вышло бы дороже, делаешь уклон на экономию бюджета заказчика-совсем токсичный чтоли он такой?)) Контроль кошелька на опережение хорошая темка, если является залогом долгого сотрудничества, но если по боку будет или нет тогда и париться незачем. По поводу n8n костылить шаблоны в ноде ради ретрая - такое себе, пинок через payload изящнее.


  1. ToniDoni
    13.09.2026 17:56

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

    Почему нельзя? Более того, он у вас уже есть - тот самый промпт на котором генерился пустой ответ.


  1. gotham_engineer Автор
    13.09.2026 17:56

    Спасибо за комментарий) Поясню: в том-то и засада, что LLM стохастичны. Если температура выше нуля, модель на один и тот же промпт может 9 раз ответить нормально, а на 10-й уйти в глухую защиту и выдать пустоту. Всунуть этот кейс в автотест можно, но он будет так сказать, "мигать": тесты покажут зелёный свет, а в проде баг всё равно выстрелит. К тому же, если начать крутить системный промпт ради одной этой фразы, итогом будет сместить баланс запретов, и модель начнёт выдавать Reasoning Lock на сотне других живых диалогов, которых в тестах ещё нет.К сожалению классический QA тут сливается, эту дичь ловить приходится только динамически и прямо на лету, как раз через валидатор.


    1. ToniDoni
      13.09.2026 17:56

      А если температуру 0 поставить воспроизводится? А если неколько раз прогнать и порог частоты воспроизведения подобрать и необходимое число испытаний? Взять в охапку ChatGPT и подумать с ней как здесь могут помочь матстат и теория надёжности.

      покажут зелёный свет, а в проде баг всё равно выстрелит

      Aways has been. И это ника не отменяет необходимость делать тесты, тобы не получилось почти как в анекдоте, выкатили новую версию с фиксами, пришёл чувак с тем же запросам и все опять упало) А потом ещё и выяснится что на том самом poison message новый солюшн даже и не проверяли...

      ловить приходится только динамически и прямо на лету, как раз через валидатор.

      Так это не отменяет необходимость QAить динамический валидатор, а тут и тот самый промпт пригодится


      1. gotham_engineer Автор
        13.09.2026 17:56

        Спасибо за развернутый комментарий и отличные инженерные мысли! Про стат-анализ, теорию надежности и фиксацию temperature: 0 для дебага конечно абсолютно правильная база для классического бэкенда.

        Однако в данном кейсе классический QA упирается в тупик. В одной из прошлых статей (https://habr.com/ru/articles/1076198/) я подробно описывал архитектуру этого агента. Промпт там не просто текст, это жесткая детерминированная карта тегов ([VERDICT:], [TASK:], [STAGE:]), совмещенная с жесткой иерархией Ступеней и JS-логикой внутри кастомных инструментов. Посмотреть на такое и становится понятно, почему автотесты на воспроизведение бессильны: 1. Комбинаторный взрыв в карте тегов. Модель обязана нарезать логику по строгим рельсам и не имеет права отвечать “из головы”. Как только на вход прилетает живая человеческая рефлексия (вне скриптов), в блоке на нулевой температуре модель гарантированно упирается в логический тупик: вызвать тул нельзя (нет триггера) = ответить от себя запрещено =единственный compliant-вывод, не нарушающий карту тегов = промолчать.
Тестировать “мигание” или семантику? Да, мы можем зафиксировать temperature: 0 и отловить этот конкретный poison message. Но семантика человеческой речи бесконечна. Завтра клиент напишет метафору, послезавтра — скинет двусмысленный смайлик. Мы не можем написать миллион тестов на все оттенки человеческих мыслей, которые выбивают модель из детерминированной карты.
А если мы начять крутить промпт и баланс запретов ради прохождения одного этого теста - так это гарантированно сместим веса детерминированной карты. На тесте всё будет “зеленым”, а в проде получится Reasoning Lock на сотне других живых диалогов, которых в базе тестов еще просто нет.
 Вы абсолютно правы в одном: QAить сам динамический валидатор — нужно. И именно для этого используется этот “отравленный” промпт на тестовом стенде. Но задача данного теста - проверить не то, что модель ответит хорошо, а то, что наш валидатор в n8n корректно перехватит пустоту, залогирует её через include_reasoning и сделает правильный ретрай с пинком. С рассуждающими LLM и жесткими бизнес-ограничениями физически нельзя гарантировать идеальное поведение модели тестами, поэтому фокус надежности смещается с “написать идеальный тест” на “построить отказоустойчивую архитектуру (слои защиты) вокруг модели”. И детерминированная карта тегов здесь как раз доказывает, что ловить такие глухие защиты нужно динамически и на лету.


        1. ToniDoni
          13.09.2026 17:56

          Промпт там не просто текст, это жесткая детерминированная карта тегов ([VERDICT:], [TASK:], [STAGE:]),

          А у вас логи не сохранились? Можно же просто послать в модель весь контекст который был на тот момент у модели и она по идее должна опять зависнуть с какой-то вероятностью, Вы же утверждаете что она может сама по себе зависнуть при некотором контексте, и так можно проверить ваш валидатор почти в e2e тесте.

          ловить такие глухие защиты нужно динамически и на лету.

          С тем что нужно в рантайме уметь обрабатывать ошибки в том числе пустые ответы никто не спорит) Но я за то что некоторые сценарии которые стрельнули на проде стоит добавить в регрессию.


          1. gotham_engineer Автор
            13.09.2026 17:56

            Запустить тот же контекст в ллм-ку, чтобы она "опять зависла с какой-то вероятностью" - это отличный способ сжечь токены, но плохой способ построить надежный бэкенд к сожалению( я уже писал выше, этот poison message давно на стенде. Но мы тестируем им валидатор, а не стохастический "черный ящик" так сказать. впринципе то у LLM-агентов регрессия самой модели не гарантирует ровным счетом ничего: завтра юзер напишет ту же мысль, но другими словами, и база тестов радостно загорится зеленым, а прод снова упадет. Именно поэтому фокус надежности смещается с регрессии модели на жесткую рантайм-архитектуру вокруг нее. Могу вкратце рассказать что когда на 2х недельном бесплатном периоде после внедрения этого проекта, мне правок в промт прилетало что то около 6 штук (таких конкретных причём), и всё из за того что на стадии согласования ТЗ из 9 листов скринов ответов для нейронки, дословно, "...таких вариантов мы не продумали, надо как то сделать чтобы не повторилось Артём..."... наверное именно поэтому данный проект запомнится на долго мне)))


            1. ToniDoni
              13.09.2026 17:56

              Опять вы почему-то настаиваете, что если у вас стохастическая система, то гарантировать ничего нельзя. Но, во-первых, любая система отчасти стохастическая. Во-вторых, можно с определенной вероятностью. Вы же понимаете разницу между вероятностью безотказной работы 99,9 % и 0.1%.

              завтра юзер напишет ту же мысль, но другими словами, и база тестов радостно загорится зеленым, а прод снова упадет. 

              значит вы не пофиксили суть проблемы и не смогли воспроизвести ее в тестах. В вашем случае тест считается состоявшимся если модель сгенерировала пустой промпт, а фикс рабочим если приложение от этого не сломалось.

              мы тестируем им валидатор, а не стохастический "черный ящик" так сказать

              Это не отменяет необходимость делать е2е тесты, а то получится что компонент отдельно протестирован и работает, а когда выкатили систему с ним на прод - она при первом сообщении упала, потому что просто не проверили все вместе.

              Я бы очень сомневался стоит ли сайнофить такой релиз на прод, если есть известные режимы работы системы которые приводили к инцидентам и которые не проверены в е2е тестах.

              А если удалось вручную протестить е2е, вроде вы сами об этом писали, то стоит подумать с нейронками, как бы это засунуть в регрессию, чтобы потом случайно не разломать - для этого же регрессия и нужна.

              Но дело хозяйское)