Loading...
Alle Artikel
AI Infrastructure · 10 min read

Ein LLM-Gateway bauen: Architekturmuster mit Portkey und Langfuse

Warum jede ernsthafte AI-Anwendung ein LLM-Gateway braucht und wie Sie eines mit Routing, Fallback, semantischem Caching, Kostenzuordnung und vollständiger Observability mit Portkey und Langfuse bauen.

Warum Sie ein Gateway brauchen

Das erste Mal, dass ein Team openai.chat.completions.create direkt aus dem Application Code aufruft, ist alles in Ordnung. Beim zweiten Mal – ein anderer Service, vielleicht ein anderer Anbieter, vielleicht eine gecachte Variante – beginnt die Drift. Bei der zehnten Aufrufstelle haben Sie:

  • Keine einheitliche Sicht auf die AI-Ausgaben
  • Keine Möglichkeit, ohne Code-Änderungen den Anbieter zu wechseln
  • Rate Limits, die einzelne Services unvorhersehbar treffen
  • Kein Caching – Sie bezahlen dieselbe Completion doppelt
  • Observability, die im Dashboard des Anbieters lebt, nicht in Ihrem
  • Keine Guardrails an der Grenze

Ein LLM-Gateway ist dieselbe Antwort, die API-Gateways vor fünfzehn Jahren auf den HTTP-Wildwuchs gaben: eine einzige, geteilte Schicht, durch die jeder ausgehende Model-Call läuft. 2026 dominieren zwei Tools für Engineering-Teams, die keins von Grund auf selbst schreiben wollen: Portkey für Routing, Fallback und Caching und Langfuse für Observability, Evaluation und Kostenzuordnung. Sie lassen sich gut kombinieren und laufen beide selbst gehostet.

Dieser Beitrag beschreibt die Architektur, die wir empfehlen, und das TypeScript, das Sie zum Verdrahten brauchen.

Was das Gateway wirklich tun sollte

Ein nützliches Gateway hat sieben Aufgaben. Nicht jedes Feature ist am Tag eins dabei, aber alle sieben sollten auf der Roadmap stehen.

  1. Einheitliches Interface. Ein SDK oder HTTP-Vertrag, den Ihre Anwendung aufruft, unabhängig davon, welcher Anbieter dahintersteht.
  2. Provider-Routing. Routen zu OpenAI, Anthropic, Google, Mistral oder einem selbst gehosteten vLLM-Endpoint, basierend auf Modellname, Feature-Bedarf oder Kosten.
  3. Fallback und Retry. Wenn der primäre Anbieter degradiert ist, automatisch fallbacken.
  4. Caching. Exact-Match und semantisches Caching, um doppelte Calls zu eliminieren.
  5. Rate Limiting und Budgets. Pro Team, Kunde, Umgebung.
  6. Observability. Jeder Call mit Prompts, Responses, Tokens, Latenz, Kosten und Nutzer-Kontext geloggt.
  7. Guardrails. Input-Scrubbing, Output-Filterung, PII-Redaktion, Prompt-Injection-Detection.

Referenzarchitektur

Hier die Form, die wir für mittelgroße Teams deployen:

                      +----------------------+
  Client app  -----> |    LLM Gateway API   | -----> OpenAI
                     |  (TypeScript / Bun)  |        Anthropic
                     |   + Portkey core     |        Google
                     +----------+-----------+        Self-hosted vLLM
                                |
                                | async traces
                                v
                         +--------------+
                         |   Langfuse   |
                         | (self-host)  |
                         +--------------+
                                |
                                v
                     Cost dashboards, evals,
                     per-customer attribution

Das Gateway selbst ist ein stateless Service – wir betreiben es als Kubernetes Deployment mit Horizontal Autoscaler. Redis übernimmt Cache und Rate Limits. Langfuse läuft als eigener Stack (Web, Worker, Postgres, ClickHouse, S3-kompatibler Storage), entweder selbst gehostet oder über Langfuse Cloud.

Das Kern-Client-Interface

Beginnen wir aus Sicht der Anwendung. Apps sollten niemals Provider-SDKs direkt aufrufen – sie rufen das Gateway über einen dünnen Client auf, der den Vertrag durchsetzt.

// packages/llm-client/src/types.ts
export interface LLMRequest {
  readonly model: string;
  readonly messages: readonly {
    readonly role: "system" | "user" | "assistant";
    readonly content: string;
  }[];
  readonly maxTokens?: number;
  readonly temperature?: number;
  readonly metadata: {
    readonly team: string;
    readonly customer?: string;
    readonly feature: string;
    readonly traceId?: string;
  };
}

