KI & Maschinelles Lernen

LLM Fine-Tuning: Praxis-Guide mit LoRA-Code

LLM Fine-Tuning in der Praxis: lauffähiger LoRA-Code, Datenaufbereitung, Evaluierung und wann RAG die bessere Wahl ist.

Editorial Team / /11 Min. Lesezeit
Eine Ingenieurin arbeitet nachts an mehreren Monitoren mit Code und Dashboards

In diesem Guide nimmst du ein Basismodell und bringst ihm ein Verhalten bei, das es ab Werk nicht hat, mit LoRA auf einer einzigen GPU. Du bekommst den echten Code: Datenformatierung, einen PEFT-Trainingslauf mit Hugging Face, Evaluierung und Inferenz. Am Ende kannst du das Ganze auf deinem eigenen Datensatz nachbauen.

Die meisten Teams, die glauben, sie brauchen Fine-Tuning, brauchen in Wahrheit Retrieval. Fine-Tuning verändert, wie sich ein Modell verhält; es fügt keine Fakten zuverlässig hinzu, die du nachschlagen kannst. Triff diese eine Entscheidung richtig, und der Rest ist mechanisch.

Wann Fine-Tuning, und wann RAG

Fine-Tuning ist das richtige Werkzeug, wenn das Verhalten das Problem ist: Du brauchst jede Antwort in einem strikten JSON-Schema, einen konsistenten Hausstil, ein Klassifikationslabel oder einen Fachdialekt, an dem das Basismodell scheitert. Das Wissen ist schon latent vorhanden; du formst die Ausgabe.

LoRA: ein kleiner Low-Rank-Adapter neben den eingefrorenen Gewichten

LoRA: ein kleiner Low-Rank-Adapter neben den eingefrorenen Gewichten

Eine schnelle Entscheidungshilfe:

  • Wenn ein neuer Fakt nächsten Monat ein Neutraining bedeutet, willst du RAG.
  • Wenn du die gewünschte Ausgabe nicht als Regel beschreiben, aber an 500 Beispielen zeigen kannst, willst du Fine-Tuning.
  • Wenn du sowohl korrekte Fakten als auch ein starres Ausgabeformat brauchst, nimm RAG für die Fakten und Fine-Tuning für das Format. Beides greift sauber ineinander.

Die zwei sind keine Rivalen. Ein großer Teil der Fine-Tuning-Anfragen, die in der Queue eines ML-Teams landen, entpuppt sich als Retrieval-Problem mit einem Fine-Tuning-Etikett darauf: Sie verlangen vom Modell, einen Fakt zu kennen, statt sich auf eine bestimmte Art zu verhalten. Trainierst du auf Fakten, bekommst du ein Modell, das neue erfindet, exakt in der Form deiner Trainingsdaten.

Was LoRA macht, in einem Absatz

Vollständiges Fine-Tuning aktualisiert jedes Gewicht im Modell. Für ein 7B-Modell in fp16 sind das grob 14 GB allein für die Gewichte, dazu Optimizer-State und Gradienten, was den realen Speicherbedarf über 60 bis 80 GB treibt. LoRA friert die ursprünglichen Gewichte ein und trainiert stattdessen zwei kleine Low-Rank-Matrizen pro angesprochener Schicht, sodass du deutlich unter 1 % der Parameter aktualisierst. Damit passt ein 7B-Fine-Tune auf eine einzelne GPU mit 16 bis 24 GB, und das Ergebnis ist ein kleiner Adapter (ein paar Dutzend MB), den du auf das Basismodell aufsetzt. Quantisierst du das eingefrorene Basismodell zuerst auf 4-Bit (QLoRA), sinkt die Untergrenze weiter, niedrig genug für einen kostenlosen Colab-T4 bei kleineren Modellen.

Schritt 1: Umgebung und Abhängigkeiten

Versionen festnageln. Die PEFT- und TRL-APIs verschieben sich von Release zu Release (TRL hat insbesondere eine Major-Version-Grenze überschritten, von 0.x auf 1.x, mit Änderungen am SFTTrainer-Interface), und ein nicht zusammenpassender Stack ist der häufigste Grund, warum ein kopiertes Snippet scheitert. Die Versionen unten sind Stand Juni 2026 aktuell; prüf sie auf PyPI, bevor du sie festnagelst.

pip install "transformers>=4.51.0" \
            "peft==0.19.0" \
            "trl==1.5.1" \
            "datasets>=3.3.0" \
            "accelerate>=1.4.0" \
            "bitsandbytes==0.49.2"

