← Alle Beiträge

Ein Klassifikator besteht hauptsächlich aus Klempnerarbeit

Auf dieser Seite

Bei Flowstate beschäftigt uns ein Problem, das trivial aussieht, bis du es versuchst: Jede eingehende Anfrage soll zum günstigsten Modell, das sie tatsächlich bewältigen kann. Um einen Prompt zu routen, musst du zuerst herausfinden, was er will, und Prompts kommen als reines menschliches Chaos an. Tippfehler. Ein Code-Dump mit 400 Zeilen, in dem die eigentliche Frage in der letzten Zeile versteckt ist. “hey kannst du dir das mal ansehen”. Daraus musst du eine saubere Absicht ziehen, und zwar in deutlich unter einer Millisekunde, im selben Prozess, auf einer CPU, umsonst. Ein semantisches Urteil in einem Speicherbedarf, der kleiner ist als ein JPEG, während jeder Blogpost zum Thema hoch und heilig schwört, dass du ein Rack voller H100 brauchst.

Für genau das gibt es ein branchenübliches Rezept. Du stellst ein Golden Dataset aus 200 bis 500 von Menschen gelabelten Prompts zusammen. Du validierst ein Teacher-LLM dagegen und gehst unter einem F1 von 0,90 nicht weiter. Du destillierst den Teacher in ein kleines Student-Modell: SetFit, eine BERT-Variante, irgendwas mit Embeddings. Du zeichnest Konfusionsmatrizen. Du machst Micro-Benchmarks für die p99-Latenz. Du fährst ein 48-stündiges Shadow-Deployment, bevor das Ding auch nur eine echte Anfrage anfassen darf. Es ist ein schönes Rezept, geschrieben von Forschern mit unendlich Rechenleistung und ohne Pager für die Produktion. Es ist auch ein hervorragender Weg, dich selbst in die Ecke zu überengineeren.

Die eigentliche Aufgabe war eng gefasst: Jeden eingehenden Prompt mit einem Aufgabentyp taggen (parent → child, etwa engineering → fix_bug oder data → spreadsheet_edit), damit der Proxy ihn sinnvoll routet, statt alles an das teuerste Modell auf der Karte zu schicken. Nur CPU, keine GPU im Hot Path, sehr viele Anfragen. Was das Training eines eigenen Klassifikators vernünftig statt verrückt macht: Flowstate sitzt bereits auf einem Berg echter Anfragen. Sie sind nur ungelabelt. Das ist der eine gute Einsatz für ein günstiges Teacher-LLM. Nicht als Türsteher, der bei jeder Anfrage mitverdient. Du richtest es einmal auf den Haufen, lässt es alles labeln und wirfst es weg. Das teure Ding nutzt du genau einmal.

Das Mautstellen-Paradox

Der naheliegende Einwand, der mich an die Decke starren ließ: den lokalen Klassifikator ganz weglassen. Ein günstiges, schnelles Modell aufrufen (Flash, Haiku, was auch immer unten in der Preistabelle steht), das den Prompt taggen lassen und bis zum Mittagessen fertig sein. Genauer als alles, was ich trainieren könnte, wäre es obendrein.

Aber das ist genau die Falle, der die ganze Architektur entkommen soll. Der Sinn, einen Prompt zu taggen, bevor er ein Modell erreicht, ist es, weniger auszugeben: das Einfache irgendwohin schicken, wo es billig ist, und Frontier-Preise nur zahlen, wenn es wirklich sein muss. Wenn der Router selbst bei jeder einzelnen Anfrage einen bezahlten API-Call macht, hast du eine Mautstelle vor deine Mautstelle gebaut. Du kaufst ein Busticket, nur um den Fahrer zu fragen, ob es der richtige Bus ist. Du hast die Ersparnis verbrannt, bevor du sie verdient hast, jeder Anfrage einen Netzwerk-Roundtrip aufgeladen und eine Kopie jedes Prompts in die Logs von jemand anderem gestellt. Ein Klassifikator, der im selben Prozess auf einer CPU läuft, die dir ohnehin gehört, kostet pro Anfrage nichts. Nicht wenig, nichts, für immer, sobald er trainiert ist. Ein klobiges Modell mit 92 %, das nichts kostet, kann also mehr wert sein als eines mit 99 %, das dir pro Token Rechnung stellt. Du willst hier einen schnellen, groben Türsteher, keinen langsamen, teuren Philosophen.

