Das Problem: Nutzerdaten verlassen deine Infrastruktur
Jedes Mal, wenn deine Anwendung einen Prompt an OpenAI, GPT-4 oder ein anderes Cloud-LLM sendet, verlässt dieser Text deine eigene Infrastruktur. Für die meisten Anwendungsfälle ist das unproblematisch — aber sobald deine Prompts personenbezogene Daten (PII) enthalten, hast du ein ernstes Problem.
So sieht PII in echten Prompts aus:
"Fasse diesen E-Mail-Thread mit john.smith@acmecorp.com zusammen.
Es geht um die Rechnung über 14.500 € an Herrn John Smith,
geb. 12.03.1985, wohnhaft Ahornstraße 42, 80331 München."
Dieser einzelne Prompt enthält:
- Eine E-Mail-Adresse
- Einen vollständigen Namen
- Ein Geburtsdatum
- Eine Postanschrift
- Einen Geldbetrag, der einer Person zugeordnet ist
Das unverschlüsselt an OpenAI zu senden, erzeugt Compliance-Risiken unter DSGVO, CCPA, HIPAA und den meisten unternehmensinternen Sicherheitsrichtlinien. OpenAI bietet zwar eine Zero-Data-Retention-Option an, aber vertragliche Kontrollen verhindern nicht, dass die Daten die eigene Infrastruktur überhaupt verlassen.
Die Lösung: Prompt abfangen, PII anonymisieren, bereinigten Text an OpenAI senden, Originalwerte in der Antwort wiederherstellen — für den Endnutzer vollkommen transparent.
Microsoft Presidio im Überblick
Microsoft Presidio ist ein quelloffenes SDK zur Erkennung und Anonymisierung von PII. Es wurde vom Datenwissenschaftsteam von Microsoft entwickelt, ist produktionsreif, erweiterbar und kostenlos.
┌─────────────────────────────────────────────────────────────────┐
│ MICROSOFT PRESIDIO — KERNARCHITEKTUR │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ ┌────────────────────────────┐ │
│ │ presidio-analyzer │ │ presidio-anonymizer │ │
│ │ │ │ │ │
│ │ Erkennt PII im Text │──────▶│ Ersetzt PII durch │ │
│ │ via NLP + Regex │ │ Platzhalter oder Ops │ │
│ │ │ │ │ │
│ │ Liefert: │ │ Operatoren: │ │
│ │ - Entity-Typ │ │ - replace (Platzhalter) │ │
│ │ - Start/End-Offset │ │ - redact (entfernen) │ │
│ │ - Konfidenzwert │ │ - hash (SHA256) │ │
│ └─────────────────────┘ │ - encrypt (AES) │ │
│ │ - custom (eigene Fkt.) │ │
│ └────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
Kernfunktionen im Überblick
| Funktion | Beschreibung |
|---|---|
| 50+ eingebaute Recognizer | E-Mail, Telefon, SSN, IBAN, Kreditkarte, Reisepass, IP-Adresse u.v.m. |
| Mehrsprachige Unterstützung | Englisch, Deutsch, Spanisch, Französisch, Italienisch über spaCy-Modelle |
| Eigene Recognizer | Regex- oder ML-basierte Erkenner für domänenspezifische Entitäten |
| Pluggbare Anonymisierer | Ersetzen, entfernen, hashen, verschlüsseln oder eigene Transformation |
| Hohe Performance | Verarbeitet tausende Texte pro Sekunde; läuft vollständig lokal |
| Keine Datenweitergabe | Analyzer und Anonymizer laufen on-premises — nur der bereinigte Text erreicht die Cloud |
Unterstützte PII-Typen (Auswahl)
| Entität | Beispiele |
|---|---|
PERSON |
John Smith, Dr. Maria Weber |
EMAIL_ADDRESS |
john@beispiel.de |
PHONE_NUMBER |
+49 89 12345678 |
CREDIT_CARD |
4111 1111 1111 1111 |
IBAN_CODE |
DE89 3704 0044 0532 0130 00 |
DATE_TIME |
12.03.1985, 15. Januar 2026 |
LOCATION |
Ahornstraße 42, München |
IP_ADDRESS |
192.168.0.1 |
Installation
pip install presidio-analyzer presidio-anonymizer spacy fastapi openai uvicorn
python -m spacy download de_core_news_lg
Grundlegende Erkennung und Anonymisierung
Bevor wir den vollständigen Proxy aufbauen, schauen wir uns die Kern-API von Presidio an.
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()
text = (
"Bitte kontaktiere john.smith@acmecorp.com oder ruf +49 89 12345678 an. "
"Seine Kreditkartennummer lautet 4111 1111 1111 1111."
)
# Schritt 1: PII erkennen
results = analyzer.analyze(text=text, language="de")
for r in results:
print(f"{r.entity_type:20} | Score: {r.score:.2f} | '{text[r.start:r.end]}'")
Ausgabe:
EMAIL_ADDRESS | Score: 1.00 | 'john.smith@acmecorp.com'
PHONE_NUMBER | Score: 0.75 | '+49 89 12345678'
CREDIT_CARD | Score: 1.00 | '4111 1111 1111 1111'
# Schritt 2: Anonymisieren
anonymized = anonymizer.anonymize(text=text, analyzer_results=results)
print(anonymized.text)
Ausgabe:
Bitte kontaktiere <EMAIL_ADDRESS> oder ruf <PHONE_NUMBER> an.
Seine Kreditkartennummer lautet <CREDIT_CARD>.
Einfach und wirkungsvoll — aber dieser Ansatz hat einen gravierenden Haken.
Das Informationsverlust-Problem
Wenn jede E-Mail-Adresse durch denselben Platzhalter <EMAIL_ADDRESS> ersetzt wird, kann das Modell nicht mehr zwischen verschiedenen Entitäten desselben Typs unterscheiden.
Beispiel-Prompt:
"Leite diesen Thread an alice@vertrieb.de weiter und setze bob@recht.de in CC.
Stelle sicher, dass alice@vertrieb.de den Anhang erhält."
Nach einfacher Anonymisierung:
"Leite diesen Thread an <EMAIL_ADDRESS> weiter und setze <EMAIL_ADDRESS> in CC.
Stelle sicher, dass <EMAIL_ADDRESS> den Anhang erhält."
Aus Sicht des Modells:
- Alle drei Platzhalter sehen identisch aus
- Es kann nicht erkennen, dass der erste und der dritte dieselbe Person sind
- Es kann Alice nicht von Bob unterscheiden
- Die Antwort wird ungenau oder falsch sein
Außerdem: Wenn das Modell gebeten wird, eine Antwort an <EMAIL_ADDRESS> zu formulieren, enthält die Antwort, die der Nutzer zurückbekommt, ebenfalls <EMAIL_ADDRESS> — das ist für den Nutzer wertlos.
Es gibt also zwei Probleme zu lösen:
- Eindeutigkeit — jeder distinct PII-Wert braucht ein eindeutiges Token, damit das Modell erkennt, dass
EMAIL_1undEMAIL_2verschiedene Personen sind - Round-Trip-Konvertierung — Platzhalter in der Modellantwort müssen vor der Ausgabe an den Nutzer wieder in echte Werte zurückverwandelt werden
Besserer Ansatz: Eindeutige Identifier mit Round-Trip-Konvertierung
Die Lösung: eine sitzungsbezogene Zuordnungstabelle zwischen echten PII-Werten und eindeutigen Platzhaltern, die beim Response wieder umgekehrt wird.
┌──────────────────────────────────────────────────────────────────┐
│ PII-PROXY — VOLLSTÄNDIGER REQUEST/RESPONSE-FLOW │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Nutzer-Prompt │
│ "Schreib an alice@co.de und bob@co.de" │
│ │ │
│ ▼ │
│ ┌──────────────────┐ Zuordnungstabelle: │
│ │ Presidio │ EMAIL_1 → alice@co.de │
│ │ Anonymisierer │ EMAIL_2 → bob@co.de │
│ │ (unique Tokens) │ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ Bereinigter Prompt │
│ "Schreib an <EMAIL_ADDRESS_1> und <EMAIL_ADDRESS_2>" │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ OpenAI API │ ← Sieht nur Tokens, niemals echte PII │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ LLM-Antwort │
│ "Ich schicke den Bericht an <EMAIL_ADDRESS_1>, CC: <EMAIL_ADDRESS_2>." │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ De-Anonymisierer│ Rücksuche in der Zuordnungstabelle │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ Endgültige Antwort an den Nutzer │
│ "Ich schicke den Bericht an alice@co.de, CC: bob@co.de." │
│ │
└──────────────────────────────────────────────────────────────────┘
Der PII-Vault
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
class PIIVault:
"""
Anonymisiert PII im Text mit eindeutigen sitzungsbezogenen Tokens
und unterstützt Round-Trip-De-Anonymisierung.
"""
def __init__(self):
self.analyzer = AnalyzerEngine()
self.anonymizer = AnonymizerEngine()
# Ordnet Token → Originalwert zu, z.B. "<EMAIL_ADDRESS_1>" → "alice@co.de"
self._mapping: dict[str, str] = {}
# Zählt, wie viele Tokens je Typ bereits erstellt wurden
self._counters: dict[str, int] = {}
def _get_token(self, entity_type: str, value: str) -> str:
"""Gibt vorhandenes Token für einen Wert zurück oder erstellt ein neues."""
for token, original in self._mapping.items():
if original == value:
return token
count = self._counters.get(entity_type, 0) + 1
self._counters[entity_type] = count
token = f"<{entity_type}_{count}>"
self._mapping[token] = value
return token
def anonymize(self, text: str, language: str = "de") -> str:
"""Ersetzt PII im Text durch eindeutige nummerierte Tokens."""
results = self.analyzer.analyze(text=text, language=language)
results.sort(key=lambda r: r.start, reverse=True)
anonymized = text
for result in results:
original_value = text[result.start:result.end]
token = self._get_token(result.entity_type, original_value)
anonymized = anonymized[:result.start] + token + anonymized[result.end:]
return anonymized
def deanonymize(self, text: str) -> str:
"""Ersetzt Tokens im Text durch die ursprünglichen PII-Werte."""
result = text
for token, original in sorted(self._mapping.items(), key=lambda x: -len(x[0])):
result = result.replace(token, original)
return result
@property
def mapping(self) -> dict:
return dict(self._mapping)
Schnelltest vor dem API-Aufbau:
vault = PIIVault()
prompt = (
"Bitte leite das Dokument an alice@vertrieb.de und bob@recht.de weiter. "
"Stelle sicher, dass alice@vertrieb.de als Hauptempfänger eingetragen ist."
)
anonymized = vault.anonymize(prompt)
print("Anonymisierter Prompt:")
print(anonymized)
print()
print("Zuordnungstabelle:")
for token, value in vault.mapping.items():
print(f" {token} → {value}")
Ausgabe:
Anonymisierter Prompt:
Bitte leite das Dokument an <EMAIL_ADDRESS_1> und <EMAIL_ADDRESS_2> weiter.
Stelle sicher, dass <EMAIL_ADDRESS_1> als Hauptempfänger eingetragen ist.
Zuordnungstabelle:
<EMAIL_ADDRESS_1> → alice@vertrieb.de
<EMAIL_ADDRESS_2> → bob@recht.de
Das Modell erhält zwei unterschiedliche Platzhalter und kann korrekt mit zwei verschiedenen Personen umgehen. Jetzt simulieren wir eine Antwort:
# Simulierte LLM-Antwort mit Tokens
llm_response = (
"Ich habe den Entwurf fertiggestellt. Die E-Mail geht an <EMAIL_ADDRESS_1>, "
"<EMAIL_ADDRESS_2> wird in CC gesetzt."
)
restored = vault.deanonymize(llm_response)
print(restored)
Ausgabe:
Ich habe den Entwurf fertiggestellt. Die E-Mail geht an alice@vertrieb.de,
bob@recht.de wird in CC gesetzt.
Der Nutzer sieht echte E-Mail-Adressen. OpenAI hat sie zu keinem Zeitpunkt gesehen.
Den FastAPI-Proxy aufbauen
Jetzt bauen wir das Ganze in eine FastAPI-Anwendung ein, die als transparenter Proxy zwischen deinem Frontend und der OpenAI API arbeitet.
# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from openai import OpenAI
import uuid
from pii_vault import PIIVault # unsere Klasse von oben
app = FastAPI(title="PII-sicherer OpenAI-Proxy")
openai_client = OpenAI() # liest OPENAI_API_KEY aus der Umgebung
# In Produktion: Redis oder Datenbank mit Session-ID als Schlüssel verwenden.
# Für dieses Beispiel reicht ein In-Memory-Store.
session_store: dict[str, PIIVault] = {}
class ChatRequest(BaseModel):
session_id: str | None = None # optional; wird erzeugt falls nicht angegeben
message: str
model: str = "gpt-4o"
system_prompt: str = "Du bist ein hilfreicher Assistent."
class ChatResponse(BaseModel):
session_id: str
message: str # de-anonymisierte Antwort
anonymized_prompt: str # nur zum Debuggen (in Produktion entfernen)
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
# 1. Vault für diese Session holen oder neu erstellen
session_id = request.session_id or str(uuid.uuid4())
if session_id not in session_store:
session_store[session_id] = PIIVault()
vault = session_store[session_id]
# 2. Nutzernachricht anonymisieren
anonymized_message = vault.anonymize(request.message)
# 3. OpenAI mit dem bereinigten Prompt aufrufen
try:
response = openai_client.chat.completions.create(
model=request.model,
messages=[
{"role": "system", "content": request.system_prompt},
{"role": "user", "content": anonymized_message},
],
)
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
raw_reply = response.choices[0].message.content
# 4. Modellantwort de-anonymisieren
restored_reply = vault.deanonymize(raw_reply)
return ChatResponse(
session_id=session_id,
message=restored_reply,
anonymized_prompt=anonymized_message, # in Produktion entfernen
)
@app.delete("/session/{session_id}")
async def clear_session(session_id: str):
"""Entfernt die PII-Zuordnung einer Session aus dem Speicher."""
session_store.pop(session_id, None)
return {"status": "gelöscht"}
Server starten:
uvicorn main:app --reload
Den Proxy testen
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Fasse den Vertrag zwischen Alice Braun (alice@firma.de) und Bob Müller (bob@lieferant.de) zusammen. Alices Geburtsdatum ist der 15.06.1990.",
"model": "gpt-4o"
}'
Antwort:
{
"session_id": "3f7a2c1d-...",
"message": "Der Vertrag besteht zwischen Alice Braun und Bob Müller. Alices Geburtsdatum ist der 15. Juni 1990. Die wichtigsten Punkte sind ...",
"anonymized_prompt": "Fasse den Vertrag zwischen <PERSON_1> (<EMAIL_ADDRESS_1>) und <PERSON_2> (<EMAIL_ADDRESS_2>) zusammen. <PERSON_1>s Geburtsdatum ist der <DATE_TIME_1>."
}
OpenAI hat <PERSON_1>, <EMAIL_ADDRESS_1>, <DATE_TIME_1> verarbeitet — der Nutzer erhält echte Namen und Daten in der Antwort.
Gesamtarchitektur
┌──────────────────────────────────────────────────────────────────────┐
│ PRODUKTIONS-ARCHITEKTUR DES PII-PROXYS │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ HTTPS ┌────────────────────────────────────┐ │
│ │ Deine App / │ ─────────▶ │ FastAPI PII-Proxy │ │
│ │ Frontend │ │ │ │
│ └──────────────┘ │ POST /chat │ │
│ ▲ │ ┌──────────────────────────────┐ │ │
│ │ │ │ 1. Session-Vault laden │ │ │
│ │ de-anonymisierte │ │ 2. Presidio: PII erkennen │ │ │
│ │ Antwort │ │ 3. Durch Tokens ersetzen │ │ │
│ │ │ │ 4. OpenAI aufrufen │ │ │
│ │ │ │ 5. Token-Mapping umkehren │ │ │
│ │ │ │ 6. Bereinigte Antwort senden │ │ │
│ └────────────────────│ └──────────────────────────────┘ │ │
│ └────────────────┬───────────────────┘ │
│ │ nur bereinigter Prompt│
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ OpenAI API (gpt-4o usw.) │ │
│ │ Sieht niemals echte PII │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Session-Store (Redis / Datenbank) │ │
│ │ session_id → { "<EMAIL_1>": "alice@co.de", ... } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
Mehrschrittige Konversationen
Der sitzungsbasierte Vault ist besonders nützlich für mehrstufige Chats. Die Zuordnungstabelle bleibt über alle Nachrichten einer Session hinweg konsistent — das Modell kann zuvor erwähnte Entitäten korrekt referenzieren.
class ChatRequest(BaseModel):
session_id: str | None = None
messages: list[dict] # vollständige Konversationshistorie
model: str = "gpt-4o"
@app.post("/chat/multi-turn")
async def chat_multi_turn(request: ChatRequest):
session_id = request.session_id or str(uuid.uuid4())
if session_id not in session_store:
session_store[session_id] = PIIVault()
vault = session_store[session_id]
# Nur die neueste Nutzernachricht anonymisieren (vorherige Turns bereits bereinigt)
sanitised_messages = []
for i, msg in enumerate(request.messages):
if msg["role"] == "user" and i == len(request.messages) - 1:
sanitised_messages.append({
"role": "user",
"content": vault.anonymize(msg["content"])
})
else:
sanitised_messages.append(msg)
response = openai_client.chat.completions.create(
model=request.model,
messages=sanitised_messages,
)
raw_reply = response.choices[0].message.content
return {
"session_id": session_id,
"message": vault.deanonymize(raw_reply),
}
Produktionshinweise
1. Session-Speicherung
Das In-Memory-Dict überlebt keine Neustarts und skaliert nicht horizontal. In der Produktion empfiehlt sich Redis mit TTL:
import redis, json
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
SESSION_TTL = 3600 # 1 Stunde
def save_vault(session_id: str, vault: PIIVault):
r.setex(f"vault:{session_id}", SESSION_TTL, json.dumps(vault.mapping))
def load_vault(session_id: str) -> PIIVault | None:
data = r.get(f"vault:{session_id}")
if not data:
return None
vault = PIIVault()
vault._mapping = json.loads(data)
return vault
2. Konfidenzschwellen
Presidio vergibt jedem Treffer einen Konfidenzwert. Niedrige Werte lassen sich herausfiltern, um Falschpositive zu reduzieren:
results = analyzer.analyze(text=text, language="de")
high_confidence = [r for r in results if r.score >= 0.75]
3. Eigene Recognizer
Domänenspezifische Muster lassen sich einfach ergänzen — zum Beispiel interne Mitarbeiter-IDs:
from presidio_analyzer import PatternRecognizer, Pattern
mitarbeiter_recognizer = PatternRecognizer(
supported_entity="MITARBEITER_ID",
patterns=[Pattern(name="mitarbeiter_id", regex=r"MA-\d{6}", score=1.0)],
)
analyzer.registry.add_recognizer(mitarbeiter_recognizer)
4. Logging und Auditing
Die Zuordnungstabelle darf niemals in Anwendungslogs erscheinen — das würde den gesamten Schutz aufheben. Protokolliere nur Metadaten:
import logging
logger = logging.getLogger(__name__)
# Richtig — nur Metadaten
logger.info(f"Session {session_id}: {len(vault.mapping)} PII-Entitäten anonymisiert")
# Falsch — PII im Log
# logger.info(f"Mapping: {vault.mapping}")
5. Was Presidio nicht erkennt
Presidio ist leistungsstark, aber nicht unfehlbar. Folgendes kann übersehen werden:
- Kontextuell eingebettete PII wie „der Kollege aus dem Dienstagsmeeting"
- Verschleierte Werte wie
alice [at] firma [punkt] de - Seltene Eigennamen in wenig unterstützten Sprachen
- Domänenspezifische Identifikatoren ohne eigenen Recognizer
Für hochsensible Pipelines empfiehlt sich ein zusätzlicher LLM-basierter Check oder die Kombination mit spaCy-NER-Ergebnissen.
Vergleich der Ansätze
| Ansatz | OpenAI sieht | Modell unterscheidet Entitäten? | Nutzer sieht echte Werte? |
|---|---|---|---|
| Keine Anonymisierung | alice@co.de |
Ja | Ja — aber PII wird übertragen |
| Einfacher Platzhalter | <EMAIL_ADDRESS> |
Nein — alle gleich | Nein — Platzhalter in Antwort |
| Eindeutige Tokens | <EMAIL_ADDRESS_1> |
Ja | Ja — de-anonymisiert |
| Hash | a3f4bc... |
Ja (opak) | Nein — Hash unlesbar für Modell |
Eindeutige Tokens mit Round-Trip-Konvertierung bieten den besten Kompromiss: Datenschutz ohne Einbußen bei Modellgenauigkeit oder Nutzererlebnis.
Zusammenfassung
Das haben wir aufgebaut:
- Presidio Analyzer erkennt PII im Nutzer-Prompt mit hoher Genauigkeit
- PIIVault ersetzt jeden distinct PII-Wert durch ein eindeutiges nummeriertes Token und speichert die Umkehrzuordnung
- FastAPI-Proxy fängt jeden Request ab, anonymisiert den Prompt, sendet ihn an OpenAI und de-anonymisiert die Antwort
- Sitzungsbezogene Zuordnungstabelle sorgt für konsistente Tokens innerhalb einer Konversation
- Redis-Speicherung macht die Lösung zustandslos, horizontal skalierbar und TTL-gesteuert
Deine Nutzer erhalten präzise, kontextbezogene LLM-Antworten. OpenAI verarbeitet zu keinem Zeitpunkt echte personenbezogene Daten. Und dein Compliance-Team kann ruhig schlafen.
Ressourcen
- Microsoft Presidio auf GitHub
- Presidio Dokumentation
- FastAPI Dokumentation
- OpenAI Datenschutz-FAQ
- spaCy NER-Modelle
Du möchtest KI-Systeme in deinem Unternehmen datenschutzkonform einsetzen und sicherstellen, dass sensible Daten deine Infrastruktur nie verlassen? Dann schreib mir einfach auf LinkedIn oder buche direkt einen kostenlosen Call.