tva
← Insights

LLM-gestütztes Job-Matching: eine Pipeline zur Bewertung der Kandidateneignung entwickeln

Keyword-Filter sind schnell und günstig – und liegen genau dort falsch, wo es darauf ankommt. Wenn ein Recruiter ein ideales Kandidatenprofil definiert, das Fachwissen, technologische Tiefe, Kommunikationsstil und ein Dutzend kontextabhängiger Präferenzen umfasst, kann kein Bag-of-Words-Ansatz zuverlässig beurteilen, ob eine Stellenanzeige dazu passt. Der semantische Unterschied zwischen „5+ Jahre Python“ und „entwickelt mit Python Datenverarbeitungs-Workflows für den Produktivbetrieb mit intensiver Nutzung asynchroner Ein- und Ausgabe“ bleibt einem Keyword-Filter verborgen. Ein Sprachmodell kann ihn mit dem richtigen Prompt hingegen genau bewerten.

Wir haben eine Matching-Pipeline entwickelt, die ein festes Kandidatenprofil verwendet und eine Warteschlange von Stellenanzeigen mithilfe eines LLM danach bewertet. Die Bewertungen laufen parallel, die Token-Kosten werden in Echtzeit erfasst, und eine native SwiftUI-Anwendung für macOS bietet Bedienelemente zum Pausieren und Fortsetzen. Dieser Beitrag beschreibt den Systemaufbau und zeigt, wann LLM-Matching seine Mehrkosten gegenüber einfacheren Ansätzen rechtfertigt.


Die Prompt-Architektur

Den Kern bildet ein einzelner Bewertungs-Prompt, der einmal pro Stellenanzeige aufgerufen wird. Jeder Aufruf erhält zwei Eingaben: das Kandidatenprofil und den Anzeigentext. Das Profil bleibt während der gesamten Sitzung unverändert. Es enthält Fähigkeiten, Erfahrungsniveau, bevorzugte Fachgebiete, zwingende Anforderungen und alle kontextbezogenen Hinweise, die der Recruiter vom Modell berücksichtigen lassen möchte. Die Anzeige liefert Stellenbezeichnung, Unternehmen, Beschreibung und Anforderungen so, wie sie beim Scraping erfasst wurden.

Das Modell gibt ein strukturiertes JSON-Objekt mit zwei Feldern zurück: einem numerischen Wert von 0 bis 100 und einer kurzen Begründung der entscheidenden Übereinstimmungen oder Abweichungen. Eine strukturierte Ausgabe ist hier wesentlich. Unstrukturierte Antworten erfordern fehleranfälliges Parsing und führen zu unterschiedlichen Darstellungsformen des Bewertungswerts. Eine Tool-Calling- oder Function-Calling-Schnittstelle mit JSON-Schema (in einem strikten Modus oder einem Modus für strukturierte Ausgaben, sofern der Anbieter einen solchen bereitstellt) beseitigt diese Fehlerklasse weitgehend und vereinfacht die anschließende Zusammenführung der Ergebnisse; eine Validierung des geparsten Objekts bleibt dennoch sinnvoll.

Eine Entwurfsentscheidung sollte ausdrücklich festgehalten werden: Das Profil muss ausführlich sein. Kurze Profile erzeugen schwankende Bewertungen, weil dem Modell der Kontext fehlt, um eine 70 von einer 85 zu unterscheiden. Ein Profil, das klar erläutert, warum bestimmte Anforderungen wichtig sind – und nicht nur, welche es sind –, liefert dem Modell genug Anhaltspunkte für aussagekräftige Abstufungen. Behandeln Sie das Profil wie ein detailliertes Bewertungsraster statt wie eine Filterzeichenfolge.

Parallele Ausführung mit ThreadPoolExecutor

Bei einer Warteschlange mit 50 oder 100 Anzeigen sind sequenzielle API-Aufrufe für eine interaktive Nutzung zu langsam. Pythons concurrent.futures.ThreadPoolExecutor löst das übersichtlich. Die Anzahl der Worker richtet sich nach den Aufruflimits des API-Anbieters. Typischerweise ermöglichen 5 bis 10 gleichzeitige Anfragen einen brauchbaren Durchsatz, ohne Kontingentfehler auszulösen.

Das Muster verwendet as_completed(), das Futures in der Reihenfolge ihrer Fertigstellung statt ihrer Einreichung liefert. Dadurch erscheinen die schnellsten Bewertungen zuerst, und die Benutzeroberfläche kann sich schrittweise aktualisieren, statt auf die gesamte Gruppe zu warten:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as executor:
    futures = {
        executor.submit(score_listing, listing, profile): listing
        for listing in listings
    }
    for future in as_completed(futures):
        listing = futures[future]
        score, rationale = future.result()
        update_results(listing, score, rationale)

