Zum Inhalt springen
Bernhard Götzendorfer
KI Deep Dives

Faktor 1000: warum ein Vision-Modell Belege falsch liest

Ein Bakeoff über 16 echte Belege zeigte einen systematischen Faktor-1000-Fehler. Warum ein deterministischer Wächter über dem Modell sitzt.

TL;DR

In BuchhaltGenie liest ein Vision-Modell Belege, und zwar ohne jede OCR-Bibliothek darunter. Kein tesseract, kein pdf-parse, kein textract, ein grep über die package.json findet nichts davon. Bevor ich mich auf ein Modell festgelegt habe, lief ein Bakeoff, also ein Direktvergleich unter identischen Bedingungen, über 16 echte Belege und sieben vision-fähige Kandidaten, identischer Prompt, identisches JSON-Schema. Das Ergebnis hat die Modellwahl gedreht. Wichtiger war der Nebenbefund: Modelle verlesen sich bei Beträgen systematisch um Zehnerpotenzen, und keine Model Card erwähnt das. Deshalb sitzt der Plausibilitäts-Wächter über dem Modell und nicht darin.

Der Beleg, der 60 Euro kostet und 60.000 heißt

Ein Beleg trägt die Zahl 60.000. Für ein Modell ist der Punkt darin mehrdeutig: Tausendertrenner oder Dezimaltrenner. Rät es falsch, liest es 60,00 statt 60.000, oder umgekehrt 31.00 als 3100,00. Die erste Richtung ist ein Faktor 1000 zu klein, die zweite ein Faktor 100 zu groß. Beide Richtungen habe ich im Bakeoff gemessen, und deshalb prüft der Wächter heute auch beide.

Das ist keine Rundungsungenauigkeit, sondern eine eigene Fehlerklasse. Ein Betrag, der um drei Stellen daneben liegt, sieht im Formular vollkommen normal aus. Er hat ein Datum, einen Lieferanten, einen USt-Satz, und alles davon stimmt. Nur die Zahl ist falsch, und zwar so falsch, dass sie im Zweifel niemandem auffällt, der schnell durchklickt.

Der Header des Wächter-Moduls sagt genau das, und ich zitiere ihn hier so, wie er im Code steht: den Modell-Output durch den Plausibilitäts-Wächter schicken. Der Wächter ist der Kern dieses Moduls. Der Bakeoff über 16 echte Belege hat gezeigt, dass Vision-Modelle systematisch an Tausendertrennern und Fremdwährungen scheitern.

Ein Vorschlag, der um Faktor 1000 danebenliegt, ist schlimmer als gar kein Vorschlag. Er sieht plausibel aus.

Ich habe keine OCR-Bibliothek gebaut, sondern gemessen

Der Bakeoff lief am 4. August. Ausgangspunkt war ein Katalog mit 338 Modellen, davon 181 vision-fähig. Sieben davon habe ich in den Vergleich genommen, alle mit identischem Prompt und identischem JSON-Schema, damit der Vergleich nicht an der Prompt-Formulierung hängt.

Das Material waren 13 Belege im Hauptlauf plus 3 im Addendum, alle echt, alle handgelabelt. Die Ground Truth war absichtlich unbequem gebaut. Es gab einen Abstain-Köder: einen DHL-Aufgabebeleg ganz ohne Betrag, bei dem die richtige Antwort null ist und nicht eine hübsche erfundene Zahl. Es gab einen Faktor-Köder mit einem indonesischen Betrag über 31.000. Es gab einen Nullbetrag und Belege mit COVID-zeitlichen USt-Sätzen, die heute niemand mehr erwartet.

Das Budget war bei 10 Euro gedeckelt. Verbraucht habe ich 0,51 Euro über 171 Calls in zwei Durchgängen, 156 plus 15. Von den 171 Antworten war 171 mal valides JSON dabei, kein einziger Parse-Fehler. Das ist die angenehme Überraschung des ganzen Versuchs: Schema-Treue ist bei aktuellen Modellen kein Problem mehr. Inhaltliche Treue schon.

Gewichtet habe ich nach dem, was in der Buchhaltung wehtut: Betrag 35 Prozent, Datum 20 Prozent, Lieferant 15 Prozent, USt-Satz 15 Prozent, Währung 10 Prozent, korrektes Abstain 5 Prozent. Dazu drei Strafen: ein Faktor-Fehler kostet 10 Prozentpunkte, ein halluzinierter Betrag ebenfalls 10, ein Parse-Fehler 5.