export interface LLMResponse {
  readonly text: string;
  readonly model: string;
  readonly provider: string;
  readonly inputTokens: number;
  readonly outputTokens: number;
  readonly costUsd: number;
  readonly latencyMs: number;
  readonly cached: boolean;
}

export interface LLMClient {
  complete(req: LLMRequest): Promise<LLMResponse>;
}

Ein paar bewusste Entscheidungen. Metadata ist verpflichtend, nicht optional – jeder Call muss einem Team und Feature zurechenbar sein. Cost und Provider werden explizit zurückgegeben, damit Caller sie loggen können. Und das Cache-Hit-Flag ist exponiert, weil es in Evals und Billing-Abgleichen auftaucht.

Die Gateway-Implementierung

Das Gateway ist ein Bun-Service, der das Portkey-SDK als Routing-Engine nutzt und Langfuse für Tracing.

// apps/llm-gateway/src/gateway.ts
import Portkey from "portkey-ai";
import { Langfuse } from "langfuse";
import type { LLMRequest, LLMResponse } from "@example/llm-client";

export interface GatewayConfig {
  readonly portkeyApiKey: string;
  readonly langfusePublicKey: string;
  readonly langfuseSecretKey: string;
  readonly langfuseHost: string;
}

export class LLMGateway {
  private readonly portkey: Portkey;
  private readonly langfuse: Langfuse;

  constructor(cfg: GatewayConfig) {
    this.portkey = new Portkey({
      apiKey: cfg.portkeyApiKey,
      config: "pc-main-router",
    });

    this.langfuse = new Langfuse({
      publicKey: cfg.langfusePublicKey,
      secretKey: cfg.langfuseSecretKey,
      baseUrl: cfg.langfuseHost,
    });
  }

  async complete(req: LLMRequest): Promise<LLMResponse> {
    const trace = this.langfuse.trace({
      name: req.metadata.feature,
      userId: req.metadata.customer,
      metadata: {
        team: req.metadata.team,
        upstream_trace: req.metadata.traceId,
      },
    });

    const generation = trace.generation({
      name: "llm.complete",
      model: req.model,
      input: req.messages,
      modelParameters: {
        temperature: req.temperature ?? 0.7,
        maxTokens: req.maxTokens ?? 1024,
      },
    });

    const started = Date.now();

    try {
      const result = await this.portkey.chat.completions.create({
        model: req.model,
        messages: req.messages as { role: string; content: string }[],
        max_tokens: req.maxTokens ?? 1024,
        temperature: req.temperature ?? 0.7,
        metadata: {
          team: req.metadata.team,
          customer: req.metadata.customer ?? "unknown",
          feature: req.metadata.feature,
        },
      });

      const choice = result.choices[0];
      const text = choice?.message?.content ?? "";
      const usage = result.usage ?? { prompt_tokens: 0, completion_tokens: 0 };
      const latency = Date.now() - started;
      const cached = (result as { cached?: boolean }).cached ?? false;

      generation.end({
        output: text,
        usage: {
          promptTokens: usage.prompt_tokens,
          completionTokens: usage.completion_tokens,
        },
      });

      return {
        text,
        model: result.model,
        provider: (result as { provider?: string }).provider ?? "unknown",
        inputTokens: usage.prompt_tokens,
        outputTokens: usage.completion_tokens,
        costUsd: estimateCost(result.model, usage),
        latencyMs: latency,
        cached,
      };
    } catch (err) {
      generation.end({ output: null, level: "ERROR", statusMessage: String(err) });
      throw err;
    } finally {
      await this.langfuse.flushAsync();
    }
  }
}

function estimateCost(
  model: string,
  usage: { prompt_tokens: number; completion_tokens: number },
): number {
  const rates: Record<string, { input: number; output: number }> = {
    "gpt-4.1": { input: 2.5e-6, output: 10e-6 },
    "claude-sonnet-4-6-20260115": { input: 3e-6, output: 15e-6 },
    "gemini-2.5-pro": { input: 1.25e-6, output: 5e-6 },
  };
  const rate = rates[model] ?? { input: 0, output: 0 };
  return usage.prompt_tokens * rate.input + usage.completion_tokens * rate.output;
}

Die Portkey-Konfiguration, auf die pc-main-router verweist, liegt in der Portkey-Console (oder als selbst gehostete Config-Datei). Dort werden Routing, Fallback und Caching deklariert – nicht im Code.

