Wir schreiben keine Testfälle für unsere KI. Die Produktion schreibt sie.
Handgeschriebene Golden Datasets verfaulen in der Woche, in der du sie schreibst. Unsere Regressions-Suite für die KI-Kategorisierung entsteht aus echten Nutzerkorrekturen, mit einer Regel als Türsteher: Ein Fehler muss dreimal passieren, bevor er einen Test verdient. Hier sind die Pipeline, die Zahlen dahinter und das Scharnier, das still gebrochen war.
- Kategorie
- Allgemein
- Aktualisiert
- Autor:in
- Stan Kharlap
Frag eine Entwicklerin bei irgendeiner KI-Firma, woran sie merkt, dass ihr Modell besser geworden ist, und du bekommst meist eine von zwei Antworten. Die selbstbewusste Antwort lautet „wir haben Evals". Die ehrliche Antwort lautet „wir haben ein Spreadsheet, das jemand im März angelegt hat".
Wir haben die ehrliche Variante gefahren. Sie funktioniert nicht, und der Grund ist nicht Faulheit. Ein handgeschriebenes Golden Dataset ist eine Momentaufnahme der Fehler, die du dir beim Modell vorstellen konntest, aufgenommen an dem Tag, an dem du dich hingesetzt hast, um sie dir vorzustellen. Unser System liest Belege, ordnet Buchhaltungskategorien zu und entwirft Steuererklärungen für deutsche Unternehmen. Was es falsch macht, entscheidet sich daran, welche Händler unsere Nutzer in diesem Quartal bezahlen, welche Bankformate welche Verwendungszwecke schicken und in welcher Ecke des deutschen Umsatzsteuerrechts ein Abo landet. Nichts davon steckt in meiner Vorstellung. Alles davon steckt in der Datenbank.
Also haben wir aufgehört, Testfälle zu schreiben, und eine Pipeline gebaut, die sie einsammelt.
Das Signal ist die Korrektur, nicht die Beschwerde
Der Reflex ist, Nutzer nach Feedback zu fragen. Daumen hoch, Daumen runter, ein „War das hilfreich?"-Widget. Das liefern wir auch aus, und nach Volumen ist es fast wertlos: Menschen bewerten Software nicht, sie korrigieren sie und machen weiter.
Das Signal mit echtem Volumen ist die Korrektur selbst. Wenn unser Kategorisierer Software für eine Transaktion vorschlägt und der Nutzer sie still auf Werbung ändert, dann ist diese Änderung ein gelabeltes Beispiel mit einem Menschen in der Schleife, kostenlos erzeugt, genau in dem Moment, in dem dieser Mensch genug Interesse hatte, um präzise zu sein. In den letzten zwei Wochen hat dieser Pfad mehrere Tausend Korrekturen produziert. Der explizite Daumen-Pfad produzierte einen Rundungsfehler.
Zwei Randbedingungen folgen daraus sofort, und beide sind langweilig auf die Art, auf die es ankommt.
Erstens: Der Recorder darf nicht im Request leben. Wer eine Transaktion aktualisiert, ist auf einem latenzkritischen Schreibpfad, und nichts an unserer Lernschleife ist eine zusätzliche Sekunde seiner Zeit oder eine zusätzliche Chance auf einen 500er wert. Die Korrektur wird an einen Background-Task übergeben, und dieser Task schluckt jede Exception, die er erzeugt. Wenn die Lernschleife kaputtgeht, merkt das Produkt es nicht.
# Illustrative Skizze, nicht unser Quellcode. Nur die Form.
@deferred(timeout="short")
def on_manual_override(record_ref, machine_value, human_value, actor):
try:
local_memory.upsert(record_ref, human_value)
if machine_value: # hier hat eine Maschine geraten, und verloren
defect_log.observe(record_ref, machine_value, human_value)
except Exception:
log.warning("override not recorded", exc_info=True)
Zweitens: Die Schleife muss eine falsche Antwort von einer anderen Antwort unterscheiden können. Das ist, wie sich zeigt, das ganze Spiel.
Die meisten Korrekturen sind keine Defekte
Dass ein Nutzer eine Kategorie ändert, ist kein Beweis dafür, dass irgendetwas kaputt ist. Es gibt mindestens vier Gründe für eine Änderung, und nur einer davon ist ein Engineering-Problem:
- Extraktionsfehler. Wir haben das Dokument oder die Verwendungszweckzeile gelesen und die falsche Antwort bekommen. Das ist ein Defekt.
- Mapping-Bug. Eine deterministische Regel hat gefeuert und auf das falsche Konto gemappt. Auch ein Defekt, und ein peinlicherer.
- Nutzerpräferenz. Zwei Kategorien sind beide vertretbar, und dieses Unternehmen bevorzugt die andere. Kein Defekt.
- Steuerliche Beurteilung. Die richtige Antwort hängt von Fakten ab, die nur das Unternehmen oder sein Berater kennt. Kein Defekt, und ganz sicher nichts, was man in einem Prompt reparieren sollte.
Nur die ersten zwei werden zu Engineering-Signal. Die anderen werden erfasst, gezählt und abgeschottet, denn eine Suite, die Geschmack als Fehler behandelt, optimiert dein Modell mit Freude in die Meinung einer anderen Person hinein.
Die Aufteilung zwischen den ersten beiden wird abgeleitet, nicht erfragt. Trug die Transaktion schon eine Kategorie, die die Rule Engine gesetzt hat, geht die Korrektur auf das Konto der Regeln; trug sie einen Modellvorschlag, geht sie auf das Konto der Extraktion. Niemand füllt ein Formular aus. In der Produktion ist dieses Verhältnis auf eine Weise schief, die ich beruhigend finde: Die deterministischen Regeln machen gut unter einem Prozent der Korrekturen aus. Wenn wir falsch liegen, liest fast immer das Modell ein Dokument falsch, und nicht unser eigenes hartkodiertes Mapping.
Fingerprints statt Tickets
Ein roher Korrekturstrom ist unbenutzbar. Tausende Änderungen sind nicht tausende Probleme; sie sind ein paar hundert Probleme mit einem langen Rauschschwanz. Korrekturen werden deshalb nicht als Work Items gespeichert. Sie werden zu Findings zusammengefaltet, adressiert über einen Fingerprint.
Der Fingerprint ist ein Hash über den Workflow, den zugewiesenen Grund, den normalisierten Feldpfad und eine Kennung für die Gegenpartei. Gleicher Händler, gleiches Feld, gleicher Fehlergrund, gleicher Fingerprint, und der Evidence-Zähler geht um eins nach oben.
# Illustrative Skizze. Identität wird abgeleitet, nie vergeben.
def group_key(stage, blame, target, party=None):
parts = {
"stage": stage,
"blame": blame or "unknown",
"target": canonical(target),
}
if party: # nur bei Wert gesetzt, damit Keys aus der
parts["party"] = party # Zeit vor diesem Feld weiter passen
return digest(parts)
Dieses if ist einen Satz wert. Als wir die Kennung pro Gegenpartei eingeführt haben, hätte die naive Variante jedes historische Finding in einen neuen Bucket umgehasht und damit Monate an angesammelter Evidenz zurückgesetzt. Den Key nur zu setzen, wenn er einen Wert hat, hält die alten Fingerprints aus drei Keys hash-identisch. Schema-Evolution in einem content-adressierten Store ist eine Datenbankmigration mit einem anderen Hut auf.
Drei Treffer, dann bist du ein Test
Hier ist die Regel, die ich in diesem ganzen Design am härtesten verteidigen würde:
Ein Finding muss mindestens dreimal auftreten, bevor es einen Testfall verdient.
Die Produktionsverteilung sagt genau, warum. Von den Fingerprints, die wir angesammelt haben, wurden etwa zwei Drittel genau einmal gesehen. Ein Nutzer, ein Händler, eine Änderung, nie wieder. Wären sie alle zu Regressionsfällen geworden, wäre die Suite heute überwiegend Rauschen, jeder Fall würde ein Verhalten festnageln, nach dem nie jemand ein zweites Mal gefragt hat, und jede künftige Prompt-Änderung würde ein Dutzend bedeutungslose rote Lampen auslösen. Eine Test-Suite, die man zu ignorieren lernt, ist schlimmer als keine Test-Suite, und der schnellste Weg zu so einer Suite ist, sie von einem Feuerwehrschlauch schreiben zu lassen.
Drei Treffer machen aus dem Lautstärkeregler einen Filter. Etwa jedes sechste Finding schafft die Hürde und wird zu einem Eval-Case befördert, und sobald ein Muster wirklich wiederkehrt, geht das schnell: Die mediane Zeit von der ersten Korrektur bis zum aktiven Regressionsfall liegt deutlich unter einem Tag, vollautomatisch, ohne irgendwo im Pfad ein Triage-Meeting.
Die Suite sieht deine Daten nie
Gegen das Erzeugen von Tests aus der Produktion gibt es einen naheliegenden Einwand: Du hast gerade ein permanentes Archiv der Buchhaltung deiner Kunden in deinem CI-System angelegt.
Haben wir nicht, und der Grund ist, dass die Eval-Cases Hashes der korrigierten Werte speichern, nicht die Werte. Das nächtliche Replay lässt die aktuelle Pipeline erneut über die gleichen Eingaben laufen, hasht das Ergebnis und vergleicht. Es kann dir sagen, dass sich das Verhalten geändert hat. Es kann dir nicht sagen, was irgendwer gekauft hat.
Die gleiche Regel gilt eine Ebene tiefer. Jede Einheit Modellarbeit wird als getracter Run festgehalten, mit Provider, Modell, Token-Zahlen, Latenz, Kosten und einem Hash von Eingabe und Ausgabe. Die Payloads selbst werden nie persistiert, und jede Trace-Zeile trägt die Retention-Policy, unter der sie geschrieben wurde, sodass die Garantie in den Daten sichtbar ist und nicht in einem Dokument. Diese Randbedingung ist tragend, nicht dekorativ: Sie ist der Grund, warum niemand darüber verhandeln muss, wie lange der Regressionskorpus leben darf.
Wie das im Volumen aussieht
Architekturdebatten sind ohne Maßstab billig, also: In den letzten zwei Wochen hat unser Agent-Layer deutlich über hunderttausend Runs getract, überwiegend Kategorisierung, mit einem Spitzenwert von über zehntausend an einem einzigen Tag, daneben ein stetiger Strom Dokumentenextraktion. Das ist in der Größenordnung von hundert Millionen Token in jede Richtung.
Eine Zahl aus diesem Haufen hat verändert, wie wir Prompts schreiben. Rund sieben von zehn Input-Token werden aus dem gecachten Prefix des Providers bedient und nicht frisch verarbeitet, und das ist kein Glück: Jeder Prompt wird statischer Teil zuerst zusammengesetzt, sämtlicher Kontext pro Unternehmen und pro Nutzer wird am Ende angehängt, damit die lange gemeinsame Präambel über alle Nutzer byte-identisch und damit cachebar bleibt. Das ist eine Regel mit Zähnen. Ein gut gemeintes Refactoring, das einen Firmennamen in den Header hochzieht, würde den Cache bei jedem Request still verbrennen, und genau deshalb bepreist unser Kostenmodell gecachte und nicht gecachte Input-Token getrennt. Der Schaden zeigt sich als Geld.
Wo unsere Schleife noch offen ist
Ich würde lieber über den Teil schreiben, der funktioniert, aber das interessante Engineering steckt in der Nahtstelle, und unsere hatte ein Loch.
Das nächtliche Replay läuft seit Wochen planmäßig, fünfzig Cases pro Nacht, und produziert brav für jeden eine Ergebniszeile. Jede einzelne dieser Zeilen ist ein error. Kein Pass, kein Fail: ein Error, jedes Mal mit derselben Meldung. Die Hälfte des Systems, die Findings befördert, schreibt ein erwartetes Ergebnis in ihrem eigenen Vokabular und beschreibt die Gründe und Feldpfade, die nicht mehr wiederkehren sollen. Die Hälfte, die ein Replay bewertet, versteht ein anderes Vokabular, nämlich das des Wertevergleichs. Keine der beiden Hälften liegt nach ihren eigenen Maßstäben falsch. Sie wurden Wochen auseinander gebaut und nie zur Übereinstimmung gebracht, also liest der Grader jeden Case, den wir erzeugen, findet nichts, was er als Assertion erkennt, und gibt auf.
Die Schleife ist bis zum letzten Scharnier vollständig, und dann dreht sie frei durch. Drei Lehren, und sie gelten über unsere Codebasis hinaus.
Eine Suite, die errort, ist gefährlicher als keine Suite. Ein fehlendes Harness ist eine bekannte Lücke. Ein Harness, das jede Nacht läuft, Zeilen schreibt und nichts bewertet, sieht auf jedem Dashboard wie Coverage aus. Der Fix sind nicht mehr Tests, sondern „nichts bewertet" als Build-brechende Bedingung zu behandeln statt als Datenpunkt.
Eine sich selbst schreibende Test-Suite braucht einen Contract-Test. Wenn Produzent und Konsument von Testfällen verschiedene Module sind, ist das Format zwischen ihnen ein öffentliches Interface und verdient seinen eigenen Test. Unseres war in beiden Hälften dokumentiert und in keiner erzwungen.
Ambition im Schema ist keine Fähigkeit. Jeder getracte Run trägt einen Fremdschlüssel auf die Prompt-Version, die ihn erzeugt hat, und in der Produktion ist diese Spalte bei allen hunderttausend-plus davon null. Das Registry existiert, es liegt in der Datenbank, Prompts ändern sich ohne Deploy, und der Code liest zur Aufrufzeit die aktive Version. Und wirft sie dann weg, statt sie aufzuschreiben. Also können wir heute die eine Frage nicht beantworten, für die der ganze Apparat existiert: Hat diese Prompt-Änderung geholfen? Zwei Zeilen reparieren das. Es hat so lange überlebt, weil nie etwas fehlgeschlagen ist, und ich behandle „kein Fehler, aber auch keine Antwort" inzwischen als eigene Bug-Kategorie.
Die langweiligen Teile sind das Produkt
Nichts von der interessanten Arbeit hier war Modellarbeit. Das Modell ist eine Komponente, die wir mieten. Die Teile, die uns gehören, sind die, die entscheiden, welche von tausenden menschlichen Änderungen Defekte heißen dürfen, die sich weigern, einen Zufall zu einem Test zu befördern, die einen Regressionskorpus halten, ohne Kundendaten zu halten, und die ehrlich genug sind, zuzugeben, wenn das letzte Glied nicht angeschlossen ist.
Lass die Produktion die Testfälle schreiben, denn die Produktion weiß Dinge, die du nicht weißt. Und sei dann paranoid gegenüber der Pipeline, die sie transportiert, denn eine Lernschleife, die still aufhört zu lernen, wird es dir nie sagen.
Norman übernimmt die operative Arbeit im Hintergrund
Von Rechnungen bis Buchhaltung: Norman organisiert wiederkehrende Finanzarbeit, damit du Fristen sauber einhältst und weniger manuell nachhalten musst.