bitsandbytes liefert die 4-Bit-Quantisierung für QLoRA und braucht eine CUDA-GPU. Auf Apple Silicon lässt du es weg und fährst LoRA ohne Quantisierung; das MPS-Backend kommt mit kleinen Modellen klar. Prüf, ob deine GPU sichtbar ist, bevor es weitergeht:

import torch
print(torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU/MPS")

Erwartete Ausgabe auf einer CUDA-Maschine:

True NVIDIA GeForce RTX 4090

Schritt 2: Den Datensatz vorbereiten

Hier entscheidet sich, ob das Projekt steht oder fällt. Das Modell lernt genau das Format, das du ihm gibst, deine Fehler inklusive, also muss der Datensatz so aussehen wie das, was du zur Inferenzzeit haben willst.

Wir machen einen Fine-Tune für Instruction Following: Eingabe-Prompt, erwartete Antwort. Der sauberste Weg ist, jedes Beispiel mit dem eigenen Chat-Template des Basismodells zu formatieren, sodass Training und Inferenz identisch formatieren. Hier nehmen wir einen kleinen Ausschnitt eines öffentlichen Instruction-Datensatzes; tausch ihn gegen dein eigenes JSONL mit den Feldern instruction und response.

from datasets import load_dataset
from transformers import AutoTokenizer

model_id = "mistralai/Mistral-7B-v0.3"
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token  # Mistral has no pad token by default

raw = load_dataset("databricks/databricks-dolly-15k", split="train[:2000]")

def to_chat(example):
    user = example["instruction"]
    if example.get("context"):
        user += f"\n\nContext:\n{example['context']}"
    messages = [
        {"role": "user", "content": user},
        {"role": "assistant", "content": example["response"]},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    return {"text": text}

dataset = raw.map(to_chat, remove_columns=raw.column_names)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
print(dataset["train"][0]["text"][:300])

Ein paar Regeln, die mehr wiegen als die Modellwahl:

  • Ein paar hundert saubere, konsistente Beispiele schlagen Zehntausende verrauschte. Lies eine Zufallsstichprobe von Hand, bevor du trainierst; du wirst doppelte Prompts und abgeschnittene Antworten finden.
  • Halt einen echten Test-Split zurück (das test_size=0.1 oben). Einen Fine-Tune kannst du nicht am Trainings-Loss allein beurteilen.
  • Halt die Verteilung ehrlich. Wenn 95 % der Trainingsbeispiele kurze Antworten sind, wird sich das Modell gegen lange sträuben.

Schritt 3: Das Modell in 4-Bit laden

Lad das Basismodell auf 4-Bit quantisiert, damit es in den Speicher passt, und lass dann PEFT die trainierbaren LoRA-Schichten obendrauf hängen.

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
)
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

Erwartete Ausgabe:

trainable params: 13,631,488 || all params: 7,261,655,040 || trainable%: 0.1877

Diese 0,19 % sind genau der Punkt. Das r (Rank) steuert die Kapazität des Adapters: 8 bis 16 reichen für die meisten Aufgaben; 32 bis 64 nimmst du nur, wenn das Verhalten wirklich komplex ist. lora_alpha setzt man üblicherweise auf das Doppelte des Rank. target_modules deckt hier die Attention-Projektionen ab, das ist das Standard-Startset; nimmst du die MLP-Projektionen dazu (gate_proj, up_proj, down_proj), steigt die Kapazität auf Kosten von mehr Speicher.

Schritt 4: Training mit dem SFTTrainer aus TRL

Der SFTTrainer aus TRL umhüllt den Hugging-Face-Trainer mit vernünftigen Defaults für überwachtes Fine-Tuning. Er kümmert sich um die Tokenisierung des text-Felds und um die Trainingsschleife.

from trl import SFTTrainer, SFTConfig

training_args = SFTConfig(
    output_dir="./mistral-lora-out",
    num_train_epochs=2,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,   # effective batch size = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=50,
    save_steps=50,
    bf16=True,
    max_seq_length=1024,
    dataset_text_field="text",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
)

trainer.train()
trainer.save_model("./mistral-lora-adapter")

Was die wichtigsten Argumente tun:

  • learning_rate=2e-4 liegt rund zehnmal höher als bei einem vollständigen Fine-Tune. LoRA verträgt eine größere Rate und braucht sie sogar, weil so wenige Parameter sich bewegen.
  • gradient_accumulation_steps täuscht eine größere Batch vor, wenn die GPU keine ganze halten kann. Die effektive Batch-Größe ist per_device_batch_size × accumulation × num_gpus.
  • bf16=True passt zum Compute-dtype aus der Quant-Config. Auf älteren GPUs ohne bfloat16-Unterstützung (vor Ampere) nimmst du stattdessen fp16=True.
  • eval_steps lässt das zurückgehaltene Set regelmäßig laufen, sodass du Overfitting in Echtzeit beobachten kannst.

Du solltest Trainings- und Eval-Loss beide fallen sehen. Wenn der Eval-Loss abflacht oder steigt, während der Trainings-Loss weiter sinkt, beginnt das Modell auswendig zu lernen. Brich hier ab.

{'loss': 1.842, 'epoch': 0.16}
{'loss': 1.401, 'epoch': 0.32}
{'eval_loss': 1.387, 'epoch': 0.40}
{'loss': 1.298, 'epoch': 0.48}

Bei einem CUDA-Out-of-Memory-Fehler setzt du per_device_train_batch_size auf 1 und erhöhst gradient_accumulation_steps, um die effektive Batch-Größe zu halten, oder du senkst max_seq_length. Beides spart Speicher, ohne die Rechnung groß zu verändern.

Schritt 5: Über die Loss-Kurve hinaus evaluieren

Der Loss sagt dir, dass das Modell fittet; er sagt dir nicht, dass das Modell gut ist. Bei einem Verhaltens-Fine-Tune ist der ehrliche Test qualitativ und zurückgehalten: Lass den Adapter auf Prompts laufen, die er nie gesehen hat, und lies die Ausgaben.

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
    model_id, quantization_config=bnb_config, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./mistral-lora-adapter")
model.eval()

def generate(instruction):
    messages = [{"role": "user", "content": instruction}]
    prompt = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    out = model.generate(
        **inputs, max_new_tokens=256, temperature=0.7,
        do_sample=True, pad_token_id=tokenizer.eos_token_id,
    )
    return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print(generate("Summarize the benefits of low-rank adaptation in two sentences."))

Drei Checks, die sich lohnen, bevor du dem Ergebnis traust:

  • Nur zurückgehaltene Prompts. Trainings-Prompts wiederzuverwenden misst Auswendiglernen, nicht Generalisierung. Halt ein Set bereit, das das Modell nie berührt hat.
  • Vergleich gegen das Basismodell auf denselben Prompts. Ist der Fine-Tune bei deinem Zielverhalten nicht klar besser, brauchen Datensatz oder Rank Arbeit, nicht mehr Epochen.
  • Achte auf Regressionen. Hartes Fine-Tuning auf eine Aufgabe kann die allgemeine Fähigkeit verbeulen (katastrophales Vergessen). Test ein paar themenfremde Prompts und bestätige, dass das Modell nicht anderswo dümmer geworden ist.

Bei Aufgaben mit messbarer Antwort (Klassifikation, Extraktion, strukturierte Ausgabe) berechnest du eine echte Metrik auf dem Test-Split: Accuracy, F1 oder Exact-Match auf dem JSON-Schema. Eine Zahl, die du über Läufe hinweg verfolgen kannst, schlägt das Schätzen nach Augenmaß.

Schritt 6: Mergen und ausliefern

Du kannst den Adapter zur Laufzeit auf dem Basismodell servieren, was das Basismodell über viele Adapter hinweg geteilt lässt. Oder du mergst den Adapter in die Gewichte zu einem einzigen eigenständigen Modell, das einfacher zu deployen und bei der Inferenz minimal schneller ist.

from peft import PeftModel
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype="bfloat16")
merged = PeftModel.from_pretrained(base, "./mistral-lora-adapter")
merged = merged.merge_and_unload()
merged.save_pretrained("./mistral-merged")
tokenizer.save_pretrained("./mistral-merged")

