Это вторая часть статьи.
Часть 1: Практика без Python и data science
AI в PHP: не теория, а место, с которого можно начать
В своей прошлой статье я описал на довольно общем уровне почему тема AI вроде бы везде, но при этом почти не пересекается с повседневной PHP-разработкой. Не потому что PHP "не подходит", а потому что сам разговор обычно идёт мимо наших задач и привычного способа мышления. Ну и, конечно, о том, что почти нет материала, который объясняет AI именно для PHP-разработчиков, их задач и их мышления.
После публикации мне несколько раз задали один и тот же вопрос, в разных формулировках:
Окей, допустим. А с чего конкретно начать?
И это, пожалуй, самый интересный вопрос из тех, что я получил. Ниже я попытаюсь дать на него ответ.
Про "точку входа"
Когда вы слышите "использование AI в проекте", скорее всего в вашей голове сразу возникает слишком много лишнего: инфраструктура, обучение моделей, эксперименты, отдельные сервисы, новые роли в команде и т.д.
Но если честно, в большинстве PHP-проектов нам это просто не нужно (хотя разобраться в этом самому - жутко интересная задача).
Но всё же, нам с вами не нужно:
обучать модели с нуля
разбираться в градиентном спуске
собирать датасеты и т.д.
Нужно понять, что AI ≠ Data Science. Друзья, в большинстве PHP-проектов никто не обучает модели, пожалуйста, запомните это! По крайней мере в прикладных PHP-проектах, где мы говорим об использовании готовых моделей, а не об исследованиях и обучении.
Нам нужно вз��ть готовый инструмент и аккуратно встроить его в уже существующую логику. Так же, как мы это делаем с любой другой библиотекой.
И вот тут начинается интересное.
Что это вообще за зверь такой TransformersPHP и с чем его едят?
Про использование LLM через API знают уже все. Это полезно и удобно, но в таком виде AI остаётся чем-то внешним: сервисом, к которому ты просто отправляешь текст. Внутри – туман.
В какой-то момент мне захотелось посмотреть на более "приземлённый" уровень: не генерация текста, а представление семантики: эмбеддинги, задачи классификации и поиска. Именно на этом уровне решается большинство прикладных задач в бэкенд-системах: поиск по тексту, сравнение и автоматическая классификация, а не диалоговое взаимодействие человека с моделью.
И здесь неожиданно выяснилось, что есть инструменты, которые позволяют делать это напрямую в PHP, без Python-стека. Один из них – TransformersPHP.
Важно сразу понять: это не попытка превратить PHP в Python и не универсальное решение. Это библиотека для inference (инференс) – использования уже обученных моделей.
Как по мне, TransformersPHP – один из самых интересных и показательных проектов в современной PHP ML-экосистеме. Отдельное спасибо его автору - Kyrian Obikwelu за то что создал этот проект и продолжает над ним работать. В общем это библиотека, которая позволяет использовать трансформер-модели (BERT, RoBERTa, DistilBERT и др.) напрямую из PHP, без Python и без внешних API.
По сути, это PHP-ориентированная обертка над идеями Hugging Face Transformers, адаптированная под PHP-экосистему и реальные прикладные сценарии.
Ключевая особенность библиотеки – локальный инференс. Модели загружаются и выполняются на стороне PHP-приложения (через ONNX Runtime), что открывает важные архитектурные возможности:
отсутствие сетевых вызовов к LLM API
полный контроль над данными (это может быть важно для privacy (конфиденциальности) в вашей работе)
предсказуемое и стабильное время обработки запроса
возможность оффлайн-работы (после первого запуска и загрузки модели)
TransformersPHP поддерживает типовые задачи NLP, такие как: получение эмбеддингов, классификацию текста, семантическое сравнение и прочее.
Что мне нравится больше всего
Самое ценное ощущение – отсутствие разрыва контекста.
Я остаюсь в PHP, я пишу тот же код, у меня тот же деплой и у меня те же подходы к архитектуре. Ничего не изменилось. Модель для меня в этом случае – не некий "магический объект", а просто ещё один источник данных. Да, непривычный, но вполне объяснимый - не хуже и не лучше других.
И это сильно меняет отношение к всей теме. Вы согласны?
Запуск модели за 10 минут
Один из самых важных моментов – это первый запуск.
Если он сложный, на этом всё обычно и заканчивается.
В случае с TransformersPHP ощущение как раз обратное: это больше похоже на работу с обычной зависимостью.
Полное руководство по установке можно найти на сайте документации.
Мы же с вами для простоты запустим докер контейнер со всеми необходимыми зависимостями. Да, Docker file выглядит объёмно – но это одноразовая инфраструктура. Сам запуск и первый демо-пример действительно укладываются в несколько минут.
Что нам нужно (требования)
PHP 8.1 или выше
Composer
Расширение PHP FFI
JIT-компиляция (опционально, для повышения производительности)
Увеличенный лимит памяти (для сложных задач, таких как генерация текста)
Структура проекта
/project/ ├── app/ │ ├── demo.php │ ├── semantic-search.php ├── docker/ │ ├── Dockerfile ├── docker-composer.yaml ├── composer.json
Установка (Docker)
Файл: docker-compose.yml
networks: ai-for-php-developers: driver: bridge services: app: build: context: . dockerfile: docker/Dockerfile volumes: - .:/var/www ports: - "8088:8088" command: php -S 0.0.0.0:8088 -t app networks: - ai-for-php-developers
Файл: docker/Dockerfile
# ------------------------------ # Install system dependencies # ------------------------------ RUN apt-get update && apt-get install -y \ libzip-dev \ zip \ unzip \ git \ libxml2-dev \ libcurl4-openssl-dev \ libpng-dev \ libonig-dev \ && rm -rf /var/lib/apt/lists/* # ------------------------------ # Install PHP extensions # ------------------------------ RUN docker-php-ext-install zip pdo_mysql bcmath xml mbstring curl gd pcntl # ------------------------------ # Enable FFI # ------------------------------ RUN apt-get update && apt-get install -y \ libffi-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* RUN docker-php-ext-install ffi RUN echo "ffi.enable=1" > /usr/local/etc/php/conf.d/ffi.ini # ------------------------------ # Install ONNX Runtime # ------------------------------ ENV ONNXRUNTIME_VERSION=1.17.1 RUN curl -L https://github.com/microsoft/onnxruntime/releases/download/v${ONNXRUNTIME_VERSION}/onnxruntime-linux-x64-${ONNXRUNTIME\_VERSION}.tgz \ | tar -xz \ && cp onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}/lib/libonnxruntime.so* /usr/lib/ \ && ldconfig \ && rm -rf onnxruntime-linux-x64-${ONNXRUNTIME_VERSION} # ------------------------------ # Install Composer # ------------------------------ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # Set working directory WORKDIR /var/www # Copy existing application directory contents COPY . /var/www # Configure PHP RUN echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/docker-php-ram-limit.ini RUN echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/docker-php-max-execution-time.ini # Expose port 9000 and start php-fpm server EXPOSE 9000 CMD ["php-fpm"]
И для установки самого TransformersPHP
Файл: composer.json
{ "type": "project", "minimum-stability": "stable", "prefer-stable": true, "require": { "codewithkyrian/transformers": "~0.6.2" }, "config": { "allow-plugins": { "codewithkyrian/platform-package-installer": true } } }
Команда для запуска окружения
docker compose build --pull docker compose up -d docker compose exec app /bin/bash -c "composer install"
Идея простая: чтобы пример можно было поднять локально без ручной настройки окружения.
Базовый демо-пример
Начнём с самого простого: анализа настроений.
Если всё, что описано выше сработало хорошо и установка прошла нормально, можно запустить пример, описанный ниже (примите во внимание, что первый запуск может занять несколько секунд):
docker compose exec app php app/demo.php
Пример использования выглядит концептуально просто: вы загружаете предобученную модель и применяете ее к тексту так же, как это делали бы в Python – но уже внутри PHP-кода. TransformersPHP предлагает простой pipeline API для задач вроде анализа настроений, классификации текста, семантического сравнения и т.д. В примере ниже модель определяет тональность двух фраз и показывает метку и score.
Файл: app/demo.php
require_once __DIR__ . '/../vendor/autoload.php'; use function Codewithkyrian\Transformers\Pipelines\pipeline; // Выделить конвейер для анализа настроений $classifier = pipeline('sentiment-analysis'); $out = $classifier(['I love transformers!']); echo 'I love transformers!'; echo print_r($out, true); $out = $classifier(['I hate transformers!']); echo 'I hate transformers!'; echo print_r($out, true);
Результат, конечно, же вполне ожидаемый:
I love transformers! Array ( [label] => POSITIVE [score] => 0.99978870153427 ) I hate transformers! Array ( [label] => NEGATIVE [score] => 0.99863630533218 )
Цель этого примера – снять у вас психологический барьер: эта модель в PHP — это просто ещё один объект, с которым можно работать.
Этот же пример можно запустить онлайн.
Важно понимать архитектурную роль TransformersPHP.
Эта библиотека не конкурирует с большими LLM-сервисами вроде GPT или Claude. Она закрывает другой, очень важный слой:
быстрые эмбеддинги
локальная классификация
семантический поиск
lightweight NLP без внешних зависимостей
В связке с PHP это выглядит особенно логично. PHP остается центральным слоем бизнес-логики, а трансформеры становятся встроенным инструментом, а не удаленным сервисом. TransformersPHP – это хороший пример того, как современный ML постепенно перестает быть "чужим" для PHP и становится частью его нативной экосистемы, пусть и через аккуратные инженерные мосты вроде ONNX.
Реальный кейс: семантический поиск по событиям
Учебные примеры хороши, но быстро надоедают. Гораздо интереснее посмотреть на задачу, которая реально встречается в бэкенде. Сейчас мы с вами сделаем кое-что поинтересней.
Для запуска этого примера используйте следующую команду
docker compose exec app php app/semantic-search.php
Сценарий
Есть события с коротким текстовым описанием. Пользователь ищет, например:
"санкции против IT-компаний"
"космическая гонка среди стран региона"
При этом, как вы понимаете, в самих данных таких формулировок может не быть вообще. Классический поиск по словам здесь начинает усложняться: синонимы, морфология, разные языки, костыли поверх костылей и прочее.
Небольшое мысленное упражнение
Допустим, у нас есть лента событий или материалов, где каждое событие описано парой предложений. Что-то вроде:
введены новые ограничения в отношении технологических корпораций
страны региона наращивают инвестиции в спутниковые программы
обострение конфликта на политической почве в нескольких провинциях
Теперь пользователь вводит запрос: "космическая гонка среди стран региона".
Ни одно из этих слов буквально не обязано встречаться в описании событий. Упс… И это нормально – люди редко формулируют мысли так же, как их описывают системы.
Идея решения
Вместо того чтобы пытаться угадать слова, можно попробовать искать по смыслу. Не в философском смысле, а в инженерном:
описание события → вектор,
запрос пользователя → вектор,
дальше – обычный поиск ближайших значений.
То есть, нам нужно использовать эмбеддинги как универсальный индекс смысла.
Таким образом мы:
Берём массив событий
{id, title, description}Считаем эмбеддинг только по
description(заголовок часто слишком короткий, неинформативный и может добавлять шум)Эмбеддим пользовательский запрос
Ищем ближайшие векторы
Сортируем и возвращаем результат
Без обучения моделей и без сложной инфраструктуры. Эмбеддинги для событий считаются один раз и могут храниться где угодно. Запрос пользователя обрабатывается в момент поиска. Дальше – сортировка и вывод результатов.
И... вуаля!
Никакого обучения моделей.
Никакой магии.
Просто другой способ представить текст.
Логика работы
Поместим логику работы в отдельный класс SemanticEventSearch. Этот класс не претендует на звание лучшего кода на планете, и написан только в в демонстрационных целях - поэтому опустим замечания по его качеству.
Класс SemanticEventSearch
final class SemanticEventSearch { private string $model = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2'; private string $cachePath; private string $defaultQuery = 'санкции против IT-компаний'; private int $topN; private ?string $query = null; /** @var list<array{id:int,title:string,description:string}> */ private array $events; /** @var array<int, list<float|int>> */ private array $eventEmbeddingsById = []; private $embedder; /** * Create a new semantic search instance. * * @param int $topN Number of results to return. */ public function __construct(int $topN = 3) { $this->cachePath = __DIR__ . '/../embeddings.events.json'; $this->events = []; $this->embedder = null; $this->topN = $topN; } /** * Inject events that will be indexed/searched. * * @param list<array{id:int,title:string,description:string}> $events * @return $this */ public function setEvents(array $events): self { $this->events = $events; $this->eventEmbeddingsById = []; return $this; } /** * Set the embeddings model identifier. * * Switching model invalidates in-memory embeddings. * * @param string $model * @return $this */ public function setModel(string $model): self { $this->model = $model; $this->embedder = null; $this->eventEmbeddingsById = []; return $this; } /** * Set the query to be searched. * * @param string $query * @return $this */ public function setQuery(string $query): self { $q = trim($query); $this->query = $q === '' ? null : $q; return $this; } /** * Run the end-to-end semantic search pipeline (cache -> embed query -> score -> top-N). * * @return array{query:string,results:list<array{score:float,event:array{id:int,title:string,description:string}}>} * @throws RuntimeException If events are not set or embeddings output is unexpected. */ public function run(): array { if (count($this->events) === 0) { throw new RuntimeException('Events list is empty. Call setEvents() before run().'); } if ($this->embedder === null) { $this->embedder = pipeline('embeddings', $this->model); } $this->loadEmbeddingsFromCacheIfCompatible(); $this->ensureAllEventEmbeddings(); $query = $this->query ?? $this->defaultQuery; $queryVec = $this->embedText($query); $results = $this->search($queryVec); return [ 'query' => $query, 'results' => $results, ]; } /** * Compute an embedding vector for a single text. * * @param string $text * @return list<float|int> * @throws RuntimeException */ private function embedText(string $text): array { $emb = ($this->embedder)($text, normalize: true, pooling: 'mean'); if (!is_array($emb) || !isset($emb[0]) || !is_array($emb[0])) { throw new RuntimeException('Unexpected embeddings output format'); } return $emb[0]; } /** * Cosine similarity between two vectors. * * @param list<float|int> $a * @param list<float|int> $b * @return float */ private function cosineSimilarity(array $a, array $b): float { $n = min(count($a), count($b)); $dot = 0.0; $normA = 0.0; $normB = 0.0; for ($i = 0; $i < $n; $i++) { $x = (float) $a[$i]; $y = (float) $b[$i]; $dot += $x * $y; $normA += $x * $x; $normB += $y * $y; } if ($normA <= 0.0 || $normB <= 0.0) { return 0.0; } return $dot / (sqrt($normA) * sqrt($normB)); } /** * Load a JSON file and decode to array. * * @param string $path * @return array|null */ private function loadJsonFile(string $path): ?array { if (!is_file($path)) { return null; } $raw = file_get_contents($path); if ($raw === false) { return null; } $data = json_decode($raw, true); return is_array($data) ? $data : null; } /** * Encode and save data to JSON file. * * @param string $path * @param array $data * @throws RuntimeException */ private function saveJsonFile(string $path, array $data): void { $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($json === false) { throw new RuntimeException('Failed to encode JSON'); } $ok = file_put_contents($path, $json); if ($ok === false) { throw new RuntimeException('Failed to write cache file: ' . $path); } } /** * Load cached event embeddings only if they were produced by the current model. * * @return void */ private function loadEmbeddingsFromCacheIfCompatible(): void { $cached = $this->loadJsonFile($this->cachePath); if (!is_array($cached) || !isset($cached['model'], $cached['events']) || !is_array($cached['events'])) { return; } if ($cached['model'] !== $this->model) { return; } foreach ($cached['events'] as $row) { if (isset($row['id'], $row['embedding']) && is_array($row['embedding'])) { $this->eventEmbeddingsById[(int) $row['id']] = $row['embedding']; } } } /** * Ensure embeddings exist for all events and persist them to cache. * * @return void * @throws RuntimeException */ private function ensureAllEventEmbeddings(): void { $missing = []; foreach ($this->events as $event) { $id = (int) $event['id']; if (!isset($this->eventEmbeddingsById[$id])) { $missing[] = $event; } } if (count($missing) === 0) { return; } foreach ($missing as $event) { $id = (int) $event['id']; $text = (string) $event['description']; $this->eventEmbeddingsById[$id] = $this->embedText($text); } $toCache = [ 'model' => $this->model, 'events' => array_values(array_map( fn(array $event): array => [ 'id' => (int) $event['id'], 'embedding' => $this->eventEmbeddingsById[(int) $event['id']], ], $this->events )), ]; $this->saveJsonFile($this->cachePath, $toCache); } /** * Score all events against the query embedding and return the top-N results. * * @param list<float|int> $queryVec * @return list<array{score:float,event:array{id:int,title:string,description:string}}> */ private function search(array $queryVec): array { $scored = []; foreach ($this->events as $event) { $id = (int) $event['id']; $score = $this->cosineSimilarity($queryVec, $this->eventEmbeddingsById[$id]); $scored[] = [ 'score' => $score, 'event' => $event, ]; } usort($scored, static fn(array $a, array $b): int => $b['score'] <=> $a['score']); return array_slice($scored, 0, $this->topN); } /** * Render results as plain text. * * @param string $query * @param list<array{score:float,event:array{id:int,title:string,description:string}}> $results * @return void */ public function render(string $query, array $results): void { echo "Query: {$query}\n\n"; foreach ($results as $row) { $event = $row['event']; $score = (float) $row['score']; echo "[" . number_format($score, 4) . "] #{$event['id']} {$event['title']}\n"; echo " {$event['description']}\n\n"; } } }
Подготовим данные
Предположим, что это наши данные, собранные из разных источников. Для простоты поместим их в массив.
Массив $events
$events = [ [ 'id' => 1, 'title' => 'Ограничения против технологических корпораций', 'description' => 'Введены новые экономические меры в отношении крупных технологических компаний.', ], [ 'id' => 2, 'title' => 'Развитие космических программ', 'description' => 'Несколько стран региона увеличили финансирование национальных спутниковых проектов.', ], [ 'id' => 3, 'title' => 'Эскалация политического конфликта', 'description' => 'Обострение конфликта на политической почве в нескольких провинциях.', ], [ 'id' => 4, 'title' => 'Ограничения против ИТ-сектора', 'description' => 'Правительство объявило о новых ограничениях для компаний, работающих в сфере информационных технологий.', ], [ 'id' => 5, 'title' => 'Рост инфляции и пересмотр ключевой ставки', 'description' => 'Центральный банк повысил ключевую ставку на фоне ускорения инфляции и роста цен на импортные товары.', ], [ 'id' => 6, 'title' => 'Запуск программы поддержки малого бизнеса', 'description' => 'Власти объявили о льготных кредитах и налоговых послаблениях для малого и среднего бизнеса в регионах.', ], [ 'id' => 8, 'title' => 'Утечка данных в сфере онлайн-ритейла', 'description' => 'Интернет-магазин расследует утечку персональных данных клиентов после компрометации учётных записей сотрудников.', ], [ 'id' => 9, 'title' => 'Прорыв в медицине: новый метод диагностики', 'description' => 'Исследователи представили метод ранней диагностики заболеваний по биомаркерам, сокращающий время анализа.', ], [ 'id' => 10, 'title' => 'Сезонный рост заболеваемости', 'description' => 'В нескольких городах отмечен рост заболеваемости респираторными инфекциями, клиники усилили приём пациентов.', ], [ 'id' => 12, 'title' => 'Засуха и риски для сельского хозяйства', 'description' => 'Из-за продолжительной засухи фермеры прогнозируют снижение урожайности, обсуждаются меры поддержки аграриев.', ], [ 'id' => 13, 'title' => 'Финал крупного спортивного турнира', 'description' => 'В решающем матче сезона команда одержала победу в дополнительное время, установив новый рекорд по посещаемости.', ], [ 'id' => 14, 'title' => 'Трансфер игрока и усиление состава', 'description' => 'Клуб подписал контракт с новым нападающим, рассчитывая усилить атакующую линию перед серией дерби.', ], [ 'id' => 15, 'title' => 'Новые правила для маркетплейсов', 'description' => 'Регулятор предложил требования к маркировке товаров и прозрачности комиссий на торговых онлайн-платформах.', ], [ 'id' => 17, 'title' => 'Сбои в поставках полупроводников', 'description' => 'Производители электроники предупредили о задержках поставок чипов из-за ограничений экспорта и перегрузки заводов.', ], [ 'id' => 18, 'title' => 'Открытие фестиваля современного искусства', 'description' => 'В столице стартовал фестиваль современного искусства с выставками, перформансами и лекциями художников.', ], [ 'id' => 19, 'title' => 'Крупная сделка на рынке недвижимости', 'description' => 'Инвестфонд приобрёл портфель коммерческой недвижимости, планируя реконструкцию и повышение энергоэффективности.', ], [ 'id' => 20, 'title' => 'Исследование океана и новые данные', 'description' => 'Научная экспедиция собрала данные о течениях и температуре воды, уточнив прогнозы по изменению климата.', ], ];
Использование примера
Здесь всё просто - запускаем наш код и ждём результата.
require_once __DIR__ . '/../vendor/autoload.php'; use function Codewithkyrian\Transformers\Pipelines\pipeline; final class SemanticEventSearch {...} $events = [...]; $query = 'санкции против IT-компаний'; $search = new SemanticEventSearch(topN: 3); $search->setModel('Xenova/paraphrase-multilingual-MiniLM-L12-v2'); $search->setEvents($events); $search->setQuery($query); $out = $search->run(); $search->render($out['query'], $out['results']);
Логика работы (по шагам)
Если вам не хочется разбираться в деталях реализации SemanticEventSearch, ниже – упрощённое пошаговое описание. Более подробный разбор кода выходит за рамки этой статьи.
Логика работы кода
Снаружи задаём:
список событий (setEvents)
модель (setModel)
запрос (setQuery)
topN через конструктор
Дальше — обычная логика в run():
Поднимаем embedder (pipeline('embeddings', model)) если ещё не поднят.
.transformers-cache:
при первом использовании модель и файлы токенизации/весов скачиваются и кладутся в .transformers-cache
дальше они берутся оттуда, чтобы не качать заново и работать быстрее
embeddings.events.json:
это наш локальный кэш эмбеддингов событий
пытаемся его прочитать и использовать только если model в кэше совпадает с текущей моделью
Если для каких-то событий эмбеддингов нет:
считаем эмбеддинги для description
сохраняем обратно в embeddings.events.json
Эмбеддим запрос (эмбеддинги нормализуются, чтобы косинусная близость была стабильной и сравнимой)
Считаем близость запроса к каждому событию (cosine similarity)
Сортируем, берём top‑N, возвращаем результаты
Рендер (вывод) делается снаружи через render(query, results)
Результат
На выходе мы получаем не совпадение слов, а совпадение по смыслу. Первый результат - наиболее подходящий по схожести с нашим запросом про "санкции против IT-компаний". И всё это - без сложной математики и без танцев с бубнами.
Query: санкции против IT-компаний [0.4288] #4 Ограничения против ИТ-сектора Правительство объявило о новых ограничениях для компаний, работающих в сфере информационных технологий. [0.3356] #15 Новые правила для маркетплейсов Регулятор предложил требования к маркировке товаров и прозрачности комиссий на торговых онлайн-платформах. [0.2598] #8 Утечка данных в сфере онлайн-ритейла Интернет-магазин расследует утечку персональных данных клиентов после компрометации учётных записей сотрудников.
Где это можно применять в проде
В целом это уже похоже на продуктовый подход, а не на эксперимент. К тому же вы можете легко заметить, что такой подход:
слабо привязан к конкретной формулировке запроса
хорошо работает на коротких описаниях
легко комбинируется с обычными фильтрами (дата, регион, тип события)
То есть это не "AI ради AI", а вполне конкретная прикладная логика. Та самая, которую можно объяснить, отладить и поддерживать.
Ограничения и подводные камни
Важно не обманываться: это не серебряная пуля.
Модели весят немало. Производительность нужно учитывать. Некоторые задачи проще и надёжнее решаются без AI обычным SQL.
Но это уже нормальный инженерный разговор – про trade-off’ы, а не про магию или чёрный ящик.
Куда двигаться дальше
Как для меня, то TransformersPHP - это хороший пример того, что AI можно использовать напрямую в PHP-проектах без смены стека и без Python.
В своей книге "AI для PHP-разработчиков" (открытой и бесплатной) я как раз и разбираю подобные кейсы: где это имеет смысл, как выбирать модели и как не превратить проект в набор экспериментальных фич.
Ссылка на книгу "AI для PHP-разработчиков".
Кстати, все примеры можно скачать и запустить через готовую среду Docker.
Или же вы также можете запускать все примеры из книги напрямую.
Если тема откликается – буду рад обсуждению и фидбэку.
Особенно интересно, какие задачи вы уже решаете или хотели бы решать с помощью AI в PHP-проектах.