Routing und Fallback als Config

Portkey beschreibt Routing-Strategien über eine JSON-Config. Eine realistische Config mit Primary, Fallback, semantischem Caching und Canary für ein neues Modell:

{
  "strategy": { "mode": "fallback" },
  "targets": [
    {
      "strategy": { "mode": "loadbalance" },
      "targets": [
        {
          "virtual_key": "openai-prod",
          "override_params": { "model": "gpt-4.1" },
          "weight": 0.9
        },
        {
          "virtual_key": "openai-prod",
          "override_params": { "model": "gpt-4.1-mini" },
          "weight": 0.1
        }
      ]
    },
    {
      "virtual_key": "anthropic-prod",
      "override_params": { "model": "claude-sonnet-4-6-20260115" }
    }
  ],
  "cache": {
    "mode": "semantic",
    "max_age": 3600
  },
  "retry": {
    "attempts": 3,
    "on_status_codes": [429, 500, 502, 503, 504]
  }
}

Von oben nach unten gelesen: 90 % der Requests gehen an GPT-4.1, 10 % an den Mini-Canary, und wenn das OpenAI-Target komplett ausfällt, fallbackt Portkey auf Claude. Semantic Cache hält eine Stunde. Retries kümmern sich um transiente Fehler.

Das Routing zu ändern erfordert jetzt keine Code-Änderung, kein Deploy und nicht einmal einen Neustart. Das ist der Hauptgrund, warum Sie ein Gateway überhaupt wollen.

Semantisches Caching

Exact-Match-Caching ist trivial – Prompt hashen, Redis verwenden. Semantisches Caching ist interessanter: den Prompt embedden, in einem Vector Store nach ähnlichen Prompts suchen und bei einer Ähnlichkeit über einem Schwellwert eine gecachte Antwort zurückgeben. Portkey bringt das ab Werk mit; das Flag ist "cache": { "mode": "semantic" } oben.

Wann zu verwenden:

  • Ja: FAQ-artige Support-Bots, Dokumentations-Q&A, bekannte Template-Antworten.
  • Vorsicht: Agents, die frische Daten ziehen – veraltete Antworten schaden Ihnen.
  • Nein: Alles mit Personalisierung oder Echtzeit-Inputs.

Cache-Hit-Raten schwanken in Produktion stark nach Anwendungsfall: 30–50 % bei internem Docs-Q&A, 5–15 % bei kundenseitigen Assistenten, nahezu null bei Code-Generierung. Messen Sie Ihre, bevor Sie mit Einsparungen rechnen.

Pro-Kunde-Budgets und Rate Limits

Budgets verhindern, dass ein einzelner Kunde die Monatsrechnung verbrennt. Das Gateway setzt sie aus Redis durch, bevor es den Anbieter aufruft.

// apps/llm-gateway/src/budget.ts
import Redis from "ioredis";

export class BudgetEnforcer {
  constructor(private readonly redis: Redis) {}

  async tryConsume(customer: string, usd: number): Promise<void> {
    const key = `budget:${customer}:${this.currentMonth()}`;
    const limitKey = `budget-limit:${customer}`;

    const [currentRaw, limitRaw] = await this.redis.mget(key, limitKey);
    const current = currentRaw ? Number(currentRaw) : 0;
    const limit = limitRaw ? Number(limitRaw) : 100;

    if (current + usd > limit) {
      throw new BudgetExceededError(customer, current, limit);
    }

    await this.redis.incrbyfloat(key, usd);
    await this.redis.expire(key, 60 * 60 * 24 * 40);
  }

  private currentMonth(): string {
    const now = new Date();
    return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
  }
}

export class BudgetExceededError extends Error {
  constructor(
    public readonly customer: string,
    public readonly spent: number,
    public readonly limit: number,
  ) {
    super(`budget exceeded for ${customer}: $${spent.toFixed(2)} / $${limit.toFixed(2)}`);
  }
}

Budgets werden nach dem Call mit den echten Kosten belastet, aber eine Pre-Flight-Schätzung vermeidet, den Anbieter überhaupt zu kontaktieren, wenn der Kunde schon drüber ist. In der Praxis tun wir beides.

Observability mit Langfuse

Langfuse gibt Ihnen Traces, Generations, Scores und Evaluations. Es funktioniert gut, weil es einen LLM-Call als erstklassiges Objekt mit Input, Output, Model, Parametern und Kosten behandelt – nicht als generische Log-Zeile.

