Як я зробив AI-асистента продукт-аналітика на PHP — і що з цього вийшло
Вітаю, мене звати Юра, я СТО у продуктовій edTech компанії BUKI, і я досі пишу код. BUKI — це міжнародний сервіс пошуку репетиторів.
Хочу розповісти про свій шлях створення корисного AI-агента. Я свідомо додав у заголовок статті згадування PHP. Тому що це збільшить аудиторію читачів, є когорта айтівців, які тригеряться на цю мову програмування, і з радістю прийдуть покритикувати у коментарі. Але головна мета — показати, що зараз не потрібно знати Python, LangChain, LangGraph тощо, щоб робити AI-агентів чи асистентів, ця можливість доступна кожному. Продакт-аналітик — це теж клікбейт, насправді це швидше молодший помічник продукт-аналітика, але про його функції згодом.
Зараз агентами називаються різні сутності, тому для кращого розуміння, загалом додаток називатиму асистентом, а агентами називатиму сутність, яка щось робить, використовуючи LLM API.
Як народилась ідея?
Ідея створити асистента була давно. Постійно чуєш рекомендації і історії «успішного успіху», як всі навколо створюють AI-агентів, але я не міг придумати ідею корисного асистента, а не просто агента заради агента.
Складність нашого продукту постійно зростає, як і команда, передавати інформацію стає все важче, вже немає єдиної людини, яка б знала, як усе працює. На тлі цього виникла ідея найняти бізнес-аналітика, який би прийшов, усе описав і надалі пропускав усі завдання через себе, що стосувалося б узгодження вимог тощо. І начебто все добре, але є сумніви. У нас немає бізнес-аналітика, ми не маємо досвіду його найму, ми не до кінця розуміємо, що він має робити, і як його правильно відібрати. Щоб дізнатися, чи найм був вдалий, потрібно дати людині попрацювати хоча б півроку, поки вона розбереться з проектом, напише документацію і нарешті почне працювати з вимогами. Але це дуже довгий цикл зворотного зв’язку (feedback loop). І на тлі загальної AI-істерії замінити всіх агентами, я не втримався і вирішив, а що якщо спробувати створити бізнес-аналітик-агента. Ідея наче чудова, але коли почав думати про реалізацію і досліджувати це питання, зрозумів, що спочатку потрібно зробити щось простіше, якийсь proof of concept. І цим POC став продукт-аналітик-асистент.
У нас є команда аналітиків, але вони перевантажені. Вони зробили багато дашбордів, де продукт та операційні менеджери можуть бачити інформацію, але часто бувають якісь індивідуальні запити, порахувати щось у базі або зробити якесь вивантаження даних. З такими запитами менеджери звертаються до тих, хто має доступ до продуктової або аналітичної баз даних. У нас це аналітики і розробники, і це відволікає. І задум такий, що цей асистент має забрати на себе це навантаження.
Занадто довгий вступ, тому давайте перейдемо до проектування.
Проектування
Я почав досліджувати, а які технології використати, на чому створюють цих справжніх AI-агентів. Gemini мені радив LangChain, LangGraph, CrewAI, DifyAI і ще якісь інструменти. Але мені не хотілося розбиратися з Python і новими сторонніми інструментами, мені хотілося витрачати час на самого асистента. І тут якраз Тейлор Отвелл (засновник Laravel) презентував Laravel AI пакет. Наш проект на Ларі, тому це був плюс. Відразу попереджу, пакет ще сируватий, але активно оновлюється. З технологією визначились. Перейдемо до самого асистента.
Насправді, як виявилося, запорука успіху будь-якого агента — це не технології, а контекст. Контекст — це король. І друга рекомендація — уникати антипатерну God Prompt. Не намагайтеся зробити один великий промпт, який буде робити всю роботу, і перевіряти, що прийшло на вхід, і генерувати контекст, надавати відповідь і ще й валідувати її.
Щоб продукт-аналітик-асистент міг давати відповіді, йому потрібний контекст — знання про структуру бази даних. Тому проект буде по суті складатися з двох агентів, один генерує контекст, інший відповідає.
Почнемо з кінця. Щоб агент міг відповідати, йому потрібно знати структуру таблиць і можливі значення полів. Тут мається на увазі: якщо у вас є integer поле status, то тільки маючи доступ до бази не можна зрозуміти, що означає кожне число. Тому потрібно згенерувати опис таблиці, який міститиме всю необхідну інформацію, а саме, призначення таблиці, назви, тип даних, наявність індексу та опис полів з описом можливих значень, також потрібно описати зв’язки з іншими таблицями. Окрім опису конкретних таблиць, також потрібен перелік усіх таблиць. Тоді агент, що обробляє запити менеджерів людською мовою, маючи список усіх таблиць та їхні описи, зможе побудувати SQL-запит для отримання відповіді з бази даних. Нещодавно дізнався, що такий підхід, коли є умовний зміст, у моєму випадку це список таблиць, називається PageIndex і є альтернативою RAG. І стверджується, що це працює краще, ніж RAG.
Для генерації цих описів потрібний інший агент, який на вхід отримуватиме структуру таблиці з бази, Eloquent-модель з описом полів, а також класи з константами для значень окремих полів, таких як status тощо. Зв’язки між таблицями теж можна отримати з моделі. Загалом потрібно LLM надати всю необхідну інформацію, щоб їй не доводилося вигадувати. У нашому випадку моделі є стартовим майданчиком і містять усю базову інформацію, тому що у нас прийнято описувати типи і назви полів у PHPDoc через @property і зараз також почали додавати текстові описи.
@property int $priority Пріоритет завдання (1 — низький, 2 — середній, 3 — високий)
У підсумку нам потрібні 2 агенти: один генерує контекст, інший відповідає людям, використовуючи цей контекст. Перейдемо до реалізації.
Реалізація
Тут запускайте ваш улюблений агент для написання коду і беріться за роботу, поки вартість підписок дозволяє це робити відносно дешево. Свого асистента я проектував і генерував код за допомогою GitHub Copilot. На етапі проектування ТЗ він запропонував низку хороших ідей. Загалом ТЗ вийшло на ~1800 рядків. І це ТЗ було використано для генерації коду, що прискорило розробку.
Генерація контексту
Нам потрібно генерувати описи таблиць. Я взяв таку ідею, щоб під кожну таблицю був окремий PHP-клас, який задаватиме налаштування для опису. І зробив базовий клас з такими методами:
/** Назва таблиці в БД. */ abstract public function tableName(): string; /** Відносний шлях до файлу Eloquent-моделі */ public function modelPath(): ?string /** Маппінг колонка → відносний шлях до Constants-файлу */ public function constantsMap(): array /** Додатковий контекст, що додається в кінець згенерованого .md файлу */ public function additionalContext(): ?string /** Додаткові інструкції, що додаються до LLM-промпту при генерації схеми. */ public function customPrompt(): ?string
Мінімальний клас для конкретної таблиці матиме такий вигляд:
final class ClientsTable extends TableDefinition
{
public function tableName(): string
{
return 'clients';
}
}
Отримання modelPath відбувається автоматично (написано власний resolver) і задавати його потрібно лише тоді, якщо іменування моделі не відповідає загальним правилам.
Якщо у таблиці є поля з типовими значеннями, то ще потрібно заповнити constantsMap
public function constantsMap(): array
{
return [
'status' => 'app/Constants/Client/ClientStatus.php',
];
}
additionalContext() — це можливість вручну вказати якийсь специфічний контекст до опису таблиці, і він додається у результуючий файл як є (as is). Це можуть бути якісь особливості цієї таблиці, про які треба повідомити LLM для кращого аналізу.
Далі створюємо клас агента і наслідуємося від Agent з laravel/ai. Laravel забирає на себе подальшу взаємодію з API провайдерів мовних моделей. Достатньо прописати відповідний API-ключ. Ми використовуємо Sonnet 4.6.
/**
* LLM-агент для генерації Markdown-документації окремої таблиці БД.
*
* Отримує сирі дані таблиці (DESCRIBE, SHOW INDEX, PHPDoc, enum-константи,
* Eloquent-зв'язки) і повертає готовий .md файл за фіксованим шаблоном.
*/
#[Provider('anthropic')]
#[Timeout(180)]
final class SchemaDocumentationAgent implements Agent
{
use Promptable;
public function model(): string
{
return config('analytics.anthropic.model');
}
public function instructions(): string
{
return <<<'INSTRUCTIONS'
You are a database documentation expert.
Your task: generate a clear, accurate Markdown documentation file for a single database table.
You will receive:
1. Table name
2. DESCRIBE output (raw SQL structure: Field, Type, Null, Key, Default, Extra)
3. Index list (from SHOW INDEX: index name, columns, unique/non-unique)
4. Full PHP source of the Eloquent model (with @property PHPDoc, relationships, casts, fillable)
5. Full PHP source of Constants classes (enum values for integer fields)
Extract from the model:
- @property annotations → use as base for field descriptions
- @property-read annotations → identify computed/relation fields (not DB columns)
- Relationship methods (belongsTo, hasMany, etc.) → Relations table
- Fully qualified class name (namespace + class) → Eloquent Model field
- Constants referenced in the model → match with Constants file for enum values
Output format — strictly follow this Markdown template:
# {table_name}
**Purpose:** {one sentence describing the table's business purpose}
**Eloquent Model:** {fully qualified class name, or "—" if not found}
## Fields
| Field | Type | Nullable | Index | Description |
|-------|------|----------|-------|-------------|
{one row per field}
## Relations
| Type | Table | JOIN Condition |
|------|-------|----------------|
{one row per relationship, or "— (no relations)" if empty}
Rules:
- "Index" column values: "PRIMARY", "UNIQUE", "INDEX", or "—" (no index)
- If a field has a @property hint — use it as the base description, enrich if needed
- If a field has no @property hint — infer description from field name + table context + platform domain
- If Constants file contains values for a field — include enum values in the Description column
- For foreign key fields (ending in _id): mention which table they reference
- Write field descriptions in Ukrainian
- Write "Purpose" value in Ukrainian
- IMPORTANT: `lesson_id` always refers to resource_lessons (academic SUBJECT, not a lesson session)
- Do NOT include sensitive fields (passwords, tokens, emails, phones) even if present in the model
INSTRUCTIONS;
}
}
У класі вище розміщено системний промпт. При виклику цього агента передаватимемо користувацький промпт. Для цього я зробив окремий клас PromptBuilder, оскільки він виконує багато функцій. По суті він готує весь необхідний контент для створення опису. Бере з бази результат команди DESCRIBE <table>, потім список індексів, увесь код Eloquent-моделі, вміст класів з константами з constantsMap() і додаткові інструкції з customPrompt(). І вся ця інформація відправляється одним великим запитом до LLM. У відповідь отримуємо опис таблиці у форматі Markdown і зберігаємо у файл <table>.md. Окремо заносимо назву таблиці та її призначення у загальний файл tables.md, який містить список усіх таблиць та їхніх коротких описів.
Ці згенеровані *.md я спочатку думав генерувати на сервері за розкладом і класти у ресурси, але це може бути витратно щодо токенів, і таблиці не так часто змінюються, тому переробив на генерацію локально зі збереженням у репозиторій. Також це вимагало би доступу до вихідного коду основного проекту на сервері асистента, з цим теж не хотілося заморочуватися. Генерація документації для найбільшої таблиці на 107 полів за допомогою Sonnet 4.6 займає близько 70 секунд і використовує ~15000 токенів на вхід і ~5000 на вихід.
Усе, контекст згенеровано, можна переходити до головного агента.
Реалізація основного агента
Основний агент складніший: він має 3 інструменти і структуровану відповідь. Я зробив два інтерфейси взаємодії з ним. Перший — це консольна команда для локального тестування, а другий — це Slack-додаток і взаємодія через вебхуки і API. Slack, тому що це наш корпоративний месенджер і досить зручний спосіб взаємодії.
Далі покроково розпишу, що відбувається, коли менеджер надсилає приватне повідомлення у Slack-додаток асистента.
Slack надсилає вебхук, відбуваються перевірки безпеки, чи це дійсно вебхук від Slack, і доступ конкретного користувача. Зараз керую доступом вручну, просто є таблиця користувачів з їхнім Slack Member ID. Якщо все добре, то запускаю асинхронну Laravel-job і відправляю відповідь, що все ок, на вебхук, бо Slack очікує лише 3 секунди.
У джобі виконується вся основна логіка.
Для початку визначається тема розмови і відсікається запит «Дай мені рецепт борщу». Це робить маленький агент TopicChecker. Уникаємо антипатерну God prompt. Якщо запит не стосується аналітики, то повертаємо користувачу відповідь, що це питання не стосується теми.
Далі визначаємо, чи це нова розмова, чи продовження, щоб агент підтягнув історію переписки.
І вже після цього починає працювати ProductAnalystAgent. У нього в арсеналі є великий системний промпт, у якому вказується його роль, основні доменні знання і поняття, словник термінів і визначень, назви і опис основних таблиць (щоб зменшити кількість викликів інструментів), правила формування SQL-запиту і якісь додаткові інструкції. Також він має 3 інструменти, а саме:
ListTablesTool— це список таблиць з можливістю фільтрації по назві чи ключовому слову.GetTableSchemaTool— отримання опису, а саме <table>.md однієї таблиці за її назвою.PreviewDataTool— вибірка до10-ти рядків з таблиці, щоб зрозуміти, які дані там містяться.
На виході агент повертає структуровані дані наступного формату:
needs_clarification — boolean, чи потрібно уточнити інформацію.
sql — SQL-запит, щоб отримати дані, про які просить менеджер. explanation — пояснення до SQL-запиту, щоб краще розуміти, що в запиті до бази, або уточнююче питання до користувача, якщо
needs_clarification=true
Логіка роботи наступна. Агент отримує запит, наприклад: «Скільки у нас зараз активних репетиторів?», викликає ListTablesTool і шукає підходящу таблицю з назвою tutors. Якщо знаходить, то викликає GetTableSchemaTool, щоб отримати опис схеми таблиці. Також може викликати PreviewDataTool, щоб зрозуміти значення полів, якщо йому не вистачає опису.
Після цього, якщо йому незрозуміле питання менеджера, то він повертає запит на уточнення і у відповіді менеджера отримує додаткову інформацію, а якщо все зрозуміло, то повертає SQL-запит і пояснення до нього.
Далі контроль виконання повертається у джобу. Там відбувається валідація отриманого SQL, чи це SELECT, із запиту прибираються конфіДані виводяться у форматі: денційні поля, обов’язково додається LIMIT, щоб не витягувати надто багато даних.
Якщо SQL є валідним, то виконуємо запит до бази. Можна було б дати самому агенту інструмент для виконання запиту і відразу від нього отримувати результат, але це менш безпечно і потребуватиме більше вихідних токенів, які завжди дорожчі. Також ми отримуємо більший контроль над самим запитом. При виконанні SQL-запиту я задаю для сесії бази даних ліміт виконання 10 секунд, щоб випадково не навантажити базу важким запитом.
Коли ми отримали дані з бази, далі відбувається форматування та відправлення відповіді користувачу у Slack thread. І логіка така: продовження розмови — це якщо користувач пише у thread, а якщо пише повідомлення в основний канал, то це завжди новий запит.
Відповідь складається з
Дані виводяться у форматі:
+----------+-------------+ | tutor_id | conversion | +----------+-------------+ | 49753 | 60.87 | | 51486 | 69.23 | +----------+-------------+
Також є генерація CSV-файлу і публікується посилання на завантаження, якщо даних більше
Усі запити менеджерів, підсумковий SQL-запит, кількість отриманих рядків і кількість викликаних інструментів фіксуються у таблицю аудиту. У цій таблиці я звертаю увагу на те, скільки ітерацій викликів інструментів було. Якщо їх більше
На цьому робота джоби закінчується.

Тут, звичайно, представлений не весь код, проект трохи складніший, я спрощував, щоб передати саму суть, але там і немає чогось надзвичайного.
Запуск
Для генерації контексту я створив консольний додаток. У ньому виводяться всі існуючі класи таблиць і можна обрати якусь одну для генерації схеми.

А для початкового тестування асистента, я створив консольний додаток.

Вже для продакшн-запуску я створив новий Slack додаток. Заповнив всю необхідну інформацію і налаштував потрібні дозволи. Як виявилось, кодинг-агенти не дуже добре знайомі зі Slack API, довелось деякий час подебажити. Найбільше проблем виникло з функцією завантаження CSV-файлу.
Зараз асистент перебуває на етапі закритого тестування. Я надаю доступ окремим колегам і прошу скористатися ним. У аудит-логах я можу бачити, якого характеру питання ставляться і чи може асистент знайти на них відповіді. На жаль, рівень використання поки розчаровує: можливо, люди ще не готові до такого формату взаємодії, або в них просто немає великої кількості індивідуальних запитів. Тому зараз займаюсь популяризацією асистента серед колег.
Приклад відповіді асистента

Зараз мене турбує питання: як переконатися, що агент сформував коректний SQL-запит, який містить всі необхідні умови? Обізнана з SQL людина зможе помітити помилку, але менеджери знають базу даних поверхнево. Вони можуть отримати хибні відповіді та, як наслідок, побудувати на їх основі невірні гіпотези. Наприклад, якщо менеджер запитає, скільки сьогодні було уроків, а агент забуде додати умову deleted_at IS NULL (оскільки ми використовуємо soft delete), то відповідь міститиме завищену кількість. Поділіться вашим досвідом з цього приводу у коментарях.
Висновок
У цій статті я хотів показати, що зараз вже немає технічної складності реалізувати свого AI-агента, не обов’язково знати Python, мати друзів дата-інженерів. Створити агента можливо практично на будь-якій мові програмування. Зараз більша складність — це придумати саме корисного асистента, яким захочуть користуватися. Пів року тому я ще зробив AI код рев’ювера, якщо буде цікаво, можу і його архітектуру описати.
Якщо у вас вже є ідея для реалізації, то перешкод немає. Пробуйте. Головне — це якісний контекст.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівДякую. Цікавий контент, laravel зараз розвивається і скоро там ще буде багато чого цікавого
«За допомогою ШІ я створив ще один насправді нікому не потрібний додаток»
Зізнаюсь відверто, у мене інколи виникало таке відчуття після запуску. Але використання асистента росте, просто люди ще не звикли до такого способу отримання даних, і потрібно пояснювати що асистент може і як працює. Дякую за коментар
Може потрібно переосмислити роль аналітиків для початку?
Якщо «багато дешбордів» не вирішують проблем бізнесу, може вони роблять не те?
Для цього не потрібен цілий АІ-агент, це вирішується через self-service analytics, для того щоб не звертатися
Потужний, направду, proof of concept!
Adoption, на мою думку, з часом буде кращий, бо у людей вже є напрацьований часом патерн з такими питаннями йти до відповідної людинки.
Цікаво буде почитати наступну статтю про агента — бізнес-аналітика)
В світі, де python став де-факто стандартом індустрії, замість того, шоб взяти готове, розібратися і горя не знати, ми будемо придумувати велосипеди на PHP.
Мова програмування — це лише інструмент, який треба вміти застосовувати.
Я сам типу як PHP розробник, але якшо треба писати на Python, JS чи Go, то це ніколи не було проблемою.
Суть статті якраз у тому, що не потрібно нічого нового вчити, а можна вже відразу писати агентів на тій мові, яку знаєш, і для цього вже є все необхідне.
Так use case якраз в цьому: є проєкт на РНР і треба контекст цього проєкту передати в LLM. Плюс вже є команда з експертизою в РНР.
Який сенс це виносити в окрему частину на іншій мові?