Özgür Işık Damar
11 Min Lesezeit

Die Woche, in der ein LLM eine politische Position halluzinierte — und ein Journalist sie fast zitiert hätte

Eine automatisch erzeugte TBMM-Zusammenfassung sagte, ein Abgeordneter habe über ein Gesetz abgestimmt, das gar nicht abgestimmt wurde. Der Journalist hielt die Geschichte 24 Stunden zurück. Vier Tage, um eine Verifikationsschicht zu bauen, die Nein sagen kann.

nlpllmcivic-techverification

An einem Mittwoch Ende Oktober schickte mir ein Journalist, der das NLP-Parliament-Projekt verfolgt, einen Screenshot per E-Mail. Die Zusammenfassung, die ich für eine einzelne TBMM-Sitzung automatisch erzeugt hatte, behauptete, ein Abgeordneter einer kleinen Oppositionspartei habe für einen umstrittenen Gesetzentwurf gestimmt. Die Zusammenfassung war ein sauberer, selbstbewusster Satz. Die Abstimmung hatte nicht stattgefunden. Über das Gesetz war an diesem Tag nicht abgestimmt worden. Der Abgeordnete hatte gar nicht darüber gesprochen. Der Journalist war im Begriff, eine Geschichte mit Verweis auf mein Modell zu veröffentlichen. Ich bat ihn, 24 Stunden zu warten.

Er wartete. Die Geschichte erschien nicht mit dem schlechten Satz. Die vier Tage danach sind das, worum es in diesem Beitrag geht.

Ich werde die Akteure hier anonymisieren. Der Journalist ist "der Journalist." Der Abgeordnete ist "ein Oppositionsabgeordneter einer kleineren Partei." Das Gesetz ist "ein umstrittener Beschaffungsgesetzentwurf." Diese Details spielen für die technische Geschichte keine Rolle; sie spielen sehr wohl eine Rolle, wenn man zufällig der falsche Abgeordnete oder die falsche Partei ist.

Was der Summariser eigentlich war

Der TBMM-Sitzungs-Summariser nahm ein Sitzungsprotokoll — nach der Sprecherzuordnungs-Pipeline, die ich im vorherigen Beitrag beschrieben habe — und erzeugte pro Sprecher zwei Dinge:

  1. Eine kurze Liste der Themen, die dieser Sprecher angesprochen hat.
  2. Eine kurze Liste der Positionen, die dieser Sprecher zu jedem Thema bezogen hat.

Intern war es ein extraktiver Durchlauf (Satz-Ranking), gefolgt von einem generativen Durchlauf (Claude schreibt die Highlights in sauberen Fließtext um). Der generative Durchlauf war der Teil, der mich gebissen hat.

Die Pipeline nutzte ein Sliding-Context-Window, weil TBMM-Sitzungen lang sind. Etwa 1.500 Token Überlappung zwischen Chunks. Die Überlappung war da, damit ein Sprecher, dessen Rede über eine Chunk-Grenze geht, immer noch vollen Kontext hat. Am Whiteboard klang das vernünftig. Es war die Wurzelursache dessen, was geschah.

Wie die Halluzination passierte

Es gab zwei aufeinanderfolgende Sitzungen an aufeinanderfolgenden Sitzungstagen. Nennen wir sie A und B.

  • Sitzung A: die inhaltliche Debatte über den Beschaffungsgesetzentwurf. Der Oppositionsabgeordnete hielt eine lange, skeptische Rede zu den Auftragsklauseln des Gesetzes. Er sagte nie, wie er stimmen würde, weil an diesem Tag keine Abstimmung stattfand.
  • Sitzung B: eine geplante Abstimmung über ein anderes Gesetz plus eine Verfahrensabstimmung, die Beschaffungssprache berührte.

Beide Sitzungen landeten im selben gechunkten Kontext für den Summariser-Lauf, weil der Produktions-Batch Sitzungen nach Woche gruppierte.

Das 1.500-Token-Überlappungsfenster enthielt zwei spezifische Fragmente:

  1. Aus Sitzung A: der Name des Abgeordneten, unmittelbar gefolgt vom Thema kamu ihale yasası (öffentliches Beschaffungsgesetz).
  2. Aus Sitzung B: ein unzusammenhängender Sprecher, der ein Abstimmungsergebnis zu Beschaffungssprache erwähnt.

Das LLM vervollständigte das Muster. Es produzierte:

Der Abgeordnete der [Oppositionspartei] stimmte für das Beschaffungsgesetz und berief sich auf Reformen der Auftragsklauseln.

Dieser Satz hat ein Subjekt, ein Verb, ein Objekt, eine Begründung und einen Ton. Er hat zugleich null Verankerung in beiden Sitzungen. Der Abgeordnete sagte nie "Ich werde abstimmen." An diesem Tag fand zu diesem Gesetz keine Abstimmung statt. Die "Reformen" waren die späteren Worte des Journalisten, nicht die des Abgeordneten.

