35 min

LoRA fahren

Der Verlust läuft nur über die Antwort, und der Prompt wird mit derselben Vorlage gerendert wie im Betrieb. Wer das trennt, trainiert ein Format, das nie ankommt.

Meilenstein Der Adapter ist trainiert, verschmolzen, hingestellt und an derselben Prüfbank gemessen.

Der Datensatz steht. Jetzt der Teil, der in diesem Kurs am wenigsten Zeit kostet und in den meisten Artikeln am meisten Platz bekommt.

Zwei Regeln, an denen alles hängt

Erstens: Der Prompt wird mit derselben Vorlage gerendert wie im Betrieb. Nicht nachgebaut, nicht „so ähnlich“. Dieselbe.

prompt = tok.apply_chat_template(msgs, tools=werkzeuge, tokenize=False,
                                 add_generation_prompt=True)

Der Grund steht am Ende von Kapitel 04. Wer den Trainingsprompt selbst zusammensetzt, trainiert auf ein Format, das der Inferenz-Server nie erzeugt, und wundert sich hinterher über ein Modell, das im Notebook glänzt und im Dienst Prosa schreibt. Dasselbe gilt für die Antwortseite: Sie entsteht als Differenz zwischen dem gerenderten Gespräch mit und ohne die Antwort.

voll = tok.apply_chat_template(msgs + [{"role": "assistant", "tool_calls": […]}],
                               tools=werkzeuge, tokenize=False)
# Die Vorlage hängt den Öffner der nächsten Runde an; das Modell hört
# davor auf. Also abschneiden und das Rundenende setzen.
antwort = voll[len(prompt):].replace("<start_function_response>", "")
antwort = antwort.rstrip() + "<end_of_turn>\n"

Die drei Zeilen lösen ein, was Kapitel 03 beobachtet hat. Die Vorlage schreibt nach dem Aufruf schon den Öffner der Werkzeugantwort hin, das Modell hört aber vorher auf. Wer das nicht abschneidet, bringt dem Spezialisten bei, seine eigene Werkzeugantwort mitzuschreiben. Das ist Fehlerbild E-2, der teuerste stille Fehler beim Anbinden dieses Modells.

Zweitens: Der Verlust läuft nur über die Antwort. Der Auftakt mit elf Werkzeugdeklarationen ist ungefähr neunhundert Token lang, die Antwort dreißig. Maskierst du nicht, besteht das Training zu siebenundneunzig Prozent daraus, die Werkzeugliste auswendig zu lernen.

p = tok(prompt, add_special_tokens=False)["input_ids"]
a = tok(antwort, add_special_tokens=False)["input_ids"]
ids = (p + a)[:max_len]
labels = ([-100] * len(p) + a)[:max_len]

-100 ist die Marke, die PyTorch beim Verlust überspringt. Mehr Mechanik braucht es nicht.

Die Werte, und woher sie kommen

Der Hersteller nennt keine. Also stehen hier die, die dieser Lauf benutzt hat, mit dem Hinweis, dass sie ein Ausgangspunkt sind und kein Rezept:

GrößeWertWarum
Rang163,8 Mio. trainierbare Parameter, 1,4 % des Modells
Alpha32Doppelter Rang, die verbreitete Faustregel
Lernrate2e-4Üblich für LoRA auf kleinen Modellen, mit Kosinus-Abfall
Epochen3Der Prüfverlust läuft danach flach
Ziel-Moduleq, k, v, o, gate, up, downAufmerksamkeit und MLP, nicht nur Aufmerksamkeit
Batch2 × 4 gesammeltDer Vokabular­kopf hat 262 144 Einträge; größer passt nicht

Die letzte Zeile ist die einzige, die wirklich vom Modell erzwungen wird. Gemma 3 hat ein sehr großes Vokabular, und die Logits einer Charge sind Charge × Länge × 262 144 groß. Bei Charge 8 sind das mehrere Gigabyte allein für den Kopf. Also kleine Charge, dafür Gradienten sammeln.

Der Lauf

docker run --rm --gpus all --ipc=host \
  -v llm-stack_hf-cache:/hf-cache -e HF_HUB_CACHE=/hf-cache -e HF_HUB_OFFLINE=1 \
  -v "$PWD":/arbeit -w /arbeit --entrypoint sh \
  vllm/vllm-openai:cu130-nightly -c \
  "pip install --quiet peft && python3 trainieren.py --epochen 3 --rang 16 \
     --alpha 32 --lr 2e-4 --batch 2 --sammeln 4"

304 Lernbeispiele, 33 zurückgehalten, um damit zu prüfen. So lief es:

EpocheLernverlustPrüfverlust
0-1,1754
10,42450,3195
20,20710,2989
30,14810,2989

Der Prüfverlust steht nach der zweiten Epoche. Der Lernverlust fällt weiter, was der übliche Hinweis darauf ist, dass die dritte Epoche schon auswendig lernt. Für den Betrieb würdest du hier bei zwei Epochen aufhören; im Kurs bleiben es drei, weil die Prüfbank danach entscheidet und nicht die Verlustkurve.

