Nummerierter Artikel-Raster1234567891025121314151617181920212223242526272829303132
Alle Beiträge
StreamingPrivacyLLM-ArchitekturSSEDSGVOSanitisierung

LLM-Antworten streamen mit PII-Redaktion — wenn man die beiden Probleme trennt, wird es lösbar

Per-Chunk-PII-Erkennung auf gestreamten LLM-Ausgaben ist die ungelöste Version des Problems. Per-Chunk-Platzhalter-Relink ist die lösbare. So hat Lucairn die beiden getrennt.

Lucairn··10 Min. Lesezeit
Auf dieser Seite
  1. Die Behauptung, die die Branche wiederholt
  2. Die zwei Probleme, die zusammengeworfen werden
  3. Warum Problem A in unserer Architektur die falsche Schicht ist
  4. Wie Problem B aussieht, wenn man es ernst nimmt
  5. Was wir bewusst NICHT gebaut haben
  6. Die Wettbewerbserzählung (ohne Über-Behauptung)
  7. Wo Lucairn passt

Die Behauptung, die die Branche wiederholt

Wer in den letzten beiden Jahren Enterprise-AI-Privacy-Posts gelesen hat, ist einer Behauptung in fast jedem davon begegnet: PII-Schutz auf gestreamten KI-Antworten sei nicht möglich. Die Begründung verläuft ungefähr so: Streaming sendet Tokens an den Client, während das Modell sie generiert. PII-Erkennung braucht ein Satz-Level-Fenster — Namen erstrecken sich über Wörter, IBANs über eine feste Zeichenanzahl, Adressen über Teilsätze. Lässt man einen Erkenner pro Chunk laufen, übersieht man alles, was eine Chunk-Grenze überschreitet. Wartet man auf die vollständige Antwort, ist der Latenzvorteil weg, der Streaming überhaupt erst attraktiv machte. Schlussfolgerung: entweder Streaming oder PII-Schutz, nicht beides.

Das Argument ist in dieser Form korrekt. Es ist gleichzeitig ein Trugschluss, sobald man die zwei verschiedenen Probleme trennt, die hier unter „PII-Schutz auf gestreamten Antworten" zusammengeworfen werden. Lucairn hat am 07.05.2026 Streaming auf allen vier LLM-Endpunkten ausgerollt (/v1/messages, /v1/chat/completions, /api/v1/mcp/messages, /api/v1/proxy/messages), gesteuert über STREAMING_ENABLED am Gateway und auf dem gehosteten gateway.lucairn.eu standardmäßig AN. Wir haben das Unlösbare nicht gelöst. Wir haben zwei Probleme getrennt und das kleinere ausgeliefert.

Dieser Beitrag erklärt diese Trennung, die architektonische Begründung dafür, warum sie in unserer spezifischen Form funktioniert, und was wir bewusst nicht gebaut haben.

Die zwei Probleme, die zusammengeworfen werden

Hier sind die zwei Probleme, die in „PII-Schutz auf gestreamten KI-Antworten" stecken.

Problem A — Output-seitige Roh-PII-Erkennung. Das Modell emittiert Text Token für Token. Ein Teil dieses Textes enthält möglicherweise PII. Diese soll erkannt werden, bevor der Chunk das Gateway verlässt, dann redigiert, und nur die sichere Version wird an den Client weitergeleitet. Das ist das bei niedriger Latenz unlösbare Problem. Satzfenster-NLP-Recall auf Teiltext ist schlecht. Hash-basiertes Pattern-Matching braucht das vollständige Muster. Edge Cases vermehren sich: Was, wenn ein Name über drei Chunks verteilt ist? Was, wenn eine IBAN durch die Tokenisierung über die Chunk-Grenze gespalten wird? Was, wenn das Modell eine echte E-Mail-Adresse zeichenweise über vier gestreamte Deltas emittiert? Industrieforschung hat keine niedrig-latente, hoch-recall-genaue Satzfenster-NLP-Lösung dafür hervorgebracht, und das operative Risiko, etwas mit weniger als hohem Recall auszuliefern, ist groß: ein einziges übersehenes Muster ist ein realer PII-Leak in einem System, von dem Sie öffentlich behauptet haben, es würde das verhindern.