Es sah richtig aus. Das war das Problem.

Warum diese Fehlerkategorie gefährlich ist

Drei Dinge machten sie gefährlich.

Erstens: selbstbewusster Ton. Das LLM sagte nicht "Ich bin nicht sicher" oder "möglicherweise." Es sagte "stimmte für." Generative Modelle sind auf Flüssigkeit getuned, nicht auf epistemische Demut. Sie füllen das fehlende Verb mit dem plausibelsten in der Trainingsverteilung, und "stimmte" ist plausibel für einen Parlamentstext.

Zweitens: strukturierte Zitation. Der Summariser hängte jeder generierten Aussage eine source_session_id an. Das machte die Lüge auf die falsche Sitzung zurückführbar, was wiederum bedeutet, dass ein menschlicher Stichprobentest auf Sitzung A landen, die Beschaffungsrede sehen und nicken würde. Ja, dieser Abgeordnete hat Beschaffung angesprochen. Die Abstimmungsbehauptung würde den Test überleben.

Drittens: Stichproben-Bias im QA. Wir hatten Zusammenfassungen durch Stichproben zufälliger Sätze überprüft, indem wir den umliegenden Transkripttext lasen. Die Prüfung war immer "ist das Thema richtig?" Die Prüfung war nie "ist das Verb passiert?"

Der Screenshot des Journalisten war das erste Mal, dass jemand versuchte, ein Verb zu verifizieren, und das nur, weil er es drucken wollte.

Der naive Prompt, der das getan hat

Hier ist der Prompt, der die Halluzination erzeugt hat. Ich behalte ihn, weil er vernünftig aussieht.

def summarise_speaker(name, party, chunks):
    prompt = (
        "Gegeben die folgenden Transkriptfragmente von einem Abgeordneten "
        "aus einer Parlamentssitzung, erzeuge 2-4 Sätze, die beschreiben, "
        "was dieser Abgeordnete gesagt hat, inklusive bezogener Positionen.\n\n"
        f"MdB: {name} ({party})\n\n"
        f"FRAGMENTE:\n{join_chunks(chunks)}\n"
    )
    return llm.generate(prompt, temperature=0.2, max_tokens=200)

Der Bug ist nicht im Prompt. Der Bug ist in der Annahme, das LLM würde sich selbst an die Fragmente halten. Das wird es nicht. Es wurde auf Millionen Nachrichtenartikeln trainiert, in denen ein Abgeordneter über ein Gesetz spricht und dann darüber abstimmt. Mustervervollständigung schließt die Lücke, sofern nichts anderes sie stoppt.

Was ich gebaut habe: Verifikation auf Aussagen-Ebene

Vier Tage. Ungefähr.

Die Kernidee ist, dass die Ausgabe des LLM in strukturierte Aussagen geparst wird und jede Aussage dann gegen das OCR re-verifiziert wird. Wenn eine Aussage nicht in einer spezifischen Satzpassage aus der Quelle verankert werden kann, wird die Aussage verworfen, nicht weichgespült.

Schritt eins: den Prompt so ändern, dass strukturierte Aussagen statt freier Prosa ausgegeben werden.

CLAIM_SCHEMA = {
    "type": "object",
    "properties": {
        "claims": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "topic":        {"type": "string"},
                    "verb":         {"enum": [
                        "addressed", "criticised", "supported",
                        "questioned", "voted_for", "voted_against",
                        "abstained"
                    ]},
                    "object":       {"type": "string"},
                    "evidence_span":{"type": "string"}
                },
                "required": ["topic", "verb", "object", "evidence_span"]
            }
        }
    }
}

Die evidence_span ist der Schlüssel. Das Modell muss die Passage aus dem Transkript wörtlich oder fast wörtlich zitieren, die die Aussage stützt. Ohne Passage kann die Aussage nicht emittiert werden.

Schritt zwei: jede Passage gegen das OCR re-verankern.

def verify_span(evidence_span, transcript, speaker_name, threshold=0.85):
    # Suche auf Zeilen einschränken, die tatsächlich diesem Sprecher zugeordnet sind.
    speaker_lines = [l for l in transcript if l.speaker == speaker_name]
    if not speaker_lines:
        return None
    # Fuzzy-Match. Edit-Distanz pro Zeichen, normalisiert nach Länge.
    best = max(speaker_lines, key=lambda l: fuzz.partial_ratio(l.text, evidence_span))
    score = fuzz.partial_ratio(best.text, evidence_span) / 100
    return best if score >= threshold else None

Gibt verify_span None zurück, wird die Aussage verworfen. Nicht annotiert, nicht markiert — verworfen. Die Zusammenfassung darf kürzer ausfallen, als das Modell sie wollte. Das ist die Disziplin.

Schritt drei: verbspezifische Gates.

ACT_VERBS = {"voted_for", "voted_against", "abstained"}
 
