Введение
laravel/ai — официальный AI SDK для Laravel. Он скрывает клиенты разных провайдеров (OpenAI, Anthropic, Gemini, Ollama и других) за одним API и покрывает текстовых агентов, изображения, аудио, эмбеддинги и классификацию. Кроме того, в пакет встроены хранение диалогов в базе, потоковая отдача ответа и тестирование без обращения к сети.
Версия 1.0 вышла 23.09.2026 и требует PHP 8.3+ и Laravel 12 или 13 (illuminate/* ^12.0 или ^13.0). Статья построена на одном сквозном примере — помощнике поддержки интернет-магазина. Рассматриваются установка, агент, ответ по схеме, инструмент, подтверждение опасного действия, стриминг и тесты; в конце — чек-лист для тех, кто переходит с беты.
Установка и настройка
Достаточно трёх команд:
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
Публикация добавляет config/ai.php, а миграция создаёт таблицы agent_conversations и agent_conversation_messages, в которых лежат диалоги. Ключи провайдеров хранятся в .env и в репозиторий не попадают:
OPENAI_API_KEY=ваш-ключ
ANTHROPIC_API_KEY=ваш-ключ
Основной провайдер указывается в ключе default файла config/ai.php. Для изображений, аудио, эмбеддингов и классификации предусмотрены свои ключи (default_for_images, default_for_embeddings, default_for_classification и т. д.). Провайдера и модель при необходимости меняют на уровне агента или отдельного вызова.
Не каждый провайдер умеет всё. Ориентир — таблица из документации 13.x:
| Возможность | Провайдеры |
|---|---|
| Текст (агенты) | OpenAI, OpenAI-compatible, Anthropic, Gemini, Azure, Bedrock, Groq, xAI, DeepSeek, Mistral, Ollama, OpenRouter |
| Изображения | OpenAI, Gemini, xAI, Azure, Bedrock, OpenRouter |
| Эмбеддинги | OpenAI, OpenAI-compatible, Gemini, Azure, Bedrock, Cohere, Mistral, Jina, VoyageAI, Ollama, OpenRouter |
| Классификация | TypeSafe, OpenRouter |
Остальные строки матрицы (аудио, транскрибация, реранкинг, файлы) приведены в документации. Локальные модели подключаются через Ollama или через драйвер openai-compatible с произвольным url в config/ai.php.
Первый агент
Агент — это класс, в котором собраны инструкции, инструменты и (по желанию) схема ответа. Заготовку даёт команда:
php artisan make:agent SupportAgent
Класс появится в app/Ai/Agents. Вот помощник поддержки, который запоминает диалог:
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\LookupOrder;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;
#[Provider(Lab::Anthropic)] // провайдер агента
#[MaxSteps(6)] // не более шести обращений к модели за запрос
class SupportAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
public function instructions(): Stringable|string
{
return 'Ты помощник поддержки интернет-магазина. Отвечай кратко и вежливо. '
. 'Статус заказа узнавай только через инструмент, ничего не выдумывай.';
}
public function tools(): iterable
{
return [new LookupOrder];
}
}
Модель в примере не указана, и тогда SDK использует модель по умолчанию для выбранного провайдера. Чтобы задать её явно, нужен атрибут #[Model('...')] или аргумент model: в вызове. Название модели лучше вынести в конфиг, а не зашивать в класс агента: линейки провайдеров быстро меняются.
Трейт RemembersConversations реализует контракт Conversational и сам сохраняет историю в БД, так что писать messages() не требуется. Вызов агента выглядит так:
$response = (new SupportAgent)
->forUser($request->user())
->prompt('Где мой заказ 1042?');
echo $response->text;
$conversationId = $response->conversationId;
// продолжение диалога
$next = (new SupportAgent)
->continue($conversationId, as: $request->user())
->prompt('А когда будет доставка?');
forUser() открывает новый диалог, а continue($id, as: $user) возвращается к существующему. Третий вариант — continueOrStart($id, as: $user): если вместо идентификатора передан null, диалог создаётся. Для разовой задачи класс не обязателен: подойдёт анонимный агент, agent(instructions: '...')->prompt('...').
Параметры можно переопределить прямо в вызове: prompt('...', provider: Lab::OpenAI, model: '...', timeout: 60). Поэтому смена провайдера не требует переписывать агента — достаточно другого аргумента или атрибута #[Provider]. Если в provider: передать массив, следующие провайдеры сработают как резерв (failover) при ошибке первого.
Структурированный ответ
Когда обращения нужно сортировать или из текста надо достать данные, свободный текст не подходит — требуется ответ заданной формы. Для этого агент реализует HasStructuredOutput и описывает схему в методе schema(). Заготовка создаётся с флагом:
php artisan make:agent TicketTriage --structured
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
class TicketTriage implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Оцени срочность обращения в поддержку и определи категорию.';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()
->enum(['delivery', 'payment', 'refund', 'other'])
->required(),
'urgency' => $schema->integer()->min(1)->max(5)->required(),
'summary' => $schema->string()->required(),
];
}
}
Результат читается как массив:
$result = (new TicketTriage)->prompt($ticket->body);
$ticket->update([
'category' => $result['category'],
'urgency' => $result['urgency'],
]);
Схема поддерживает вложенные объекты ($schema->object(fn ($schema) => [...])), массивы ($schema->array()->items(...)) и объединения типов (anyOf).
Инструменты (tools)
Через инструмент модель получает доступ к коду приложения. Когда его вызывать, решает сама модель: она формирует аргументы по описанной схеме, SDK запускает handle() и отдаёт результат обратно. Заготовку создаёт php artisan make:tool LookupOrder:
<?php
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class LookupOrder implements Tool
{
public function description(): Stringable|string
{
return 'Возвращает статус и состав заказа по его номеру.';
}
public function handle(Request $request): Stringable|string
{
$data = $request->validate([
'order_id' => 'required|integer',
]);
$order = Order::with('items')->find($data['order_id']);
if (! $order) {
return 'Заказ не найден.';
}
return json_encode([
'status' => $order->status,
'total' => $order->total,
'items' => $order->items->pluck('title'),
], JSON_UNESCAPED_UNICODE);
}
public function schema(JsonSchema $schema): array
{
return [
'order_id' => $schema->integer()->required(),
];
}
}
Аргументы, которые прислала модель, считаются пользовательским вводом. Поэтому validate() в handle() обязателен, а права доступа проверяются так же, как в контроллере: например, принадлежит ли заказ текущему пользователю. Инструмент подключается через tools() агента, как выше, или на лету — ->withTools([new LookupOrder]).
Когда инструментов десятки, их описания попадают в каждый запрос и расходуют токены. Редко нужные инструменты стоит обернуть в ToolSearch: провайдер подгрузит их только при необходимости. Механизм работает у OpenAI и Anthropic:
use Laravel\Ai\Providers\Tools\ToolSearch;
public function tools(): iterable
{
return [
new LookupOrder,
new ToolSearch(tools: [new SearchInvoices, new RefundOrder]),
];
}
Кроме собственных инструментов, в 1.0 есть провайдерские (WebSearch, WebFetch, FileSearch, CodeExecution). CodeExecution выполняет код в песочнице провайдера и поддерживается у Anthropic, OpenAI, Azure, Gemini и xAI.
Подтверждение опасных вызовов
Возврат денег или удаление данных нельзя пускать на самотёк. Для таких случаев в 1.0 появились контракт Approvable и трейт InteractsWithApprovals: дойдя до подобного инструмента, агент не выполняет его, а останавливается и ждёт решения.
<?php
namespace App\Ai\Tools;
use App\Services\RefundService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class RefundOrder implements Approvable, Tool
{
use InteractsWithApprovals;
public function description(): Stringable|string
{
return 'Оформляет возврат средств по заказу.';
}
public function handle(Request $request): Stringable|string
{
app(RefundService::class)->refund($request['order_id'], $request['amount']);
return "Возврат по заказу {$request['order_id']} оформлен.";
}
public function schema(JsonSchema $schema): array
{
return [
'order_id' => $schema->integer()->required(),
'amount' => $schema->integer()->min(1)->required(),
];
}
protected function needsApproval(Request $request): Approval|bool
{
// небольшие суммы проходят без подтверждения
return $request['amount'] <= 500
? false
: Approval::required('Возврат на сумму больше 500.');
}
}
По умолчанию (без переопределения needsApproval()) подтверждение требуется всегда. Требование можно задать и снаружи, при подключении инструмента: (new RefundOrder)->requireApproval('Проверка возврата.') или ->withoutApproval().
Пауза видна в ответе: hasPendingApprovals() и коллекция pendingApprovals, где у каждого элемента есть id, tool, arguments и reason.
$response = (new SupportAgent)
->forUser($user)
->prompt('Верните деньги за заказ 1042, 3000 рублей.');
if ($response->hasPendingApprovals()) {
return response()->json([
'conversation_id' => $response->conversationId,
'approvals' => $response->pendingApprovals->toArray(),
]);
}
Решает человек, например оператор в админке. Диалог затем продолжается, но в prompt() вместо текста уходят решения: вызов можно одобрить, отклонить с причиной (её увидит модель) или изменить его аргументы.
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
$response = (new SupportAgent)
->continue($conversationId, as: $user)
->prompt(Decisions::from([
'call_abc' => Decision::approve(),
'call_def' => Decision::reject('Возврат возможен только в течение 14 дней.'),
'call_ghi' => Decision::edit(['order_id' => 1042, 'amount' => 1500]),
]));
Ключи массива — идентификаторы вызовов из pendingApprovals. Для остальных вызовов есть сокращения: Decisions::from([...])->rejectRemaining('Не одобрено.'), approveRemaining(), а также Decision::approveAll() и Decision::rejectAll($reason). Значения true и false в массиве трактуются как одобрение и отклонение. Подтверждения работают с prompt, stream, queue и методами broadcast.
Потоковый ответ и фронтенд
Результат stream() можно вернуть из маршрута или контроллера как есть — SDK сам превратит его в SSE-ответ.
Route::post('/support/stream', function (Request $request) {
return (new SupportAgent)
->forUser($request->user())
->stream($request->string('message')->toString());
});
Итоговые данные (полный текст, расход токенов) доступны в колбэке then():
use Laravel\Ai\Responses\StreamedAgentResponse;
return (new SupportAgent)
->forUser($request->user())
->stream($message)
->then(function (StreamedAgentResponse $response) {
logger()->info('Ответ агента', ['tokens' => $response->usage?->outputTokens]);
});
Для готовых JS-клиентов SDK умеет говорить на их протоколах. Для Vercel AI SDK (Chat) запрос отдаётся в Vercel::chat(), а результат передаётся агенту: этот маршрут принимает новое сообщение, обновляет историю в БД и разбирает решения по подтверждениям.
use Laravel\Ai\Vercel\Vercel;
Route::post('/chat', function (Request $request) {
$chat = Vercel::chat($request);
return (new SupportAgent)
->withMessages($chat->history())
->stream($chat)
->usingProtocol($chat->protocol());
});
Для клиентов на протоколе AG-UI (например, CopilotKit) есть аналог — AgentUserInteraction::chat($request) и метод usingAgentUserInteractionProtocol(). Восстановить экран чата после перезагрузки страницы помогает Vercel::toUiMessages($messages).
Если ответ нужно доставлять не в HTTP-ответе, а через WebSocket, события потока транслируются в канал: broadcastOnQueue($prompt, new Channel('support.1')). Настройка канала и сервера описана в статье про трансляцию событий и Reverb. Для тяжёлых запросов без ожидания подходит queue() с колбэками then() и catch().
Хранение диалогов
Диалоги хранятся в двух таблицах: agent_conversations (заголовок, участник) и agent_conversation_messages (сообщения). Одна строка сообщения соответствует одному ходу диалога. Чтобы «пользователь» мог получать свои диалоги напрямую, в модель добавляется трейт Laravel\Ai\Concerns\HasConversations:
$conversations = $user->conversations()
->latest('updated_at')
->paginate(20);
В 1.0 изменилась структура сообщений. Раньше вызовы инструментов и их результаты лежали в двух плоских списках (tool_calls, tool_results), из-за чего терялось, на каком шаге сделан вызов, и провайдеры иногда отвергали восстановленную историю. Теперь у сообщения одна JSON-колонка steps: по записи на каждый обмен с моделью, а результат хранится рядом с вызовом, который его породил. Колонка approval_state заменена на status (completed, paused, failed). Аксессоры toolCalls и toolResults работают как прежде.
Middleware агента
До 1.0 middleware срабатывал один раз на запрос. Теперь он оборачивает каждый шаг генерации: агент, вызвавший три инструмента перед ответом, пройдёт через middleware несколько раз. Шаг приходит как объект PendingStep, который можно читать и копировать с изменениями. Заготовка создаётся командой php artisan make:agent-middleware.
Пример: после первого шага убрать из набора дорогой инструмент поиска по базе знаний и ограничить длину финального ответа:
<?php
namespace App\Ai\Middleware;
use Closure;
use Laravel\Ai\PendingStep;
class TrimCosts
{
public function handle(PendingStep $step, Closure $next)
{
if (! $step->isFirstStep()) {
// убираем инструмент по его имени
$step = $step->withoutTools('SearchKnowledgeBase');
}
if ($step->isFinalStep) {
$step = $step->withMaxTokens(800);
}
return $next($step);
}
}
Подключается middleware методом middleware() агента, который реализует HasMiddleware:
use App\Ai\Middleware\TrimCosts;
use Laravel\Ai\Contracts\HasMiddleware;
// class SupportAgent implements Agent, ..., HasMiddleware
public function middleware(): array
{
return [new TrimCosts];
}
Доступные методы PendingStep: withModel, withInstructions, withMessages, withTools, onlyTools, withoutTools, withToolChoice, withMaxTokens, withProviderOptions. Результат шага можно перехватить через $next($step)->then(fn (StepResponse $response) => ...) — удобно для логирования расхода токенов.
Параметры самого агента задаются атрибутами: #[Provider], #[Model], #[MaxSteps], #[MaxTokens], #[Temperature], #[Timeout], а также #[UseCheapestModel] и #[UseSmartestModel].
Классификация и Str::decide
В 1.0 классификация стала отдельной возможностью SDK наряду с текстом, изображениями, аудио и эмбеддингами. Это класс моделей, которые не пишут текст, а быстро выбирают вариант ответа: маршрутизация обращений, флаг для комментария, ветка процесса. Сейчас классификация работает через провайдеры TypeSafe (модель Jev) и OpenRouter; это отдельные, в том числе платные сервисы, а не то же самое, что обычный чат-агент.
Простейший вариант — макрос Str::decide(): один вопрос «да/нет» и логический результат. Порог уверенности задаётся аргументом threshold (по умолчанию 0.5):
use Illuminate\Support\Str;
$isComplaint = Str::decide(
$ticket->body,
'Клиент выражает недовольство обслуживанием?',
threshold: 0.7,
);
Для выбора из нескольких вариантов используется класс Laravel\Ai\Classification:
use Laravel\Ai\Classification;
use Laravel\Ai\Classification\Choice;
$answer = Classification::of($ticket->body)
->question('topic', new Choice('Тема обращения', [
'delivery' => 'Вопросы доставки',
'payment' => 'Оплата и возвраты',
'other' => null,
]))
->classify()
->answer('topic');
$answer->choice; // выбранный вариант
$answer->confidence; // уверенность
Если провайдер классификации не настроен, вместо него для той же задачи можно использовать агента со структурированным ответом из раздела выше.
Тесты без обращения к API
Тест не должен обращаться к сети и тратить токены. Для этого у каждого типа операций в SDK есть свой fake() и набор проверок. Для агентов fake() вызывается на классе самого агента:
use App\Ai\Agents\SupportAgent;
use App\Ai\Agents\TicketTriage;
test('помощник отвечает по статусу заказа', function () {
SupportAgent::fake(['Заказ 1042 отправлен, доставка в среду.']);
$response = (new SupportAgent)->prompt('Где мой заказ 1042?');
expect($response->text)->toContain('отправлен');
SupportAgent::assertPrompted('Где мой заказ 1042?');
SupportAgent::assertPromptedTimes(1);
});
test('сортировка обращений возвращает структуру', function () {
TicketTriage::fake([
['category' => 'refund', 'urgency' => 4, 'summary' => 'Возврат за заказ'],
]);
$result = (new TicketTriage)->prompt('Хочу вернуть деньги');
expect($result['category'])->toBe('refund');
});
fake() принимает массив ответов, которые выдаются по очереди (строка — текст, массив — структурированный ответ) или замыкание fn (string $prompt) => .... Без аргументов SDK возвращает заглушку вида «Fake response for prompt: ...», а для агентов со схемой генерирует данные, подходящие под схему. Чтобы тест падал при незапланированных обращениях к модели, используется preventStrayPrompts():
SupportAgent::fake()->preventStrayPrompts();
Проверка того, что агент не вызывался вообще: SupportAgent::assertNeverPrompted(). Для очередей есть assertQueued().
Остальные типы операций подменяются аналогично:
use Laravel\Ai\Audio;
use Laravel\Ai\Classification;
use Laravel\Ai\Embeddings;
use Laravel\Ai\Image;
Image::fake();
Audio::fake();
Embeddings::fake();
Classification::fake();
// после выполнения кода
Image::assertGenerated(fn ($prompt) => true);
Embeddings::assertNothingGenerated();
Classification::assertNothingClassified();
Сигнатуры проверок принимают замыкания (assertGenerated(Closure $callback)), поэтому условие описывается явно. Для проверки цепочек с инструментами и подтверждениями фейк-ответ можно собрать объектом Laravel\Ai\Responses\TextResponse, StructuredTextResponse или Laravel\Ai\Responses\Data\ToolCall; подробности — в тестах пакета.
Обновление с беты на 1.0
Руководство UPGRADE.md в ветке 1.x написано для перехода с 0.11. Если бета не использовалась, раздел можно пропустить. Иначе перед деплоем нужно пройти следующий список:
| Что изменилось | Что сделать |
|---|---|
Колонки tool_calls, tool_results, approval_state заменены на steps и status |
Создать миграцию из руководства (backfill) и выполнить до выкладки; заранее закрыть или отклонить все диалоги, ожидающие подтверждения: после миграции их продолжить нельзя |
| Middleware оборачивает шаг, а не весь запуск | Переписать обработчики: принимают PendingStep, возвращают результат $next($step) |
promptTokens и completionTokens |
Заменить на inputTokens и outputTokens; кешированные, записанные в кеш и reasoning-токены теперь подмножества этих значений |
| Протоколы стриминга стали объектами | В usingVercelDataProtocol() больше нет логического аргумента |
| AWS SDK не входит в пакет | Для Bedrock установить AWS SDK отдельно |
| Диалоги привязаны к агенту | Диалог одного агента не подхватывается другим агентом |
SQL-запросы к tool_calls и tool_results |
Переписать на колонку steps |
Разбор изменений с примерами «до и после» и оценкой вероятности влияния есть в самом UPGRADE.md. Проходить его вручную необязательно: Laravel Boost содержит команду /upgrade-ai-sdk-v1, которая проводит ассистента по руководству шаг за шагом.
Стоимость, безопасность, ограничения
- Токены. Расход складывается из входных и выходных токенов каждого шага; агент с инструментами делает несколько обращений за один запрос. Ограничивают его
#[MaxSteps],#[MaxTokens],ToolSearchи middleware. Итог виден в$response->usage(inputTokens,outputTokens). - Персональные данные. Всё, что попало в промпт, уходит внешнему провайдеру. Адреса, телефоны и содержимое документов передаются только при необходимости, а в инструменты стоит отдавать минимально нужный набор полей.
- Промпт-инъекции. Текст обращения и содержимое найденных документов — недоверенный ввод. Опасные инструменты закрываются подтверждением (
Approvable), а проверка прав выполняется вhandle(), а не в инструкции модели. - Таймауты и очереди. Ответ модели занимает секунды; для тяжёлых запросов подходят
#[Timeout], аргументtimeout:и методqueue(). - Доступ из РФ. Часть провайдеров недоступна из России напрямую или требует зарубежной оплаты. Альтернативы: локальные модели через Ollama, шлюз OpenRouter, произвольный OpenAI-совместимый endpoint (драйвер
openai-compatible) — условия каждого нужно проверять самостоятельно.
Итог
SDK оправдан, если приложению нужны агенты с инструментами, диалоги в БД, стриминг для фронтенда и тесты без сети, а провайдер в будущем может смениться. Если задача сводится к одному запросу к одной модели, прямой клиент провайдера проще: меньше зависимостей и слоёв абстракции.
Главное из версии 1.0: подтверждение опасных вызовов (Approvable), middleware по шагам, колонка steps в диалогах, протоколы Vercel Chat и AG-UI, классификация. При обновлении с беты обязательны backfill-миграция и замена promptTokens/completionTokens.
Остальные статьи рубрики — на странице «Все статьи по Laravel».