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:

  1. EOS Token explizit zu jedem Sample hinzufügen
  2. pad_token = unk_token statt eos_token in 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

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 with pad_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:

  1. pad_token = eos_tokenpad_token_id = 2
  2. eos_token_id = 2 (per Definition)
  3. DataCollator maskiert alle Tokens mit pad_token_id
  4. → Alle Tokens mit ID 2 werden zu -100 in Labels
  5. → Das schließt ALLE EOS Tokens ein!
  6. → 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:

  1. Loss sieht gut aus: Model lernt Content-Tokens korrekt → Loss sinkt normal
  2. Training konvergiert: Kein technisches Problem, keine Errors
  3. Viele Tutorials haben denselben Bug: Copy-Paste aus ungeprüften Quellen
  4. 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:

  1. Hypothese 1 (Chunk-Gruppierung) → getestet → widerlegt
  2. Hypothese 2 (Fehlende EOS) → getestet → teilweise bestätigt
  3. 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:

  1. Problem erkannt → Model generiert endlos statt zu stoppen
  2. Hypothese 1 (Chunk-Gruppierung) → Diagnose-Script → Widerlegt (0.1% consecutive)
  3. Hypothese 2 (Fehlende EOS) → Tokenizer-Test → Bestätigt (kein auto-EOS)
  4. Fix 1 implementiert → EOS explizit hinzugefügt
  5. Community Research → pad_token Anti-Pattern entdeckt
  6. Root Cause Analysis → DataCollator maskiert alle EOS Tokens
  7. Fix 2 implementiert → pad_token = unk_token in 4 Files
  8. 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.