def gate_act_verb(claim, vote_record_api):
    if claim["verb"] not in ACT_VERBS:
        return True
    # Abstimmungsverben benötigen eine explizite Zeile im offiziellen Abstimmungsregister.
    row = vote_record_api.lookup(
        session=claim["session_id"],
        topic=claim["object"],
        member=claim["speaker_id"],
    )
    if row is None:
        return False
    # Die protokollierte Stimme muss dem vom LLM behaupteten Verb entsprechen.
    return row["vote"] == claim["verb"]

Das ist das Gate, das die Halluzination gefangen hätte. Die Vote-Record-API hätte für "Abstimmung über Beschaffungsgesetz in Sitzung A" None zurückgegeben, weil es eine solche Abstimmung nicht gab. Die Aussage wäre verworfen worden, die Zusammenfassung hätte gesagt "sprach das Beschaffungsgesetz an," und der Journalist hätte keinen Screenshot zum Verschicken gehabt.

Ergebnisse

Ich ließ die Verifikationsschicht gegen den Rückkatalog laufen. Der erste Scan war unangenehm.

  • 17 % der ursprünglichen Aussagen wurden verworfen, weil ihre evidence_span nicht über dem Schwellenwert verankert werden konnte.
  • Von diesen verworfenen Aussagen waren etwa 6 % (also grob 1 % aller Aussagen) die schlimme Sorte — Aussagen, die ein spezifisches Verb behaupteten, das nicht stattgefunden hatte. Halluzinierte Positionen, halluzinierte Stimmen, halluzinierte "stimmte zu mit."
  • Die verbleibenden 11 % waren weiche Drops — Aussagen, deren Beleg vorhanden war, aber vom LLM zu aggressiv umformuliert wurde. Die Information war echt; die Formulierung ließ sich nicht verankern. Wir lockerten später den Schwellenwert für Nicht-Aktions-Verben auf 0,78 und gewannen die meisten zurück.
  • Vom Gate verworfene Abstimmungsverb-Aussagen: 4,2 % aller Abstimmungsaussagen. Jede einzelne davon war ein falsches Positiv.

Warum der Fix auf Systemebene leben muss

Die Versuchung, wenn man eine LLM-Halluzination entdeckt, ist, den Prompt zu fixen. Füge hinzu "Mache keine Aussagen, die nicht gestützt sind." Füge hinzu "Nenne nur Fakten aus den Fragmenten." Ich habe beides probiert. Sie reduzieren die Halluzinationsrate um vielleicht 30 %. Sie eliminieren sie nicht. Sie können es nicht.

Das Modell tut, wofür es trainiert wurde: plausible Sequenzen vervollständigen. Plausibilität ist nicht Wahrheit. Der Fix muss außerhalb des Modells leben, in einer Schicht, die sich weigern kann, eine Aussage zu veröffentlichen. Die Schicht muss bereit sein, Nein zu sagen.

Dieser Satz ist der ganze Beitrag: die Schicht muss bereit sein, Nein zu sagen.

Eine Zusammenfassung, die nichts über ein Thema sagt, ist heilbar. Eine Zusammenfassung, die das Falsche über ein Thema sagt, mit selbstbewusstem Verb und Zitation, ist ein Problem des öffentlichen Registers. Der schlimmste Fall ist nicht "wir haben eine Position verpasst." Der schlimmste Fall ist "wir haben eine erfunden."

Eine Anmerkung zur Verantwortung

Ich möchte das vorsichtig sagen, weil das der Teil ist, an dem KI-Schreiben in Predigt kippt, und ich mich sehr bemühe, nicht zu predigen.

Parlamentsakten sind Dokumente, die Journalisten, Forscher, Studierende und Bürger verwenden, um Aussagen über gewählte Vertreter zu treffen. Wenn ein generatives Modell einen flüssigen Satz produziert, der in einen Nachrichtenartikel gepastet wird, hat das Modell faktisch zum öffentlichen Register beigetragen. Das Modell kann nicht zur Rechenschaft gezogen werden. Die Person, die das System gebaut hat, schon.

Also bin ich zurückgegangen und habe jeden Teil der Pipeline mit einer einzigen Frage gelesen: wo könnte diese Lüge sitzen, und wer würde sie fangen? Wo immer die Antwort "niemand" war, fügte ich eine Schicht hinzu, die es könnte.

Ich glaube nicht, dass das das System sicher macht. Ich glaube, es macht es weniger unsicher. Da liegt ein Unterschied, und der Unterschied ist die Arbeit.

Übrigens — der Journalist schickte mir zwei Monate später einen zweiten Screenshot. Die Geschichte, die er schreiben wollte — basierend auf meinem Modell — war tatsächlich korrekt. Er hatte sechs Stunden mit Verifizieren verbracht. Ich sagte ihm, ich hätte vier Tage damit verbracht, etwas zu bauen, das ihm diese sechs Stunden gespart hätte. Er sagte: "Ja, aber ich würde ihm trotzdem nicht vertrauen." Fair.

// wenn du schon hier bist