Ця стаття — коротка. Вона не пояснює архітектуру, не будує наратив. Це довідник для розробника, який будує AI-агента на n8n і хоче не витратити 4-6 годин на кожен з цих багів (як витратив я).
Всі п'ять — з реального debugging journey puramur sales-агента: 39 нодів, semantic router, state machine з 8 стадій, LOAD → PROCESS → SAVE цикли з Postgres на кожному повідомленні. Симптоми, репродукції і фікси перевірені у продакшн workflow.
Мета: пробігтись по 5 карткам за 15 хвилин, зафіксувати паттерни у пам'яті, і при наступному дебагу впізнати їх за 2 хвилини замість 2 годин.
Bug #1 — AI Agent не пропускає input далі
AI Agent output не містить попередній input
Ти передав state у AI Agent, він відпрацював, генерував відповідь. У наступній ноді (Extract State Update code) звертаєшся до $json.state.stage — undefined. У $json тільки поле output (текст LLM) і нічого крім нього.
Load Session State → Build Prompt → AI Agent → Extract State Update.
У Extract State Update пишеш:
const state = $json.state; // undefined
const customer = $json.state.customer; // TypeError
const llmOutput = $json.output; // worksAI Agent node у n8n не є transparent proxy. Він не передає input.json далі — його output це тільки те що згенерував LLM (+ intermediate steps і tool results, залежно від конфігу). Всі попередні поля з input pipeline «зникають» на цій ноді.
Це поведінка «by design» але контрінтуїтивна — більшість інших нод (Code, Set, HTTP Request) або пропускають input далі, або мають опції на це.
Звертайся до попередніх нод по імені, а не через $json. У n8n синтаксис: $('Node Name').first().json.
const state = $('Route Decision').first().json.state;
const customer = state.customer;
const llmOutput = $json.output; // AI Agent outputЦе працює на будь-якому вузлі pipeline незалежно від того що між ними. n8n тримає результати кожної ноди у виконанні workflow і $(...) — це прямий доступ до них.
У n8n workflow з AI Agent — ніколи не покладайся на $json для state. Завжди використовуй $('SpecificNode').first().json. Це trivial refactor який рятує від класу помилок, а іменована залежність — легше читається у review.
Bug #2 — Postgres executeQuery підміняє input
Після Postgres executeQuery оригінальний input недоступний як $json
Ти зробив Postgres node з операцією Execute Query (наприклад SELECT з бази знань). Наступна нода — Code, яка має використати і SQL результат, і дані з попередніх нод. $json містить тільки колонки SELECT'а. Дані до Postgres — не видно.
Detect Language (виставляє session_id, chat_id) → Postgres executeQuery SELECT * FROM kb_faq WHERE ... → Code node:
const faqRows = $input.all(); // rows з SQL
const chatId = $json.chat_id; // undefined!
const sessionId = $json.session_id; // undefined!Postgres node у executeQuery mode заміщає input результатом SELECT. Кожен рядок з SQL стає окремим item у output. Оригінальні поля з попередніх нод не мержаться. Це відрізняється від Insert/Update operations, де можна включити return.
Плутанина посилюється тим що для деяких Postgres операцій (Insert with returnAll) поведінка інша — там input може частково пройти.
Той самий паттерн що з AI Agent — звертайся до попередньої ноди по імені:
const faqRows = $input.all();
const chatId = $('Detect Language').first().json.chat_id;
const sessionId = $('Detect Language').first().json.session_id;Це має ще одну перевагу — робить залежності явними. Якщо хтось перейменує Detect Language, workflow одразу впаде з ясним повідомленням, а не тихо працюватиме з undefined.
Bug #3 — Load node зависає на порожньому SELECT
Workflow обривається коли SELECT повертає 0 rows
Load Session State для нової сесії (session_id ще не в БД). SELECT повертає 0 rows. Наступні ноди не запускаються. Workflow виглядає «застряглим». У executions dashboard — status Waiting або обірваний run без помилки.
// Load Session State (Postgres executeQuery)
SELECT * FROM puramur_sessions_state
WHERE session_id = $1;
// Query Parameters
={{ [$('Detect Language').first().json.session_id] }}Для нового користувача цей SELECT повертає 0 rows. Наступний Code node «Initialize State» не запускається взагалі. Немає error, немає output, workflow просто зупиняється на Load Session State.
За замовчуванням n8n нода з zero items output обриває pipeline. Логіка «якщо немає результату — немає що передавати наступному». Це прийнятно для більшості випадків, але руйнує LOAD-паттерн для стану — саме коли нова сесія (немає рядка) треба створити default state, а не обірватись.
У Postgres ноди відкрий вкладку Settings (не Parameters!) і увімкни Always Output Data. Тепер при 0 rows нода віддасть один порожній item, далі — твій Init State code перевіряє це і будує default state.
// Initialize State (Code node, runOnceForAllItems)
const upstream = $('Detect Language').first().json;
const rows = $input.all();
let state;
if (rows.length === 0 || !rows[0].json.session_id) {
// Нова сесія — default state
state = {
session_id: upstream.session_id,
stage: 'greeting',
stage_iteration: 0,
customer: {},
pet: {},
cart: [],
is_new_session: true,
};
} else {
// Existing session — parse
const row = rows[0].json;
state = { ...row, is_new_session: false };
}
return [{ json: { ...upstream, state }}];Always Output Data потрібен на будь-якій Postgres/HTTP/Split ноді яка може легально повернути 0 items. Правило: якщо нода читає зовнішні дані і 0 items — це valid business case, увімкни toggle.
Bug #4 — Merge combineByPosition зависає на порожній гілці
Merge не запускається якщо один вхід порожній
Ти маєш workflow з IF-нодою що розгалужує на два шляхи. Обидва шляхи мерджаться назад через Merge (mode: combineByPosition). Коли IF йде в одну гілку — інша порожня — Merge не спрацьовує. Workflow виглядає «залипшим» на Merge ноді.
Route Decision (Code)
↓
Switch by Route (5 outputs: pricing / faq / handoff / off_topic / sales)
↓ ↓
Pricing branch (Postgres tool) Sales branch (RAG)
↓ ↓
Merge (combineByPosition) ← висить
↓
Build PromptКоли Route Decision класифікує запит як pricing, тільки pricing branch виконується. Sales branch віддає 0 items. Merge combineByPosition чекає items на обох входах — і зависає.
combineByPosition об'єднує items по індексу — item[0] лівого входу + item[0] правого входу → merged item[0]. Якщо один вхід має 0 items, для індексу 0 немає пари. Нода або зависає, або дає порожній output, або кидає internal error залежно від версії n8n.
Це фундаментальна невідповідність між ментальною моделлю розробника («мержу гілки») і фактичною роботою mode («роблю декартовий join по позиції»).
Використовуй Merge mode append замість combineByPosition. Append просто конкатенує items з обох входів. Порожня гілка — 0 items додається. Не-порожня — свої items. Далі Build Prompt code читає що прийшло і формує промпт.
// Merge node config
{
"mode": "append" // не "combineByPosition"
}
// Build Prompt (Code) — обробляє один або кілька items
const items = $input.all();
const route = $('Route Decision').first().json.route;
let context;
if (route === 'pricing') {
// items тут — pricing rows
context = formatPricingContext(items.map(i => i.json));
} else if (route === 'sales') {
// items — RAG chunks
context = formatRagContext(items.map(i => i.json));
}Альтернатива — робити окремі AI Agent ноди для кожної гілки і з'єднувати без Merge взагалі, використовуючи Switch → окремий AI Agent per route → окремий Extract → загальний Save. Але це розмножує кількість нодів у workflow, тому append + гілко-aware Build Prompt зазвичай кращий.
Bug #5 — queryReplacement з $json ламається після Postgres
Postgres SAVE node ламається з cryptic type errors
SAVE Session State (Postgres INSERT/UPSERT) кидає помилку типу:
error: column "customer" is of type jsonb but expression is of type text
error: invalid input syntax for type integer: "undefined"
error: null value in column "session_id" violates not-null constraintЦе трапляється тільки коли перед SAVE стоїть інша Postgres нода (наприклад SELECT для верифікації). Якщо тестуєш ізольовано з чистими даними — все працює.
// SAVE Postgres node, executeQuery mode
INSERT INTO puramur_sessions_state (
session_id, stage, customer, pet, cart_total
) VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::numeric);
// Query Parameters (проблемно)
={{ [
$json.session_id,
$json.stage,
JSON.stringify($json.customer),
JSON.stringify($json.pet),
$json.cart_total
] }}Тут $json вказує на попередню Postgres ноду (SELECT). Але поля session_id, stage, customer з state (згенерованого Extract State code), а не з SELECT результату.
Три речі накладаються одна на одну:
$jsonу Query Parameters посилається на прямий попередник у pipeline. Якщо це Postgres нода —$json= row з SELECT, а не state з Extract.- Якщо поле відсутнє (SELECT не містить
session_id) —$json.session_id=undefined. n8n серіалізує це у string"undefined", Postgres кидає type error. - Для JSONB полів потрібен явний
JSON.stringify. Без нього n8n передає JavaScript object як string"[object Object]", Postgres не парсить це як JSONB.
Три правила для Query Parameters у Postgres save-нодах:
={{ [
// (1) Явні посилання на ноди з даними
$('Extract State Update').first().json.state.session_id,
$('Extract State Update').first().json.state.stage,
// (2) JSON.stringify для JSONB полів
JSON.stringify($('Extract State Update').first().json.state.customer || {}),
JSON.stringify($('Extract State Update').first().json.state.pet || {}),
// (3) Number/Boolean casts для числових/boolean полів
Number($('Extract State Update').first().json.state.cart_total || 0),
Boolean($('Extract State Update').first().json.state.cart_gift_eligible)
] }}Плюс — у SQL використовуй явні cast: $1::text, $3::jsonb, $5::numeric. Це подвійна страховка: якщо JavaScript-сторона передає щось не те, cast зловить це з ясною помилкою «cannot cast X to Y», а не тихо запише stringified junk.
Об'єднана ментальна модель
Ці п'ять багів здаються різними, але у них спільна першопричина: ноди у n8n workflow не завжди пропускають input прозоро. AI Agent і Postgres executeQuery активно замінюють input своїм output. Merge combineByPosition не терпить асиметрії. Postgres не запускається на 0 items. Query Parameters лінкуються на прямий попередник, а не на «останнє релевантне джерело даних».
Ментальна модель яку я виробив після цього debugging journey:
- Не покладайся на
$json. Завжди звертайся до конкретних нод через$('NodeName').first().json.field. Це trivial refactor який рятує від класу помилок. - Читає з БД → Settings → Always Output Data. Пусті результати — це legitimate business state, не error.
- Merge = append, не combineByPosition. Якщо потрібен справжній JOIN по позиції, знай що робиш. У 95% AI workflow достатньо append.
- Postgres save = явні refs + typecasts. У Query Parameters:
$('...').first().json.state.field. У SQL:$1::jsonb. Без цього ти пишеш «work by luck». - Тестуй кожну ноду з edge cases. 0 rows, undefined fields, порожній branch у Switch. Успішний happy-path тест не покриває ці баги.
Pre-deploy checklist
- Всі references до попередніх нод через
$('NodeName').first().json, не$json - Всі Postgres ноди що читають — Settings → Always Output Data: ON
- Кожен Init State code node обробляє
rows.length === 0як valid path - Merge nodes → mode: append (не combineByPosition, крім явних join сценаріїв)
- Query Parameters у Postgres save-нодах — з явними refs до state-ноди
- JSONB fields →
JSON.stringify(...)+ SQL cast::jsonb - Numeric fields →
Number(...)+::numeric - Boolean fields →
Boolean(...)+::boolean - UPSERT для полів з history (handoff_triggered_at) →
COALESCE(existing, new) - Test cases: (a) новий session_id, (b) існуючий session_id, (c) кожна гілка Switch окремо