Die Fehlerbehandlung verdient hier besondere Aufmerksamkeit. Einzelne API-Aufrufe können scheitern – etwa an Aufruflimits, vorübergehenden Netzwerkproblemen oder Ablehnungen aufgrund von Inhaltsrichtlinien. Jeder Worker sollte Ausnahmen abfangen und einen definierten Ersatzwert zurückgeben (im obigen Codebeispiel wurde dies der Kürze halber weggelassen); andernfalls löst future.result() die Ausnahme des Workers in der Ergebnisschleife erneut aus und beendet die Verarbeitung der verbleibenden Ergebnisse. Eine Anzeige, deren Bewertung fehlschlägt, wird für einen Wiederholungsversuch eingereiht, statt stillschweigend zu entfallen. Sichtbare Fehler sind wesentlich leichter nachzuvollziehen als eine Bewertungsliste mit unerklärlichen Lücken.

Token-Verbrauch in Echtzeit erfassen

Token-Kosten werden bei größeren Mengen leicht unterschätzt. Ein Prompt mit 400 Token für 100 Anzeigen und Antworten mit jeweils 150 Token summiert sich je nach Modellklasse schnell. Die Pipeline erfasst laufende Summen mit einem threadsicheren Zähler: einer einfachen Ganzzahl, die durch ein threading.Lock geschützt ist und nach jeder API-Antwort anhand der Token-Zahlen aus deren Metadaten erhöht wird.

Das SwiftUI-Frontend fragt diesen Zähler in regelmäßigen Abständen ab und zeigt die aufsummierten Eingabe- und Ausgabe-Token sowie geschätzte Kosten anhand der Token-Preise des aktuellen Modells an. Damit sieht der Recruiter die Sitzungskosten unmittelbar und erkennt, wenn das Profil ungewöhnlich lange Begründungen erzeugt. Das deutet häufig darauf hin, dass der Prompt präziser gefasst werden muss oder die Anzeigentexte mehr irrelevante Inhalte enthalten als erwartet.

Eine praktische Verbesserung: Schätzen Sie die Token-Zahl vor dem Versand, nicht erst danach. Große Anbieter bieten eine Tokenizer-Bibliothek oder einen Endpunkt zum Zählen von Token an (etwa die Bibliothek tiktoken von OpenAI oder den Token-Zählendpunkt von Anthropic). Durch vorheriges Zählen kann die Pipeline warnen, wenn ein Anzeigentext ungewöhnlich lang ist und unverhältnismäßig viel Kontext beansprucht oder der gesamte Prompt sich dem Kontextlimit des Modells nähert. Wird das vor dem API-Aufruf erkannt, vermeiden Sie unnötige Ausgaben für eine Anfrage, die abgeschnitten oder abgelehnt wird.

Pausieren und fortsetzen

Wenn ein Recruiter während einer Sitzung Ergebnisse prüft, möchte er neue Bewertungsanfragen möglicherweise pausieren, ohne bereits laufende Arbeit zu verlieren. Der Mechanismus zum Pausieren und Fortsetzen verwendet ein threading.Event als gemeinsames Signal. Vor jedem API-Aufruf prüft der Worker-Thread dessen Zustand. Ist die Ausführung pausiert, wartet er bei event.wait(). Sobald das Event wieder gesetzt wird, läuft die Ausführung weiter.

Das ist sauberer als Threads zu beenden und neu zu starten, weil laufende API-Aufrufe regulär abgeschlossen werden dürfen. Pausieren bedeutet „keine neuen Aufrufe starten“ statt „sofort alles abbrechen“. Dieser Unterschied zählt, wenn die aktuelle Gruppe vollständig abgearbeitet werden soll, bevor der Nutzer Ergebnisse prüft: Unvollständige Gruppen sind schwerer nachzuvollziehen als vollständige.

Auch die Zustandssicherung beim Pausieren und Fortsetzen sollte ausdrücklich behandelt werden. Die Pipeline führt eine Checkpoint-Datei: eine einfache JSON-Liste der IDs bereits bewerteter Anzeigen. Beim Fortsetzen überspringt sie jede darin enthaltene Anzeige. Sofern neben dem Checkpoint auch die Bewertungswerte und Begründungen dauerhaft gespeichert werden, müssen bei einem Absturz der Anwendung während einer Sitzung beim Neustart folglich keine bereits bewerteten Anzeigen erneut bewertet werden. Idempotenz auf Sitzungsebene lässt sich mit geringem Aufwand umsetzen und erspart erhebliche Unannehmlichkeiten.

Das SwiftUI-Frontend für macOS