Der Haken ist das Wort “trainiert”. Ein solches Modell ist hungrig. Es will weit mehr gelabelte Beispiele, als ich je aus Spaß von Hand labeln würde. Der Teacher verdient sich sein Geld also genau einmal: Er labelt den Haufen offline, und der Klassifikator läuft danach für nichts.

Bevor ich über irgendetwas davon gestritten habe, habe ich getan, was ich meistens tue: ein kleines Testgerüst gebaut und gemessen.

Erst ein Geständnis

Ich habe den Teacher noch nicht auf den echten Haufen angesetzt, also habe ich für diesen ersten Durchgang einen Ersatz erzeugt. Ein paar tausend synthetische Prompts über achtzehn Aufgabentypen, mit absichtlich eingebauter Mehrdeutigkeit, damit der Klassifikator überhaupt etwas falsch machen kann. Prompts wie “look at the broken endpoint”, was ehrlich gesagt fix_bug oder debug ist, und am Text siehst du es nicht. Rund 2.800 zum Trainieren, 600 zum Testen.

Das ist wichtig, also sage ich es laut: Synthetische Daten, die von dem gelabelt werden, was sie erzeugt hat, sind ein manipuliertes Spiel. Sie sagen dir, ob die Pipeline funktioniert und wie die Ansätze untereinander abschneiden. Sie sagen dir nicht, wie genau das Ganze in der echten Welt ist. Und sie schmeicheln einfachen Modellen ganz leise, weil Text aus Vorlagen lexikalische Fingerabdrücke hinterlässt, die ein Bag of Words einsaugt. Behalte das im Hinterkopf. Ich komme darauf zurück.

Damit an die Tür genagelt, weiter zu den Zahlen.

Das Modell ist die langweilige Entscheidung

Fünf Ansätze, vom günstigsten zum ausgefallensten. Ein schlichtes TF-IDF-Bag-of-Words in eine logistische Regression. Dasselbe mit Zeichen-n-Grammen und einer SVM. Ein Hashing-Vektorisierer in einen SGD-Klassifikator. Und der, den das Rezept wirklich von dir bauen lassen will: Satz-Embeddings (ein quantisierter BGE-Transformer) in einen linearen Kopf.

AnsatzLeaf-AccParent-Accp95 (1 Anfrage)p95 (unter Last)Größe
TF-IDF + logreg0,9231,0000,18 ms0,17 ms336 KB
TF-IDF + char + SVM0,9261,0001,4 ms29,6 ms3,2 MB
Hashing (2²⁰) + SGD0,9261,00020,7 ms42,4 ms147 MB
Hashing (2¹⁸) + SGD0,9241,0004,1 ms6,8 ms37 MB
Embeddings + logreg0,9180,9976,0 ms20,8 ms131 MB

Die Genauigkeitsspalte ist die langweilige. Jeder Ansatz liegt höchstens einen Prozentpunkt neben den anderen, alle gruppiert um 92 %. Der 131-MB-Transformer, den du mit einem Design-Dokument rechtfertigen würdest, landete ganz hinten und war der einzige, der das grobe Parent-Label nicht perfekt getroffen hat. Das Bag of Words mit 336 KB, eine Idee, die älter ist als die meisten Frameworks in deiner package.json, hat gleichgezogen und in einer Drittelmillisekunde geliefert.

Die Latenz ist die Spalte, die nicht flach ist. Zwei Größenordnungen zwischen oben und unten. Die einzige Achse, auf der sich diese Ansätze wirklich unterscheiden, ist die operative, und dort gewinnt die dümmste Option haushoch.

Die Parent-Spalte verbirgt ein leiseres Ergebnis: Sie steht bei fast allen auf glatten 1,000. Die ganze Verwechslung findet innerhalb einer Domäne statt: viz wird mit data_analysis verwechselt, market_research mit web_research. Nie hält jemand eine Tabellenkalkulation für einen Bug-Report. Braucht der Proxy nur die grobe Domäne, hatte das Billigste auf der Liste das schon gelöst, und ich hätte nach Hause gehen können.

Wenn also das Modell kaum eine Rolle spielt, was dann?

Die Klempnerarbeit

Drei Dinge haben weit mehr von meiner Aufmerksamkeit gefressen als das Modell, und keines davon steht in irgendeiner Anleitung.

