LLMy w PHP: integracja modeli językowych z systemami produkcyjnymi bez przepisywania wszystkiego
Każdy zespół, z którym rozmawiałem przez ostatnie dwa lata, przechodził tę samą rozmowę. Inżynierowie chcą dodać funkcje LLM, CTO mówi Python, a zespół platformowy, który jest właścicielem monolitu PHP z dziesięcioma latami logiki biznesowej, milknie. Argument brzmi, że narzędzia ML są Python-first, SDK dla LLMów są lepsze w Pythonie i tam jest pula talentów.
Ten argument jest w większości błędny, a zespoły, które na nim działają, spędzają sześć miesięcy budując mikroserwis Python, który wywołuje ich monolit PHP po logikę biznesową przez HTTP, wprowadzając granicę sieciową, dwa pipeline'y deploymentu i budżet latencji, którego nie planowały.
Tak wygląda integracja LLMów z produkcyjnym systemem PHP w praktyce, nie demo, ale system działający pod prawdziwym ruchem.
Krajobraz PHP + LLM
Ekosystem PHP ma trzy wiarygodne opcje integracji z LLMami. Bezpośrednie HTTP do API, OpenAI, Anthropic, Mistral udostępniają REST API, a klient HTTP i dekoder JSON to technicznie wszystko, czego potrzebujesz. Używałem tego do prostych completion w systemach, gdzie dodanie zależności było trudniejsze niż napisanie 40 linii kodu wrapper.
LLPhant to najbardziej kompletna biblioteka PHP do produkcyjnej pracy z LLMami. Opakowuje OpenAI i Anthropic, obsługuje streaming, implementuje wzorce RAG i wspiera function calling. To opcja, po którą teraz sięgam w nowych projektach PHP.
Integracja Symfony AI, dostarczona w Symfony 7.2, to first-party komponent z właściwym dependency injection, integracją z systemem eventów i szanowaniem konwencji frameworka. Jeśli jesteś na Symfony, to jest coraz lepsza odpowiedź.
Benchmark "production-ready" dla integracji z LLM to: czy obsługuje streaming poprawnie, czy wspiera function calling, czy pozwala wstrzyknąć obserwowalność i czy gracefully zawodzi gdy API zwraca 500. LLPhant spełnia wszystkie cztery.
Co zrobiłem źle przy pierwszym deploymencie
Nasza pierwsza integracja z LLMem to był system triage'u zgłoszeń supportu. Model czytał przychodzące tickety i klasyfikował je według pilności i działu. Kod PHP był czysty. Deployment był katastrofą.
Nie wzięliśmy pod uwagę latencji API w timeoucie queue workera. Wywołanie LLM uśredniało 3,2 sekundy. Domyślny timeout queue workera to 30 sekund. Pod burst loadem workery przetwarzające wiele ticketów jednocześnie osiągały timeout, job był retryowany, a my płaciliśmy API dwa razy za ten sam ticket, z różnymi klasyfikacjami, co psuło downstream logikę routingu.
// Co mieliśmy:
class TicketTriageJob implements ShouldQueue
{
public $timeout = 30; // default — nie myśleliśmy o latencji LLM
public function handle(LLMClient $client): void
{
$classification = $client->classify($this->ticket->body);
$this->ticket->update(['department' => $classification->department]);
}
}
// Co było potrzebne:
class TicketTriageJob implements ShouldQueue
{
public $timeout = 120; // wywołanie LLM + overhead przetwarzania
public $tries = 1; // nigdy nie retry — wywołania LLM nie są idempotentne
public $uniqueFor = 3600; // zapobiegaj duplikatom przetwarzania
public function handle(LLMClient $client): void
{
if ($this->ticket->fresh()->triaged_at !== null) {
return; // już przetworzone przez poprzednią próbę
}
$classification = $client->classify($this->ticket->body);
DB::transaction(function () use ($classification) {
$this->ticket->update([
'department' => $classification->department,
'priority' => $classification->priority,
'triaged_at' => now(),
]);
});
}
}
Nieidempotentność wywołań LLM to coś, co zespoły konsekwentnie niedoceniają. Model nie zwraca tego samego outputu dla tego samego inputu, a ponowne uruchomienie klasyfikacji po częściowej awarii nie jest bezpieczne, jeśli systemy downstream już zareagowały na pierwszy wynik.
RAG w produkcji: indeks jest produktem
Retrieval-augmented generation to miejsce, gdzie integracje PHP z LLMem stają się interesujące i gdzie luka z Pythonem kurczy się prawie do zera. Ciężka praca (generowanie embeddingów, storage wektorowy, wyszukiwanie podobieństwa) dzieje się w czasie indeksowania, nie w czasie zapytania. W momencie zapytania wykonujesz wywołanie HTTP i zapytanie do bazy danych.
use LLPhant\Embeddings\EmbeddingGenerator\OpenAI\OpenAI3LargeEmbeddingGenerator;
use LLPhant\Embeddings\VectorStores\Doctrine\DoctrineVectorStore;
// Indeksowanie (uruchom raz lub przy aktualizacji treści)
$generator = new OpenAI3LargeEmbeddingGenerator();
$vectorStore = new DoctrineVectorStore($entityManager, DocumentChunk::class);
foreach ($documents as $doc) {
$chunks = $splitter->splitDocument($doc, chunkSize: 512, overlap: 64);
foreach ($chunks as $chunk) {
$chunk->embedding = $generator->embedText($chunk->content);
}
$vectorStore->addDocuments($chunks);
}
// Czas zapytania (per request użytkownika)
$query = $request->input('question');
$embedding = $generator->embedText($query);
// pgvector cosine similarity — pojedyncze zapytanie, < 20ms na zindeksowanych danych
$relevant = $vectorStore->similaritySearch($embedding, maxResults: 5, minScore: 0.78);
$context = implode("\n\n", array_map(fn($c) => $c->content, $relevant));
$answer = $llm->chat([
['role' => 'system', 'content' => "Odpowiadaj używając tylko dostarczonego kontekstu.\n\n{$context}"],
['role' => 'user', 'content' => $query],
]);
Próg podobieństwa 0,78 nie jest domyślny, jest dostrojony. Zbyt niski i pobierasz nieistotny kontekst, który myli model. Zbyt wysoki i nie pobierasz niczego. Uruchomiliśmy 200 przykładowych zapytań na holdout odpowiedziach i zmierzyliśmy recall przy różnych progach przed wdrożeniem. 0.78 było punktem, gdzie recall był stabilny, a halucynacje spadły do akceptowalnego poziomu.
Function calling: gdzie PHP pasuje lepiej niż się spodziewasz
Function calling (model decydujący o wywołaniu narzędzia i zwracający ustrukturyzowane argumenty) to podstawowy mechanizm, który czyni agentów LLM praktycznymi. PHP dobrze się do tego nadaje, bo "narzędzia" to zazwyczaj istniejąca logika domenowa: pobierz klienta, sprawdź status zamówienia, wykonaj obliczenie. Masz już ten kod.
$tools = [
Tool::create('get_order_status')
->description('Zwraca aktualny status i ETA dla podanego ID zamówienia')
->parameter('order_id', 'string', 'UUID zamówienia', required: true),
Tool::create('calculate_refund')
->description('Oblicza kwotę zwrotu na podstawie ID zamówienia i powodu')
->parameter('order_id', 'string', required: true)
->parameter('reason', 'string', 'cancellation | defect | not_received', required: true),
];
$response = $llm->chat($messages, tools: $tools);
// Model może zwrócić wywołanie narzędzia zamiast tekstu
while ($response->hasToolCalls()) {
foreach ($response->toolCalls() as $call) {
$result = match ($call->name) {
'get_order_status' => $orderService->getStatus($call->arguments['order_id']),
'calculate_refund' => $refundCalculator->calculate(
$call->arguments['order_id'],
$call->arguments['reason']
),
default => throw new UnknownToolException($call->name),
};
// Przekaż wynik narzędzia z powrotem do konwersacji
$messages[] = ['role' => 'tool', 'tool_call_id' => $call->id, 'content' => json_encode($result)];
}
$response = $llm->chat($messages, tools: $tools);
}
Pętla while obsługuje wieloetapowe użycie narzędzi. W praktyce większość produkcyjnych agentów wykonuje 1–3 wywołania narzędzi per tura rozmowy. Więcej niż to i latencja staje się dominującym problemem UX.
Obserwowalność, której faktycznie potrzebujesz
Trzy metryki, które śledzę dla każdej integracji z LLM. Zużycie tokenów per endpoint, koszty LLM skalują się z tokenami, nie requestami. Jeden endpoint przekazujący 10 000-tokenowy system prompt przy każdym wywołaniu zdominuje twój rachunek API w ciągu dni.
// Po każdym wywołaniu LLM
$this->metrics->increment('llm.tokens.prompt', $response->usage()->promptTokens);
$this->metrics->increment('llm.tokens.completion', $response->usage()->completionTokens);
$this->metrics->timing('llm.latency_ms', $response->latencyMs());
Dla ustrukturyzowanych outputów (klasyfikacje, wywołania funkcji, ekstrakcja JSON) model będzie okazjonalnie zwracał malformed output. Śledź wskaźnik błędów parsowania. Jeśli przekroczy 2%, twój prompt degraduje się, model był po cichu zaktualizowany, albo dystrybucja inputu się przesunęła.
Jeśli wykonujesz pracę LLM w background jobach, głębokość kolejki jest leading indicator tego, czy twoja liczba workerów nadąża za wolumenem requestów. Obserwuj ją zanim stanie się pagerem.
Pytanie o przepisywanie, odpowiedziane uczciwie
Czy Python jest lepszy do pracy z LLMami? Do czystych badań ML, trenowania i fine-tuningu, tak, bez wątpienia. Do budowania funkcji rozszerzonych o LLM na istniejącym systemie PHP: luka jest mniejsza niż koszt migracji w prawie każdym przypadku, który oceniałem.
Pytanie nie brzmi "który język jest lepszy do LLMów", ale "gdzie mieszka logika biznesowa, na której LLM musi działać?" Jeśli jest w systemie PHP z dziesięcioma latami modelowania domeny, nie zduplikujesz tego w sześć miesięcy w nowym serwisie Python. Skończyć z cienkim wrapperem Python wywołującym twoje API PHP, i zapłacisz pełną cenę za przepisanie, nie zyskując niczego, czego LLPhant nie mógłby zrobić bezpośrednio z PHP.