Wir haben SwiftUI für das Frontend gewählt, weil das Zielgerät macOS verwendet und wir eine native Anwendung wollten: reaktionsschnell, mit geringem Ressourcenbedarf und regulärer macOS-Fensterverwaltung. Eine Electron-Hülle hätte funktioniert, für ein Produktivitätswerkzeug mit einem einzelnen Nutzer aber unnötige Komplexität hinzugefügt. Das Python-Backend läuft als lokaler Prozess und stellt mit Flask eine minimale HTTP-API bereit. Die SwiftUI-App kommuniziert damit über localhost.

SwiftUIs Muster mit ObservableObject und @Published bildet die reaktiven Aktualisierungen übersichtlich ab. Ein View Model fragt das Backend in regelmäßigen Abständen ab und aktualisiert die veröffentlichten Arrays, auf denen die Ergebnisliste und die Token-Zähleranzeige beruhen. Das Hauptfenster enthält drei Bereiche: eine nach Bewertungswert absteigend sortierte Ergebnisliste, einen laufenden Token- und Kostenschätzungszähler sowie eine Steuerleiste mit Schaltflächen zum Pausieren, Fortsetzen und Stoppen.

Jede Ergebniszeile zeigt die Stellenbezeichnung, den Unternehmensnamen, eine farblich markierte Bewertung und die einzeilige Begründung des LLM. Ein Klick auf die Zeile öffnet eine Detailansicht mit der vollständigen Begründung, den vom Modell hervorgehobenen Faktoren und einem direkten Link zur ursprünglichen Anzeige. Dort verbringt der Recruiter den größten Teil seiner Prüfzeit. Die Bewertung dient dazu, Aufmerksamkeit zu lenken; sie ist keine abschließende Entscheidung.

Wann LLM-Matching Keyword-Filter übertrifft

Keyword-Filter sind das richtige Werkzeug, wenn Anforderungen eindeutig und binär sind: eine bestimmte Zertifizierung, ein Standort in einer bestimmten Stadt oder eine Mindestzahl von Jahren in einer Position. Ein einfacher Filter prüft das in Millisekunden und ohne Kosten.

In der Praxis sind die meisten interessanten Anforderungen jedoch kontextabhängig. „Gesucht wird jemand, der sauberen, gut getesteten Code schreibt“ ist eine Präferenz, die sich in der Formulierung einer Stellenbeschreibung zeigt, nicht als einzelnes Keyword. „Kein Interesse an Positionen mit ausschließlich veralteten Technologien ohne Modernisierungsperspektive“ erfordert das Lesen und Interpretieren der Beschreibung. Ein Keyword-Filter kann dieses Signal nicht erkennen. Ein LLM, dem diese Anforderungen in einem klar formulierten Profil vorliegen, kann sie direkt bewerten.

Der Abstand wächst in Märkten, in denen Keywords inflationär verwendet werden. Wenn jede Stellenanzeige dieselben Fähigkeiten nennt, unabhängig davon, ob sie für die Position tatsächlich zentral sind, erzeugt Keyword-Matching eine nahezu gleichförmige Verteilung: Die meisten Anzeigen wirken gleichwertig. Die semantische Bewertung durchdringt dieses Rauschen, weil sie die Gesamtpassung der Anzeige zum Profil beurteilt statt nur das Vorkommen einzelner Begriffe.

Wann der Aufwand zu groß ist

LLM-Bewertungen verursachen spürbare Kosten und Wartezeiten. Bei großen Mengen mit wenig Interpretationsbedarf – Tausenden Anzeigen mit klaren binären Anforderungen – ist eine strukturierte Filterkette um Größenordnungen schneller und günstiger. Eine geeignete Architektur kombiniert häufig beide Ansätze: Ein schneller Vorfilter entfernt offensichtliche Nichttreffer und reduziert die Warteschlange auf eine handhabbare Teilmenge; nur der Rest wird vom LLM bewertet. Entfernt ein Vorfilter 80 % der Anzeigen, sinken die Kosten der LLM-Bewertung ungefähr um denselben Anteil; die Qualität der Zuordnung bleibt erhalten, solange der Vorfilter nur eindeutige Nichttreffer verwirft.

Ein weiterer Aspekt ist die Konsistenz. LLMs arbeiten nicht deterministisch. Dieselbe Anzeige kann bei zwei Bewertungen leicht unterschiedliche Werte erhalten. Für die meisten Recruiting-Anwendungen ist diese Schwankung akzeptabel: Ein paar Punkte mehr oder weniger ändern nicht, welche Anzeigen eine Prüfung wert sind. Bei Vergleichen zwischen Sitzungen mit unterschiedlichen Modellversionen oder deutlich überarbeiteten Prompts sollte sie jedoch berücksichtigt werden.

Verwandte Beiträge

Weiterführende Beiträge