ai-agents/llm-integration@v1.0.0
artykuł··14 min czytania

LLMy w PHP: integracja modeli językowych z systemami produkcyjnymi bez przepisywania wszystkiego

#php #llm #ai-agents #llphant #openai #production

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.

koniec artykułu