Der Verifikations-Loop in der Praxis: Bewerbungstexte, die sich selbst prüfen
Schema erzwingen, Ausgabe validieren, mit Fehlerliste neu versuchen, reparieren statt abbrechen – wie die Jobschleuse ein kleines lokales Modell zuverlässig genug macht. Konkreter Code statt Theorie.
In „Du brauchst kein teures Modell“ habe ich behauptet: Die Qualität kommt aus enger Spezifikation und einem Loop, der Fehler auffängt – nicht aus dem nächstgrößeren Modell. Das war Haltung. Hier ist die Praxis: Die Jobschleuse lässt ein LLM Bewerbungsanschreiben in eine HTML-Vorlage einsetzen, und zwar mit einem lokalen Modell. Dass das taugt, liegt an vier kleinen Bausteinen um den einzelnen Aufruf herum.
Ausgangslage
Die Vorlage hat data-slot-Blöcke (Anrede, Einstieg, Motivation, …), jeder mit einem Beispieltext. Das Modell bekommt Stellenanzeige, Profil und die Slots und soll für jeden Slot einen Text liefern. Was dabei schiefgehen kann, habe ich nicht erraten, sondern aus echten Bewerbungen gesammelt:
- Slots fehlen oder sind leer.
- Das Modell erfindet eine Anschrift oder setzt Platzhalter wie „[Ihr Name]“ ein.
- Es hängt eine Grußformel an, obwohl die Vorlage selbst schon eine setzt.
- Der Firmenname kommt nirgends vor, der Text ist also generisch.
- Deutsche Anführungszeichen brechen das JSON auf.
Baustein 1: Schema statt „gib JSON“
Nur „antworte als JSON“ zu verlangen reicht nicht: Die Grammatik lässt das Modell das Objekt früh schließen, und es fehlen Slots. Also bekommt jeder Aufruf ein striktes Antwortschema, in dem jeder Slot ein Pflichtfeld ist:
def slot_schema(namen) -> dict:
return {
"type": "json_schema",
"json_schema": {
"name": "slots",
"strict": True,
"schema": {
"type": "object",
"properties": {name: {"type": "string"} for name in namen},
"required": list(namen),
"additionalProperties": False,
},
},
}Das löst gleichzeitig das Anführungszeichen-Problem: Mit erzwungenem Schema bleibt der JSON-String intakt. Das ist Spezifikation im engsten Sinn – eine ganze Fehlerklasse verschwindet, ohne dass ich das Modell dafür bitten muss.
Baustein 2: Prüfen, was das Schema nicht prüfen kann
Ein Schema sagt, dass ein String da ist, nicht, ob er brauchbar ist. Dafür gibt es eine Validierung mit klaren, einzeln testbaren Regeln:
def validate_values(values, slots, company) -> list[str]:
problems = []
if set(values) != set(slots):
problems.append("Slot-Namen stimmen nicht überein …")
empty = [k for k, v in values.items() if not isinstance(v, str) or not v.strip()]
if empty:
problems.append(f"Leere Slots: {empty}")
for name, wert in values.items():
problems.extend(pruefe_text(name, wert, slots.get(name, ""))) # Platzhalter, Grußformel
if _company_core(company).lower() not in " ".join(values.values()).lower():
problems.append("Firmenname kommt in keinem Slot vor")
return problemsDie Funktion gibt eine Liste von Problemen in Klartext zurück, keinen Wahrheitswert. Das ist der wichtigste Designentscheid, denn die Liste ist gleichzeitig Fehlermeldung, Testziel und Material für den nächsten Schritt.
Baustein 3: Mit der Fehlerliste neu versuchen
Wenn die Prüfung etwas findet, geht der Aufruf ein zweites Mal raus – diesmal mit den konkreten Fehlern im Prompt:
for _attempt in range(2):
…
last_problems = validate_values(values, slots, job.company)
if not last_problems:
return values
prompt = build_prompt(job, slots, profile) + (
f"\n\nDein letzter Versuch hatte diese Fehler: {last_problems}. Korrigiere sie."
)
raise GenerationError(f"LLM-Ausgabe nach 2 Versuchen ungültig: {last_problems}. …")Zwei Dinge sind bewusst so: Es gibt eine harte Obergrenze (zwei Versuche, kein Endlos-Retry), und der Abbruch ist laut – mit Fehlerliste und einem Auszug der Rohantwort, nicht mit einem stillen leeren Ergebnis.
Baustein 4: Reparieren statt abbrechen
Nicht jeder Fehler braucht ein neues Modell. Die überzählige Grußformel hängt das Modell hartnäckig an, und zwei gescheiterte Versuche hießen am Ende: gar keine Bewerbung. Der Abschluss ist aber eindeutig abtrennbar. Also repariert die Pipeline deterministisch, bevor validiert wird:
def ohne_ueberzaehlige_grussformel(wert: str, beispiel: str) -> str:
if _grussformel_in(beispiel): # Vorlage setzt selbst eine → nichts kappen
return wert
… # nur kappen, wenn hinter der Formel höchstens noch der Name stehtDie Bedingung ist absichtlich eng: gekappt wird nur, wenn dahinter nichts Inhaltliches mehr kommt. Eine Reparatur, die im Zweifel Text löscht, wäre schlimmer als der Fehler.
Wo die Analogie zu „plan → test → review“ endet
Im Post zum Loop habe ich eine feste Pipeline beschrieben: plan, implement, test, review aus frischem Kontext, verify, persist. Die Jobschleuse hat davon nur einen Teil, und das gehört zur ehrlichen Bilanz:
| Stufe im Loop | In der Jobschleuse |
|---|---|
| Spezifikation | striktes Schema, Profil im Prompt, Verbot erfundener Angaben |
| Verifikation | validate_values – deterministisch, kein zweites Modell |
| Retry mit Feedback | 2 Versuche, Fehlerliste im Prompt |
| Reparatur | Grußformel kappen |
| Review aus frischem Kontext | ich: Vorschau der Bewerbung, bevor das PDF entsteht |
| Persist | jeder reale Fehlerfall wird ein Test |
Ein zweites Modell als Reviewer gibt es bewusst nicht. Die Regeln sind so konkret, dass Code sie besser prüft als ein LLM – und für den Rest sitzt ein Mensch vor der Vorschau. Das spart Token, Strom und eine Fehlerquelle.
Persist: Jeder Fehler wird ein Test
Der Teil, der den Loop über die Zeit besser macht: Die Regeln stammen nicht aus dem Kopf, sondern aus echten Fehlversuchen, und jede bekommt einen Test (test_validate_values_meldet_erfundene_adresse, test_validate_values_meldet_zusaetzliche_grussformel, test_validate_values_erlaubt_grussformel_wenn_vorlage_eine_hat). Der letzte ist der wichtige: Er schützt die Reparatur davor, zu viel zu tun. Beim nächsten Modellwechsel laufen diese Tests als Regressionsnetz mit.
Was es nicht ist
- Kein Beweis, dass kleine Modelle immer reichen. Es zeigt nur: Für diese Aufgabe – eng spezifiziert, prüfbar, mit Mensch am Ende – genügt ein lokales Modell.
- Keine Garantie auf Inhalt. Die Validierung fängt Form und bekannte Fehlermuster ab. Ob ein Motivationsabsatz überzeugt, prüft kein Code.
- Zahlen fehlen. Wie oft der erste Versuch durchgeht und wie oft der zweite rettet, habe ich nicht systematisch gemessen. TODO: Erfolgsquote über die letzten N Generierungen aus der Datenbank ziehen.
Fazit
Der Loop besteht aus vier kleinen, langweiligen Teilen: Schema, Prüfliste, begrenzter Retry mit Feedback, eng gefasste Reparatur – dazu ein Test pro echtem Fehler. Keiner davon braucht ein größeres Modell. Alle vier brauchen, dass ich vorher hingeschaut habe, wie es schiefgeht.