Der Adapter wird gespeichert, ins Grundmodell verschmolzen und als gewöhnliches Modell hingestellt. Dann braucht der Inferenz-Server keine Adapter-Unterstützung:

docker run -d --name fg-spezialist --gpus all --ipc=host \
  -v "$PWD":/arbeit -p 127.0.0.1:8016:8000 \
  vllm/vllm-openai:cu130-nightly \
  --model /arbeit/spezialist --served-model-name spezialist \
  --dtype bfloat16 --max-model-len 8192 --gpu-memory-utilization 0.04 \
  --enable-auto-tool-choice --tool-call-parser functiongemma

Die dritte Messung

Dieselbe Prüfbank. Dieselben zweiundvierzig Fälle.

KennzahlGroßes ModellRohNachtrainiert
Formkorrekte Aufrufe100,0 %100,0 %100,0 %
Richtige Aufrufe je Schritt96,9 %78,1 %93,8 %
Aufträge gelungen95,2 %73,8 %90,5 %
Stillgehalten (S4 + S5)93,8 %68,8 %93,8 %
Median je Anfrage6,42 s0,20 s0,16 s

Nach Stufen, und hier steht die eigentliche Nachricht:

StufeRohNachtrainiertGroßes Modell
S187,5 %100,0 %100,0 %
S266,7 %83,3 %91,7 %
S383,3 %83,3 %100,0 %
S487,5 %100,0 %100,0 %
S550,0 %87,5 %87,5 %

S5 springt von 50 auf 87,5 und liegt damit auf dem Wert des großen Modells. Elf Trainingsbeispiele haben gereicht. Was Kapitel 04 am Ende vermutet hat, war falsch, und der Ausgang ist der lehrreichere. Was zählt, ist offenbar, dass es die unbequemen Beispiele überhaupt gibt, und nicht, wie viele. Eine Gattung, die im Datensatz mit null Fällen steht, kann das Modell nicht lernen. Mit elf kann es sie.

stillgehalten von 68,8 auf 93,8 sagt dasselbe aus einer anderen Richtung. Das ist die Zahl, an der Bauform 3 hängt. Ein Spezialist, der nicht schweigen kann, löst die Weiterleitung nie aus.

Und die Zeit? 0,16 Sekunden im Median, 1,66 im schlechtesten Fall. Vierzigmal schneller als das große Modell, bei 90,5 statt 95,2 Prozent.

Was noch schiefgeht

Vier Fälle von 42, und drei davon haben dieselbe Handschrift:

S2-8  soll: create_event{title: "Kickoff", start: "2026-09-14T09:00:00Z", …}
      ist:  create_event{title: "Kickoff", start: "2026-09-14", end: "2026-09-14"}
S2-9  soll: book_room{event_id: …, room: "besprechung"}
      ist:  book_room{event_id: …}                       ← Pflichtfeld fehlt
S3-6  soll: invite{…, calendar: "brandt"} + invite{…, calendar: "keller"}
      ist:  vier Aufrufe — brandt, keller, ruben und „orto"

Der dritte ist der interessanteste. Der Spezialist hat gelernt, dass „lade A und B ein“ mehrere Aufrufe bedeutet, und lädt daraufhin alle Kalender ein, die er kennt - einschließlich eines erfundenen namens orto, offenbar ein verunglücktes organisator. Übergeneralisierung ist die typische Handschrift eines kleinen Modells nach einem Feintuning, und sie ist der Grund, warum das Gatter im nächsten Kapitel nicht wegfallen darf.

Und S5-2 hält sich hartnäckig: „Delete the contact of Mr Rutkowski“ wird weiterhin zu delete_contact{contact_id: "Mr. Rutkowski"}. Von den drei S5-Erfindungen aus Kapitel 03 ist eine geblieben. Ein Feintuning verschiebt eine Verteilung, es beweist nichts.

Die Temperatur, noch einmal

Die Frage aus Kapitel 03 gehört auch für den nachtrainierten Stand gestellt, denn das Training könnte sie verschoben haben. Hat es - und zwar deutlich:

roh t 0.0roh t 1.0fein t 0.0fein t 1.0
Formkorrekte Aufrufe100,0 %100,0 %100,0 %98,7 %
Aufträge gelungen73,8 %65,1 %90,5 %88,9 %
Stillgehalten68,8 %68,8 %93,8 %91,7 %

Roh kostet die Herstellertemperatur 8,7 Punkte. Nach dem Feintuning kostet sie 1,6. Das passt zu dem, was ein Feintuning tut. Es schärft die Verteilung über die nächsten Token, und eine scharfe Verteilung überlebt das Sampling besser als eine flache.

Praktisch heißt das zweierlei. Auf 0.0 bleiben lohnt sich weiterhin, es ist nur nicht mehr dramatisch. Und brauchst du in deinem Aufbau Sampling, für Vielfalt oder für Selbstkonsistenz, dann kannst du es dir nach dem Training eher leisten als davor.