Интеграция LLM в Laravel: HTTP-клиент, сервис, стриминг и очереди

Подключаем модель к Laravel без сторонних пакетов: фасад Http, сервис-класс, потоковый ответ, фоновые задачи с повторами и тесты на Http::fake. С разбором ошибок 401–503.

4 мин · · разработка

Для интеграции LLM в Laravel не нужен отдельный пакет: встроенный HTTP-клиент (фасад Http) умеет таймауты, повторы и подмену ответов в тестах. Ниже — структура для Laravel-проекта: конфиг, сервис-класс для OpenAI-совместимого API Zukko, контроллер с обычным и потоковым ответом, задача в очереди и тесты. Код рассчитан на Laravel 11 и новее; в более старых версиях отличаются в основном заготовки задач и маршрутов.

Конфигурация

В .env:

ZUKKO_API_KEY=fetch_ваш_ключ ZUKKO_BASE_URL=https://api.zukko.pro/v1 ZUKKO_MODEL=claude-sonnet-5

В config/services.php:

bash
'zukko' => [
    'base_url' => env('ZUKKO_BASE_URL', 'https://api.zukko.pro/v1'),
    'api_key' => env('ZUKKO_API_KEY'),
    'model' => env('ZUKKO_MODEL', 'claude-sonnet-5'),
],

Через env() значения читаются только в конфиге, в коде используйте config('services.zukko...') — иначе после php artisan config:cache переменные окажутся пустыми. Ключ создаётся на странице API-ключи; для приложения удобно завести отдельный ключ с дневным лимитом.

Сервис-класс

Файл app/Services/ZukkoService.php:

bash
<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

class ZukkoService
{
    private string $baseUrl;
    private string $apiKey;

Что здесь важно:

  • Базовый адрес — https://api.zukko.pro/v1, путь — /chat/completions, авторизация — Bearer-токен через withToken.
  • retry(2, 500, ...) повторяет запрос только при сетевых ошибках (ConnectionException). Повторять 401, 402 или 403 бессмысленно, а 429 лучше обрабатывать через очередь с задержкой. Параметр throw: false возвращает неудачный ответ вместо исключения, и мы сами решаем, что с ним делать.
  • $response->throw() выбрасывает RequestException, из которого контроллер достанет статус.
  • timeout(120) — длинные ответы генерируются долго; стандартных 30 секунд может не хватить.

Laravel создаёт сервис через контейнер, поэтому регистрировать его вручную не обязательно — достаточно указать класс в аргументах контроллера или задачи.

Контроллер с обработкой ошибок

bash
<?php

namespace App\Http\Controllers;

use App\Services\ZukkoService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Request;

class ChatController extends Controller
{
    private const ERRORS = [
        401 => 'Ошибка конфигурации: неверный API-ключ',
        402 => 'Сервис временно недоступен: закончился баланс',
        403 => 'Модель не разрешена для этого ключа',

Маршрут: Route::post('/chat', [ChatController::class, 'send']);. В Laravel 11 файл routes/api.php создаётся командой php artisan install:api; если используете routes/web.php, не забудьте про CSRF-токен в запросе. Пользователю уходит понятный текст, а сырое сообщение исключения остаётся в логах.

Потоковый ответ

Чтобы текст появлялся по мере генерации, запросите поток у API и пробросьте его клиенту. Добавьте метод в ZukkoService:

bash
public function stream(array $messages): \Generator
{
    $response = Http::withToken($this->apiKey)
        ->withOptions(['stream' => true])
        ->timeout(120)
        ->post("{$this->baseUrl}/chat/completions", [
            'model' => $this->model,
            'messages' => $messages,
            'max_tokens' => 1024,
            'stream' => true,
        ])
        ->throw();

    $body = $response->toPsrResponse()->getBody();

Опция stream передаётся в Guzzle через withOptions и не даёт загрузить весь ответ в память. Проверка empty($chunk['choices']) обязательна: последний чанк потока содержит только usage и пустой массив choices.

Метод контроллера:

bash
public function stream(Request $request, ZukkoService $zukko)
{
    $data = $request->validate(['message' => 'required|string|max:4000']);

    return response()->stream(function () use ($zukko, $data) {
        foreach ($zukko->stream([['role' => 'user', 'content' => $data['message']]]) as $text) {
            echo $text;
            if (ob_get_level() > 0) {
                ob_flush();
            }
            flush();
        }
    }, 200, [
        'Content-Type' => 'text/plain; charset=utf-8',
        'X-Accel-Buffering' => 'no',
    ]);
}

Заголовок X-Accel-Buffering: no отключает буферизацию в nginx. Под PHP-FPM потоковый ответ держит воркер всё время генерации — учитывайте это при расчёте pm.max_children.

Задача в очереди

Длинные документы обрабатывайте в фоне. Создайте задачу командой php artisan make:job ProcessTextWithLLM и заполните:

bash
<?php

namespace App\Jobs;

use App\Models\ProcessedText;
use App\Services\ZukkoService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\RequestException;
use Throwable;

class ProcessTextWithLLM implements ShouldQueue
{
    use Queueable;

Временные ошибки (429, 503, сеть) приводят к повтору через 60 секунд, максимум три попытки. Ошибки ключа и баланса сразу завершают задачу — метод failed() запишет причину. Запуск: ProcessTextWithLLM::dispatch($processedText);, воркер — php artisan queue:work.

Если модель выключена, API вернёт 503 с кодом model_disabled. Актуальный список моделей отдаёт GET https://api.zukko.pro/v1/models (поле data[].id) — по нему можно выбрать замену. Тот же список есть на странице «Модели» в кабинете.

Тесты с Http::fake

bash
use App\Services\ZukkoService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

public function test_chat_returns_model_answer(): void
{
    config(['services.zukko.api_key' => 'test-key']);
    Http::fake([
        'api.zukko.pro/*' => Http::response([
            'choices' => [['message' => ['content' => 'Готово']]],
            'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 2],
        ]),
    ]);

    $answer = app(ZukkoService::class)->chat([['role' => 'user', 'content' => 'test']]);

    $this->assertSame('Готово', $answer);
    Http::assertSent(fn (Request $request) =>
        $request->hasHeader('Authorization', 'Bearer test-key')
        && $request['model'] === 'claude-sonnet-5');
}

Http::fake подменяет все запросы к указанному домену, поэтому тесты не тратят баланс.

Стоимость и выбор модели

Модель задаётся переменной ZUKKO_MODEL: claude-sonnet-5, gpt-5.6-sol, glm-5.3-flash и другие из прайса. Какая подходит вашему приложению, сравните на своих запросах. Пример расчёта для claude-sonnet-5 ($0.3 за 1M входных и выходных токенов): запрос с 1 000 входных и 400 выходных токенов стоит (1 000 × 0.3 + 400 × 0.3) / 1 000 000 = $0.00042. Токены пишутся в лог из поля usage, а общий расход виден в разделе «Использование» кабинета. Формат запроса описан в справке по chat completions, авторизация — в разделе аутентификация.

Частые вопросы

Нужен ли пакет openai-php/laravel?

Не обязательно. Встроенного HTTP-клиента хватает для чата, стриминга и тестов. Пакет может быть удобнее, если нужны типизированные ответы и много разных эндпоинтов, — тогда укажите в его настройках базовый адрес https://api.zukko.pro/v1 и сверьтесь с его документацией.

Можно ли передавать temperature?

Для GPT и GLM — да, через массив $options. Новые модели Claude (claude-sonnet-5, claude-opus-5-5) могут отклонить temperature, отличную от 1, и top_p ниже 0.99 с ошибкой 400, поэтому для них эти параметры лучше не передавать.

Как посчитать токены до отправки запроса?

Есть эндпоинт POST https://api.zukko.pro/v1/messages/count_tokens в формате Anthropic: передайте model и messages и заголовок anthropic-version: 2023-06-01, в ответе будет input_tokens. Подробнее — в справке по count_tokens.

Почему потоковый ответ приходит целиком?

Проверьте буферизацию: output_buffering в PHP, сжатие ответа и буферизацию прокси. Заголовок X-Accel-Buffering: no и вызовы flush() решают большинство случаев.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.