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:

  1. Eindeutigkeit — jeder distinct PII-Wert braucht ein eindeutiges Token, damit das Modell erkennt, dass EMAIL_1 und EMAIL_2 verschiedene Personen sind
  2. 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:

  1. Presidio Analyzer erkennt PII im Nutzer-Prompt mit hoher Genauigkeit
  2. PIIVault ersetzt jeden distinct PII-Wert durch ein eindeutiges nummeriertes Token und speichert die Umkehrzuordnung
  3. FastAPI-Proxy fängt jeden Request ab, anonymisiert den Prompt, sendet ihn an OpenAI und de-anonymisiert die Antwort
  4. Sitzungsbezogene Zuordnungstabelle sorgt für konsistente Tokens innerhalb einer Konversation
  5. 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


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.