Problem B — Platzhalter-Relink im Stream. Das Modell emittiert Text Token für Token. Ein Teil dieses Textes enthält möglicherweise Platzhalter, die das Gateway ausgegeben hatte, bevor der Prompt das Modell erreichte[PERSON_1], [EMAIL_2], [IBAN_3]. Diese Platzhalter werden für die Tier-Stufe des Kunden, die Output-Relink aktiviert hat, auf die Originalwerte zurückgemappt. Das ist lösbar. Die Platzhalter folgen einem bekannten Regex (\[[A-Z_0-9]+_\d+\]), sind in der Länge begrenzt (≤24 Zeichen) und werden vom Modell verbatim emittiert, weil sie verbatim im Prompt standen. Die einzige Komplikation ist, dass die Tokenisierung einen Platzhalter über Chunk-Grenzen hinweg aufspalten kann — [PERSON_ kommt im einen SSE-Delta an, 1] im nächsten.

Die ganze Branche wirft diese beiden Probleme zusammen, weil die meisten Architekturen keine Platzhalter haben. Vault-basierte Anbieter wie Skyflow tokenisieren strukturierte Daten at-Rest, sind aber nicht im Antwort-Stream des LLMs — es gibt kein Relink-im-Stream-Problem zu lösen, weil die Daten nie durch ihre Schicht gelaufen sind. LLM-Gateways wie Cloudflare AI Gateway oder Helicone proxien Antworten, machen aber keine PII-Pseudonymisierung upstream — es gibt keinen Platzhalter zurückzumappen, weil nie einer ausgegeben wurde. Die Kombination aus (a) Gateway gibt Platzhalter aus, bevor das Modell den Prompt sieht, und (b) Gateway ist im Antwort-Stream, ist selten. Wenn man beides hat, wird Problem B zu einer kleinen, mechanischen Engineering-Aufgabe, die mit NLP nichts zu tun hat.

Warum Problem A in unserer Architektur die falsche Schicht ist

Die Split-Knowledge-Architektur, die Lucairn ausliefert, ist ausführlich in split-knowledge-architecture dokumentiert. Die hier relevante Eigenschaft ist, dass der Sanitiser läuft, bevor der Prompt das Gateway verlässt. Bis ein Byte das Upstream-Modell erreicht, sind Identifier wie Namen, E-Mails, IBANs, Geburtsdaten und Kundenreferenzen bereits durch nummerierte Platzhalter ersetzt worden. Das Modell sieht die Rohwerte nie.

Genau diese architektonische Tatsache macht Problem A in unserem Fall nicht nur schwer, sondern überflüssig. Die Standard-Begründung für Output-seitige Roh-PII-Erkennung lautet: „Das Modell könnte PII emittieren, die es im Prompt oder in den Trainingsdaten gesehen hat." In Lucairns Pipeline hat das Modell im Prompt keine PII gesehen — der Sanitiser hat sie entfernt. Wenn das Modell etwas emittiert, das wie eine E-Mail-Adresse aussieht, ist diese E-Mail-Adresse entweder (a) eine Halluzination ohne Bezug zu Ihren Daten oder (b) ein von uns ausgegebener Platzhalter, möglicherweise über Chunk-Grenzen hinweg gespalten. Halluzinierte PII ist eine unbegrenzte Kategorie, die kein Erkenner zuverlässig fangen würde; die richtige Verteidigung gegen halluzinierte PII ist der Content-Filter des Upstream-Modell-Anbieters, nicht ein Per-Chunk-Regex auf unserer Seite.

Die architektonische Begründung steht im Gateway-Code unter services/gateway/internal/api/proxy.go:616-628. Der Kommentarblock dort hält ausdrücklich fest, dass Output-seitiges Scannen die Hausaufgabenkontrolle des Sanitisers wäre — eine Überdeckung von Sanitiser-Bugs statt der Lösung eines realen Problems. Falls der Sanitiser-Recall auf Kundendaten tatsächlich schlecht wäre, ist die richtige Antwort, in den Sanitiser zu investieren (mehr Erkenner, kundenseitig gelieferte Known-Entity-Listen, das Enterprise-Tier-Custom-trained-Level-3-PII-Shield), nicht ein Output-seitiger Regex-Pass, der Upstream-Lücken kompensiert.

