Skip to main content

LLM-Plattform — selbst gehostet, mit echten Berechtigungen

· By Rebar Hassan · 6 min read

Nicht wer das Modell hostet entscheidet über den Nutzen. Sondern wer auf welche Daten kommt.

Die meisten Self-Hosted-LLM-Setups lösen das falsche Problem. Sie optimieren für Inference-Geschwindigkeit oder Datenschutz — und ignorieren dabei die eigentlich schwierige Frage: Was passiert, wenn verschiedene Teams im Unternehmen unterschiedliche Daten sehen dürfen?

Marketing darf keine Entwicklungs-Repos sehen. Entwickler-Team A hat keinen Zugriff auf Projekt B. Das LLM muss das wissen — und durchsetzen.

Dieser Post dokumentiert den vollständigen Gedankenweg: alle verworfenen Ansätze, alle getroffenen Entscheidungen und die resultierende Architektur.


Die Grundanforderungen

  • Verschiedene Nutzergruppen bekommen Antworten nur auf Basis der Daten die sie sehen dürfen
  • Das Modell kann jederzeit ausgetauscht werden ohne Infrastruktur-Umbau
  • Jeder OpenAI-kompatible Client kann sich verbinden — Chat-UI, IDE-Extension, Custom App, Automation
  • Berechtigungen werden an einer einzigen Stelle verwaltet
  • Kein Sync, kein Indexing — Quellen werden live gelesen
  • GPU läuft auf eigener Hardware (Produktion) — kein dauerhaftes Cloud-Commitment

Gesamtarchitektur

flowchart TD subgraph entra["Microsoft 365 / EntraID"] identity["User-Management · Groups · SSO"] end subgraph clients["Clients — beliebig, OpenAI-kompatibel"] ow["OpenWebUI — Chat-UI optional"] ide["OpenCode · Continue.dev · Cursor — IDE-Extensions"] app["Custom App · n8n — SDK / Automation"] end subgraph gateway["LiteLLM Proxy — zentraler Endpunkt"] ll["API-Keys · Budgets · Rate Limits · Logging · Routing"] end subgraph inference["Inference"] vllm["vLLM — Phase 1: CPU · Phase 3: eigene GPU"] runpod["RunPod Serverless — Phase 2: GPU-Test"] end subgraph permissions["Permissions"] wrapper["MCP Wrapper — 150 Zeilen Python"] cerbos["Cerbos — YAML-Policies · git-versioniert"] mcp["azure-devops-mcp — offiziell · Microsoft"] end subgraph data["Datenquellen"] ado["Repos — live · kein Sync"] end identity -->|OIDC Login| ow identity -->|OIDC Login| ide identity -->|OIDC Login| app ow -->|OpenAI API + JWT| ll ide -->|OpenAI API + JWT| ll app -->|OpenAI API + JWT| ll ll -->|LLM-Anfrage| vllm ll -.->|on-demand Phase 2| runpod ll -->|JWT + Query| wrapper wrapper -->|Groups aus JWT| cerbos cerbos -->|erlaubte Repos| wrapper wrapper -->|gefilterte Repo-Liste| mcp mcp -->|live lesen| ado

Entscheidung 1: Ollama vs. vLLM

Ollama ist der Standard-Tipp für Self-Hosted — ideal für Single-User-Homelab und schnellen Einstieg. Für diesen Use Case falsch.

Verworfen: Ollama

  • Serialisierte Verarbeitung — schlechte Concurrency bei mehreren gleichzeitigen Nutzern
  • Modellwechsel erfordert manuelle Pulls und Neukonfiguration
  • Kein production-grade Throughput

Entschieden: vLLM

  • OpenAI-kompatibler API-Endpunkt — alles drumherum bleibt beim Modellwechsel unverändert
  • Modellwechsel = eine Zeile in .env ändern, restart
  • PagedAttention für echte Concurrency
  • CPU-Modus (--device cpu --dtype float32) für Tests ohne GPU — exakt dieselbe API

Entscheidung 2: GPU-Strategie in drei Phasen

