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
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?
| Option | Kosten | GPU | Max. Modellgröße | Breakeven vs. RunPod |
|---|---|---|---|---|
| Hetzner GEX44 | ~212 €/Monat | RTX 4090 (24 GB) | bis 32B | ~300 Stunden/Monat |
| Hetzner GEX131 | ~895 €/Monat | mehrere GPUs | bis 70B | bei sehr hoher Nutzung |
| On-Premise | ~2.500–3.000 € einmalig + 30–80 €/Monat Strom | frei wählbar | frei | nach ~12–18 Monaten |
On-Premise lohnt sich bei hoher Nutzung und strengen Datenschutz-Anforderungen. Bis dahin: RunPod on-demand.
Was existiert, was noch fehlt
| Komponente | Status |
|---|---|
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.