Was man am Tag eins verdrahten sollte:

  • Traces gruppiert nach User-Session und Feature.
  • Generations für jeden Model-Call.
  • Scores an Generations gehängt – User-Thumbs-up/down, Offline-Eval-Ergebnisse, Safety-Classifier-Outputs.
  • Datasets für Regressionstests – bekannte, gute Interaktionen speichern und bei jedem Model-Upgrade abspielen.

Das SDK braucht drei Zeilen pro Call (oben bereits gezeigt). Der Wert ist erheblich: reale Kosten pro Kunde, reale Latenz pro Feature und die Fähigkeit, sich den Vorfall vom letzten Dienstag anzusehen und den exakten Prompt zu finden, der ihn ausgelöst hat.

Deployment

Das Gateway ist ein kleiner Service und sollte auch so laufen.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-gateway
  namespace: ai-platform
spec:
  replicas: 3
  selector:
    matchLabels: { app: llm-gateway }
  template:
    metadata:
      labels: { app: llm-gateway }
    spec:
      containers:
        - name: gateway
          image: ghcr.io/example/llm-gateway:1.4.2
          ports:
            - containerPort: 8080
          env:
            - name: PORTKEY_API_KEY
              valueFrom: { secretKeyRef: { name: portkey, key: api-key } }
            - name: LANGFUSE_PUBLIC_KEY
              valueFrom: { secretKeyRef: { name: langfuse, key: public } }
            - name: LANGFUSE_SECRET_KEY
              valueFrom: { secretKeyRef: { name: langfuse, key: secret } }
            - name: LANGFUSE_HOST
              value: https://langfuse.ai.example.com
            - name: REDIS_URL
              valueFrom: { secretKeyRef: { name: redis, key: url } }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits: { memory: 512Mi }
          readinessProbe:
            httpGet: { path: /ready, port: 8080 }
          livenessProbe:
            httpGet: { path: /live, port: 8080 }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-gateway
  namespace: ai-platform
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-gateway
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }

Mindestens 3 Replicas fahren – das Gateway liegt auf dem kritischen Pfad jedes AI-Features im Produkt, und ein Single-Pod-Ausfall ist nicht okay.

Canary-Deployments neuer Modelle

Wenn eine neue Modellversion landet (etwa OpenAI shippt gpt-4.2), wollen Sie sie wie eine neue Version eines eigenen Services ausrollen. Die oben gezeigte Portkey-Load-Balance-Config ist genau das: 10 % des Traffics an das neue Modell routen, Qualität und Latenz in Langfuse beobachten und dann ausweiten.

Definieren Sie Erfolgskriterien vor dem Canary explizit. Wir betrachten typischerweise:

  • User-Satisfaction-Score (Thumbs up/down) – darf nicht regredieren.
  • Task-Completion-Rate (bekommt der Nutzer, was er wollte?) – darf nicht regredieren.
  • p95-Latenz – darf nicht signifikant regredieren.
  • Kosten pro Interaktion – dürfen nicht um mehr als 20 % steigen.

Bricht eines davon, rollback per Config-Wert. Kein Redeploy.

Was nicht ins Gateway gehört

Feature Creep ist der Feind. Dinge, die anderswo hingehören:

  • Prompt-Templates. Die leben beim Feature-Code. Das Gateway soll Ihre Prompts nicht kennen.
  • Agent-Orchestrierung. Dafür Temporal, LangGraph oder Ähnliches nutzen. Das Gateway ruft ein Modell auf einmal auf.
  • Business-Logik. Das Gateway macht einen LLM-Call und kehrt zurück. Es entscheidet nicht, ob überhaupt einer stattfinden soll.

Halten Sie das Gateway langweilig und schmal. Schmales bricht nicht.

Nächste Schritte

Ein LLM-Gateway ist das erste Stück AI-Plattform-Infrastruktur, das die meisten Teams nach ihrem ersten Produktions-Feature bauen sollten. Es amortisiert sich binnen eines Quartals durch Kosteneinsparungen und erlaubt Anbieterwechsel, Guardrails und Debugging in Produktion ohne Code-Änderungen. Portkey plus Langfuse bringt Sie mit zwei Tagen Integrationsarbeit den größten Teil des Wegs. Wenn Sie Unterstützung beim Entwurf eines Gateways für Ihren Stack oder bei der Migration bestehender Direct-Calls wünschen, nehmen Sie Kontakt auf.

abgelegt unter
llmgatewayportkeylangfuseai
mit uns arbeiten

Soll unser Team Ihrer Infrastruktur helfen?

talk to an engineerFree 30-min discovery callBook
close