Footgun eins: Der Hashing-Vektorisierer frisst ganz leise 147 Megabyte

Das Hashing-Modell mit 2²⁰ Features kam auf 20,7 ms und 147 MB residenten Speicher, für eine Aufgabe mit achtzehn Klassen. Das ist eine Fünftelsekunde Latenz und eine kleine Videodatei an RAM, um zu entscheiden, ob jemand “fix this” oder “why is this broken” getippt hat.

Die Ursache ist banal und komplett selbstverschuldet: Ein Featureraum von 2²⁰ mal achtzehn Klassen ist eine dichte Koeffizientenmatrix von der Größe eines Urlaubsfotoalbums, und jede Vorhersage fährt mit der Hand einmal darüber. Senk den Hash auf 2¹⁸, und es wird fünfmal schneller und viermal kleiner bei gleicher Genauigkeit. Der Standardwert ist die Footgun, und niemand warnt dich, dass sie geladen ist.

Footgun zwei: Die Fata Morgana “bei mir läuft’s”

Die Zeichen-n-Gramm-SVM sah isoliert wunderschön aus: 1,4 ms pro Anfrage, knapp die beste Genauigkeit im Feld. Ich hätte sie fast committet und wäre Kaffee holen gegangen. Dann habe ich acht parallele Worker darauf losgelassen, und ihr p95 stürzte auf 29,6 Millisekunden ab. Ein Einbruch um das Zwanzigfache in dem Moment, in dem sie Gesellschaft bekam.

Dafür gibt es zwei Gründe, beide unsichtbar für einen Benchmark mit einer einzelnen Anfrage. Um aus einer SVM Wahrscheinlichkeiten zu bekommen, kalibrierst du sie, und das trainiert und führt pro Vorhersage drei Teilmodelle aus, ganz nebenbei. Und die ganze zusätzliche CPU-Arbeit pro Anfrage läuft direkt in Pythons Global Lock, also stellen sich die Anfragen höflich in einer Schlange an, während das Haus brennt, statt vorbeizufliegen. Das Bag of Words, das pro Anfrage fast nichts tut, hat die Last gar nicht bemerkt. 0,17 ms unter acht Workern, genauso viel wie allein. Den Median zu gewinnen ist nett. Den Kopf zu behalten, wenn alles brennt, ist das, was dir um 3 Uhr nachts wichtig ist.

Der Kleber: Truncation ist der Unterschied zwischen 17 % und 83 %

Bei diesem hier kam ich mir wie ein Idiot vor. Ich habe das Siegermodell einem Stresstest unterzogen: ein Java-Brocken mit 400 Zeilen und einer entscheidenden Anweisung an der letzten Zeile, “now fix the bug that makes the total wrong”. Die echte Form eines Prompts in einem Coding-Tool.

Es erreichte 16,7 %. Es verlor den Verstand und labelte fast alles als refactor, denn für ein Bag of Words ist eine Wand aus Code ein Refactoring, und die eine menschliche Anweisung ganz unten ging im Rauschen unter.

Die Lösung war kein ausgefalleneres Modell und keine Embedding-Matrix. Es waren vier Zeilen, die alles außer den letzten 200 Zeichen wegwerfen und nur diese klassifizieren.

Von sechzehn Prozent auf dreiundachtzig, nur dadurch, dass ich geändert habe, was das Modell sehen durfte, nicht, welches Modell es war. (Nur das Ende gewinnt, wenn die Anweisung hinten steht. Anfang plus Ende ist die sicherere Wahl im Allgemeinen, falls die Frage oben steht.) Das Modell war die ganze Zeit in Ordnung. Die Klempnerarbeit nicht.

Der andere Kleber: ihm beibringen, “Weiß ich nicht” zu sagen

Ein Tagger, der selbstsicher falsch labelt, ist schlechter als einer, der sich enthält. Praktischerweise wusste das Modell schon, wann es raten musste. Seine falschen Antworten bei mehrdeutigen Ein-Wort-Prompts (“debug”, ”?”) kamen mit allerniedrigster Konfidenz. Also habe ich einen Konfidenz-Schwellenwert durchgefahren: Darunter routet es an einen Catch-all, statt zu raten.