Das Ergebnis: die Model Card sagt es nicht

Die Rangliste aus dem Hauptlauf. Wo zwei Score-Werte stehen, führt die Doku beide, und ich zitiere sie unverändert.

ModellScoreFaktor-FehlerLatenzKosten pro Beleg
Gemini 3.6 Flash100,0 / 98,602,83 s0,0056 EUR
Claude Sonnet 587,5 / 87,504,19 s0,0078 EUR
Qwen3 VL 235B75,0 / 81,213,18 s0,0008 EUR
Mistral Medium 3.572,5 / 72,511,99 s0,0028 EUR
Mistral Small 469,011,85 s0,0002 EUR
Claude Haiku 4.568,113,88 s0,0022 EUR
Ministral 3 8B65,4 / 65,411,67 s0,0002 EUR

Zwei Dinge daran gefallen mir. Erstens haben alle sieben beim betragslosen DHL-Aufgabebeleg korrekt null geliefert, und im Hauptlauf gab es null halluzinierte Beträge. Die Angst, dass ein Modell aus Verlegenheit eine Zahl erfindet, hat sich in diesem Material nicht bestätigt. Zweitens ist der Preisunterschied zwischen Platz eins und Platz drei so klein, dass er die Wahl nicht entscheidet.

Der eigentliche Test war das Addendum. Drei Belege, alle mit Tausendertrenner ab 1.000: ein Schweizer QR-Beleg über 2 500.00, ein CORD-Beleg über 60.000, ein CORD-Beleg über 174,600. Ministral 3 8B holte 2 von 3, Mistral Small 4 ebenfalls 2 von 3, Mistral Medium 3.5 nur 1 von 3. Claude Sonnet 5 und Gemini 3.6 Flash lagen bei 0 von 3, obwohl beide im Hauptlauf ohne Faktor-Fehler geblieben waren. Die 0 in der Tabelle oben und die 0 hier bedeuten also das Gegenteil voneinander: dort keine Fehler, hier kein Treffer. Im Hauptlauf war der Faktor-Köder mit dem indonesischen 31.000-Beleg bei 5 von 7 Modellen durchgegangen. Die Bakeoff-Doku zieht daraus einen Schluss, den ich für den wichtigsten Satz des ganzen Dokuments halte: der Faktor-1000-Fehler der Mistral-Familie ist eine systematische Eigenschaft, kein Ausreißer.

Eine Einschränkung gehört dazu. Der Punkt-Beleg im Addendum ist indonesisch. Ich habe rund 25 Volltextsuchen auf Wikimedia Commons gebraucht und trotzdem keinen deutschsprachigen Euro-Beleg über 1.000 gefunden, der frei nutzbar gewesen wäre. Der Fehlerraum, den ich gemessen habe, ist also nicht der, in dem meine Nutzer leben. Das Muster stimmt, die Stichprobe ist schmal.

Der Wächter sitzt über dem Modell, nicht darin

Aus diesem Befund folgt die Architektur. Der Kreuzcheck rechnet netto plus Umsatzsteuer gegen brutto, mit einer Toleranz von 2 Cent, und prüft die Zehnerpotenzen 10, 100 und 1000 in beide Richtungen.

// Kreuzcheck: netto plus USt muss brutto ergeben, Toleranz 2 Cent
const CROSS_CHECK_TOLERANCE_CENTS = 2;
const SCALING_FACTORS = [10, 100, 1000] as const;

function isScalingMismatch(grossCents: number, expectedCents: number): boolean {
  return SCALING_FACTORS.some(
    (factor) =>
      Math.abs(grossCents * factor - expectedCents) <= CROSS_CHECK_TOLERANCE_CENTS ||
      Math.abs(grossCents / factor - expectedCents) <= CROSS_CHECK_TOLERANCE_CENTS,
  );
}

Der Aufruf steckt als Schritt c im Aufbau des Vorschlags, zwischen Modell-Output und allem, was danach kommt. Daneben stehen ein paar unspektakuläre Deckel: maximal 8 Seiten pro PDF, Lieferantenname maximal 200 Codepoints, Belegalter maximal 10 Jahre. Der letzte Wert ist eine Implementierungsentscheidung in meinem Code und keine Aussage darüber, wie lange irgendwer Belege aufzuheben hat.

