KI-Features ohne Vendor Lock-in: Die Multi-Provider-Architektur für SaaS

#Multi-Provider-LLM-Architektur

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

LinkedIn

Die meisten Teams fügen KI-Features hinzu, indem sie das OpenAI-SDK direkt aus einer Controller- oder Service-Klasse heraus aufrufen. Das funktioniert, und es geht schnell live. Es bedeutet aber auch, dass jeder Teil deiner Anwendung, der ein LLM berührt, jetzt von der Uptime eines einzelnen Unternehmens abhängt, von einer Preisseite und von einer Reihe von API-Entscheidungen, die du nicht kontrollierst. Wenn dieser Anbieter die Preise erhöht, ein Modell abkündigt oder einen schlechten Tag hat, erbt deine Anwendung das Problem.

Eine Multi-Provider-LLM-Architektur vermeidet diesen Vendor-Lock-in, indem sie eine Abstraktionsschicht zwischen deinen Anwendungscode und jeden einzelnen KI-Anbieter setzt. Statt den Client von OpenAI direkt aufzurufen, ruft dein Code ein Interface auf. Darunter sitzt OpenAI, Anthropic, Mistral oder ein selbst gehosteter Ollama-Endpunkt, und zwischen ihnen zu wechseln wird zu einer Konfigurationsänderung statt zu einem Rewrite. Das ist kein theoretisches Anliegen. Teams, die diesen Schritt übersprungen haben, mussten ganze KI-Features kurzfristig neu schreiben, weil ein Anbieter über Nacht seine API oder sein Preismodell geändert hat.

Warum Single-Vendor-Integration zu einer Belastung wird

Die Fehlermodi sind vorhersehbar, sobald du ein paar davon miterlebt hast. Ein Anbieter hat während deines Traffic-Peaks einen Ausfall, und jedes KI-abhängige Feature in deinem Produkt geht gleichzeitig aus. Ein Anbieter erhöht die Preise pro Token, und deine Margen bei KI-Features schrumpfen ohne Vorwarnung. Ein Modell wird mit ein paar Monaten Vorlauf abgekündigt, und die Prompts, die du auf seine Eigenheiten abgestimmt hast, produzieren nicht mehr dieselbe Ausgabe. Nichts davon ist hypothetisch: OpenAI, Anthropic und Google haben in den letzten zwei Jahren alle mehrfach Modelle abgekündigt, Rate-Limits geändert und Preise angepasst.

Das tiefere Problem ist architektonisch. Wenn OpenAI\Client direkt in deinen Controllern und Services instanziiert wird, muss sich jede einzelne dieser Stellen ändern, wenn du einen zweiten Anbieter hinzufügen, ein günstigeres Modell für eine bestimmte Aufgabe testen oder sensible Daten aus Compliance-Gründen an ein selbst gehostetes Modell routen willst. Dieses Refactoring wird umso schwieriger, je länger du wartest, weil sich die Integration Feature für Feature durch die Codebase verteilt.

Die Abstraktionsschicht

Die Lösung ist ein Provider-Interface, durch das jeder LLM-Aufruf läuft, unabhängig davon, welcher Anbieter den Request letztlich behandelt. In einer Symfony-Anwendung sieht das wie ein kleines Set von Interfaces und ein Service pro Anbieter aus.

interface LlmProviderInterface
{
    public function complete(LlmRequest $request): LlmResponse;
    public function stream(LlmRequest $request): \Generator;
    public function supports(LlmCapability $capability): bool;
}

Jeder Anbieter bekommt seine eigene Implementierung: OpenAiProvider, AnthropicProvider, MistralProvider, OllamaProvider. Anwendungscode referenziert diese Klassen nie direkt. Er fragt einen Router nach einem Anbieter, der den aktuellen Request bearbeiten kann, und der Router entscheidet, welche Implementierung er zurückgibt.

final class OpenAiProvider implements LlmProviderInterface
{
    public function __construct(
        private readonly OpenAiClient $client,
        private readonly PromptNormalizer $normalizer,
    ) {}

    public function complete(LlmRequest $request): LlmResponse
    {
        $payload = $this->normalizer->toOpenAiFormat($request);
        $response = $this->client->chat()->create($payload);

        return LlmResponse::fromOpenAi($response);
    }
}

