AI як backend-інтерфейс для stateful-пошуку

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Мене звати Роман Гладун. Я Backend Developer та Technical Architect із понад 10 роками досвіду в розробці веб-систем, high-load сервісів та автоматизації бізнес-процесів. У цій статті я ділюся практичним досвідом використання AI не як «магії» чи простого чат-бота, а як інженерного компонента бекенду з чітким розмежуванням відповідальності між LLM та серверною логікою, що дозволяє отримувати прогнозований результат у продакшені.

Матеріал буде корисний бекенд-розробникам, техлідам та архітекторам, які вже експериментують з AI у продакшені або тільки планують це робити й хочуть уникнути типових помилок на старті. Пропоную розібрати архітектуру на прикладі реальної бізнес-задачі: реалізації розумного пошуку нерухомості.

Ми звикли, що пошук — це форма з купою фільтрів. Але користувачі хочуть писати так, як вони думають: «Знайди квартиру на Оболоні, щоб можна було з котом, не перший поверх, і відсортуй від найдешевших».

Щоб це працювало, нам потрібно вирішити дві інженерні задачі:

  1. Context Management (Stateful Conversation): збереження та керування історією діалогу на бекенді (щоб уточнення «а тепер покажіть тільки з ремонтом» не скидало фільтр міста).
  2. Complex Filtering: Перетворити людське «не перший поверх» на чітке floor_min: 2.

У цій статті реалізуємо це на Node.js (OpenAI SDK). Ми не будемо давати AI доступ до бази (це небезпечно), а створимо для нього безпечний шар інструментів (Tools Layer).

Архітектура: Чому контекст вирішує все

LLM (Large Language Model) — це stateless система. Вона не пам’ятає, що ви писали 5 секунд тому. Всю відповідальність за пам’ять (State Management) несе наш бекенд.

Сценарій:

  1. User: «Квартири в Києві, 2 кімнати.» -> AI викликає пошук {city: "Kyiv", rooms: 2}.
  2. User: «Давай дешевші.» -> Ми відправляємо в AI весь ланцюжок повідомлень.
  3. 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) — це погана практика, яка веде до галюцинацій та помилок.

Тому ми використовуємо двоетапний підхід:

  1. AI (Text Normalization): Передає в бекенд текстове значення (наприклад,"district": "Obolon").
  2. Service Layer (ID resolution): Вже наш сервіс apartmentService виконує резолюцію цієї назви у внутрішній ID через довідники та fuzzy-matching.

Це надійніше, ніж делегувати цю відповідальність LLM, і дозволяє зберігати всю бізнес-логіку та джерело істини в одному місці — на бекенді.

Безпека та Нюанси (Best Practices)

Чому ми не пускаємо AI в базу?

Деякі туторіали пропонують давати AI схему таблиць SQL і дозволяти генерувати SELECT * FROM.... Ніколи так не робіть у продакшені.

  1. SQL Injection: AI може випадково (або навмисно через jailbreak) видалити дані.
  2. Бізнес-логіка: Тільки ваш код (findApartmentsInDb) знає, що не можна показувати забанені оголошення або оголошення без фото. AI цього не знає.
  3. Валідація: Якщо юзер скаже «ціна мінус 500», ваш код в сервісному шарі перехопить це до запиту в БД.

Як працює Enum-магія?

У схемі ми вказали pets_policy як enum: ["allowed", "cats_only" ...]. Якщо користувач напише: «У мене є собака», AI намагається зіставити користувацький ввід з дозволеними значеннями enum. Якщо користувач напише: «У мене крокодил», а такого варіанту немає, AI або вибере найближче, або (що частіше) перепитає користувача, бо він бачить обмеження схеми.

Висновок

Використання Function Calling в Node.js дозволяє будувати інтерфейси, де «фронтендом» виступає людська мова.

  • Користувач отримує свободу висловлювання.
  • Розробник отримує структурований JSON на бекенді.
  • Збереження контексту (messages history) дозволяє реалізувати природний флоу уточнення запиту, якого немає у звичайних фільтрах.

Універсальність рішення головна перевага цієї архітектури — абстракція від джерела вводу. Сьогодні це може бути текстовий рядок на сайті, завтра — голосове повідомлення в Telegram, яке ви транскрибуєте через Whisper, або телефонний дзвінок. Бекенду байдуже: він отримує текст, а віддає структуровані дані.

Це вже не майбутнє, це сучасний стандарт UX для складних пошукових систем.


👍ПодобаєтьсяСподобалось10
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Привіт!

У продакшені не варто передавати в LLM всю історію діалогу достатньо лише релевантного контексту.

Як визначати «релевантний контекст»?