Produktionsziel ist eigene Hardware. Aber man kauft keine GPU bevor man weiß ob die Architektur trägt und welches Modell die richtige Qualität liefert.

Phase 1 — CPU (Integration testen):
vLLM mit --device cpu auf einem normalen VPS (z.B. Hetzner CX42, 16 GB RAM). Kleines Modell, ~1–5 Tokens/Sekunde. Langsam — aber die komplette Infrastruktur (LiteLLM, Cerbos, MCP, EntraID-Login) kann so getestet werden ohne GPU-Kosten.

Phase 2 — RunPod Serverless (GPU-Verhalten testen):
Bevor Hardware gekauft wird, testen ob das gewählte Modell auf dem gewählten GPU-Typ die erwartete Qualität und Geschwindigkeit liefert. RunPod Serverless: kein SSH, kein Terminal, einmalig im UI konfigurieren, Endpoint-URL in .env. Kosten nur bei Nutzung (Min Workers = 0). Kein Fixkosten-Commitment.

Phase 3 — Eigene GPU (Produktion):
Nach erfolgreichem RunPod-Test: dedizierter Server. GPU-Block in docker-compose.yml einkommentieren, CPU-Flags entfernen — der Rest des Stacks bleibt unverändert.

Der Trick: Die Architektur ist in allen drei Phasen identisch. Nur die Inference-Schicht tauscht aus — alles andere (LiteLLM, Cerbos, MCP, EntraID) läuft ab Phase 1 vollständig.


Entscheidung 3: Vector DB vs. MCP — die wichtigste

Das Kernproblem: verschiedene Nutzer sollen LLM-Antworten bekommen, die nur auf einem erlaubten Subset einer größeren Codebasis basieren.

Erster Gedanke: Vector DB (Qdrant) + Sync-Pipeline

Konzept: Alle Repos einlesen, embedden, mit Berechtigungs-Metadaten speichern. Bei Anfragen nach Nutzerrechten filtern.

Verworfen, weil:

  • Konzeptuelle Änderungen — neue Abstraktionen, umbenannte Module, verschobene Strukturen — machen ganze Embeddings inhaltlich falsch, ohne dass ein File-Diff das sichtbar macht
  • Das LLM antwortet dann auf Basis von Embeddings die technisch "aktuell" sind aber konzeptuell veraltet
  • Sync-Pipeline = eigenes System das gewartet werden muss
  • Embedding-Kosten und -Latenz bei dieser Datenmenge erheblich

Zweiter Gedanke: n8n für Sync

n8n (ohnehin im Stack) könnte Git-Webhooks empfangen und nur geänderte Dateien neu embedden.

Verworfen, weil: Konzept-Änderungen sind nicht über Datei-Diffs abbildbar. Das Staleness-Problem bleibt.

Entschieden: MCP (Model Context Protocol)

Das LLM bekommt Tools um Repos live zu lesen — bei der Anfrage wird direkt in der Quelle gelesen. Kein Pre-Indexing, kein Sync, kein Staleness. Immer der aktuelle Stand. Offizieller MCP-Server von Microsoft verfügbar.


Entscheidung 4: Permissions — repo-zentrisch vs. eigene Policy-Schicht

Erster Ansatz: Repo-Platform-Permissions direkt übernehmen

Idee: Nutzer-Token an MCP weitergeben, bestehende Zugriffsrechte greifen automatisch.

Verworfen, weil:

  • LLM-Permissions ≠ Code-Permissions. Marketing braucht LLM-Zugriff auf Repo-Inhalte — hat aber keinen Code-Zugriff
  • Zu stark an eine spezifische Plattform gekoppelt
  • Keine zentrale Stelle für LLM-spezifische Policies

Entschieden: Cerbos als unabhängige Policy-Engine

Policies als YAML-Dateien, git-versioniert, PR-fähig, Audit-Trail. Vollständig entkoppelt von der Repo-Plattform. Cerbos Query Plan API übersetzt User + Rolle in eine konkrete Repo-Liste in <1ms.

Eine Änderung an Berechtigungen = ein Git-Commit. Pull-Request + Review möglich.

