Laravel AI SDK 1.0: агенты, инструменты и потоковый ответ

Laravel AI SDK 1.0: агенты, инструменты и потоковый ответ
содержание

Введение

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».

Похожие