Die Objekte LlmRequest und LlmResponse sind anbieterunabhängig. Sie tragen die Nachrichten, die angeforderte Fähigkeit (Klassifikation, Generierung, Embedding) und jegliche Constraints (maximale Tokens, Temperature, erforderliche Kontextlänge). Die Implementierung jedes Anbieters ist dafür zuständig, diesen generischen Request in die Form zu übersetzen, die seine API erwartet, und die Antwort zurückzuübersetzen.

Routing nach Aufgabe, nicht nach Gewohnheit

Sobald das Interface existiert, hört die Routing-Strategie auf, ein nachträglicher Gedanke zu sein, und wird zu einer echten Entscheidung. Unterschiedliche Aufgaben haben unterschiedliche Anforderungen, und ein einzelnes „bestes" Modell passt selten für alle.

Ein Support-Ticket-Klassifikator braucht nicht dein leistungsfähigstes Modell. Ein günstiges, schnelles Modell erledigt das gut und hält die Kosten pro Request niedrig. Lange Texterzeugung, bei der Qualitätsunterschiede für Nutzer tatsächlich sichtbar sind, ist der Bereich, in dem sich das teurere Modell lohnt. Alles, was regulierte oder sensible Kundendaten berührt, muss möglicherweise auf einer selbst gehosteten Ollama-Instanz bleiben, statt deine Infrastruktur überhaupt zu verlassen - was für Teams wichtig ist, die unter der DSGVO oder branchenspezifischen Compliance-Anforderungen arbeiten.

final class TaskBasedRouter
{
    public function __construct(
        private readonly ProviderRegistry $registry,
    ) {}

    public function route(LlmRequest $request): LlmProviderInterface
    {
        return match ($request->getTaskType()) {
            TaskType::Classification => $this->registry->get('mistral-small'),
            TaskType::Generation => $this->registry->get('claude-sonnet'),
            TaskType::SensitiveData => $this->registry->get('ollama-local'),
            default => $this->registry->getDefault(),
        };
    }
}

Hier verdient sich auch die Fähigkeitserkennung ihren Platz. Nicht jeder Anbieter unterstützt jedes Feature, und ein Request, der ein 200.000-Token-Kontextfenster oder natives Tool-Calling braucht, sollte niemals bei einem Anbieter landen, der das nicht kann.

final class CapabilityAwareRouter
{
    public function route(LlmRequest $request): LlmProviderInterface
    {
        $candidates = $this->registry->getForTaskType($request->getTaskType());

        foreach ($candidates as $provider) {
            if ($provider->supports($request->getRequiredCapability())) {
                return $provider;
            }
        }

        throw new NoCapableProviderException($request->getRequiredCapability());
    }
}

Failover: der Teil, der den Aufwand tatsächlich rechtfertigt

Routing nach Aufgabe ist eine Kosten- und Qualitätsoptimierung. Failover ist das, was dich schützt, wenn ein Anbieter ausfällt. Ohne Failover wird eine 429-Rate-Limit-Antwort oder ein 500er deines einzigen Anbieters zu einem 500er in deiner eigenen Anwendung, sichtbar für deine Nutzer im denkbar ungünstigsten Moment.

Das Muster ist eine Failover-Kette: Versuche den primären Anbieter, und bei einem wiederholbaren Fehler falle auf den nächsten Anbieter in der Kette durch, statt den Fehler sofort sichtbar zu machen.

final class FailoverProvider implements LlmProviderInterface
{
    /** @param LlmProviderInterface[] $providers */
    public function __construct(
        private readonly array $providers,
        private readonly LoggerInterface $logger,
    ) {}

    public function complete(LlmRequest $request): LlmResponse
    {
        $lastException = null;

        foreach ($this->providers as $provider) {
            try {
                return $provider->complete($request);
            } catch (RateLimitException|ServerErrorException $e) {
                $this->logger->warning('Provider failed, trying next', [
                    'provider' => $provider::class,
                    'error' => $e->getMessage(),
                ]);
                $lastException = $e;
                continue;
            }
        }

        throw new AllProvidersFailedException(previous: $lastException);
    }
}

Eine Kette, die als OpenAI, dann Anthropic, dann ein selbst gehosteter Ollama-Fallback konfiguriert ist, bedeutet, dass der Ausfall eines einzelnen Anbieters deinen Service verschlechtert, statt ihn zu stoppen. Die Fallback-Stufe muss die Qualität der primären nicht exakt erreichen. Nutzer tolerieren eine leicht andere Antwort deutlich besser als ein kaputtes Feature.

Die Prompt-Normalisierungsschicht

