AI як backend-інтерфейс для stateful-пошуку
Мене звати Роман Гладун. Я Backend Developer та Technical Architect із понад 10 роками досвіду в розробці веб-систем, high-load сервісів та автоматизації бізнес-процесів. У цій статті я ділюся практичним досвідом використання AI не як «магії» чи простого чат-бота, а як інженерного компонента бекенду з чітким розмежуванням відповідальності між LLM та серверною логікою, що дозволяє отримувати прогнозований результат у продакшені.
Матеріал буде корисний бекенд-розробникам, техлідам та архітекторам, які вже експериментують з AI у продакшені або тільки планують це робити й хочуть уникнути типових помилок на старті. Пропоную розібрати архітектуру на прикладі реальної бізнес-задачі: реалізації розумного пошуку нерухомості.
Ми звикли, що пошук — це форма з купою фільтрів. Але користувачі хочуть писати так, як вони думають: «Знайди квартиру на Оболоні, щоб можна було з котом, не перший поверх, і відсортуй від найдешевших».
Щоб це працювало, нам потрібно вирішити дві інженерні задачі:
- Context Management (Stateful Conversation): збереження та керування історією діалогу на бекенді (щоб уточнення «а тепер покажіть тільки з ремонтом» не скидало фільтр міста).
- Complex Filtering: Перетворити людське «не перший поверх» на чітке floor_min: 2.
У цій статті реалізуємо це на Node.js (OpenAI SDK). Ми не будемо давати AI доступ до бази (це небезпечно), а створимо для нього безпечний шар інструментів (Tools Layer).
Архітектура: Чому контекст вирішує все
LLM (Large Language Model) — це stateless система. Вона не пам’ятає, що ви писали 5 секунд тому. Всю відповідальність за пам’ять (State Management) несе наш бекенд.
Сценарій:
- User: «Квартири в Києві, 2 кімнати.» -> AI викликає пошук
{city: "Kyiv", rooms: 2}. - User: «Давай дешевші.» -> Ми відправляємо в AI весь ланцюжок повідомлень.
- AI аналізує історію, розуміє, що «дешевші» стосується попереднього запиту, і генерує новий виклик:
{city: "Kyiv", rooms: 2, sort_by: "price_asc"}.
Це той момент, де зʼявляється відчуття «розумного» асистента — хоча вся логіка лишається на бекенді.
Реалізація (Node.js + OpenAI)
1. Описуємо «Меню» для AI (Schema Definition)
Тут ми визначаємо, що вміє робити наш бекенд. Зверніть увагу на використання enum для чітких значень (наприклад, політика щодо тварин) та діапазони поверхів.
const tools = [
{
type: "function",
function: {
name: "search_apartments",
description: "Шукає нерухомість у базі даних на основі фільтрів.",
parameters: {
type: "object",
properties: {
location: {
type: "object",
properties: {
city: { type: "string", description: "Місто (англійською, наприклад Kyiv)" },
district: { type: "string", description: "Район міста, якщо вказано" }
},
required: ["city"]
},
price_range: {
type: "object",
properties: {
min: { type: "number" },
max: { type: "number" }
}
},
floor_preferences: {
type: "object",
properties: {
min_floor: {
type: "integer",
description: "Мінімальний бажаний поверх. Якщо 'не перший', ставити 2."
},
max_floor: { type: "integer" }
}
},
// Приклад ENUM: перераховуємо тільки дозволені значення
pets_policy: {
type: "string",
enum: ["allowed", "cats_only", "small_dogs_only", "no_pets"],
description: "Політика проживання з тваринами. 'allowed' якщо просто сказано 'з тваринами'."
},
sort: {
type: "object",
properties: {
field: { type: "string", enum: ["price", "date", "area"] },
direction: { type: "string", enum: ["asc", "desc"] }
}
}
},
required: ["location"]
}
}
}
];
Фактично, AI в цій архітектурі виступає intent parser та orchestrator, а не виконавцем бізнес-логіки. Він не працює з базою даних напряму, не знає внутрішніх правил фільтрації і не приймає фінальних рішень. Уся критична логіка залишається на бекенді, який надає AI лише дозволений набір інструментів.
2. Controller: Обробка циклу розмови
У реальному додатку масив messages ви дістаєте наприклад з Redis або бази даних для конкретної сесії користувача.
import OpenAI from "openai";
import { findApartmentsInDb } from "./services/apartmentService"; // Ваш сервіс пошуку
const openai = new OpenAI();
async function chatHandler(userSessionId, userMessageText) {
// userMessageText може прийти звідки завгодно:
// - розпізнаний голос (Whisper API)
// - повідомлення з Telegram-бота
// - текст з React-форми
// 1. Завантажуємо історію листування (Context)
let messages = await loadHistory(userSessionId);
// 2. Додаємо нове повідомлення користувача
messages.push({ role: "user", content: userMessageText });
// 3. Перший запит до AI
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", // Швидка і дешева модель
messages: messages,
tools: tools,
tool_choice: "auto", // AI сам вирішує: відповідати текстом чи кликати функцію
});
const responseMessage = completion.choices[0].message;
// 4. Перевіряємо, чи AI хоче виконати дію
if (responseMessage.tool_calls) {
// Додаємо намір AI в історію, щоб він "знав", що він це зробив
messages.push(responseMessage);
for (const toolCall of responseMessage.tool_calls) {
if (toolCall.function.name === "search_apartments") {
// Парсинг аргументів + try/catch + schema validation
let args;
try {
args = JSON.parse(toolCall.function.arguments);
} catch (e) {
// У продакшені повідомляємо AI про помилку і даємо шанс виправитись
messages.push({
role: "tool",
content: "Error: invalid JSON arguments. Please try again."
});
return await chatHandler(userSessionId, ""); // або інший контрольований retry
}
console.log("AI виконує фільтрацію:", args);
/* Приклад args:
{
location: { city: "Kyiv", district: "Obolon" },
floor_preferences: { min_floor: 2 },
pets_policy: "allowed",
sort: { field: "price", direction: "asc" }
}
*/
// 5. Виклик вашого БЕКЕНДУ (Безпечний шар)
// Ми не передаємо SQL, ми передаємо чистий об'єкт параметрів
const searchResult = await findApartmentsInDb(args);
// 6. Повертаємо результат назад в AI
messages.push({
tool_call_id: toolCall.id,
role: "tool",
name: "search_apartments",
content: JSON.stringify(searchResult), // Наприклад: "Знайдено 3 квартири: ID 1..."
});
}
}
// 7. Фінальний запит: AI формує людську відповідь на основі результатів пошуку
const finalResponse = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: messages,
});
return finalResponse.choices[0].message.content;
}
return responseMessage.content;
}
У продакшені не варто передавати в LLM всю історію діалогу достатньо лише релевантного контексту. Керування цим контекстом завжди залишається на бекенді.
Ще один важливий момент — резолюція доменних сутностей.
Навіть якщо користувач явно вказує параметр (наприклад, «район Оболонь» або «вулиця Саксаганського»), AI не може використовувати ці значення напряму для SQL-запиту, якщо у вас в базі використовуються ID.
З іншого боку, змушувати AI вгадувати внутрішні ідентифікатори (district_id) — це погана практика, яка веде до галюцинацій та помилок.
Тому ми використовуємо двоетапний підхід:
- AI (Text Normalization): Передає в бекенд текстове значення (наприклад,
"district": "Obolon"). - Service Layer (ID resolution): Вже наш сервіс apartmentService виконує резолюцію цієї назви у внутрішній ID через довідники та fuzzy-matching.
Це надійніше, ніж делегувати цю відповідальність LLM, і дозволяє зберігати всю бізнес-логіку та джерело істини в одному місці — на бекенді.
Безпека та Нюанси (Best Practices)
Чому ми не пускаємо AI в базу?
Деякі туторіали пропонують давати AI схему таблиць SQL і дозволяти генерувати SELECT * FROM.... Ніколи так не робіть у продакшені.
- SQL Injection: AI може випадково (або навмисно через jailbreak) видалити дані.
- Бізнес-логіка: Тільки ваш код (
findApartmentsInDb) знає, що не можна показувати забанені оголошення або оголошення без фото. AI цього не знає. - Валідація: Якщо юзер скаже «ціна мінус 500», ваш код в сервісному шарі перехопить це до запиту в БД.
Як працює Enum-магія?
У схемі ми вказали pets_policy як enum: ["allowed", "cats_only" ...]. Якщо користувач напише: «У мене є собака», AI намагається зіставити користувацький ввід з дозволеними значеннями enum. Якщо користувач напише: «У мене крокодил», а такого варіанту немає, AI або вибере найближче, або (що частіше) перепитає користувача, бо він бачить обмеження схеми.
Висновок
Використання Function Calling в Node.js дозволяє будувати інтерфейси, де «фронтендом» виступає людська мова.
- Користувач отримує свободу висловлювання.
- Розробник отримує структурований JSON на бекенді.
- Збереження контексту (messages history) дозволяє реалізувати природний флоу уточнення запиту, якого немає у звичайних фільтрах.
Універсальність рішення головна перевага цієї архітектури — абстракція від джерела вводу. Сьогодні це може бути текстовий рядок на сайті, завтра — голосове повідомлення в Telegram, яке ви транскрибуєте через Whisper, або телефонний дзвінок. Бекенду байдуже: він отримує текст, а віддає структуровані дані.
Це вже не майбутнє, це сучасний стандарт UX для складних пошукових систем.

15 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів