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öße | Wert | Warum |
|---|---|---|
| Rang | 16 | 3,8 Mio. trainierbare Parameter, 1,4 % des Modells |
| Alpha | 32 | Doppelter Rang, die verbreitete Faustregel |
| Lernrate | 2e-4 | Üblich für LoRA auf kleinen Modellen, mit Kosinus-Abfall |
| Epochen | 3 | Der Prüfverlust läuft danach flach |
| Ziel-Module | q, k, v, o, gate, up, down | Aufmerksamkeit und MLP, nicht nur Aufmerksamkeit |
| Batch | 2 × 4 gesammelt | Der Vokabularkopf 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:
| Epoche | Lernverlust | Prüfverlust |
|---|---|---|
| 0 | - | 1,1754 |
| 1 | 0,4245 | 0,3195 |
| 2 | 0,2071 | 0,2989 |
| 3 | 0,1481 | 0,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.
| Kennzahl | Großes Modell | Roh | Nachtrainiert |
|---|---|---|---|
| Formkorrekte Aufrufe | 100,0 % | 100,0 % | 100,0 % |
| Richtige Aufrufe je Schritt | 96,9 % | 78,1 % | 93,8 % |
| Aufträge gelungen | 95,2 % | 73,8 % | 90,5 % |
| Stillgehalten (S4 + S5) | 93,8 % | 68,8 % | 93,8 % |
| Median je Anfrage | 6,42 s | 0,20 s | 0,16 s |
Nach Stufen, und hier steht die eigentliche Nachricht:
| Stufe | Roh | Nachtrainiert | Großes Modell |
|---|---|---|---|
| S1 | 87,5 % | 100,0 % | 100,0 % |
| S2 | 66,7 % | 83,3 % | 91,7 % |
| S3 | 83,3 % | 83,3 % | 100,0 % |
| S4 | 87,5 % | 100,0 % | 100,0 % |
| S5 | 50,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.0 | roh t 1.0 | fein t 0.0 | fein t 1.0 | |
|---|---|---|---|---|
| Formkorrekte Aufrufe | 100,0 % | 100,0 % | 100,0 % | 98,7 % |
| Aufträge gelungen | 73,8 % | 65,1 % | 90,5 % | 88,9 % |
| Stillgehalten | 68,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.