Laravel 13 AI SDK in der Praxis: Eine echte RAG-Pipeline, kein Hello-World

Laravel 13 AI SDK in der Praxis: Eine echte RAG-Pipeline, kein Hello-World

Foto von Ecliptic Graphic auf Unsplash

Im Februar 2026 hat das Laravel-Team die First-Party AI-SDK als Beta veröffentlicht. Mit dem Release von Laravel 13 am 17. März 2026 wurde sie stabil. Acht Wochen später habe ich sie in einem echten SaaS-Projekt eingesetzt, einer RAG-as-a-Service-Plattform für mittelständische Wissensbestände. Dieser Artikel ist der Erfahrungsbericht.

Bis zu diesem Release war Python mit LangChain oder LlamaIndex der faktische Standard für RAG-Systeme. Laravel-Teams, die KI brauchten, mussten entweder einen parallelen Python-Stack aufsetzen oder mit Custom-HTTP-Clients gegen OpenAI programmieren. Beides bedeutet doppelte Infrastruktur, doppeltes Deployment, doppelte Monitoring-Pipelines. Die neue SDK ändert diese Rechnung grundlegend.

Der Kern-Claim: Die SDK spart 60 bis 70 Prozent der RAG-Infrastruktur. Die restlichen 30 bis 40 Prozent sind Business-Logic, und das ist gut so.

Transparenz-Hinweis: Das im Artikel referenzierte SaaS-Projekt ist ein eigenes Pre-MVP-Vorhaben. Alle Code-Snippets stammen aus dieser Codebasis, sind aber von Tenant-Identifikatoren und Firmen-Spezifika bereinigt. Versionsstand: PHP 8.3, Laravel 13.x, laravel/ai 0.4.4 als Build-Stand der Codebasis. Die SDK hat sich seitdem weiterentwickelt, einige der hier diskutierten Lücken sind in 0.6.x bereits geschlossen.

Warum Laravel und KI überhaupt eine Frage ist

Bis 2025 war die Antwort auf "Wir bauen ein RAG-System" fast immer Python. LangChain, LlamaIndex, llama-index-server, Haystack: das ML-Ökosystem hat sich klar in Python angesiedelt. Für Laravel-Teams hieß das: einen Python-Service neben der Hauptanwendung betreiben, mit eigener Deployment-Pipeline, eigenem Monitoring, eigener Skalierung. Die Reibung war hoch genug, dass viele Teams den Aufwand mieden und auf "wir brauchen kein RAG" plädierten.

Was Laravel-Teams ohnehin haben: Horizon für Queues, Eloquent als ORM, Sanctum für Auth, Livewire für Dashboards, Cashier für Billing. Alles, was eine RAG-Anwendung an Infrastruktur braucht, mit Ausnahme des AI-Teils selbst. Die Aufteilung in zwei Stacks (PHP-App plus Python-AI-Service) bedeutete, dieselbe Infrastruktur zweimal aufzusetzen, nur weil die KI in einer anderen Sprache lief.

Die neue SDK integriert sich in genau diese Infrastruktur, statt daneben zu existieren. Das ist die strategische Veränderung, nicht die einzelnen Features.

Kern-These: Wenn ein Team Laravel kann, ist Laravel für 80 Prozent der RAG-Use-Cases die schnellere Wahl. Nicht weil Python schlechter ist, sondern weil Infrastruktur-Konsistenz mehr wert ist als Framework-Feinheiten. Die Build-vs-Buy-Logik gilt auf Stack-Ebene genauso wie auf Feature-Ebene.

Die RAG-Pipeline im Überblick

Die Anwendung ist eine RAG-as-a-Service-Plattform. Tenants laden Dokumente hoch, definieren Projekte, stellen Fragen und erhalten Antworten mit Quellenverweisen. Multi-Tenant von Anfang an, weil das die typische Anforderung im B2B-SaaS ist.

