LLM w aplikacji PHP: kolejki, idempotencja i walidacja zamiast przepisywania na Pythona
Model językowy udostępniany przez dostawcę (Anthropic, OpenAI, Mistral) to endpoint HTTPS, który przyjmuje i zwraca JSON. Nie obchodzi go, w jakim języku napisano aplikację, która go woła. Python ma realną przewagę przy trenowaniu, fine-tuningu i uruchamianiu modeli lokalnie. Przy dokładaniu funkcji opartych na LLM do istniejącego systemu PHP trudności leżą gdzie indziej: długie i nieprzewidywalne opóźnienia, odpowiedzi różne przy każdym wywołaniu, walidacja tych odpowiedzi i koszt. To problemy kolejek, bazy danych i kontraktów. Aplikacja PHP ma już na nie narzędzia.
Ten tekst omawia je na przykładzie aplikacji w Laravelu. Przykłady wołają Anthropic Messages API przez klienta HTTP Laravela, więc każde pole w kodzie da się sprawdzić w dokumentacji dostawcy.
Biblioteka czy zwykłe HTTP
Typowe możliwości:
- Zwykłe HTTP przez Guzzle albo fasadę
Http. Jeden endpoint, kilka nagłówków, zero nowych zależności. Wystarcza, gdy wołasz jednego dostawcę w kilku miejscach. - Biblioteka kliencka:
openai-php/client(z nakładką dla Laravela), Prism dla Laravela albo LLPhant. Zależnie od biblioteki dają abstrakcję nad dostawcami, obsługę streamingu albo gotowe elementy do wyszukiwania. - Komponenty AI Symfony (projekt
symfony/ai), jeśli aplikacja stoi na Symfony i chcesz integracji z kontenerem i konfiguracją frameworka.
Niezależnie od wyboru sprawdź przed produkcją, czy ustawisz osobno timeout połączenia i odczytu, czy decydujesz, które błędy są ponawiane, czy z każdej odpowiedzi dostajesz zużycie tokenów i czy da się dodać własne logowanie wokół wywołania. Niektóre biblioteki chowają coś z tego za własnymi ustawieniami domyślnymi i potem trzeba je obchodzić.
Cienki klient
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
final class AnthropicClient
{
public function __construct(
private readonly string $apiKey,
private readonly string $model,
) {}
/** @return array<string, mixed> */
public function messages(array $payload): array
{
return Http::baseUrl('https://api.anthropic.com/v1')
->withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
])
->connectTimeout(5)
->timeout(30)
->retry(2, 2000, fn (\Throwable $e): bool =>
$e instanceof ConnectionException
|| ($e instanceof RequestException
&& in_array($e->response->status(), [429, 500, 529], true))
)
->post('/messages', ['model' => $this->model, ...$payload])
->json();
}
}
Nazwa modelu siedzi w konfiguracji. Dostawcy wycofują wersje modeli według własnego harmonogramu. Ponawiane są tylko błędy połączenia, limit zapytań (429), wewnętrzne błędy serwera (500) i przeciążenie (529). Odpowiedź 400 oznacza, że błędne jest samo żądanie, więc klient jej nie ponawia. Wyjątek i tak wyjdzie z messages(), a job z $tries = 3 spróbuje jeszcze raz, chyba że przy błędzie 4xx sam zawoła fail().
Timeout odczytu musi pasować do najdłuższej odpowiedzi, o jaką prosisz. Czas rośnie z długością odpowiedzi, a max_tokens wyznacza jej górną granicę. Klasyfikacja ograniczona do 256 tokenów kończy się w kilka sekund (na modelach, w których myślenia nie da się wyłączyć, tokeny myślenia też liczą się do tego limitu, więc trzeba go podnieść), długie streszczenie może nie zmieścić się w 30 sekundach i wtedy potrzebny jest wyższy timeout albo streaming.
Przy modelach lokalnych widać to jeszcze wyraźniej. Na moim domowym serwerze z Ollamą mały model (4B) na procesorze generował 12 tokenów na sekundę, a cała odpowiedź na log o długości około 3 tysięcy tokenów zajmowała 146 sekund, z czego samo czytanie logu (prefill) szło z prędkością 28 tok/s. Z kartą graficzną było to 66 tok/s, a prefill od 1900 do 3400 tok/s. Pierwsze zapytanie po wczytaniu modelu i tak szło z prędkością 3 tok/s, więc timeouty ustawiłem pod zimny start, a nie pod średnią z rozgrzanego modelu.
Wywołania modelu w jobach kolejki
Większość pracy z modelem powinna iść przez kolejkę. Użytkownik nie czeka wtedy kilku sekund na odpowiedź HTTP, a worker może ponowić próbę. Kłopoty zaczynają się od tego, jak timeouty mają się do siebie.
W Laravelu job, który działa dłużej niż retry_after połączenia (w config/queue.php), zostaje oddany innemu workerowi, choć pierwszy wciąż go wykonuje. Przy wolnym API kończy się to dwoma równoległymi wykonaniami tego samego joba, dwoma opłaconymi wywołaniami i czasem dwoma różnymi wynikami. Dokumentacja Laravela wymaga, żeby timeout joba był krótszy niż retry_after, a --timeout workera co najmniej o kilka sekund. Do tego timeout joba musi objąć całe wywołanie klienta razem z ponowieniami. Dla klienta powyżej to najwyżej 2 × 30 s plus 2 s przerwy, więc timeout joba 90 s i retry_after 120 s są spójne.
Do tego dochodzi niedeterminizm. To samo wejście przy kolejnym wywołaniu może dać inną klasyfikację, więc ponowienie w praktyce nie powtarza tej samej operacji. Jeśli pierwsza próba zapisała wynik, a coś dalej już na niego zareagowało, retry może go nadpisać inną odpowiedzią. Rozwiązanie to warunkowy zapis, po którym wygrywa dokładnie jedna próba:
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
final class TriageTicket implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $timeout = 90;
public int $tries = 3;
public array $backoff = [10, 60];
public int $uniqueFor = 600;
public function __construct(public readonly int $ticketId) {}
public function uniqueId(): string
{
return (string) $this->ticketId;
}
public function handle(TicketClassifier $classifier): void
{
$ticket = Ticket::findOrFail($this->ticketId);
if ($ticket->triaged_at !== null) {
return;
}
$result = $classifier->classify($ticket->body);
$updated = Ticket::whereKey($ticket->id)
->whereNull('triaged_at')
->update([
'department' => $result->department->value,
'priority' => $result->priority->value,
'triaged_at' => now(),
]);
if ($updated === 1) {
TicketTriaged::dispatch($ticket->id);
}
}
}
ShouldBeUnique nie pozwala wrzucić do kolejki drugiej kopii joba, dopóki pierwsza nie skończy się wykonywać albo nie wyczerpie wszystkich prób (lub nie minie uniqueFor). Warunek whereNull('triaged_at') rozstrzyga, która próba wygrywa, jeśli mimo to wykonają się dwie. Zdarzenie dla dalszych kroków odpala tylko ta próba, która faktycznie zapisała wiersz. Retry może kosztować drugie wywołanie API, ale wiersz i zdarzenie powstają tylko raz.
Ustrukturyzowana odpowiedź i walidacja
Wolny tekst to słaby interfejs między modelem a kodem. Przy klasyfikacji i ekstrakcji użyj structured outputs: przekaż schemat JSON Schema w output_config.format, a model zwróci blok text z JSON-em zgodnym ze schematem. Wymuszanie wywołania narzędzia przez tool_choice to starszy, obejściowy sposób na ustrukturyzowane dane, a najnowsze modele (np. Claude Opus 5.5) odrzucają wymuszony tool_choice błędem 400. Dokumentacja wymienia przypadki, w których wynik nie pasuje do schematu: odmowa (stop_reason: refusal), odpowiedź ucięta przez max_tokens i wartości enuma zwrócone z inną wielkością liter, dlatego wynik walidujesz w kodzie.
enum Department: string
{
case Billing = 'billing';
case Technical = 'technical';
case Sales = 'sales';
}
enum Priority: string
{
case Low = 'low';
case Normal = 'normal';
case Urgent = 'urgent';
}
final class TicketClassifier
{
public function __construct(private readonly AnthropicClient $client) {}
public function classify(string $body): TicketClassification
{
$values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);
$response = $this->client->messages([
'max_tokens' => 256,
'system' => 'Classify the customer support ticket by department and priority.',
'messages' => [['role' => 'user', 'content' => $body]],
'output_config' => [
'format' => [
'type' => 'json_schema',
'schema' => [
'type' => 'object',
'properties' => [
'department' => ['type' => 'string', 'enum' => $values(Department::cases())],
'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],
],
'required' => ['department', 'priority'],
'additionalProperties' => false,
],
],
],
]);
$text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';
$output = (array) json_decode($text, true);
$department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));
$priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));
if ($department === null || $priority === null) {
throw new InvalidModelOutput('ticket_classification', $response);
}
return new TicketClassification($department, $priority, $response['usage']);
}
}
Enumy są jedynym źródłem dozwolonych wartości: z nich powstaje schemat i nimi sprawdzana jest odpowiedź. Przed tryFrom() wartości idą na małe litery, bo nikt nie gwarantuje wielkości liter w wartościach enuma. Niepoprawna odpowiedź rzuca wyjątek i job idzie do ponowienia. Po ostatniej próbie zgłoszenie zostaje niesklasyfikowane, a job ląduje w failed_jobs. Takie zgłoszenie ktoś tam zobaczy, a zgadnięta wartość przeszłaby niezauważona.
Jak pewnie potrafi wyglądać błędna odpowiedź, zobaczyłem przy testach małych modeli lokalnych na zadaniach w PHP. Oba modele 4B napisały błędną walidację NIP-u i podały ją bez cienia wątpliwości. Większy model (26B) napisał ją poprawnie, 6 na 6 przypadków testowych, przy 33 tok/s. Jeden z modeli ignorował też wyłączenie „myślenia” i zjadał cały limit 400 tokenów na rozumowanie, więc część odpowiedzi wracała pusta, ze zwykłym statusem HTTP. API nie zgłasza wtedy błędu, więc kod musi taki przypadek wyłapać sam. Dotyczy to też pętli narzędzi poniżej, która przy stop_reason równym max_tokens albo refusal może zwrócić pusty tekst.
Narzędzia działają z uprawnieniami użytkownika
Kiedy model może wołać funkcje aplikacji (status zamówienia, wyliczenie zwrotu), PHP ma przewagę: narzędzia to istniejące serwisy z istniejącą kontrolą dostępu. Pętla poniżej trzyma się protokołu Messages API. Model odpowiada z stop_reason: tool_use, aplikacja wykonuje narzędzia i odsyła bloki tool_result, i tak do chwili, gdy model zwróci tekst.
public function answer(User $user, string $question): string
{
$messages = [['role' => 'user', 'content' => $question]];
for ($step = 0; $step < 5; $step++) {
$response = $this->client->messages([
'max_tokens' => 1024,
'tools' => $this->tools,
'messages' => $messages,
]);
if ($response['stop_reason'] !== 'tool_use') {
return collect($response['content'])
->where('type', 'text')
->pluck('text')
->implode("\n");
}
$messages[] = ['role' => 'assistant', 'content' => $response['content']];
$results = [];
foreach ($response['content'] as $block) {
if ($block['type'] !== 'tool_use') {
continue;
}
$results[] = [
'type' => 'tool_result',
'tool_use_id' => $block['id'],
'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),
];
}
$messages[] = ['role' => 'user', 'content' => $results];
}
throw new ToolLoopLimitExceeded($step);
}
private function runTool(User $user, string $name, array $input): array
{
return match ($name) {
'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),
default => ['error' => "Unknown tool: {$name}"],
};
}
Pętla potrzebuje limitu kroków. Bez niego model, który wciąż prosi o narzędzia, kręci się aż do timeoutu, a za każdą iterację się płaci.
Argumenty od modelu to niezaufane wejście, tak samo jak pole formularza. statusFor($user, ...) ogranicza zapytanie do zamówień tego użytkownika, więc to narzędzie nie zwróci cudzego zamówienia, nawet jeśli ktoś w rozmowie każe modelowi o nie zapytać.
Narzędzia zmieniające stan (zwroty, anulowania) nie powinny wykonywać się wprost na prośbę modelu. Niech narzędzie przygotuje propozycję, a zatwierdzi ją ktoś lub coś poza modelem: przycisk, osobne żądanie, człowiek.
Wyszukiwanie w Postgresie z pgvector
Jeśli masz już Postgresa, do retrieval-augmented generation nie potrzebujesz osobnej bazy wektorowej. Najdroższa część, liczenie embeddingów, dzieje się przy indeksowaniu dokumentów. Przy samym zapytaniu to jedno wywołanie po embedding i jedno zapytanie SQL.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
content text NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX document_chunks_embedding_idx
ON document_chunks USING hnsw (embedding vector_cosine_ops);
$embedding = Http::withToken(config('services.openai.key'))
->timeout(15)
->post('https://api.openai.com/v1/embeddings', [
'model' => 'text-embedding-3-small', // 1536 wymiarów, tyle co w kolumnie
'input' => $question,
])
->throw()
->json('data.0.embedding');
$vector = '[' . implode(',', $embedding) . ']';
$chunks = DB::select(
'SELECT id, content, embedding <=> ?::vector AS distance
FROM document_chunks
ORDER BY embedding <=> ?::vector
LIMIT 5',
[$vector, $vector],
);
$relevant = array_filter($chunks, fn (object $c): bool => $c->distance <= $maxDistance);
<=> to odległość kosinusowa, a indeks hnsw z vector_cosine_ops obsługuje dokładnie takie sortowanie. Próg filtruję w PHP po LIMIT, żeby widzieć odległości odrzuconych fragmentów. Warunek WHERE na odległości w SQL, przy zachowanym ORDER BY … LIMIT, też korzystałby z indeksu.
$maxDistance nie ma uniwersalnej wartości domyślnej. Zależy od modelu embeddingów, języka i długości fragmentów. Trzeba go zmierzyć. Przygotuj kilkadziesiąt pytań ze znanymi pasującymi fragmentami, puść je przy kilku progach i zapisz, ile pasujących fragmentów wraca i ile niepasujących przychodzi razem z nimi. Powtórz pomiar po zmianie modelu embeddingów, bo odległości z różnych modeli nie są porównywalne. Zmiana modelu oznacza też ponowne zindeksowanie wszystkich dokumentów.
Gdy nic nie przechodzi przez próg, powiedz to użytkownikowi. Model zawołany z pustym kontekstem chętnie odpowie z ogólnej wiedzy, a temu właśnie miało zapobiec wyszukiwanie.
Co mierzyć
Każde wywołanie zapisuj w tabeli albo w systemie metryk z polami: nazwa funkcji, model, usage.input_tokens, usage.output_tokens, czas odpowiedzi i wynik (sukces, niepoprawna odpowiedź, błąd API). Z tego da się odpowiedzieć na pytania, które w praktyce padają:
- Koszt w rozbiciu na funkcje. Koszt rośnie z liczbą tokenów. Długi prompt systemowy wysyłany przy każdym wywołaniu często dominuje w rachunku, a grupowanie po funkcji pokazuje, gdzie.
- Percentyle czasu odpowiedzi (p50, p95). Średnia chowa wolne wywołania, a to one wpadają w timeout.
- Odsetek niepoprawnych odpowiedzi. Alarm ustaw na odchylenie od jego zwykłego poziomu. Skok zwykle oznacza zmianę promptu, inny rozkład danych wejściowych albo nową wersję modelu.
- Czas oczekiwania jobów LLM w kolejce. Widać w nim, że workerów jest za mało, zanim zauważą to użytkownicy.
Jeśli prompty zawierają dane osobowe albo finansowe, loguj identyfikatory i liczby. Pełna treść promptu nie powinna trafiać do logów.
Kiedy osobny serwis w Pythonie ma sens
Osobny serwis jest uzasadniony, gdy uruchamiasz własne modele (fine-tuning, inferencja lokalna), gdy potrzebujesz bibliotek dostępnych tylko w Pythonie albo gdy zespół utrzymuje pipeline'y ML niezależnie od produktu. Nawet wtedy niech będzie wąski: określone wejście, określone wyjście, bez logiki biznesowej. Reguły domenowe (kto może zobaczyć które zamówienie, czym jest zwrot) powinny zostać tam, gdzie już są. Przeniesienie ich do nowego serwisu tylko po to, żeby model mógł je wołać, dokłada granicę sieciową, drugi deploy i drugą kopię reguł.
Przed pierwszym wdrożeniem
$timeoutjoba obejmuje całe wywołanie klienta z ponowieniami i jest krótszy niżretry_after.- Wynik zapisuje się warunkowo, a efekty uboczne uruchamia tylko próba, która zapisała.
- Pętla narzędzi ma limit kroków, a narzędzia zmieniające stan czekają na zatwierdzenie.
- Próg wyszukiwania pochodzi z pomiaru na własnych danych.