# policies/repository.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: repository
  rules:
    - actions: ["read"]
      effect: EFFECT_ALLOW
      roles: ["LLM-Dev"]
      condition:
        match:
          expr: resource.id in ["repo-alpha","repo-beta","repo-gamma"]

    - actions: ["read"]
      effect: EFFECT_ALLOW
      roles: ["LLM-Senior-Dev"]
      # kein Filter = alle Repos erlaubt

    - actions: ["read"]
      effect: EFFECT_ALLOW
      roles: ["LLM-Marketing"]
      condition:
        match:
          expr: >
            resource.id in ["repo-alpha"] ||
            resource.id.startsWith("sharepoint_")

Entscheidung 5: EntraID als Single Source of Truth

Die Organisation nutzt bereits Microsoft 365. Keine Parallelstruktur aufbauen.

  • EntraID Groups für LLM-Rollen: LLM-Dev, LLM-Marketing, LLM-Admin
  • Alle Clients loggen sich per OIDC gegen EntraID ein
  • JWT-Token enthält Group-Claims → werden direkt in Cerbos ausgewertet
  • Neuer Mitarbeiter: einmal in M365 zur richtigen Gruppe hinzufügen — kein weiteres System anfassen

OpenWebUI OIDC-Konfiguration:

ENABLE_OAUTH_SIGNUP=true
OAUTH_PROVIDER_NAME=Microsoft
OPENID_PROVIDER_URL=https://login.microsoftonline.com/<tenant-id>/v2.0
OAUTH_CLIENT_ID=<app-registration-client-id>
OAUTH_CLIENT_SECRET=<app-registration-secret>
OAUTH_SCOPES=openid profile email

Entscheidung 6: LiteLLM als zentraler Gateway

Cerbos kontrolliert den Datenzugriff — aber wer das LLM überhaupt aufrufen darf, wie viel Budget ein Team hat, Rate-Limiting — das ist eine andere Dimension. Und: nicht alle Nutzer verwenden dieselbe UI.

LiteLLM Proxy ist der einzige Endpunkt den externe Clients sehen. Jeder Client der die OpenAI API versteht kann sich direkt verbinden:

OpenWebUI (Chat-UI)       → https://llm.deine-domain.de/v1
OpenCode / Continue.dev   → https://llm.deine-domain.de/v1
Cursor                    → https://llm.deine-domain.de/v1
Custom App / SDK          → https://llm.deine-domain.de/v1
n8n Workflow              → https://llm.deine-domain.de/v1

Jeder Client bekommt einen eigenen virtuellen API-Key mit eigenen Regeln:

  • Budget-Limits — z.B. Marketing-Team: max 50 €/Monat
  • Rate Limiting — z.B. IDE-Extensions: max 20 Requests/Min
  • Vollständiges Logging — wer hat wann was gefragt
  • Routing — CPU-vLLM, RunPod oder eigene GPU, transparent für den Client

OpenWebUI ist damit optional — ein Frontend unter vielen, nicht Pflicht. Wer lieber im IDE-Kontext arbeitet, verbindet seinen Editor direkt.

# litellm-config.yaml
model_list:
  - model_name: "local-cpu"
    litellm_params:
      model: openai/vllm-model
      api_base: http://vllm:8000/v1
      api_key: dummy

  - model_name: "runpod-gpu"
    litellm_params:
      model: openai/runpod-model
      api_base: https://api.runpod.ai/v2/<endpoint-id>/openai/v1
      api_key: ${RUNPOD_API_KEY}

router_settings:
  routing_strategy: latency-based-routing

Der einzige Custom-Code: ~150 Zeilen Python

Zwischen LiteLLM und dem MCP-Server sitzt ein schlanker Wrapper. Er ist der einzige Code den wir selbst geschrieben haben. Alles andere läuft als Standard-Container.

# mcp-wrapper/main.py
from fastapi import FastAPI, Header
import jwt, httpx

app = FastAPI()