Die Pipeline hat sechs Stages, die in einer Job-Chain abgearbeitet werden:

  1. Text Extraction: PDF, DOCX, HTML in einheitlichen Text umwandeln
  2. Chunking: Text in semantisch sinnvolle Stücke zerlegen
  3. Embedding Generation: Vektor-Repräsentationen pro Chunk berechnen, batched
  4. Similarity Search: pgvector-Cosine-Distance gegen Query-Embedding
  5. Answer Generation: Agent mit Streaming via Server-Sent Events
  6. Verification: optionaler zweiter Agent, prüft die Antwort gegen die Quellen

Architektur-Entscheidungen, die früh getroffen wurden und sich bewährt haben:

EntscheidungBegründung
Actions/Agents/Services-Pattern statt Fat ControllersTestbarkeit, Wiederverwendbarkeit, klare Verantwortlichkeiten
Job-Chain für asynchrone DokumentenverarbeitungExtract, Chunk, Embed sind einzeln retry-bar, keine 30-Minuten-Jobs
Single-Database Multi-Tenancy via stancl/tenancyEin DB-Server, Isolation über Global Scopes, einfacher Betrieb
Eigene Action für Vector SearchKapselung, einfache Migration auf neuere SDK-Features
RagAgent als PHP-Klasse mit Promptable-TraitTypsicher, mockbar, in Pest-Tests einsetzbar

Was die SDK großartig macht

Drei Beispiele aus dem Produktivcode, die zeigen, was die SDK an Infrastruktur löst.

Embeddings als Einzeiler

Vor der SDK bedeutete Embedding-Generierung: HTTP-Client, Error-Handling, Retry-Logik bei Rate-Limits, Batching-Strategie, Caching. Mit der SDK ist es ein Aufruf:

// app/Services/Embedding/EmbeddingService.php
namespace App\Services\Embedding;
 
use Laravel\Ai\Embeddings;
 
class EmbeddingService
{
    private string $model;
    private int $dimensions;
    private int $batchSize;
 
    public function __construct()
    {
        $this->model = (string) config('embedding.model', 'text-embedding-3-small');
        $this->dimensions = (int) config('embedding.dimensions', 1536);
        $this->batchSize = (int) config('embedding.batch_size', 100);
    }
 
    /**
     * @param  string[]  $texts
     * @return array<int, array<int, float>>
     */
    public function embed(array $texts): array
    {
        if (empty($texts)) {
            return [];
        }
 
        $allEmbeddings = [];
 
        foreach (array_chunk($texts, $this->batchSize) as $batch) {
            $response = Embeddings::for(array_values($batch))
                ->dimensions($this->dimensions)
                ->generate(model: $this->model);
 
            foreach ($response->embeddings as $embedding) {
                $allEmbeddings[] = $embedding;
            }
        }
 
        return $allEmbeddings;
    }
}

Was die SDK intern leistet: Provider-Abstraktion (OpenAI, Anthropic, Bedrock, Ollama, mehr), automatisches Caching, Retry-Logik bei Rate-Limits, einheitliches Response-Format. Was bewusst Domain-Logik bleibt: das Batching auf Anwendungsebene und die Konfiguration des Embedding-Modells. Die Trennung ist sauber.

Agent-Interface mit Promptable-Trait

Ein Agent ist eine PHP-Klasse mit einem System-Prompt und Provider-Optionen. Keine Runtime-Composition aus Dictionary-Configs wie in LangChain. Keine impliziten Bindings. Eine normale Klasse:

// app/Agents/RagAgent.php
namespace App\Agents;
 
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
 
class RagAgent implements Agent, HasProviderOptions
{
    use Promptable;
 
    public function __construct(
        private string $systemPrompt,
        private float $temperature = 0.1,
    ) {}
 
    public function instructions(): string
    {
        return $this->systemPrompt;
    }
 
    public function providerOptions(Lab|string $provider): array
    {
        return [
            'temperature' => $this->temperature,
        ];
    }
}

Warum das besser ist als eine LangChain-Chain:

  • Dependency Injection: Der Agent ist eine normale Klasse. Pest-Tests instanziieren ihn mit Mock-Daten, ohne Container-Setup.
  • Typsicher: PHP 8.3-Types sind statisch prüfbar mit PHPStan oder Larastan. Falsche Provider-Konstanten fallen im Build auf, nicht erst zur Laufzeit.
  • Explizit: Das HasProviderOptions-Interface macht provider-spezifisches Verhalten sichtbar, nicht versteckt in einem Konfigurations-Hash.