Merge in fp16/bf16, nicht in 4-Bit; das Mergen in quantisierte Gewichte verschlechtert die Qualität. Lad das Basismodell für den Merge-Schritt in voller Präzision, auch wenn du in 4-Bit trainiert hast. Das gemergte Verzeichnis fällt direkt in einen Serving-Stack wie vLLM oder TGI.

Fallstricke, die am meisten Zeit kosten

Eine kurze Liste der Fehler, die immer wieder auftauchen:

  • Overfitting auf einem kleinen Datensatz. Zwei Epochen reichen oft. Dreht der Eval-Loss nach oben, hast du zu lange trainiert; senk die Epochen oder erhöhe lora_dropout.
  • Trainiere und inferiere mit exakt demselben Chat-Template; ein nicht passendes Format bei der Inferenz lässt einen guten Fine-Tune kaputt aussehen.
  • Die falschen target_modules. Die Namen unterscheiden sich je nach Architektur. Bei Llama und Mistral heißen die Attention-Projektionen q_proj/k_proj/v_proj/o_proj; bei anderen Familien inspizierst du model.named_modules() und sprichst die linearen Schichten an.
  • Ein LoRA-Lauf mit 2.000 Beispielen auf einem 7B-Modell ist auf einer einzelnen gemieteten A100 in deutlich unter einer Stunde durch, für ein paar Dollar zu Spot-Preisen. Dasselbe Modell vollständig zu fine-tunen braucht Multi-GPU-Speicher und kostet eine Größenordnung mehr. Fang mit LoRA an; greif erst dann zum vollständigen Fine-Tuning, wenn LoRA nachweislich an seine Grenze stößt.
  • Das Pad-Token des Tokenizers. Viele Basismodelle kommen ohne eines. pad_token = eos_token zu setzen ist der Standard-Fix; vergisst du es, fliegt mitten im Lauf ein Padding-Fehler.

