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

1. -  20:02 агент отправил подтверждение заказа = клиентка в 20:06 ответила "Да";  2. -  после этого только  запись n8n в поле сделки MESSENGER-ID. В директе тишина
1. - 20:02 агент отправил подтверждение заказа = клиентка в 20:06 ответила "Да"; 2. - после этого только запись n8n в поле сделки MESSENGER-ID. В директе тишина

Пункт 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') - схлопнуть провалы в одиночный перенос. В тот же вечер она прислала открытку:

То самое сообщение в amoCRM: "доставлено", приветствие, цены, список для анкеты и «Буду рада помочь». Строки идут одна за другой, пустых строк между абзацами нет - читается как стена текста
То самое сообщение в amoCRM: "доставлено", приветствие, цены, список для анкеты и «Буду рада помочь». Строки идут одна за другой, пустых строк между абзацами нет - читается как стена текста

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

Нода Send direct message1 после правки: слева в Input живой formattedOutput с \n\n
Нода Send direct message1 после правки: слева в Input живой 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 подлкючение. Но самая фишка , хотя заказчик пока вроде как отложил до середины ноября -начала декабря - это визуальная обработка украшений: её разработка всё ни как не даёт мне покоя из всех проектов что есть, хоть кому то другому разрабатывай так сказать (кстати мне её подсказал в личке один старожил Хабра - задумка пушка!)

Всем добра, и спасибо за внимание)

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