Релевантний контекст це мінімум, потрібний для поточного кроку: наприклад останні N реплік + компактний state JSON. Все, що можна тримати структуровано на бекенді, не варто передавати в LLM текстом.

Ви б не могли, будь ласка, трохи детальніше розповісти.

Наприклад, що саме ви зберігаєте в «компактний state JSON». Як вирішуєте (як програма вирішує) що саме тути зберігти?

Все, що можна тримати структуровано на бекенді, не варто передавати в LLM текстом.

Тут ідея в тому, що краще передавати в LLM щось в структурованоми вигляді? Ви б не могли показати якийсь приклад такого структурованого стану і текст, який ви надсилаєте в LLM, щоб отримати кращий результат?

Так, ідея саме в цьому — LLM сама вирішує, чи потрібно оновлювати state і що саме зберігати.

У цьому кейсі state — це компактний структурований JSON (початкову структуру можна задати вручну з даних користувача які вже є в системи або сформувати за допомогою LLM).

Щоб не передавати всю історію діалогу, кожні N повідомлень запускається окремий AI-крок,
який робить summary попередніх N повідомлень + поточний state,
і за потреби оновлює його. (LLM вирішує чи state змінився і що саме треба доповнити)

Далі в LLM завжди передається: актуальний state + тільки нові повідомлення користувача.

Фактично це дозволяє зберігати необмежено довгу історію, не передаючи весь діалог кожного разу і економлячи токени.

openai.chat.completions.create

Completions API застарілий (platform.openai.com/...​api-reference/completions). Замість нього у вашому випадку краще використовувати Responses API (platform.openai.com/...​eference/responses/create) або Conversations API.

If you’re building any text generation app, we recommend using the Responses API over the older Chat Completions API. And if you’re using a reasoning model, it’s especially useful to migrate to Responses.

Вони як раз надають можливість додати ідентифікатор попереднього запиту, щоб наступний запит враховував контекст.

previous_response_id (string, Optional) — The unique ID of the previous response to the model. Use this to create multi-turn conversations.

platform.openai.com/...​guides/conversation-state

Так, Responses API зручні,
але в даному випадку зберігається state на бекенді для більшого контролю і можливості легко змінювати LLM без привязки до апі OpenAI

2 роки тому робили подібну штуку і теж без прямого доступу через SQL (які багато хто рекомендував). Але бувало, що LLM галюцинувала з доступними параметрами.
Справді прикольна штука для початкового запиту, коли є величезна кількість фільтрів. Далі можна показати прев’ю і тюнити результати вручну фільтрами.

Дякую! Так, з галюцинаціями зараз стало значно простіше, добре працює режим strict: true та Structured Outputs.

Якщо в схемі задані жорсткі enum і обмеження, LLM практично не може згенерувати неіснуючий параметр, а далі вже бекенд вирішує, що з цим робити

strict: true та Structured Outputs.

Це нові фічі OpenAI, наскільки я розумію?

Так, відносно нові. Дуже корисні для отримання структурованих даних — рекомендую)

Ми звикли, що пошук — це форма з купою фільтрів. Але користувачі хочуть писати так, як вони думають: «Знайди квартиру на Оболоні, щоб можна було з котом, не перший поверх, і відсортуй від найдешевших».

Я б не хотів так писати. Чекбоксів та дропдаунів наклацати ментально легше.

це сучасний стандарт UX для складних пошукових систем

І чим складніші варіації пошуку — тим легше оперувати кнопками. Банально — бачиш одразу існуючі опції, та контрольовано звужуєш вікно пошуку. Просто ui треба гарно поділити та оформити по всім бест-практіс. А вже зберегти останні фільтри — не велика проблема)

Звичайно, класичний UI з фільтрами поки нікуди не зникає
і для багатьох сценаріїв залишається найзручнішим.

Але є ситуації, де він працює гірше:
коли це Telegram-бот,
коли користувач у дорозі й простіше надиктувати запит,
або коли кількість можливих фільтрів і комбінацій стає дуже великою.

У таких випадках AI зручний як додатковий канал вводу, який допомагає швидко сформувати початковий state, а не як заміна класичного інтерфейсу.

Колеги, цікаво почути вашу думку.

Де ви для себе проводите межу відповідальності між AI та класичним бекендом? Що залишаєте на стороні LLM, а що принципово не віддаєте йому (валідація, state, доступ до даних)?

Цікаво, як це вирішують у ваших проєктах — чи даєте AI більше свободи?

Цікаво чи ви валідуєте схему після парсингу JSON? Чи покладаєтесь на те, що ШІ її дотримується?

Бо виглядає як вразлива точка

Так, обовʼязково. JSON відповіді від LLM валідую так само, як будь-які інші вхідні дані

Підписатись на коментарі