@app.post("/query")
async def query(request: QueryRequest, authorization: str = Header()):
    # 1. JWT dekodieren (kommt von beliebigem EntraID-authentifizierten Client)
    token = authorization.replace("Bearer ", "")
    claims = jwt.decode(token, options={"verify_signature": False})
    groups = claims.get("groups", [])  # EntraID Groups direkt aus Token

    # 2. Cerbos: erlaubte Repos für diese Gruppen
    async with httpx.AsyncClient() as client:
        plan = await client.post("http://cerbos:3592/api/plan/resources", json={
            "principal": {"id": claims["sub"], "roles": groups},
            "resource": {"kind": "repository"},
            "action": "read"
        })
    allowed_repos = extract_repos_from_plan(plan.json())

    # 3. MCP nur mit erlaubten Repos aufrufen
    result = await call_ado_mcp(
        query=request.query,
        repos=allowed_repos,
        pat=ADO_PAT
    )
    return result

Der Wrapper ist client-agnostisch: egal ob die Anfrage von OpenWebUI, einer IDE-Extension oder einem n8n-Workflow kommt — der JWT mit den EntraID Group-Claims wird immer mitgeschickt und hier ausgewertet.


Was passiert bei einer Anfrage — konkret

1. Ein Entwickler fragt: "Wie ist die Auth-Logik in Projekt X implementiert?"

2. MCP Wrapper:
   → JWT aus dem Request lesen
   → Groups: ["LLM-Dev"]
   → Cerbos Query Plan: "was darf LLM-Dev lesen?"
   → Antwort: repo-alpha, repo-beta, repo-gamma

3. MCP-Server:
   → Sucht nach "Auth-Logik" in den erlaubten Repos
   → Findet relevante Dateien, liest sie live

4. vLLM:
   → Beantwortet die Frage basierend auf den gefundenen Inhalten
   → Repos außerhalb der Berechtigung werden nie geladen, nie gesehen

5. Entwickler bekommt eine Antwort
   → Ohne zu wissen welche anderen Repos existieren

Das ist keine Filter-UI die man umgehen kann — die Repos werden strukturell nie in den Kontext geladen.


Hardware-Entscheidung: wann lohnt sich was

Nach den RunPod-Tests (Phase 2) stellt sich die Frage: eigene Hardware oder dauerhaftes Cloud-Commitment?

OptionKostenGPUMax. ModellgrößeBreakeven vs. RunPod
Hetzner GEX44~212 €/MonatRTX 4090 (24 GB)bis 32B~300 Stunden/Monat
Hetzner GEX131~895 €/Monatmehrere GPUsbis 70Bbei sehr hoher Nutzung
On-Premise~2.500–3.000 € einmalig + 30–80 €/Monat Stromfrei wählbarfreinach ~12–18 Monaten

On-Premise lohnt sich bei hoher Nutzung und strengen Datenschutz-Anforderungen. Bis dahin: RunPod on-demand.


Was existiert, was noch fehlt

KomponenteStatus
vLLM + OpenWebUI Stack (vllm/docker-compose.yml)✅ fertig
Konfigurationsvorlage (vllm/example.env)✅ fertig
Install-Script (vllm/vllm-install.sh)✅ fertig
RunPod Serverless Anleitung (vllm/runpod-setup.md)✅ fertig
LiteLLM Stack🔧 in Arbeit
Cerbos Stack + Policies🔧 in Arbeit
MCP Wrapper (Python)🔧 in Arbeit
EntraID OIDC Integration🔧 in Arbeit

Der Stack ist open source dokumentiert im tool-configurations Repo.


Offene Fragen

  • SharePoint: Marketing soll auch auf SharePoint-Dateien zugreifen — Microsoft Graph API + eigener MCP-Connector. Architektur identisch, andere Datenquelle.
  • Modellwahl Produktion: Entscheidung nach Phase-2-Tests (Qualität vs. VRAM-Bedarf vs. Kosten).
  • Audit-Retention: Wie lange müssen LLM-Zugriffe geloggt werden? LiteLLM und Cerbos loggen beide — Retention-Policy noch offen.
Updated on 14. Apr. 2026