Дисклеймер: в статье упоминается Meta - организация, признанная экстремистской и запрещённая на территории РФ.
Все ключи, телефоны и названия в примерах изменены; код, схема, логика и скрины‑ из рабочего проекта заказчика, и строго с его разрешения! Статья не является рекламой/самопиаром и тому подобное .
Всем привет!) В своей статье ( https://habr.com/ru/articles/1081780/ ) я второпях лечил ИИ-агента от Reasoning Lock: рассуждающая модель в проде заказчика тратила токены на скрытые мысли, закрывала генерацию со статусом "успех" - и клиенту уезжала пустая строка. Я добавил в промпт Ступень 4 "Светская беседа", поставил валидатор пустого ответа с ретраем и включил телеметрию <think>. И в итоговой заметке написал сакраментальное: "лог пуст - слои работают" .
Лог молчал ровно 20 дней. В процессе обычной рутины, я и позабыл в принципе про данный проект - заказчик не звонит = значит всё нормально. Занимаюсь в штатном режиме как говорится. Однако на днях, через заказчика менеджеры снова прислали "приветы": клиент пишет в Директ а агент молчит...
Но на этот раз виновата не LLM, а то, что вокруг неё: невидимые символы, проверка "не пусто", которая проверяла не то, и один выключенный мной чекбокс. Слои из прошлой статьи работали. Просто смотрели не туда.
И сразу про долги. В комментариях к той статье мне справедливо накидали две вещи: валидатор тестируй не отдельно, а вместе с системой (e2e); и инвариант "пустой content + потраченные токены + finish_reason: stop не равно успех" надо проверять тестом, а ещё вопрос был про ретрай после уже выполненных действий. Ребята, вы были правы, и новый баг прошёл ровно сквозь оба пункта. Разбор будет в главе 1 - для структурности я поделил по главам разбор.
Для тех, кто не в курсе: я строю ИИ-агентов на self-hosted n8n. Герой = агент в проде заказчика: по ночам принимает заявки в НЕЛЬЗЯgram Direct ателье украшений, ведёт диалог, собирает анкету и создаёт сделку в amoCRM. Стек тот же: n8n + Redis (память диалогов, TTL 30 дней) + LangChain, системный промпт на с шестью тулзами. Поменялась только LLM: вместо DeepSeek V4 Pro теперь z-ai/glm-5.3-flash через OpenRouter, резервная LLMqwen/qwen3.8-flash. воркфлоу за это время вырос с 64 до 88 нод :

Под разбором три истории: тишина под честный 200 OK, Bad request на ровном месте и слепой каталог. Плюс фикс - компактное выражение на JS, которым я доволен. Теперь по делу и по порядку...
Глава 1. Тишина под зелёными нодами
Начинаю как в прошлый раз: менеджеры бьют тревогу = я открываю executions. А там стерильная чистота. Ни одного упавшего прогона, ноды отправки зелёные, сделки капают в amoCRM как по расписанию. Гипотеза "опять Reasoning Lock" рассыпается сразу: модель отвечает, причём шустро.
Вторая странность довольно таки позорная. Executions чистые не потому, что ловить нечего, а потому, что мы сами выключили сохранение успешных прогонов saveDataSuccessExecution: none = экономили диск. В прошлой статье я учил вас искать страховку в executions, а сам тихо себя ослепил... Первым делом возвращаю Save successful production executions. Расследовать вслепую явный спиритизм что ли.
Единственное место, где остались следы - amoCRM. Заказчик скинул мне в Viber скрин: клиентка ответила в директ "Да" и в ответ тишина. Я как раз сидел за компом и полез смотреть сразу.

Пункт 2 на скриншоте ключевой. Воркфлоу дошёл до самого конца: поля записались, ветка отработала, никто не упал. Упало "ничего" = тихо, успешно и с записью в CRM. Был ли в Директе пустой пузырь или вообще ничего, по CRM не видно...да и клиентке разницы нет..всё равно тишина.
Заодно ответ на вопрос из комментариев про ретрай после выполненных действий: прод ответил сам, CRM уже записана, а клиенту ничего не ушло. Guard "retry после tool-вызовов" по-прежнему в бэклоге, но теперь он должен начинаться с вопроса "что делать с уже записанными полями".
Начинай искать дальше причину - смотрю провайдер. Открываю карточку генерации в OpenRouter:
{ "provider_name": "Relace", "model": "z-ai/glm-5.3-flash-20260826", "streamed": true, "generation_time": 24608, "latency": 1293, "tokens_prompt": 6887, "tokens_completion": 911, "native_tokens_prompt": 5801, "native_tokens_completion": 871, "native_tokens_reasoning": 766, "native_tokens_cached": 0, "usage": 0.000639695, "finish_reason": "stop", "native_finish_reason": "stop", "content_guardrail_invoked": false }

871 − 766 = 105 видимых токенов. Сырого текста ответа в карточке нет, поэтому дальше - реконструкция: по объёму и по тому, что случилось ниже по цепочке, это были одни служебные теги протокола:
text [VERDICT:ORDER:DATA|HOT] [NEED_MANAGER_CALL] [STAGE:PERSONAL]
Вот новая анатомия призрака. В прошлый раз сигнатура была "ноль контента": все токены ушли в <think>, клиенту - пустая строка. Сейчас модель ответила. По протоколу. И ни одного человеческого слова. DeepSeek 4.0 PRO в непокрытом сценарии замыкался в себе, а GLM оказался примерным отличником так сказать: человеческий текст "не положен" = сдаёт одну форму. Мы просили протокол = получили, без приложения в виде ответа.
Почему промолчал валидатор? Он проверяет выход агента на пустоту, а выход непустой: для детектора теги это легитимный контент. Тот самый инвариант из комментариев тоже не сработал: токены потрачены - да, stop -да, а content непустой - нет. Он проверял факт отсутствия пустоты, а не "есть ли в ответе человек".
Дальше по конвейеру нода ✂️ Format Response for IG вырезает теги - так задумано, клиенту внутренности протокола не нужны. Но вместе с тегами уходит весь текст, и остаётся шлейф:
text модель: "\n\n[VERDICT:CHAT:CHAT_ONLY] [TASK:NONE] [STAGE:CONSULT]\n" Format: теги вырезаны → остаётся шлейф "\n\n \n" n8n: строка непустая ✓ Meta: символы есть ✓ клиент: пустота
В нодах отправки в директ при этом стоял guard из патча, который был разработан в прошлой статье:
{{ (() => { const fmt = $("✂️ Format Response for IG"); const raw = fmt.isExecuted ? fmt.item?.json?.formattedOutput : ''; const clean = (typeof raw === 'string' ? raw : '') .replace(/\n\s*\n/g, '\n\u00A0\n') .trim(); return clean || $("🔧 Stub Enrichment").item?.json?.formattedOutput || "Спасибо за Ваше сообщение!✨ Я передала его — менеджер ответит Вам в рабочее время."; })() }}
Логика казалась мне абсолютно железной: Format выполнился = значит берём его текст, а уж если не выполнился = значит берём заглушку. Тут "дырка" в том, что guard проверяет факт исполнения, а не содержимое. Честно: какую именно ветку тернарника выбрал n8n на проде, я не снимал (в песочнице старый $node[...].isExecuted вёл себя странно - отдавал undefined). Но итог от этого не меняется: шлейф "\n\n \n" - это непустая строка, и любая проверка "не пусто" его пропускает. Meta смотрит: символы есть? Значит 200 OK. Для n8n и Meta это валидный текст, для клиентки - ничего. Цепочка целиком:
text сообщение клиентки «Да» ↓ агент: 766 токенов в <think> → решение: «по протоколу» ↓ output: [VERDICT:…] [TASK:NONE] [STAGE:CONSULT] ↓ Reasoning_Validator: output непустой → SUCCESS (мимо) ↓ Format Response: вырезает теги → шлейф "\n\n \n" ↓ guard: шлейф непустой → заглушка не нужна ↓ Send direct message: строка непустая → 200 OK, нода зелёная ↓ клиентка ждёт ответа. тишина.
Немного философии хочется сказать: зелёная нода - худший вид красной = с падением хотя бы понятно где искать.
Глава 2. Bad request на ровном месте
Радость от диагноза длилась почти сутки. Потом в executions всплыла красная нода = самый дальний "рубильник":
text NodeApiError: Bad request - please check your parameters node: Send direct message1 (@mookielianhd/n8n-nodes-НЕЛЬЗЯgram, v1) operation: messaging / sendMessage, itemIndex: 0
Сразу про границы знания хочу прямо сказать чтобы потом не было обидно = Сырого лога у меня нет: нода отдаёт ошибку одной строкой, без httpCode и без error.description. Из какого именно места прилетел 400 - из Graph API или из самой ноды - по этому execution не скажу. Падение было на финальной анкете: имя, дата рождения, сфера, телефон, абзацы через \n\n. Это самое длинное сообщение в жизни агента.
Первая мысль: сырые переводы строк ломают JSON в теле запроса. Мимо = те же \n\n месяцами льпровлялись в директ нормально, включая "Итого… Все верно?" со скрина выше. Второй раз про это думать не буду.
Зато вторая мысль оказалась интереснее. Открыл документацию Meta (НЕЛЬЗЯgram API with НЕЛЬЗЯgram Login - Send Messages - Limitations) и нашёл вот это:
Message text must be UTF-8 and be a **1000 bytes** or less.
Не символов, а байт. А у нас кириллица: русская буква в UTF-8 весит 2 байта, эмодзи 3-4 байта (✨ = 3, 💎 = 4). Считаем: 1000 байт - это примерно 500–550 символов русского текста, а с эмодзи и телефоном ещё меньше. Я прогнал в Node обычный русский текст из 570 символов (с пробелами и знаками): он занял 1030 байт. Анкета с именем, датой, сферой, тремя абзацами и "Все верно?" легко переезжает такую планку и при этом выглядит совсем не длинной.
Это пока гипотеза, не приговор: лога нет, и я не знаю, какой именно вариант API использует community-нода. Но проверяется она дёшево = в упавшей ноде в Input лежит messageText, его достаточно скормить любому счётчику UTF-8 байт. Если там больше 1000, дело закрыто. Если меньше то гипотеза отпадает, и я напишу об этом. Упавшие прогоны n8n сохраняет всегда, так что при следующем Bad request материал у меня будет.
Самое обидное: \n\n = вообще не мусор и не баг модели. Это человеческие абзацы, как в Word. пайплайн, который лечил reasoning-тупики, споткнулся о перенос строки.
Глава 3. Слепой каталог и протухший валидатор
Пока разбирался с отправкой, дожал ещё жалобу менеджеров ( пока другими проектами и бэклогами занимался, у данного заказчика как говорится "накопилось") : "агент показывает не то". Просят серьги = вываливает полкаталога. Просят часы = снова полкаталога. Модель тупит? Нет, блин, ей не дали "руль" так сказать.
За каталог отвечает tool-нода get_product_catalog1. В ней включён Specify Input Schema, а поле Schema Type не задано, и n8n молча берёт дефолт - Generate From JSON Example: "дай пример данных, я сам выведу схему". А в jsonSchemaExample лежала не пример данных, а сама JSON Schema:
{ "type": "object", "properties": { "category": { "type": "string", "enum": ["bracelets", "earrings", "necklaces", "luch", "candles"], "description": "Категория украшений для выборки каталога" } }, "required": ["category"] }
n8n содержимое не проверяет: он честно принял схему за данные и вывел схему из схемы. Верхние ключи type, properties, required стали "примером объекта с тремя полями", а category уехала на дно. Я воспроизвёл это через generate-schema - вот что получила модель:
{ "type": "object", "properties": { "type": { "type": "string" }, "properties": { "type": "object", "properties": { "category": { "type": "object", "properties": { "type": {"type":"string"}, "enum": {"type":"array","items":{"type":"string"}}, "description": {"type":"string"} } } } }, "required": { "type": "array", "items": { "type": "string" } } } }
Поля category на верхнем уровне не существует. Код инструмента читает inputData.json.category, получает undefined и уходит в дефолт all. Любой запрос = весь каталог. Фикс в один клик: Schema Type - Define using JSON Schema и переезд текста из jsonSchemaExample в Input Schema. Один клик...часа 1,5 поиска... классика.
Бонус. Слой 2 из прошлой статьи - Reasoning_Validator с веткой RETRY_REQUIRED? Судя по коду, он протух. Он ищет в ответе агента tokenUsage, response.generations, generationInfo.finish_reason - внутренности Langсhain прошлых версий, а Tools Agent v3 отдаёт наружу только (output). Значит, счётчик токенов всегда false, каждая пустота уходит как EMPTY_NO_TOKENS мимо ретрая прямо в Fallback Stub. На живом воркфлоу я это не воспроизводил, так что пишу с оговоркой: по коду сходится. Но урок остаётся: защита протухает молча. Сменили версию ноды считай сменился контракт, и слой превратился в "декорацию" блин.
Глава 4. Фикс: одно выражение в поле Message Text
В поле messageText нужно было убить трёх зайцев:
1. Не уронить ноду, если Format в этом прогоне вообще не выполнялся (фулбэк-ветка);
2. Не пропустить шлейф, если Format выполнился и вычистил всё до переносов - это и есть дыра из главы 1.
3. Вернуть абзацы, потому что первая версия фикса уехала в Директ "стеной" (история ниже);
И всё это без новых нод: воркфлоу и так 88 штук. Код, который стоит в поле Message Text обеих нод отправки (Send direct message1 и Send direct message2):
{{ (() => { const fmt = $("✂️ Format Response for IG"); const raw = fmt.isExecuted ? fmt.item?.json?.formattedOutput : ''; const clean = (typeof raw === 'string' ? raw : '') .replace(/\n\s*\n/g, '\n\u00A0\n') .trim(); return clean || $("🔧 Stub Enrichment").item?.json?.formattedOutput || "Спасибо за Ваше сообщение!✨ Я передала его — менеджер ответит Вам в рабочее время."; })() }}
Десять строк. Что здесь важно:
$("Имя") вместо $node["Имя"]. Современный доступ возвращает умный объект с .isExecuted и .item. У легаси $node["X"] обращение к .json невыполненной ноды бросает исключение (Node 'X' hasn't been executed), и оператор ?. от него не спасает: исключение летит прямо из прокси, до null дело не доходит. Проверял в песочнице n8n-workflow (версия 2.25.7, на проде v2.41.4 , так что на своей версии перепроверьте):
| Выражение | A: текст есть | B: Format не выполнялся | C: Format дал пустоту | |---|---|---|---| | `$node["…"]?.json?.formattedOutput` | текст | исключение | заглушка | | Тернарник Патча | текст | заглушка | **шлейф уходит клиенту** | | Финальное выражение | текст | заглушка | заглушка |
.item, а не просто .json. Это Item Linking: n8n берёт результат ноды, связанный с текущим айтемом, даже если между ними десять нод и полхолста (тот самый канат со скрина 1). Оговорка: работает, пока цепочка не теряет связь элементов, проверялось только на топологии нашего воркфлоу.
typeof raw === 'string' = страховка на случай, если Format когда-нибудь вернёт объект: тогда мы молча уйдём в заглушку.
.replace(/\n\s*\n/g, '\n\u00A0\n').Регулярка ищет типа "провал" = перенос, любые пробелы, ещё перенос и подставляет \n + неразрывный пробел + \n. Получается визуально пустая строка между абзацами; одиночные \n в списках не трогаются, а шлейфы вроде \n \n схлопываются.
clean (..литерал это каскад из трёх уровней: что сказал ИИ - обогащённая заглушка из Stub Enrichment - строка прямо в выражении. Пустая после .trim() строка falsy, поэтому пустота и шлейф честно падают на следующий уровень. Сама затея "клиент получает текст всегда" держится ещё на одном условии: Stub Enrichment стоит в главной цепочке до нод отправки. Выносите его оттуда = пересчитывайте каскад.
Зачем IIFE? Выражение n8n - это ровно одно выражение, переменных там не объявить. Самовызывающаяся функция позволяет положить внутрь {{ }} настоящую логику и не плодить ноды.
Как первая версия ушла "стеной"
Первая боевая версия была суше: .replace(/\n\s*\n/g, '\n') - схлопнуть провалы в одиночный перенос. В тот же вечер она прислала открытку:

Моя же регулярка "слопала" \n\n: переносы остались, а воздух между абзацами исчез. Отсюда NBSP-разделитель: \n\u00A0\n рисует пустую строку, не оставляя двойного \n\n.

formattedOutput с \n\nК Bad request это исправление отношения не имеет, и честно: NBSP делает сообщение тяжелее. \n\n это 2 байта, а \n\u00A0\n уже 4 байта (NBSP в UTF-8 весит 2). Я проверил на тексте с двумя разрывами абзацев: +4 байта, то есть +2 байта на каждый абзац. При лимите в 1000 байт это не помощь, а минус. Так что страховку по длине надо считать в байтах, а не в символах. Вот вариант, который я собрал и прогнал в обычном Node (в песочнице n8n и на проде - нет), поэтому в прод пока не ставлю:
// вместо: return clean || ... || литерал let text = String(clean || $("🔧 Stub Enrichment").item?.json?.formattedOutput || "Спасибо за Ваше сообщение!✨ Я передала его — менеджер ответит Вам в рабочее время."); const bytes = s => [...s].reduce((n, c) => { const p = c.codePointAt(0); return n + (p < 0x80 ? 1 : p < 0x800 ? 2 : p < 0x10000 ? 3 : 4); }, 0); if (bytes(text) > 950) { const arr = [...text]; while (arr.length && bytes(arr.join('') + ' …') > 950) arr.pop(); text = arr.join('').trimEnd() + ' …'; } return text;
Запас 50 байт я взял на случай, если API считает как-то иначе, чем я. Минус: обрезка может отхватить хвост анкеты вместе с телефоном. Поэтому это крайняя мера, а нормальное решение в итоге - разбивать длинный ответ на два сообщения, но это уже отдельная нода и отдельная история. Сначала проверка гипотезы по реальному messageText, потом приговор.
Эпилог: что я забираю из этих 20 дней
1. Проверяй последний рубильник. Единственное место, где можно честно сказать "клиенту уйдёт человеческий текст" - это нода отправки, после всех вычисток тегов. Инвариант из прошлой статьи надо дописать: проверять не "пусто", а "есть ли человеческий текст".
2. Guard проверяет данные, а не факты. isExecuted отвечает "выполнялась ли нода", а не "есть ли в ней смысл".
3. Защита протухает молча. Версия ноды = это тоже релиз. Раз в месяц роняйте свои слои руками и смотрите, сработают ли.
4. В Generate From JSON Example кладут данные, не схему. Парсер не отличит одно от другого ... отличать должен я сам.
5. Не выключай логи успешных прогонов и читай лимиты API в байтах. Первое стоило мне этих дней "слепоты".. второе уже вероятная причина Bad request.
И обещанный поклон участникам спора про тесты в коментах прошлой статьи про баг. Вы были правы: инвариант валидатора живёт в стенде property-based тестом, а "человеческий текст" теперь проверяется ещё и на последней ноде. Это два этажа одной обороны, а не альтернативы.
Дальше: follow-up с цифрами (сколько раз модель пробовала молчать, что поймал валидатор), а когда сниму messageText с реального Bad request - вердикт по байтам. Потом виджет на сайт и Facebook подлкючение. Но самая фишка , хотя заказчик пока вроде как отложил до середины ноября -начала декабря - это визуальная обработка украшений: её разработка всё ни как не даёт мне покоя из всех проектов что есть, хоть кому то другому разрабатывай так сказать (кстати мне её подсказал в личке один старожил Хабра - задумка пушка!)
Всем добра, и спасибо за внимание)