Ehrlichkeitsklausel: Dieses Argument trägt nur, solange der Sanitiser-Recall tatsächlich gut ist. Wir verfolgen das mit einem 1.000-Payload-Eval-Datensatz auf Englisch und Deutsch sowie mit Property-Based Tests auf den einzelnen Sanitiser-Schichten (L1 Known-Entity-Matching, L2 Presidio NER, L3 LLM PII Shield). Der Eval-Datensatz-Score auf dem aktuellen Build steht im Changelog. Sollten wir je eine Sanitiser-Regression ausliefern, die den Recall unter die Schwelle drückt, wird Output-seitiges Scannen dadurch trotzdem nicht zur richtigen Schicht — den Sanitiser zu reparieren bleibt es. Diese Reihenfolge ist in der Architektur fest verankert.

Wie Problem B aussieht, wenn man es ernst nimmt

Problem B ist das, was wir tatsächlich im Stream lösen müssen. Hier ist die Form konkret.

Die Platzhalter, die wir ausgeben, passen auf den Regex \[[A-Z_0-9]+_\d+\]. Per Konstruktion sind sie begrenzt — der längste heute ausgegebene Platzhalter liegt bei rund 22 Zeichen (Stil [GERMAN_MEDICAL_TERM_999]), und wir capen den Regex auf 24 Bytes. Innerhalb eines einzigen SSE-Chunks lässt sich match-and-replace mit einem zustandslosen strings.ReplaceAll durchführen, und der Chunk wird rausgeschickt. Über SSE-Chunks hinweg sieht die Lage anders aus.

Die Tokenisierung arbeitet auf Bytes, nicht auf der Absicht des Modells, einen Platzhalter atomar zu emittieren. Ein typisches Anthropic-SSE-Delta enthält etwa die Bytes "Erreichbar unter [PERSON_", das nächste Delta enthält "1] für dieses Ticket.". Zwei zustandslose strings.ReplaceAll-Aufrufe — einer pro Chunk — leaken beide Fragmente unrelinkt: Der Client empfängt Erreichbar unter [PERSON_ gefolgt von 1] für dieses Ticket., und der Relink feuert nie, weil keiner der beiden Chunks einen vollständigen Platzhalter-Match enthält.

Die Lösung ist ein zustandsbehafteter Relinker mit einem begrenzten Pufferbereich. Der Algorithmus hat zwei Operationen: Feed(chunk) und Close(). Pseudocode:

struct Relinker {
  pending: bytes     // Schwanzpuffer, ≤24 Byte
  mapping: map[string]string    // [PERSON_1] → "Marc"
}

fn Feed(chunk):
  combined = pending + chunk
  // Sicheren Trennpunkt finden: die letzte Position,
  // an der kein verbleibender Suffix einen Platzhalter
  // beginnen könnte.
  safe = scan_back_to_safe_boundary(combined)
  emit_part = relink(combined[0..safe], mapping)
  pending = combined[safe..]      // bis zu 24 Byte
  return emit_part

fn Close():
  // Verbleibende gepufferte Bytes nach finalem Relink-Pass emittieren.
  return relink(pending, mapping)

Der „sichere Trennpunkt" ist die Schlüsselinvariante. Wir laufen vom Ende des kombinierten Puffers rückwärts und stoppen am letzten Byte, das nicht der Beginn eines Platzhalter-Matches sein könnte, der über den aktuellen Chunk hinausgeht. In der Praxis: Endet der kombinierte Puffer auf ...etwas [PERS, halten wir die letzten sechs Bytes in pending zurück. Endet er auf ...etwas vollständig., liegt die sichere Grenze am Ende und wir flushen alles. Die Invariante ist beweisbar: jeder Platzhalter, der vor der sicheren Grenze beginnt, ist vollständig in den bereits emittierten Bytes enthalten, und jeder Platzhalter, der ab der sicheren Grenze beginnt, ist vollständig in pending enthalten (weil Platzhalter ≤24 Byte sind und pending die letzten ≤24 Byte hält).

Latenzkosten: ~30–80 ms TTFT-Verzögerung durch den begrenzten Schwanz. Für Chat-Anwendungen, RAG-Agenten und Dokumenten-Zusammenfassungs-Tools ist das unsichtbar. Für Voice-Anwendungen, die Sub-100-ms-TTFT anzielen, liegt es über dem Budget — wir sagen das gleich noch einmal explizit.