Das ist ein sauberer Kompromiss. Halte den Schwellenwert bei etwa 0,7, und du beantwortest immer noch 90 % der Prompts, die Genauigkeit der beantworteten steigt auf 95,5 %, und du fängst 90 % des wirklich themenfremden Mülls. Wo du ihn setzt, ist eine Produktentscheidung (wie oft du bereit bist, mit den Schultern zu zucken, gegen wie oft du bereit bist, falsch zu liegen) und hat, wieder einmal, nichts mit dem Modell zu tun.

Wo das manipuliert ist und wo nicht

Zurück zum Geständnis. Ein aufmerksamer Leser tippt schon: Deine Daten sind synthetisch und lexikalisch aufgeräumt, natürlich hat das Bag of Words gewonnen. Embeddings verdienen sich ihr Geld bei unordentlichen echten Prompts, die du nie getestet hast. Dieser Leser hat recht, und ich würde seine Wette annehmen. Bei echtem Traffic, mit Tippfehlern, halben Gedanken, drei Sprachen in einem Satz und derselben Absicht in hundert Formulierungen, rechne ich fest damit, dass Embeddings vorbeiziehen. Gerade den Modellvergleich solltest du an alledem am wenigsten glauben.

Der ehrliche Test liegt direkt vor uns: den Teacher auf den Flowstate-Haufen ansetzen, ihn einmal labeln lassen und den Vergleich mit echten Prompts wiederholen. Dann wüsstest du, ob die 92 % den Daten zu verdanken waren, die es gut mit mir meinten, oder dem Ansatz, der taugt. Wenn ich dazu komme, wird das ein eigener Post.

Der Klempnerei ist es dagegen egal, mit welchen Daten du sie fütterst. Der Hashing-Vektorisierer frisst 147 MB, egal was. Die kalibrierte SVM bricht unter Parallelität bei jeder Eingabe zusammen. Die Truncation entscheidet, ob das Modell die Anweisung überhaupt sieht. Der Konfidenz-Schwellenwert ist eine Eigenschaft des Deployments, nicht des Datensatzes. Diese Erkenntnisse überstehen den Vorbehalt unbeschadet, und sie waren der größte Teil der Arbeit.

Was ich wirklich gelernt habe

  • Miss, bevor du die Architektur baust. Ich hätte eine Woche darüber streiten können, SetFit oder BERT. Ein Nachmittag mit einem Skript hat mir gezeigt, dass das Modell die uninteressanteste Variable im ganzen System war.
  • Benchmarks mit einer einzelnen Anfrage lügen. Die SVM sah allein am besten und unter Last am schlechtesten aus. Wenn dein Proxy parallelen Traffic bedient, benchmarke parallelen Traffic.
  • Defaults sind Footguns. Ein Hash-Raum von 2²⁰ für achtzehn Klassen sind 147 MB Nichts. Wisse, was der Regler tut, bevor du ihn dort lässt, wo du ihn gefunden hast.
  • Truncation ist ein Modell. Die Wahl, was du dem Klassifikator zeigst, hat die Genauigkeit stärker bewegt als jede Architekturentscheidung: 17 % auf 83 % mit vier Zeilen.
  • Lass ihn sich enthalten. Ein Konfidenz-Schwellenwert macht aus “manchmal selbstsicher falsch” ein “meistens richtig, gelegentlich ehrlich”. Das ist ein Regler, den du haben willst.
  • Die langweilige Baseline ist das, was du schlagen musst, nicht das, was du überspringst. Fang mit 336 KB und ernster Miene an. Greif zum 131-MB-Transformer, wenn du echte Daten hast, die beweisen, dass du ihn brauchst, und nicht einen Commit früher.

Das aufwendige Rezept war nicht direkt falsch. Es hat nur die eine Entscheidung optimiert, die keine Rolle spielte, und zu den vier, die es taten, geschwiegen. Die ganze Branche verkauft dir einen H100-Cluster, um einen Prompt zu lesen. Was wirklich etwas bewegt hat, waren vier Zeilen String-Slicing. Der Großteil eines Klassifikators ist Klempnerarbeit, und Klempnerarbeit ist nicht fotogen, weshalb wohl niemand den Leitfaden dafür schreibt.

Das ganze Gerüst sind etwa 600 Zeilen Python. Es ist Firmencode, deshalb stelle ich es nicht online, aber alles, was zählt, steht in diesem Post: die fünf Ansätze, der Parallelitätstest, die Truncation-Lösung, der Schwellenwert-Sweep. Mit dem Vorbehalt von oben sind Löcher genau das, wonach ich suche, also zielt auf die Methode.