Интеграция 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:
'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:
<?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 создаёт сервис через контейнер, поэтому регистрировать его вручную не обязательно — достаточно указать класс в аргументах контроллера или задачи.
Контроллер с обработкой ошибок
<?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:
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.
Метод контроллера:
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 и заполните:
<?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
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.
Похожие статьи
- Промпт-инъекция: что это и как защитить бота и агентаОбъясняем, как работает промпт-инъекция через пользовательский ввод и чужие документы, и какие меры снижают ущерб: изоляция данных, права инструментов, лимиты ключа.
- Свой прокси к нейросети на Node.js: не показывать API-ключ во фронтендеНебольшой сервер на Express между вашим фронтендом и API нейросети: ключ остаётся на сервере, а модели, длина запроса и частота ограничены.
- DeepSeek API на Python: запросы, стриминг, JSON и обработка ошибокГотовый код для DeepSeek V4.1 Flash и V4 Pro на OpenAI SDK: запрос, стриминг, разбор JSON и аккуратная обработка ошибок.
- Промпт-инжиниринг: примеры и 12 готовых шаблонов для работыДвенадцать шаблонов промптов для рабочих задач: что в них написано, почему именно так и сколько стоит один вызов по API.