Die Korrektheits-Eigenschaft lautet: Für jede Byte-Offset-Splitposition in einer Nicht-Streaming-Antwort muss die gestreamte Rekonstruktion identische Ausgabe erzeugen. Wir testen das mit einem Property-Based Test, der eine nicht-gestreamte Lucairn-Antwort nimmt, sie an jedem möglichen Byte-Offset spaltet, die Splits in den Streaming-Relinker speist und Byte-für-Byte-Gleichheit mit der nicht-gestreamten Ausgabe einfordert. Der Test fängt Multi-Chunk-Split-Bugs, die Punkt-Fixture-Tests verfehlen, weil das Fixture selten exakt die Tokenizer-Grenze trifft, die das Upstream-Modell in einem konkreten Lauf gewählt hat. Die Implementierung liegt unter services/gateway/internal/api/streaming.go; der Property-Test direkt daneben.

Was wir bewusst NICHT gebaut haben

Drei verlockende Features in unmittelbarer Nähe zum Streaming haben wir nicht gebaut, und die Gründe sind wichtig.

Per-Chunk-Roh-PII-NLP-Scan auf der Ausgabe. Das ist Problem A, oben behandelt. Wir haben es nicht gebaut, weil es in unserer Architektur die falsche Schicht ist und weil das Ausliefern einer Low-Recall-Variante uns etwas behaupten ließe, das wir tatsächlich nicht liefern können. Sollte eine zukünftige regulatorische Anforderung oder ein Kundenwunsch Output-Scanning unausweichlich machen (zum Beispiel, falls Lucairn jemals gebeten wird, Antworten eines Modells zu proxien, das nicht durch unseren Sanitiser auf der Eingangsseite gelaufen ist), prüfen wir das neu — die Antwort wird höchstwahrscheinlich ein separater Sanitiser-Pass an einer anderen Grenze sein, kein in den Streamer eingefügter Per-Chunk-Regex.

Voice / Realtime mit Sub-100 ms Streaming. Die ~30–80 ms TTFT-Verzögerung durch den 24-Byte-Schwanz drückt uns über die meisten Voice-Budgets. Wir könnten den Puffer schrumpfen, aber nicht eliminieren: jedes Platzhalter-Relink-im-Stream-Design braucht einen Puffer von mindestens der Länge des längsten Platzhalters, sonst kommt der Cross-Chunk-Split-Bug zurück. Für Voice-Anwendungen mit Sub-100-ms-TTFT-Anforderung ist die richtige Antwort ein anderer Gateway-Pfad, der nicht-relinkte Platzhalter ausliefert, wobei der Voice-Client des Kunden das Relink auf seiner Seite macht. Den liefern wir aus, sobald ein Voice-Kunde ihn anfragt; heute braucht ihn kein Produktionsverkehr.

Tool-Call-Argument-Sanitisierung. Tool-Calls und Function-Calling werden separat auf der /integration Capability-Matrix verfolgt, weil der Sanitiser heute keine tools- oder tool_choice-Felder durch die Gateway-Handler weiterreicht. Platzhalter-Relink-im-Stream auf Tool-Call-Argument-JSON-Bodies funktioniert über denselben Regex (die Platzhalter stehen in JSON-String-Werten), sodass die Streaming-Seite kostenlos kommt, sobald wir die Tool-Call-Sanitisierung ausliefern. Der Blocker ist der eingangsseitige Sanitiser-Pass, nicht der Streamer. Tool-Call-Definitionen versus -Argumente zu sanitisieren hat Korrektheits-Fallen, die leicht falsch ausgeliefert werden; ein Half-Feature wollen wir nicht live haben.

Die Wettbewerbserzählung (ohne Über-Behauptung)

Was haben wir tatsächlich ausgeliefert? Ein kleines, mechanisches, korrektheits-fokussiertes Stück Streaming-Infrastruktur, das die 90 % der LLM-Workloads — Text rein, Text raus, mit Redaktions-Nachweis — abdeckt, die unsere Kunden tatsächlich betreiben. Wir haben keine Niedrig-Latenz-Satzfenster-NLP erfunden. Wir haben kein Forschungspapier ausgeliefert. Wir haben zwei Probleme getrennt, die die Branche zusammenwirft, und das ausgeliefert, das wir auf dem Recall-Niveau ausliefern können, das produktiver Verkehr verdient.

