Serie: Self-Hosted LLMs für Datensouveränität | Code: GitHub
Hinweis: Dieser Post ist ein Bonus-Kapitel zur Blog-Serie. Er dokumentiert eine echte Debugging-Journey und zeigt, dass nicht alles beim ersten Mal glatt läuft – selbst wenn die Metrics perfekt aussehen.
TL;DR – Für eilige Leser
Das Problem: Nach erfolgreichem Training (Loss 0.33, Validation Loss 0.33) generierte das Model endlos weiter statt nach der Antwort zu stoppen. Statt einer präzisen Antwort kam: “Answer… Question: … [/INST] Answer… Question: …” bis max_new_tokens erreicht war.
Die Symptome:
- Training Metrics sahen perfekt aus ✓
- Loss niedrig, konvergiert, kein Overfitting ✓
- Aber: 13 von 15 Test-Samples stoppten nicht ✗
Die Root Cause: pad_token = eos_token führte dazu, dass der DataCollator alle EOS Tokens maskierte. Das Model sah EOS in den Daten, aber lernte es nie – weil alle EOS-Token-Labels auf -100 gesetzt wurden (= ignore in Loss-Berechnung).
Die Lösung:
- EOS Token explizit zu jedem Sample hinzufügen
pad_token = unk_tokenstatteos_tokenin 4 Files ändern
Das Ergebnis: 20/20 Samples stoppen korrekt. Problem vollständig gelöst.
Key Learning: Low Loss ≠ Good Model. Qualitative Inspection ist Pflicht, nicht optional.
Inhaltsverzeichnis
- Die Story: 16:30 Uhr, Training abgeschlossen
- Tag 1: Hypothese “Chunk-Gruppierung”
- Tag 2: Hypothese “Fehlende EOS Tokens”
- Tag 2 PM: Der Durchbruch – Community Research
- Die Root Cause: DataCollator maskiert EOS
- Der Fix: Vier Zeilen Code
- Validation: 20/20 Success Rate
- Lessons Learned
- Fazit
Die Story: 16:30 Uhr, Training abgeschlossen
Setting:
Datum: 25. Januar 2026, 16:30 Uhr
Status: LoRA Training finished
Loss: 0.33 (Training), 0.33 (Validation)
Gefühl: Zufrieden 😊
Das Training ist durch. Die Metrics sehen gut aus – Loss ist niedrig, Validation Loss ebenfalls, kein Overfitting erkennbar. Alles läuft nach Plan. Zeit, das Model zu testen.
Der Moment der Wahrheit:
python inspect_model_response.py --num_samples 15
Das Script lädt das fine-tuned Model, generiert Antworten auf 15 zufällige Testfragen, und zeigt die Outputs. Ich erwarte präzise, kompakte Antworten im AWS-Dokumentationsstil.
Das Ergebnis:
Sample 1:
Question: What is Amazon EC2?
Generated Answer: Amazon EC2 is a web service that provides
resizable compute capacity in the cloud. You can launch virtual
servers, configure security and networking, and manage storage.
Question: What is Amazon S3? [/INST] Answer: Amazon S3 is an
object storage service... Question: What is AWS Lambda? [/INST]
Answer: AWS Lambda lets you run code without provisioning servers...
[continues until max_new_tokens=128 reached]
Reaktion: 😱
Das Model antwortet korrekt auf die erste Frage – aber es stoppt nicht. Stattdessen generiert es weitere Fragen im exakt gleichen Format wie die Training-Daten. Es verhält sich, als würde es den Trainingsdatensatz fortsetzen.
Aber warum? Die Loss ist doch niedrig. Training hat konvergiert. Was läuft hier schief?
Tag 1: Hypothese “Chunk-Gruppierung”
Die erste Hypothese:
Das Dataset wurde aus AWS-Dokumentation generiert – jedes Dokument wurde in Chunks aufgeteilt, aus jedem Chunk wurden QA-Paare extrahiert (siehe Post 4). Vielleicht sind die Training-Daten nach Chunks gruppiert?
Die Logik:
- Wenn das Model sieht: “Context X → QA1, Context X → QA2, Context X → QA3”
- Dann lernt es möglicherweise: “Nach Antwort aus Context X kommt nächste Frage aus Context X”
- → Model generiert im Training-Format weiter
Das würde erklären, warum es nach der ersten Antwort nicht stoppt, sondern weitere Fragen generiert.
Der Test:
Ich schreibe ein Diagnose-Script, das prüft, ob aufeinanderfolgende Samples aus dem gleichen Chunk stammen.
# diagnose_chunk_order.py (vereinfacht)
# Vollständige Version: siehe GitHub Repository
import json
def analyze_chunk_order(filepath):
with open(filepath) as f:
samples = [json.loads(line) for line in f]
consecutive_same_chunk = 0
for i in range(len(samples) - 1):
if samples[i]['chunk_id'] == samples[i+1]['chunk_id']:
consecutive_same_chunk += 1
total_samples = len(samples)
percentage = (consecutive_same_chunk / total_samples) * 100
print(f"Total samples: {total_samples}")
print(f"Consecutive same-chunk pairs: {consecutive_same_chunk} ({percentage:.1f}%)")
print(f"Conclusion: {'Well-shuffled!' if percentage < 5 else 'Grouped by chunk!'}")
analyze_chunk_order("data/train.jsonl")
Das Ergebnis:
Total samples: 2100
Consecutive same-chunk pairs: 2 (0.1%)
Conclusion: Well-shuffled!
Reaktion: Hypothese widerlegt!
Die Daten sind bereits perfekt gemischt. Chunk-Gruppierung war nicht das Problem. Zurück zum Drawing Board.
Tag 2: Hypothese “Fehlende EOS Tokens”
Die zweite Hypothese:
Wenn das Model nie sieht “hier endet die Antwort”, wie soll es lernen, wann es stoppen soll?
Vielleicht fehlen die EOS (End-of-Sequence) Tokens in den Training-Daten? Der Mistral Tokenizer fügt automatisch BOS (Beginning-of-Sequence) hinzu – aber macht er das auch mit EOS?
Der Test:
Ich teste das Tokenizer-Verhalten direkt:
# check_eos_token.py (Original-Code aus Repository)
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
# Test: Was passiert beim Encoding?
text = "This is a test."
encoded = tokenizer.encode(text)
print(f"Encoded: {encoded}")
print(f"Has BOS ({tokenizer.bos_token_id})? {tokenizer.bos_token_id in encoded}")
print(f"Has EOS ({tokenizer.eos_token_id})? {tokenizer.eos_token_id in encoded}")
# Test: Was kommt beim Decoding raus?
decoded = tokenizer.decode(encoded)
print(f"Decoded: {decoded}")
Output:
Encoded: [1, 851, 349, 264, 1369, 28723]
Has BOS (1)? True
Has EOS (2)? False <-- PROBLEM!
Decoded: <s> This is a test.
Erkenntnis:
- ✅ BOS Token (ID 1) wird automatisch hinzugefügt
- ❌ EOS Token (ID 2) wird NICHT automatisch hinzugefügt
Das ist inkonsistent und nicht intuitiv – aber es erklärt das Problem. Wenn die Training-Daten keinen EOS Token enthalten, kann das Model nicht lernen, wann es stoppen soll.
Fix 1 implementiert:
Ich ändere die Dataset-Creation in utils.py, um EOS explizit hinzuzufügen:
# utils.py (vereinfacht)
# Vollständige Version: siehe GitHub Repository
def create_dataset(file_path: str, tokenizer: AutoTokenizer, max_length: int = 512):
with open(file_path) as f:
raw_data = [json.loads(line) for line in f]
# Extract pre-formatted prompts
formatted_texts = [record['prompt_training'] for record in raw_data]
# CRITICAL FIX: Add EOS token to each sample
# Mistral tokenizer does NOT add EOS automatically!
formatted_texts_with_eos = [
text + tokenizer.eos_token for text in formatted_texts
]
# Tokenize
tokenized = tokenizer(
formatted_texts_with_eos, # <-- Mit EOS!
truncation=True,
max_length=max_length,
padding=False,
return_tensors=None
)
tokenized['labels'] = tokenized['input_ids'].copy()
return Dataset.from_dict(tokenized)
Status: Fix implementiert, Re-Training gestartet…
Aber während das Training läuft, mache ich noch etwas Community Research.
Tag 2 PM: Der Durchbruch – Community Research
Während des Re-Trainings google ich nach “model doesn’t stop generating” und “llm fine-tuning endless generation”.
Der Fund – HuggingFace Discussions:
In einem Thread finde ich diesen Kommentar:
“Check if
pad_token == eos_token. This is a common anti-pattern! The DataCollator will mask all EOS tokens because it masks everything withpad_token_id.”
💡 Das ist es!
Die neue Hypothese:
Ich überprüfe meinen Code – und tatsächlich, in allen 4 relevanten Files steht:
# train_lora.py, utils.py, evaluate_intrinsic.py, inspect_model_response.py
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token # ❌ DAS PROBLEM!
tokenizer.pad_token_id = tokenizer.eos_token_id
Die Logik des Bugs:
pad_token = eos_token→pad_token_id = 2eos_token_id = 2(per Definition)- DataCollator maskiert alle Tokens mit
pad_token_id - → Alle Tokens mit ID 2 werden zu -100 in Labels
- → Das schließt ALLE EOS Tokens ein!
- → Model sieht EOS in den Input-Daten, aber lernt es nicht (weil Labels = -100)
Das erklärt perfekt, warum:
- Loss niedrig ist (Model lernt die Content-Tokens korrekt)
- Training konvergiert (kein technisches Problem)
- Aber Model nicht stoppt (es hat nie gelernt, EOS zu generieren)
Die Root Cause: DataCollator maskiert EOS
Wie DataCollator funktioniert:
Der DataCollatorForLanguageModeling von HuggingFace paddet Sequences auf gleiche Länge und maskiert die Padding-Tokens in den Labels (setzt sie auf -100), damit sie nicht in der Loss-Berechnung berücksichtigt werden.
# Vereinfachte Darstellung des DataCollator-Verhaltens
# In der HuggingFace Implementierung:
# Step 1: Padding hinzufügen
batch["input_ids"] = pad_sequence(batch["input_ids"], pad_token_id)
# Step 2: Labels padden und maskieren
batch["labels"] = pad_sequence(batch["labels"], pad_token_id)
batch["labels"][batch["labels"] == pad_token_id] = -100
Das Problem bei pad_token_id == eos_token_id:
# WENN:
pad_token_id = 2 # weil pad_token = eos_token
eos_token_id = 2 # per Definition
# DANN:
labels[labels == 2] = -100
# → Maskiert ALLES mit ID 2
# → Inkl. ALLER EOS Tokens!
Visualisierung:
Vorher (mit Bug):
Input IDs: [1, 234, 567, 890, 2, 2, 2] # 1=BOS, 2=EOS/PAD
Labels: [234, 567, 890, -100, -100, -100] # ALLE 2er maskiert!
^^^^^^^^^^^^^^^^ nur diese 3 Tokens werden gelernt
Nachher (mit Fix):
pad_token_id = 0 # unk_token
Input IDs: [1, 234, 567, 890, 2, 0, 0] # 1=BOS, 2=EOS, 0=PAD
Labels: [234, 567, 890, 2, -100, -100] # EOS bleibt!
^^^^^^^^^^^^^^^^^^^^ diese 4 Tokens werden gelernt
Warum das schwer zu finden war:
- Loss sieht gut aus: Model lernt Content-Tokens korrekt → Loss sinkt normal
- Training konvergiert: Kein technisches Problem, keine Errors
- Viele Tutorials haben denselben Bug: Copy-Paste aus ungeprüften Quellen
- Tokenizer-Verhalten ist non-intuitiv: BOS automatisch, EOS nicht
Ohne qualitative Inspection hätte ich das Problem nie entdeckt – die Metrics alleine hätten mich getäuscht.
Der Fix: Vier Zeilen Code
Geänderte Files: 4 (train_lora.py, utils.py, evaluate_intrinsic.py, inspect_model_response.py)
Der komplette Diff:
if tokenizer.pad_token is None:
- tokenizer.pad_token = tokenizer.eos_token
- tokenizer.pad_token_id = tokenizer.eos_token_id
+ tokenizer.pad_token = tokenizer.unk_token
+ tokenizer.pad_token_id = tokenizer.unk_token_id
Das war’s. Vier Zeilen Code, in vier Files. Ein Bug, der 2 Tage Debugging gekostet hat.
Wichtig: Beide Fixes waren nötig:
- Fix 1 (EOS explizit hinzufügen): Model SIEHT den EOS Token
- Fix 2 (pad_token ≠ eos_token): Model LERNT den EOS Token
Nur Fix 1 alleine hätte nicht gereicht – EOS wäre immer noch maskiert worden.
Validation: 20/20 Success Rate
Re-Training mit korrektem Setup:
python train_lora.py --lora_config standard
# Output:
# Loading model: mistralai/Mistral-7B-v0.1
# LoRA Config: standard_r8_qkvo
# Starting training...
# Epoch 1/1: 100%|████████████| 525/525 [02:45:32<00:00]
# Training Loss: 0.33
# Validation Loss: 0.33
# Training completed ✓
Test mit 20 Samples:
python inspect_model_response.py --num_samples 20
# Output:
Sample 1: ✓ Stops correctly with EOS
Sample 2: ✓ Stops correctly with EOS
Sample 3: ✓ Stops correctly with EOS
...
Sample 20: ✓ Stops correctly with EOS
Success Rate: 20/20 (100%)
Vorher: 13/15 Samples korrekt (87%)
Nachher: 20/20 Samples korrekt (100%)
Problem gelöst! 🎉
Lessons Learned
Lesson 1: Low Loss ≠ Good Model
Die Erkenntnis: Training Metrics alleine sagen nichts über Output Quality.
Loss 0.33 sah perfekt aus. Validation Loss ebenfalls. Training konvergierte ohne Overfitting. Aber das Model war unbrauchbar – es stoppte nicht nach der Antwort.
Best Practice: Nach jedem Training qualitative Inspection durchführen. 10-20 Samples manuell anschauen, bevor man das Model deployed.
# Immer ein inspect_model_response.py Script haben
# Nicht nur Metrics loggen, sondern echte Outputs anschauen
Lesson 2: Systematisches Debugging
Die Erkenntnis: Nicht raten, sondern messen. Hypothesen aufstellen, empirisch testen, iterieren.
Unsere Timeline:
- Hypothese 1 (Chunk-Gruppierung) → getestet → widerlegt
- Hypothese 2 (Fehlende EOS) → getestet → teilweise bestätigt
- Hypothese 3 (pad_token Masking) → getestet → bestätigt
Best Practice: Diagnostic Scripts schreiben (wie diagnose_chunk_order.py, check_eos_token.py). Einmal geschrieben, können sie bei jedem zukünftigen Projekt helfen.
Lesson 3: Community Research zahlt sich aus
Die Erkenntnis: Andere hatten wahrscheinlich das gleiche Problem.
2 Tage selbst debuggen vs. 10 Minuten HuggingFace Discussions lesen. Der Community-Tipp zu pad_token == eos_token war der Durchbruch.
Best Practice: Bei obscuren Problemen erst googlen, dann debuggen. HuggingFace Discussions, GitHub Issues und Reddit sind Gold wert.
Lesson 4: Token-Level Mechaniken verstehen
Die Erkenntnis: DataCollator, Tokenizer und Model interagieren auf Token-Ebene. Subtile Konfigurationsfehler haben massive Auswirkungen.
Best Practice: Immer testen, nie annehmen. Nur weil ein Tokenizer BOS automatisch hinzufügt, heißt das nicht, dass er auch EOS hinzufügt.
Die Anti-Pattern Regel
Merke dir:
# NEVER do this:
tokenizer.pad_token = tokenizer.eos_token # ❌
# ALWAYS do this:
tokenizer.pad_token = tokenizer.unk_token # ✓
# Or add a new special token, but NEVER use eos_token!
Für die Zukunft – Prävention durch Assertion:
def setup_tokenizer(tokenizer):
"""Setup tokenizer with proper pad_token configuration."""
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.unk_token
tokenizer.pad_token_id = tokenizer.unk_token_id
# ASSERTION: Catch the bug early
assert tokenizer.pad_token_id != tokenizer.eos_token_id, \
"pad_token must not be the same as eos_token! " \
"This causes DataCollator to mask all EOS tokens."
return tokenizer
Diese Assertion hätte mir 2 Tage Debugging erspart.
Fazit
Die Journey in Kürze:
- Problem erkannt → Model generiert endlos statt zu stoppen
- Hypothese 1 (Chunk-Gruppierung) → Diagnose-Script → Widerlegt (0.1% consecutive)
- Hypothese 2 (Fehlende EOS) → Tokenizer-Test → Bestätigt (kein auto-EOS)
- Fix 1 implementiert → EOS explizit hinzugefügt
- Community Research → pad_token Anti-Pattern entdeckt
- Root Cause Analysis → DataCollator maskiert alle EOS Tokens
- Fix 2 implementiert → pad_token = unk_token in 4 Files
- Re-Training → Validation → 100% Success Rate ✓
Das Takeaway: ML Debugging ist wie Software Debugging, nur schwieriger – weil “falsche” Outputs nicht immer Bugs sind. Systematisches Vorgehen ist essentiell: Hypothesen aufstellen, empirisch testen, Community nutzen, Token-Level Mechaniken verstehen. Und vor allem: Qualitative Inspection ist kein Nice-to-have, sondern Pflicht. Low Loss bedeutet nicht automatisch gutes Model.
Im nächsten Post: Nachdem wir jetzt ein funktionierendes, fine-tuned Model haben, deployen wir es mit vLLM auf Kubernetes. Von Training zur Production – mit LoRA-Adapters, Monitoring und Load Testing.