Über der Reichweite steht ein Kommentar, den ich beim Schreiben unangenehm fand und deshalb stehen ließ: dieser Wächter greift ausschließlich beim vollständigen Tripel aus Bruttobetrag, USt-Betrag und österreichischem USt-Satz. Der Kreuzcheck ist eine Stichprobe, kein genereller Betragsschutz. Bei einem Beleg ohne ausgewiesene Steuer greift er gar nicht.

Drei Stufen statt eines Vertrauensbalkens

Was der Wächter feststellt, muss irgendwo landen. Ein Prozentbalken hilft niemandem, weil er keine Handlung nahelegt. Also gibt es drei Stufen: autonom, ein_klick, review. Neun Regeln in fester Reihenfolge entscheiden darüber, dazu neun Reason-Codes, damit später nachvollziehbar ist, welche Regel gegriffen hat. Die Confidence läuft von 0 bis 1, die Schwelle für autonom liegt bei 0,98, und die Logik ist fail-closed: wer sich nicht sicher ist, landet in review, nicht im Autopilot.

// Regel 3 der Stufen-Logik: ein verworfener Betrag zwingt immer in review
if (guard.amountDiscarded) {
  return { stage: 'review', reason: 'plausibility_guard_rejected_amount' };
}

Die Begründung dazu steht im Code und ist kurz: ein Zehnerpotenz-Kreuzcheck, der den Betrag verwirft, ist eine explizite Warnung, und die darf kein Autopilot übergehen. Die Materialitätsgrenze teilt sich diese Logik mit dem Bank-Autopiloten, damit ein Betrag nicht in einem Modul als klein und im anderen als groß gilt.

Was ich daraus mitnehme

  1. Ein Bakeoff mit echtem Material schlägt jeden Doku-Vergleich. Keine der sieben Model Cards erwähnt Tausendertrenner. Die Fehlerklasse, die mein Produkt am teuersten zu stehen käme, stand in keiner Spezifikation, sondern erst in 16 handgelabelten Belegen. 0,51 Euro und ein Nachmittag waren dafür ein lächerlicher Preis.

  2. Ein deterministischer Guard ist billiger als ein besseres Modell. Der Kreuzcheck ist keine 20 Zeilen und kostet nichts pro Aufruf. Er schützt auch den Qualitätssieger gegen künftige Modell-Sprünge, wie es die Bakeoff-Doku als Konsequenz formuliert. Ein Modellwechsel ist ein Commit, kein Umbau.

  3. Die Reichweite ist Teil des Guards. Ein Wächter, dessen Grenzen niemand kennt, erzeugt genau das falsche Vertrauen. Der Kommentar über die Reichweite ist deshalb kein Selbstzweifel, sondern Dokumentation: er sagt, wo ich noch nichts habe.

  4. Fail-closed als Standard, auch wenn es Klicks kostet. Jeder verworfene Betrag zwingt in review. Das nervt gelegentlich. Die Alternative wäre, dass ein um Faktor 1000 falscher Betrag lautlos durchrutscht, und das ist keine Alternative.

Fazit

Die Reihenfolge war: erst messen, dann wählen, dann absichern. Der Bakeoff hat die Modellwahl gedreht, aber sein bleibender Ertrag ist der Wächter, den ich ohne ihn nicht gebaut hätte. Ein Modell, das 100 von 100 Punkten holt, bleibt ein Modell, und das nächste Update kann die Eigenschaft wieder verlieren, wegen der ich es gewählt habe. Der Kreuzcheck bleibt.

Wie ich generell mit Modell-Output umgehe, statt ihm zu glauben, steht in Verifikation statt Tippen. Warum aus so einem Prototyp erst mit viel Kleinarbeit ein Produkt wird, habe ich in Von Prototypen zum Produkt beschrieben, und wie ein Modellvergleich auch ausgehen kann, in meinem gescheiterten Versuch mit einem lokalen Coding-Modell. Womit ich sonst arbeite, steht hier. Wenn du ein ähnliches Extraktionsproblem hast und wissen willst, wo dein Guard sitzen sollte: schreib mir.