01 «Пам'ять» — це неправильна метафора
Коли ти вперше налаштовуєш AI Agent у n8n, ти бачиш там суб-нод «Memory» з опціями: Window Buffer, Motorhead, Zep. У документації написано «додайте memory щоб агент пам'ятав контекст діалогу». Виглядає логічно — людина розмовляє з ботом, бот має «пам'ятати» що вже було сказано.
Це і є пастка. LLM не має пам'яті. LLM — це stateless функція, яка на кожному виклику отримує повний контекст і повертає одне продовження. «Пам'ять» — це просто спосіб підсунути LLM попередні повідомлення у контекст. Все.
Проблема з цією метафорою у тому, що вона приховує що насправді відбувається. Розробник думає що агент «розуміє» історію діалогу, «слідкує» за станом клієнта, «пам'ятає» що показав. Але LLM щоразу перечитує текстовий транскрипт і вгадує контекст із нього.
Для чат-бота, який відповідає на питання — це працює. Для sales-агента, який має провести клієнта через 8 стадій воронки — ні.
Sales-агенту потрібен явний session state — структура даних у БД яку код читає, оновлює і зберігає. LLM отримує compact представлення цього стану у промпті, генерує текст, і код витягує з відповіді оновлення для state. Пам'яті у сенсі «LLM щось пам'ятає» не існує — і не потрібно.
02 Що робить Window Buffer Memory насправді
Розберемо конкретно. У n8n Window Buffer Memory зберігає повідомлення у таблиці, яку n8n автоматично створює:
CREATE TABLE n8n_chat_histories (
id SERIAL PRIMARY KEY,
session_id VARCHAR(255) NOT NULL,
message JSONB NOT NULL
);
Кожне повідомлення (від клієнта і від бота) записується як окремий рядок. Коли AI Agent виконується наступного разу, n8n читає останні N рядків (у мене було 10), формує з них масив ChatMessage[] і прикріплює до контексту LLM.
Реальний prompt який отримує LLM виглядає приблизно так:
// Це те що бачить LLM насправді
{
"messages": [
{ "role": "system", "content": "Ти — консультант Puramur..." }, // 3500 tokens
{ "role": "user", "content": "Привіт" },
{ "role": "assistant", "content": "Доброго дня! Як допомогти?" },
{ "role": "user", "content": "У мене кіт сфінкс" },
{ "role": "assistant", "content": "У сфінксів чутлива шкіра..." },
{ "role": "user", "content": "Порекомендуйте шампунь" },
{ "role": "assistant", "content": "Раджу Exotic Spa..." },
{ "role": "user", "content": "Скільки коштує?" } // поточне повідомлення
]
}
LLM бачить всю історію як текст. Щоб відповісти на «Скільки коштує?», він має відновити з тексту: про який товар йде мова (Exotic Spa), для якої тварини (сфінкс), яку ціну шукати. LLM це вміє — але це ймовірнісний процес, а не гарантований.
03 Три реальні проблеми які це створило у моєму v1
Проблема #1: LLM неточно відновлює state з тексту
У 8 з 10 випадків LLM «згадує» правильно. У 2 з 10 — ні. Особливо коли:
- Діалог довгий і критична деталь була 8 повідомлень тому (за межами window)
- Клієнт сказав щось двозначне («ну, беру перший») — LLM плутає що саме «перший»
- Клієнт передумав («не той, інший») — LLM тримає обидва варіанти у голові
- Була пауза, потім повернення — LLM не має сигналу «нова тема»
Ось реальний фрагмент з мого v1:
// Тredо: session 1227845053
USER: У мене йорк, треба щось для блиску шерсті
BOT: Раджу Brilliant White (PR243487) — м'який шампунь
для світлої шерсті + Extra Gloss серум...
USER: А ціна?
BOT: [шукає у KB, знаходить різні товари]
Brilliant White коштує від 340 грн, Extra Gloss —
від 380 грн. Точну ціну уточніть у менеджера.
USER: Беру
BOT: [не розуміє що саме — Brilliant White чи Extra Gloss]
Чудовий вибір! Розкажіть детальніше що саме вас цікавить?
«Беру» — це coreference до попереднього повідомлення. LLM бачить у тексті два товари, не має контексту що клієнт вирішив брати обидва (або перший, або другий). Гадає. Гадає невдало. Клієнт закриває чат.
Проблема #2: Токени ростуть з довжиною діалогу
Кожен виклик LLM оплачується за токени. У моєму v1 середній діалог мав 8-12 повідомлень до передачі менеджеру. Ось як ростав prompt:
| Turn | System prompt | Історія | User msg | Разом (input) |
|---|---|---|---|---|
| 1 | 3500 | 0 | 15 | 3515 |
| 3 | 3500 | ~300 | 15 | 3815 |
| 5 | 3500 | ~700 | 15 | 4215 |
| 10 | 3500 | ~1800 | 15 | 5315 |
| 15 | 3500 | ~3000 (обрізано до 10 msg) | 15 | 6515 |
На довгих діалогах кожен turn коштував майже вдвічі більше ніж на першому. Плюс — історія забирає attention LLM. Прочитавши 1800 токенів попередніх повідомлень, LLM гірше слідкує за поточним контекстом і специфічними інструкціями з system prompt.
У v2 із явним state кожен turn виглядає так:
System: 400-800 tokens (route-specific)
State summary: 150 tokens (JSON compact)
User message: 15 tokens
Разом: ~600-1000 tokens на будь-якому turn.
Стабільно, передбачувано, у 3-5 разів дешевше на довгих діалогах.
Проблема #3: Немає observability
Це найболючіше. У v1 я не міг відповісти на прості питання про роботу бота:
- Скільки зараз активних сесій? На яких стадіях?
- Де клієнти найчастіше «відвалюються»?
- Скільки середньо триває діалог до передачі менеджеру?
- Який відсоток діалогів доходить до пропозиції товару?
- Які товари найчастіше пропонує бот, і які з них реально купують?
Все це — «десь у голові LLM». Аналітик би сказав «дивись історію повідомлень», але це текст. Не структура. SQL там не напишеш.
04 State-first архітектура
У v2 я перевернув модель. Тепер state — це структура даних у Postgres, а не «щось всередині LLM». Кожен цикл виглядає так:
Trigger (Telegram/Widget)
↓
Save User Message (Postgres — журнал діалогів)
↓
LOAD Session State (Postgres) ←── читаємо поточний state
↓
Router + Business Logic ←── code вирішує що робити
↓
Build Route-Specific Prompt ←── state → компактний JSON у промпт
↓
AI Agent (LLM) ←── LLM генерує текст + структурований output
↓
EXTRACT State Updates ←── парсимо output, оновлюємо state
↓
SAVE Session State (UPSERT) ←── зберігаємо оновлений state
↓
Send Reply
Три ключові операції — LOAD → PROCESS → SAVE — точно як у будь-якій нормальній вебзаявці з БД. Просто ми додали LLM у середину як генератор тексту.
Window Buffer Memory (v1)
Стан: у голові LLM (ймовірнісно)
Формат: транскрипт діалогу
Витягти field: LLM має «прочитати і зрозуміти»
Оновити field: сказати LLM у промпті
Observability: ніяка
Витрати: ростуть з довжиною
Session State (v2)
Стан: JSONB рядок у Postgres
Формат: структурований об'єкт
Витягти field: state.pet.breed
Оновити field: state.pet.breed = 'Sphinx'
Observability: SQL queries
Витрати: стабільні
05 Повна DDL puramur_sessions_state
Ось таблиця цілком, як вона крутиться у продакшн puramur:
CREATE TABLE puramur_sessions_state (
session_id TEXT PRIMARY KEY,
-- Stage machine
stage TEXT NOT NULL DEFAULT 'greeting',
stage_iteration INT NOT NULL DEFAULT 0,
stage_history JSONB NOT NULL DEFAULT '[]',
-- Клієнт і тварина
customer JSONB NOT NULL DEFAULT '{}',
pet JSONB NOT NULL DEFAULT '{}',
-- Прогрес по воронці
discovery_completeness INT NOT NULL DEFAULT 0,
conversation_summary TEXT DEFAULT '',
-- Sales tracking
presented_products JSONB NOT NULL DEFAULT '[]',
objections_raised JSONB NOT NULL DEFAULT '[]',
-- Кошик
cart JSONB NOT NULL DEFAULT '[]',
cart_item_count INT NOT NULL DEFAULT 0,
cart_total NUMERIC(10,2) NOT NULL DEFAULT 0,
cart_gift_eligible BOOLEAN NOT NULL DEFAULT FALSE,
cart_free_delivery BOOLEAN NOT NULL DEFAULT FALSE,
-- Мова
detected_language TEXT DEFAULT 'uk',
response_language TEXT DEFAULT 'uk',
-- Handoff
handoff_channel TEXT,
handoff_triggered_at TIMESTAMPTZ,
handoff_reason TEXT,
-- Таймлайн
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_message_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_stage ON puramur_sessions_state(stage);
CREATE INDEX idx_sessions_last_msg ON puramur_sessions_state(last_message_at DESC);
CREATE INDEX idx_sessions_handoff ON puramur_sessions_state(handoff_triggered_at)
WHERE handoff_triggered_at IS NOT NULL;
Розберу ключові поля.
Поточна стадія sales-воронки. Одне з: greeting, discovery, presentation, cart_building, contact_collection, handoff. Це те, що визначає який prompt отримає LLM. У greeting — прості інструкції «привітай і запитай чи кіт чи собака». У presentation — інструкції з живими цінами і expertise chunks. У handoff — фінальне повідомлення.
Скільки разів LLM «повертався» до тієї самої стадії. Використовується для exit conditions: якщо у stage=objection_handling stage_iteration ≥ 3 — час зробити soft-offer менеджера («здається краще з'єднати вас з живою людиною»).
Історія переходів між стадіями. Формат: [{"stage": "greeting", "exited_at": "2026-09-14T..."}, ...]. Використовується для funnel-аналізу — де саме клієнти «відвалюються».
Все про клієнта: {"name": "Антон", "phone": "+380661234567", "delivery_address": null, "preferred_time": null}. Оновлюється поступово — коли клієнт назвав ім'я, коли залишив телефон.
Профіль тварини: {"type": "cat", "name": "Мурчик", "breed": "Sphinx", "age_years": 3, "problems": ["dry_skin", "sensitive_skin"]}. Це та інформація, яка визначає які товари бот запропонує. Заповнюється у стадії discovery.
Скор повноти профілю. Формула: +25 pet.type, +25 pet.problems, +25 pet.breed, +15 pet.age, +5 pet.name, +5 customer.name. Коли ≥ 60 — автоматичний перехід у presentation. Це явна exit condition яка керує flow, а не «LLM сам зорієнтується коли достатньо інформації».
SKU які бот уже показав у цій сесії. Формат: [{"sku": "PR243587", "presented_at": "...", "stage_iteration": 1}, ...]. Використовується щоб не пропонувати повторно ті самі товари при iteration presentation.
Кошик як явна структура. Формат: [{"sku": "PR243587", "name": "...", "price": 277, "qty": 1, "line_total": 277, "added_at": "..."}, ...]. Плюс окремим item з is_gift: true — Brilliant Gloss який автоматично додається при cart_total ≥ 1000 грн.
Денормалізовані поля для швидкого чекання порогів у логіці і у промпті. Так, technically можна порахувати з cart JSONB. Але у промпті ми вставляємо готові булеани — LLM не має рахувати.
NULL поки не тригернуло. Коли клієнт передан менеджеру — timestamp. Використовується для idempotency: якщо handoff_triggered_at не NULL, повторне notification менеджера не відправляється.
06 Чому JSONB, а не окремі колонки
Логічне питання — чому не зробити:
CREATE TABLE puramur_sessions_state (
session_id TEXT PRIMARY KEY,
customer_name TEXT,
customer_phone TEXT,
pet_type TEXT,
pet_name TEXT,
pet_breed TEXT,
pet_age_years INT,
pet_problems TEXT[],
...
);
Три причини чому я вибрав JSONB:
Гнучкість схеми. У розробці sales-агента я 5 разів додавав нові поля до pet (спочатку тільки type + problems, потім додав breed, age, name). З окремими колонками кожен раз — міграція, ALTER TABLE, оновлення всіх SELECT/UPSERT запитів. З JSONB — просто починаю писати нове поле, старі рядки автоматично мають його як undefined.
Природна структура для LLM output. LLM виводить JSON. Мій парсер витягує його як object. Записати цей object як JSONB — одна операція. Розкладати по 15 колонках — переклад форматів на кожному циклі.
Атомарність оновлення. UPSERT одного JSONB поля атомарний. Оновлення 15 колонок в одному запиті теж атомарне, але SQL стає громіздким і легко зробити помилку типу «оновлюємо всі поля крім одного».
Коли колонки перемагають: для полів по яких часто фільтруємо або сортуємо. Тому stage, discovery_completeness, cart_total, cart_item_count, handoff_triggered_at — окремі колонки з індексами. Все решта — JSONB.
Fields по яких пишеш WHERE або ORDER BY у аналітичних запитах — окремі колонки з індексами. Fields які тільки читаєш цілком або передаєш у LLM — JSONB.
07 LOAD/SAVE патерни у n8n
LOAD — з важливим gotcha
Спочатку зі спрощеної, потім важлива деталь:
// Postgres node: "Load Session State"
SELECT * FROM puramur_sessions_state
WHERE session_id = $1;
// Query Parameters (n8n expression)
={{ [$('Detect Language').first().json.session_id] }}
Тут критично важлива опція, яка не в Parameters а в Settings:
У Postgres ноди відкрий Settings → Always Output Data → ON. Без цього коли session не існує (нова сесія, SELECT повертає 0 rows) — нода обриває виконання. Наступна нода не запускається. Workflow висить.
З Always Output Data увімкнуто нода віддає пустий item коли rows = 0. Далі Code node ловить це і будує default state:
// Code node: "Initialize State"
const upstream = $('Detect Language').first().json;
const rows = $input.all();
let state;
if (rows.length === 0 || !rows[0].json.session_id) {
// Нова сесія — будуємо default
state = {
session_id: upstream.session_id,
stage: 'greeting',
stage_iteration: 0,
stage_history: [],
customer: { name: null, phone: null },
pet: { type: null, breed: null, problems: [] },
discovery_completeness: 0,
presented_products: [],
cart: [],
cart_total: 0,
is_new_session: true,
// ... інші поля з default значеннями
};
} else {
// Існуюча сесія — parse JSONB fields з рядка
const row = rows[0].json;
state = {
session_id: row.session_id,
stage: row.stage,
stage_iteration: parseInt(row.stage_iteration) || 0,
stage_history: row.stage_history || [],
customer: row.customer || {},
pet: row.pet || {},
// ... і так далі
is_new_session: false,
};
}
return [{ json: { ...upstream, state }}];
Тепер state — об'єкт JavaScript, з яким далі працює router, prompt builder і extract-нода. І кожна з них знає що state там гарантовано є, з дефолтами де треба.
UPSERT — оновлення без race conditions
Після LLM ми маємо оновлений state. Треба зберегти. Тут інша gotcha:
INSERT INTO puramur_sessions_state (
session_id, stage, stage_iteration, stage_history,
customer, pet, discovery_completeness,
presented_products, cart, cart_item_count, cart_total,
cart_gift_eligible, cart_free_delivery,
detected_language, response_language,
handoff_channel, handoff_triggered_at, handoff_reason,
created_at, updated_at, last_message_at
) VALUES (
$1, $2, $3::int, $4::jsonb,
$5::jsonb, $6::jsonb, $7::int,
$8::jsonb, $9::jsonb, $10::int, $11::numeric,
$12::boolean, $13::boolean,
$14, $15,
'telegram',
CASE WHEN $16::boolean THEN NOW() ELSE NULL END,
$17,
NOW(), NOW(), NOW()
)
ON CONFLICT (session_id) DO UPDATE SET
stage = EXCLUDED.stage,
stage_iteration = EXCLUDED.stage_iteration,
stage_history = EXCLUDED.stage_history,
customer = EXCLUDED.customer,
pet = EXCLUDED.pet,
-- ... всі поля
handoff_triggered_at = COALESCE(
puramur_sessions_state.handoff_triggered_at, -- зберігаємо старе якщо було
EXCLUDED.handoff_triggered_at
),
handoff_reason = COALESCE(EXCLUDED.handoff_reason, puramur_sessions_state.handoff_reason),
updated_at = NOW(),
last_message_at = NOW();
ON CONFLICT DO UPDATE — це Postgres UPSERT. Або створюємо новий рядок, або оновлюємо існуючий. Гарантовано атомарно навіть якщо два повідомлення прийшли одночасно (наприклад клієнт швидко надіслав два повідомлення підряд).
Для handoff_triggered_at використовуємо COALESCE(existing, new) — тобто зберігаємо старий timestamp якщо він був. Це важливо: якщо handoff тригернувся 5 повідомлень тому, ми не хочемо перезаписати timestamp кожним новим повідомленням у пост-handoff режимі. Це також гарантує idempotency notification: код перевіряє alreadyNotified = !!state.handoff_triggered_at перед відправкою alerts менеджеру.
У Postgres node в n8n значення $json.state недоступне в наступній ноді після Postgres — executeQuery повертає лише результат query, не проходить input далі. Тому у Query Parameters треба явно посилатись на попередню ноду з state: {{ $('Extract State Update').first().json.state.stage }}, а не {{ $json.state.stage }}.
08 Observability queries
Головна перевага state як структури — це SQL. Ось п'ять запитів які я запускаю регулярно на продукті.
Кількість активних сесій за стадіями
SELECT stage, COUNT(*) AS sessions
FROM puramur_sessions_state
WHERE last_message_at > NOW() - INTERVAL '24 hours'
AND handoff_triggered_at IS NULL
GROUP BY stage
ORDER BY sessions DESC;
-- Приклад результату:
-- stage | sessions
-- discovery | 12
-- presentation | 8
-- cart_building | 3
-- contact_collection| 1
-- greeting | 1
Одразу видно де застряють люди. Якщо у presentation набагато більше ніж у cart_building — це означає що продукти показуються але не переконують. Треба покращувати presentation prompt.
Sales funnel за останні 7 днів
WITH stages_reached AS (
SELECT
session_id,
ARRAY_AGG(DISTINCT h->>'stage') AS stages,
stage AS current_stage,
handoff_triggered_at
FROM puramur_sessions_state,
jsonb_array_elements(stage_history) h
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY session_id, stage, handoff_triggered_at
)
SELECT
COUNT(*) FILTER (WHERE 'greeting' = ANY(stages)) AS reached_greeting,
COUNT(*) FILTER (WHERE 'discovery' = ANY(stages)) AS reached_discovery,
COUNT(*) FILTER (WHERE 'presentation' = ANY(stages)) AS reached_presentation,
COUNT(*) FILTER (WHERE 'cart_building' = ANY(stages)) AS reached_cart,
COUNT(*) FILTER (WHERE handoff_triggered_at IS NOT NULL) AS converted_handoff
FROM stages_reached;
-- Приклад результату:
-- reached_greeting | 47
-- reached_discovery | 38 (81%)
-- reached_presentation | 22 (47%)
-- reached_cart | 8 (17%)
-- converted_handoff | 5 (11%)
Це справжня conversion funnel з реальних діалогів. Одразу видно де найбільший drop-off (discovery → presentation, майже вдвічі). Значить треба покращувати triggering presentation — можливо discovery_completeness threshold закритий занадто високо.
Скільки часу середньо триває кожна стадія
WITH stage_durations AS (
SELECT
session_id,
h->>'stage' AS stage,
(h->>'exited_at')::timestamptz -
LAG((h->>'exited_at')::timestamptz)
OVER (PARTITION BY session_id ORDER BY ordinality)
AS duration
FROM puramur_sessions_state,
jsonb_array_elements(stage_history) WITH ORDINALITY h(h, ordinality)
)
SELECT
stage,
AVG(EXTRACT(EPOCH FROM duration))::int AS avg_seconds,
COUNT(*) AS samples
FROM stage_durations
WHERE duration IS NOT NULL
GROUP BY stage
ORDER BY avg_seconds DESC;
Якщо presentation триває 15 хвилин у середньому — LLM надто розтягує. Якщо discovery закінчується за 45 секунд — прошли поверхнево, треба глибше збирати профіль.
Найчастіше пропоновані товари
SELECT
p->>'sku' AS sku,
COUNT(*) AS times_presented,
COUNT(*) FILTER (WHERE handoff_triggered_at IS NOT NULL) AS converted
FROM puramur_sessions_state,
jsonb_array_elements(presented_products) p
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY sku
ORDER BY times_presented DESC
LIMIT 20;
Це топ-20 «улюбленців» бота. Порівнюємо з реальними продажами з CRM — якщо якийсь SKU часто пропонується але не продається, треба дивитись чому (можливо неоптимальний prompt для цього товару, або він не підходить під ті проблеми які бот на нього мапить).
Handoff reasons distribution
SELECT
handoff_reason,
COUNT(*) AS count,
AVG(cart_total)::numeric(10,0) AS avg_cart,
AVG(EXTRACT(EPOCH FROM handoff_triggered_at - created_at))::int AS avg_time_to_handoff_sec
FROM puramur_sessions_state
WHERE handoff_triggered_at IS NOT NULL
AND handoff_triggered_at > NOW() - INTERVAL '30 days'
GROUP BY handoff_reason
ORDER BY count DESC;
-- Приклад:
-- handoff_reason | count | avg_cart | avg_time
-- cart_confirmed | 42 | 850 | 640 sec
-- explicit_request | 15 | 0 | 45 sec
-- complaint | 3 | 0 | 25 sec
Це report для менеджера/CEO. За місяць 42 клієнти дійшли до confirmed cart з середньою корзиною 850₴ — і бот з готовим замовленням передав їх менеджеру. 15 — прямий запит «хочу оператора» (не всі дошли до кошика). 3 — скарги.
09 Practical takeaways
Головне питання не «memory чи не memory». Головне питання — де живе стан твого агента. Якщо стан — це «текст діалогу який LLM щоразу перечитує», ти будуєш consultant-довідник. Якщо стан — це структура даних у БД яку код читає, оновлює і зберігає, ти будуєш нормальний sales-agent з observability, метриками і передбачуваною поведінкою.
Window Buffer Memory має свою нішу — для чат-ботів «поговорити на різні теми», для demo-агентів, для прототипів. Але у продажах, де є конкретна воронка, конкретні exit conditions, конкретні дані які треба зібрати перед handoff — явний JSONB state завжди перемагає. Це не архітектурна забаганка, це необхідність.
Три речі які я б зробив у будь-якому AI-агенті з першого дня. По-перше, окрема таблиця для session state з JSONB для гнучких полів і колонками для тих що використовуються у WHERE/ORDER BY. По-друге, LOAD → PROCESS → SAVE як явний цикл у workflow, з Always Output Data увімкнено на LOAD-ноді. По-третє, з першого тижня — SQL-запити для funnel-аналізу, drop-offs, часу на стадію. Без цього ти не знаєш чи agent «продає» — ти тільки знаєш що він «розмовляє».
- Створена окрема таблиця для session state (не n8n_chat_histories)
- Ключові fields для аналітики — окремі колонки з індексами
- Гнучкі структури (customer, pet, cart, arrays) — JSONB
- Load Session State нода: Settings → Always Output Data → ON
- Init State Code node будує default для нових сесій
- Upsert через
ON CONFLICT DO UPDATE, не два запити - COALESCE для полів які не мають перезаписуватись (handoff_triggered_at)
- У Postgres node звертайся до state через
$('Extract State').first().json, не$json - Мінімум 3 observability queries налаштовані на dashboard
Наступна стаття у серії
#3 — Semantic Router у n8n: як розкласти запити на 5 маршрутів
Стаття про embedding + cosine search, threshold tuning з реальними числами (6.7% → 93% accuracy),
Bruno test suite і Python-генератор workflow.