Das ist der Teil, der übersprungen wird und später den meisten Debugging-Schmerz verursacht. Anbieter sind sich nicht einig, wie System-Nachrichten funktionieren, wie Tool-Calls formatiert werden oder wie Streaming-Antworten strukturiert sind. OpenAI, Anthropic und Mistral erwarten jeweils subtil unterschiedliche Request-Formen, und ein für einen Anbieter gebauter Request wird bei einem anderen entweder einen Fehler werfen oder sich still anders verhalten.

Die Normalisierungsschicht ist das, was den Rest der Architektur ehrlich macht. Sie ist ein Übersetzungsschritt, kein Wrapper, der Daten nur durchreicht:

final class PromptNormalizer
{
    public function toOpenAiFormat(LlmRequest $request): array
    {
        return [
            'model' => $request->getModel(),
            'messages' => $this->buildOpenAiMessages($request),
            'max_tokens' => $request->getMaxTokens(),
        ];
    }

    public function toAnthropicFormat(LlmRequest $request): array
    {
        return [
            'model' => $request->getModel(),
            'system' => $request->getSystemPrompt(),
            'messages' => $this->buildAnthropicMessages($request),
            'max_tokens' => $request->getMaxTokens(),
        ];
    }
}

Anthropic nimmt den System-Prompt als separates Top-Level-Feld statt als Nachricht im Array. Tool-Call-Formate unterscheiden sich zwischen Anbietern genug, dass ein naiver Pass-Through in dem Moment bricht, in dem du denselben Request an einen zweiten Anbieter routest. Auch Streaming-Protokolle unterscheiden sich: OpenAI und Anthropic nutzen beide Server-Sent Events, aber das Chunk-Format und die Art, wie ein Stream Abschluss signalisiert, sind nicht identisch. Das an einer Stelle zu handhaben bedeutet, dass jede Anbieter-Implementierung einfach bleibt und die Eigenheiten in genau einer Datei leben statt über die Codebase verstreut.

Konfiguration statt Codeänderungen

Der Lohn für diese ganze Struktur ist, dass der Wechsel zwischen Anbietern, oder das Aufteilen von Traffic zwischen ihnen, zu einer Konfigurationsänderung wird:

llm_providers:
  classification:
    primary: mistral-small
    fallback: [openai-gpt-4o-mini]
  generation:
    primary: claude-sonnet
    fallback: [openai-gpt-4o, ollama-local]
  sensitive:
    primary: ollama-local
    fallback: []

Wenn ein neues Modell erscheint oder sich die Preise eines bestehenden Anbieters ändern, ist die Aktualisierung dieser Datei die gesamte Änderung. Kein Controller fasst ein SDK direkt an, kein Deployment ist über ein Config-Update hinaus nötig, und die Routing- und Failover-Logik funktioniert genau wie zuvor weiter.

Wo das in eine breitere KI-Strategie passt

Bei alldem geht es nicht darum, gute Anbieter zu meiden. OpenAI und Anthropic bauen beide starke Modelle, und sie gut zu nutzen ist für die meisten Generierungsaufgaben weiterhin die richtige Wahl. Der Punkt ist, dass die Verfügbarkeit und Kostenstruktur deiner Anwendung nicht dauerhaft an die Entscheidungen eines Anbieters geschweißt sein sollten. Teams, die diese Abstraktion früh bauen, verbringen ein bis zwei zusätzliche Tage mit dem Interface und den Anbieter-Implementierungen. Teams, die das überspringen, verbringen am Ende Wochen damit, es nachträglich einzubauen - meist direkt nachdem ein Ausfall oder eine Preisänderung die Frage erzwungen hat.

Wenn dein SaaS-Produkt seine ersten KI-Features hinzufügt, oder wenn du bereits an einen einzelnen Anbieter gebunden bist und einen Weg heraus ohne Rewrite suchst, ist das die Art von Architekturentscheidung, die es wert ist, richtig zu treffen, bevor sie tragend wird. Wolf-Tech arbeitet mit SaaS-Teams genau an dieser Art von Individualsoftware-Entwicklung und Tech-Stack-Strategie und baut die Abstraktionsschichten, die KI-Features flexibel halten, während sich die Anbieterlandschaft unter ihnen ständig verschiebt.

Fragen zu deiner eigenen KI-Integration, oder willst du eine zweite Meinung zu einem bestehenden Setup, bevor es schwerer zu ändern wird? Melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io.