Der Trait Promptable liefert die prompt()-Methode, die intern den Provider auswählt, das Modell aufruft und die Response strukturiert zurückgibt. Aus Sicht der Anwendung ist das ein Methodenaufruf.

Streaming via Server-Sent Events

Streaming ist UX-kritisch in RAG-Anwendungen. Niemand wartet zehn Sekunden auf eine fertig generierte Antwort, aber alle akzeptieren zehn Sekunden, in denen der Text token-für-token erscheint. Vor der SDK bedeutete das: eigener Event-Parser, eigene Buffer-Logik, manuelle Cancel-Handhabung. Mit der SDK liefert die StreamableAgentResponse strukturierte TextDelta-Events, die direkt als SSE durchgereicht werden können.

// Vereinfachter Auszug aus dem Streaming-Endpoint
return response()->stream(function () use ($agent, $userMessage, $model) {
    foreach ($agent->stream($userMessage, model: $model) as $event) {
        if ($event instanceof TextDelta) {
            echo "data: " . json_encode(['delta' => $event->text]) . "\n\n";
            ob_flush();
            flush();
        }
    }
 
    echo "event: done\ndata: {}\n\n";
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

Etwa 30 Zeilen für vollständiges Streaming, inklusive Header-Setup für Nginx-Buffering. Vorher waren das mehrere hundert Zeilen, die in jeder zweiten Anwendung neu geschrieben wurden.

Wo die SDK Lücken hat oder hatte

Keine SDK ist perfekt, First-Party-SDKs in der frühen Reifephase erst recht nicht. Drei Stellen, an denen ich beim Bau eigene Implementierungen geschrieben habe, mit der ehrlichen Anmerkung, dass die SDK sich schnell weiterentwickelt.

Vector Similarity Search im Beta-Stadium

In der Version 0.4.x, mit der die Codebasis gebaut wurde, gab es keinen integrierten Query-Builder für Vector-Operationen. Die Lösung war raw SQL gegen pgvector:

// app/Actions/SearchSimilarChunks.php
namespace App\Actions;
 
use App\Models\Project;
use Illuminate\Support\Facades\DB;
 
class SearchSimilarChunks
{
    /**
     * Cosine-Similarity-Suche gegen project-scoped Chunks.
     *
     * @param  array<int, float>  $queryEmbedding
     * @return array<int, object>
     */
    public function execute(Project $project, array $queryEmbedding, int $topK, float $similarityThreshold): array
    {
        if (empty($queryEmbedding)) {
            return [];
        }
 
        $embeddingVector = '['.implode(',', $queryEmbedding).']';
 
        return DB::select('
            SELECT c.id, c.content, c.metadata, c.position, c.document_id,
                   d.original_filename,
                   1 - (c.embedding <=> ?::vector) AS similarity
            FROM chunks c
            JOIN documents d ON d.id = c.document_id
            WHERE c.project_id = ?
              AND c.embedding IS NOT NULL
              AND 1 - (c.embedding <=> ?::vector) >= ?
            ORDER BY similarity DESC
            LIMIT ?
        ', [$embeddingVector, $project->id, $embeddingVector, $similarityThreshold, $topK]);
    }
}

Der <=>-Operator ist die Cosine-Distance aus pgvector. 1 - distance liefert die Similarity zwischen null und eins. Die Migration für die Tabelle:

DB::statement('ALTER TABLE chunks ADD COLUMN embedding vector(1536)');
DB::statement('CREATE INDEX chunks_embedding_hnsw_idx ON chunks USING hnsw (embedding vector_cosine_ops)');

HNSW ist der State-of-the-Art-Index für approximative Nearest-Neighbor-Suche in pgvector. Mit den Default-Parametern liefert er bei wenigen Millionen Vektoren Antwortzeiten unter zehn Millisekunden.

Was sich seitdem geändert hat: Mit laravel/ai 0.5 und insbesondere 0.6 ist die Methode whereVectorSimilarTo() Teil der SDK. Ein direktes Äquivalent zum obigen SQL sieht jetzt so aus:

$chunks = Chunk::query()
    ->where('project_id', $project->id)
    ->whereVectorSimilarTo('embedding', $queryEmbedding, minSimilarity: 0.7)
    ->limit(5)
    ->get();

Die ursprüngliche Action-Klasse bleibt im Backlog für die Migration. Genau diese Migration ist der Punkt: Weil Vector Search in einer eigenen Action gekapselt war, nicht in einem Model-Scope, ist der Wechsel auf die native SDK-Methode eine isolierte Refactoring-Aufgabe, kein flächendeckender Umbau. Lesson Learned: Bei First-Party-SDKs in der frühen Reifephase muss man damit rechnen, dass Features nachgereicht werden. Sauber gekapselte Abstraktionen machen Migration zur Routine, nicht zum Drama.

Chunking-Strategien fehlen bewusst

Chunking ist der wichtigste Qualitätsfaktor einer RAG-Pipeline. Ein Vertrag muss anders zerlegt werden als ein technischer Wikiartikel und anders als ein Sales-PDF. Die SDK bleibt hier domain-agnostisch, was die richtige Entscheidung ist. Wer Chunking erzwingt, bindet die SDK an eine Annahme, die selten passt.

Die Codebasis nutzt ein eigenes Strategy-Pattern:

// app/Services/Chunking/ChunkingService.php
class ChunkingService
{
    private array $strategies = [];
 
    public function __construct()
    {
        $this->registerStrategy(new SemanticChunkingStrategy);
        $this->registerStrategy(new ParagraphChunkingStrategy);
        $this->registerStrategy(new FixedSizeChunkingStrategy);
    }
 
    public function chunkText(
        string $text,
        string $strategy = 'semantic',
        ?int $chunkSize = null,
        ?int $overlap = null,
    ): array {
        $chunkSize ??= (int) config('chunking.default_chunk_size', 512);
        $overlap ??= (int) config('chunking.default_overlap', 50);
 
        return $this->getStrategy($strategy)->chunk($text, $chunkSize, $overlap);
    }
}

Drei Strategien decken die meisten Anwendungsfälle ab. SemanticChunkingStrategy splittet auf Satz- und Absatzgrenzen unter Berücksichtigung eines Token-Limits. ParagraphChunkingStrategy nutzt doppelte Newlines als natürliche Trenner. FixedSizeChunkingStrategy erzwingt eine feste Token-Anzahl mit konfigurierbarem Overlap, gut für homogene Texte ohne natürliche Struktur.

Die Wahl der Strategie pro Projekt ist eine Tenant-Entscheidung, keine Framework-Vorgabe. Genau das ist die Grenze, die die SDK richtig zieht.

Halluzinations-Check als eigener Pipeline-Step

Verification ist ein Qualitätsanspruch, kein Framework-Feature. Jedes Projekt definiert den Schwellwert selbst. Die Codebasis nutzt einen separaten VerificationAgent, der nach der Antwort-Generierung prüft, ob die Antwort durch die Quellen gedeckt ist:

// app/Agents/VerificationAgent.php
namespace App\Agents;
 
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasProviderOptions;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
 
class VerificationAgent implements Agent, HasProviderOptions
{
    use Promptable;
 
    public function __construct(
        private float $verificationTemperature = 0.0,
    ) {}
 
    public function instructions(): string
    {
        return 'You are a verification agent. Your job is to check whether a given answer is accurately supported by the provided source excerpts. '
            .'Analyze the answer against the sources and respond with EXACTLY one of these verdicts on the first line: VERIFIED, PARTIALLY_VERIFIED, or COULD_NOT_VERIFY. '
            .'On the second line, provide a brief explanation (1-2 sentences) of your reasoning.';
    }
 
    public function providerOptions(Lab|string $provider): array
    {
        return ['temperature' => $this->verificationTemperature];
    }
}

Drei Verdicts: VERIFIED, PARTIALLY_VERIFIED, COULD_NOT_VERIFY. Temperature 0.0, weil es um eine Klassifikation geht, nicht um Kreativität. Der Agent läuft als zweiter Aufruf nach der eigentlichen Antwort, wird optional pro Projekt aktiviert, und die drei Verdicts werden in der Datenbank gespeichert für nachgelagerte Auswertung. Wie diese Qualitätsprüfung systematisch organisiert wird, ist ein eigenes Thema.

Kern-Takeaway dieser Sektion

Die SDK löst 60 bis 70 Prozent der Infrastruktur. Die restlichen 30 bis 40 Prozent sind Business-Logic, und das ist genau dort, wo sie hingehören sollten. Eine SDK, die alles macht, ist in Wahrheit ein Framework. Sobald die Anforderungen spezifisch werden, wird das Framework zur falschen Abstraktionsebene.

Multi-Tenancy und RAG, die unterschätzte Komplexität

Multi-Tenant-RAG ist erheblich schwieriger als Multi-Tenant-CRUD, aus drei Gründen. Embeddings sind teure Assets (OpenAI-Kosten pro Dokument), Query-Caches müssen pro Tenant invalidiert werden, und Vector-Search kann Tenant-Grenzen brechen, wenn die WHERE-Clauses fehlen.

Die Architektur-Entscheidung in der Codebasis: Single-Database Multi-Tenancy über stancl/tenancy mit leeren Bootstrappers. Keine per-tenant Datenbank, sondern Isolation über Global Scopes auf einer gemeinsamen Datenbank. Das vereinfacht Backups, Monitoring und Schema-Migrationen erheblich.

Der BelongsToTenant-Trait registriert einen Global Scope, der automatisch WHERE tenant_id = ? an jede Query anhängt. Modelle, die diesen Trait nutzen, können nie versehentlich tenant-fremde Daten zurückgeben, solange der Scope nicht explizit deaktiviert wird.

Die Chunks-Tabelle ist eine Ausnahme. Chunks haben keine direkte tenant_id-Spalte, weil die Isolation durch die Parent-Kette Tenant → Project → Document → Chunk gegeben ist. Jede Chunk-Query geht über project_id, was im SQL-Beispiel weiter oben die erste WHERE-Bedingung ist. Diese Asymmetrie ist bewusst, weil eine doppelte Indizierung auf tenant_id zusätzlichen Speicher und Wartungsaufwand kostet, ohne Sicherheitsgewinn.

Cache-Invalidation pro Projekt

Query-Ergebnisse werden gecached, weil RAG-Anfragen teuer sind. Aber jedes Dokument-Update macht alle gecachten Antworten dieses Projekts potenziell falsch. Die Lösung: ein Version-Counter pro Projekt, der in Cache-Keys einfließt.

Ein DocumentObserver inkrementiert den Counter bei jeder Dokument-Änderung. Cache-Keys haben das Format query:{project_id}:{version}:{hash(question)}. Eine Änderung erhöht die Version, und alle alten Keys werden durch die natürliche TTL aus dem Cache verdrängt. Keine explizite Invalidierung, kein Risiko, einen Key zu vergessen.

Queue-Jobs verlieren Tenant-Context

Beim Serialisieren eines Jobs geht der aktuelle Tenant-Kontext verloren. Wer das übersieht, bekommt subtile Fehler: ein Job läuft im "main"-Schema und greift auf Tabellen zu, die im aktuellen Tenant-Schema nicht existieren oder andere Daten haben.

Die Lösung steht in jedem handle():

public function handle(): void
{
    tenancy()->initialize($this->tenant);
 
    // Eigentliche Job-Logik, die jetzt Tenant-Context hat
}

Im Test wird das gegen-geprüft mit explizitem Model::withoutGlobalScope(TenantScope::class) und Assertion auf assertEmpty(). Cross-Tenant-Leakage ist eine der gefährlichsten Bug-Klassen in Multi-Tenant-Systemen, weil sie selten auffällt, aber im DSGVO-Kontext gravierende Konsequenzen hat.

Zahlen und ehrliche Bilanz

Der Stack in Zahlen, Stand der Codebasis:

KomponenteVersion
PHP8.3
Laravel Framework13.x
laravel/ai0.4.4 (Build-Stand, aktuell ist 0.6.x)
PostgreSQL17 mit pgvector 0.8.x
HNSW-Indexm=16, ef_construction=64
Embedding-Modelltext-embedding-3-small, 1536 Dimensionen, Batch 100
Completion-Modellgpt-4.1-mini, Temperature 0.1
QueueHorizon mit Redis, 3 Retries, 60s exponential Backoff
CacheRedis, 24h TTL, Version-Counter-Invalidation
Multi-Tenancystancl/tenancy 3.10, Single-Database

Was die SDK an Code gespart hat

Geschätzte Linecounts gegenüber einer Custom-Implementierung mit OpenAI-PHP-Client und eigener Infrastruktur:

BereichGeschätzte LOC ohne SDK
Provider-Abstraktion (OpenAI, Anthropic, Bedrock)200
Streaming-Implementierung (SSE, Buffer-Logik, Cancel)150
Agent-Tool-Calling und strukturiertes Output300
Retry- und Error-Handling100
Gesamt~750 LOC

Das ist Infrastruktur-Code, der ohne SDK in jedem AI-Projekt neu geschrieben würde, mit den bekannten Kosten: Wartung, Tests, Bug-Fixes über Jahre.

Was trotzdem selbst geschrieben werden musste

BereichLOC inklusive Tests
Chunking-Strategien (3 Strategien plus Service)~400
Vector-Search mit raw SQL (zur Build-Zeit)~100
Verification-Agent~150
Multi-Tenancy-Scoping und Test-Helper~250
Cache-Invalidation via Observer~80
Gesamt~980 LOC

Diese Zahlen sind die Realität: Die SDK spart Infrastruktur, nicht Domain-Logic. Wer geht, mit ungefähr 1000 LOC eigenem Code zu rechnen, plant realistisch.

Ehrliche Aussage

"Production-ready" heißt nicht "Pre-MVP fertig". Die referenzierte Codebasis ist Pre-MVP. Sie funktioniert, hat Tests, läuft in einer Staging-Umgebung, ist aber kein Beweis, dass die SDK in einer Hochlast-B2B-Plattform mit hundert Tenants ohne Anpassungen läuft. Wer jetzt adoptiert, plant mit dem Reifegrad der frühen 1.0-Phase: eigene Abstraktionen, die später gegen native SDK-Features ausgetauscht werden können, klare Versions-Pinnings, Mut zum Refactoring.

Fazit und Empfehlung

Die Laravel AI SDK ist die strategisch wichtigste Veränderung im PHP-Ökosystem für KI-Anwendungen seit Jahren. Sie löst nicht alles, aber sie löst genug, um Laravel als ernsthaften Stack für RAG-Systeme zu etablieren. Für wen sie passt, für wen nicht:

Geeignet, wenn: das Team bereits einen produktiven Laravel-Stack hat, KI-Features zur bestehenden Anwendung hinzukommen sollen, und ein paralleler Python-Stack vermieden werden soll. Das beschreibt die Mehrheit der mittelständischen Laravel-Projekte mit KI-Bedarf.

Python und LangChain bleiben besser, wenn: das Projekt eine Research-Phase hat, mit bleeding-edge Modellen experimentiert wird, das Team bereits Python-ML-Erfahrung hat, oder spezialisierte ML-Bibliotheken (Transformers, LangGraph mit komplexen Agent-Hierarchien) tragend sind. Auch das ist eine valide Wahl, keine ideologische Frage.

Die größere Lektion: "Buy the infrastructure, build the domain." Die SDK ist gute Infrastruktur. Das Domain-Wissen, also Chunking, Verification, Prompting, Multi-Tenancy, bleibt Arbeit des Entwicklers. Und das ist richtig so. Eine SDK, die Domain-Wissen erzwingt, wird zur falschen Abstraktionsebene, sobald die Anforderungen real werden.

RAG-Systeme sind kein Python-Monopol mehr. Für Laravel-Teams gibt es jetzt einen gangbaren Weg, mit Lücken, aber mit klarer Richtung. Frühere Schritte in diesem Ökosystem zeigten den Bedarf, diese SDK liefert die Antwort.


Sie sind unsicher, ob Laravel oder Python der richtige Stack für Ihr KI-Projekt ist? Kontaktieren Sie mich für einen IT-Strategie-Check, der die Architekturfrage in zwei bis drei Tagen klärt.