Fazit

Die Arbeitsschleife ist klein: Entscheide Fine-Tune gegen RAG, formatiere deine Daten exakt wie zur Inferenz, häng LoRA an, trainiere zwei Epochen und behalte dabei den Eval-Loss im Auge, dann urteile an zurückgehaltenen Prompts statt an der Loss-Kurve. Alles andere ist Feinjustieren von Rank, Learning Rate und target_modules rund um dieses Rückgrat.

Fang mit dem kleinsten brauchbaren Datensatz und dem niedrigsten Rank an, der funktioniert, und füg Kapazität erst dann hinzu, wenn die Evaluierung sagt, dass du sie brauchst. Stellt sich heraus, dass das eigentliche Problem Fakten sind statt Verhalten, leite es lieber an Retrieval weiter und behalte den Fine-Tune für das, was er gut kann.

Das Wichtigste auf einen Blick Das Wichtigste auf einen Blick

FAQ

Fine-Tuning oder RAG: Wozu sollte ich zuerst greifen?

Meist zu RAG. Geht es darum, dass das Modell etwas weiß (aktuelle Dokumente, interne Daten, alles, was sich ändert), ist Retrieval billiger, aktualisierbar und zitierbar. Fine-Tune dann, wenn das Modell sich auf eine bestimmte Art verhalten soll (Format, Ton, Aufgabe), die du an Beispielen zeigen, aber nicht leicht als Dokumente injizieren kannst.

Wie viele Daten brauche ich?

Für einen Verhaltens-Fine-Tune mit LoRA liegt die übliche Spanne bei ein paar hundert bis ein paar tausend hochwertigen Beispielen. Ab einem gewissen Punkt bringt das Säubern und Entdoppeln deiner Daten mehr als das Hinzufügen weiterer. Tausend konsistente Beispiele schlagen zehntausend verrauschte, und verrauschte Daten bringen dem Modell aktiv schlechte Gewohnheiten bei.

Was kostet es, ein 7B-Modell zu fine-tunen?

Mit QLoRA auf einer einzelnen gemieteten A100 oder einer 4090 kostet ein Lauf mit ein paar tausend Beispielen typischerweise ein paar Dollar und dauert unter einer Stunde. Der Kostentreiber ist Datensatzgröße mal Epochen, nicht der Modellname. Ein 7B-Modell vollständig zu fine-tunen kostet grob eine Größenordnung mehr, weil es Multi-GPU-Speicher braucht.

LoRA oder vollständiges Fine-Tuning?

LoRA für fast alles. Es passt auf eine GPU, erzeugt einen kleinen austauschbaren Adapter und hält bei den meisten Downstream-Aufgaben mit vollständigem Fine-Tuning mit. Vollständiges Fine-Tuning wählst du nur, wenn du gemessen hast, dass LoRA bei einem komplexen Verhalten zu kurz greift, und du das Multi-GPU-Budget dafür hast.

Mein fine-getuntes Modell ist bei allgemeinen Aufgaben schlechter geworden. Warum?

Katastrophales Vergessen. Hartes Training auf einer engen Aufgabe kann die allgemeine Fähigkeit erodieren. Du milderst es mit weniger Epochen, einer niedrigeren Learning Rate, einem niedrigeren LoRA-Rank oder indem du einige allgemeine Beispiele zurück in den Trainingssatz mischst, damit das Modell sich seine Basisfähigkeiten nicht wegspezialisiert.


Foto: cottonbro studio / Pexels

#llm#fine-tuning#machine-learning#lora