Die architektonische Grundlage, die unsere Variante machbar macht, ist der nummerierte Platzhalter. Der Platzhalter ist das, was das LLM verbatim emittiert, weil es ihn verbatim erhalten hat, und weil er ≤24 Byte lang ist, lässt er sich über SSE-Chunk-Grenzen mit einem kleinen begrenzten Schwanzpuffer rekonstruieren. Vault-basierte Privacy-Anbieter haben keine Platzhalter im Antwort-Stream, weil sie nicht im Antwortpfad sind. LLM-Gateways ohne eingangsseitige Sanitisierung haben keine Platzhalter, weil nichts redigiert wurde, was zurückzumappen wäre. Lucairns spezifische Form — Gateway sanitisiert die Eingabe und sieht dann den Antwort-Stream — ist das, was Problem B klein genug macht, um es sauber zu engineeren. Genau das ist auch der Grund, warum unsere Streaming-Erzählung sich nicht auf, sagen wir, ein reines Prompt-DLP-Produkt verallgemeinert: Die Architektur muss beide Enden des Aufrufs erreichen.

Eine echte Korrekturlieferung gehört auch dazu. Vor dem Cross-Chunk-Relinker leakte ein zustandsloses strings.ReplaceAll pro Chunk unrelinkte Platzhalter-Fragmente, sobald die Tokenisierung einen Platzhalter über eine SSE-Delta-Grenze gespalten hatte. Die Fragmente waren gültiges HTML / sichere Zeichen — Clients gingen also nicht kaputt — aber sie tauchten als rohe [PERSON_1]-förmige Strings in der nutzersichtbaren Antwort auf der Tier-Stufe mit aktiviertem Output-Relink auf. Das war ein realer Produktions-Bug, keine reine Marketing-Story.

Für Teams, die 2026 eine LLM-Privacy-Schicht auswählen, schlagen wir einen strukturellen statt feature-basierten Test vor. Fragen Sie den Anbieter, ob er (a) die Eingabe sanitisiert, bevor das Modell sie sieht, (b) im Antwort-Stream auftaucht und (c) einen Property-Based Test ausliefert, der zeigt, dass die gestreamte Rekonstruktion über alle Byte-Offset-Splits gleich der nicht-gestreamten Ausgabe ist. Ist die Antwort auf (a) nein, ist die Privacy-Garantie eine Richtlinie und keine Architektur. Ist (b) nein, ist die Streaming-Antwort für die Privacy-Schicht vollständig undurchsichtig. Ist (c) nein, sollten Sie nicht annehmen, dass der Cross-Chunk-Split-Fall korrekt behandelt wird, auch wenn die Demo gut aussieht — diese Bugs werden latent ausgeliefert, weil sie auf Tokenizer-Mustern beruhen, die das QA-Fixture nicht abdeckt.

Wo Lucairn passt

Lucairns Gateway sitzt zwischen Ihrer Anwendung und dem Upstream-LLM-Anbieter. Jede Anfrage durchläuft dieselbe Pipeline, ob gestreamt oder nicht: Eingabe wird sanitisiert, Identifier werden durch nummerierte Platzhalter ersetzt, der Prompt geht an das Upstream-Modell, die Antwort wird erfasst. Für Streaming-Anfragen läuft am Gateway zusätzlich der Bounded-Buffer-Relinker über den SSE-Antwort-Stream, sodass die Tier-passende Ausgabe (Platzhalter im Developer-Tier, optionales Relink in Pro und Enterprise) den Client ohne den Cross-Chunk-Split-Bug erreicht.

Dasselbe Lucairn-Zertifikat wird für gestreamte und nicht-gestreamte Anfragen erzeugt — der Per-Call-signierte Nachweis (ai-act-article-12-logging-in-practice) hält fest, welche Sanitiser-Schichten gefeuert haben, welche Platzhalter ausgegeben wurden und welche Upstream-Modell-Identität verwendet wurde. Streaming schwächt die Beweiskette nicht; das Zertifikat ist auf denselben kanonischen Request-Hash verankert.

Wer eine Streaming-Chat-Anwendung baut oder einen gestreamten RAG-Agenten und den eingangsseitigen PII-Schutz haben möchte, ohne den Client für Per-Chunk-Relink umzuschreiben: Der Proxy ist Drop-in. base_url tauschen, auf einem selbst gehosteten Lucairn STREAMING_ENABLED=true setzen (auf dem gehosteten Gateway bereits AN), und das vorhandene SDK-Streaming-Muster läuft. Die Capability-Matrix unter /integration listet jeden Endpunkt und die ehrlich verbleibenden Lücken — Tool-Calls und multimodale Inhalte gehören noch dazu; Streaming nicht mehr.

Verwandte Artikel