Startseite / Artikel / LoRA-Feinabstimmung lokal ausführen: Überprüfen, kombinieren und stille Fehler vermeiden

LoRA-Feinabstimmung lokal ausführen: Überprüfen, kombinieren und stille Fehler vermeiden

Beweisen, dass ein LoRA-Adapter tatsächlich ein kleines Modell verbessert hat, es mit anderen Komponenten verknüpfen und hinter einer lokalen, OpenAI-kompatiblen API bereitstellen, sowie die Fehler aufspüren, die zu zuverlässig falschen Ausgaben führen.

2570 Wörter

Das Beenden einer LoRA-Trainingsrunde hinterlässt Ihnen eine kleine Adapterdatei – in diesem Fall etwa 11 MB – und sonst kaum etwas. Eine Datei ist noch kein Ergebnis: Solange das feinabgestimmte Modell nicht gegen denselben Baseline-Modus bewertet wurde und an einem Ort bereitgestellt wird, von dem aus eine Anwendung darauf zugreifen kann, bleibt nur die Hoffnung. Diese Anleitung verwendet einen Adapter für ein 2B-Parameter-Modell aus dem Support-Ticket-System, misst ihn, fügt ihn zu eigenständigen Gewichten zusammen, stellt ihn über einen lokalen, OpenAI-kompatiblen Endpunkt zur Verfügung und erläutert die Fehlermuster, durch die plausibel klingende, korrekt formatierte, aber falsche Antworten ohne jegliche Fehlermeldung erzeugt werden.

Alles, was gezeigt wird, läuft aus dem finetune-demo-Repository – das den trainierten Adapter enthält – sodass Sie ohne eigenes Training folgen können. Fazit: Bei dieser Aufgabe stieg die Anzahl der vollständig korrekten Antworten des Modells von null bei 40 Versuchen auf 40 von 40.

Messung des Adapters gegenüber dem Baseline-Modell

Die einzige faire Messmethode ändert genau eine Variable. Die Bewertung verwendet denselben Script, dieselben 40 zurückgehaltenen Tickets sowie dieselbe Temperatur wie der Baseline-Test mit dem untrainierten Modell; die einzige Ergänzung ist das --adapter-Flag, das auf die trainierten Gewichte verweist. --no-think deaktiviert den Denkmodus des Modells, sodass es direkt antwortet.

python evaluate.py - model mlx-community/Qwen3.5–2B-MLX-4bit \
--adapter adapters/triage-2b --limit 40 --no-think

===== mlx-community/Qwen3.5–2B-MLX-4bit (adapter: adapters/triage-2b) =====
examples : 40
usable : 40/40 (100%) returned parseable JSON
fully valid : 40/40 (100%) <- the headline
median latency: 0.33s
errors by rule:

Der Abschnitt Errors by rule ist leer – genau das ist der Sinn: Jede der 40 Antworten erfüllte alle Validierungsregeln. Die mittlere Latenz betrug 0,33 Sekunden pro Ticket.

Verglichen mit dem Baseline ist der Unterschied auffällig. Das untrainierte Modell erzeugte bereits bei jedem Mal verarbeitbares JSON, nutzte aber niemals das erforderliche Vokabular für Kategorie, Priorität oder Tags:

|                         |  Before   | After     |
|-------------------------|-----------|-----------|
| Returned parseable JSON |   40/40   | 40/40     |
| **Fully valid**         |  **0/40** | **40/40** |
| `category` errors       |     40    | 0         |
| `priority` errors       |     40    | 0         |
| `tags` errors           |     40    | 0         |
| `needs_human` errors    |      8    | 0         |

Das gleiche Ticket, das zur Demonstration der Ausgangslage verwendet wurde, zeigt den Grund dafür. Vor dem Training erdachte das Modell Labels wie "IT Support" sowie Tags im Titelkursivformat; danach verwendete es die Kleinbuchstabenwerte des Hausschemas:

TICKET : The password reset email never arrives, I have checked spam.

BEFORE : {"category": "IT Support", "priority": "High", "needs_human": true,
          "tags": ["Password Reset","Email Delivery","Account Access","Spam Filter"]}

AFTER  : {"category": "account", "priority": "medium", "needs_human": true,
          "tags": ["password", "email_change"]}

In diesem Beispiel gibt es einen weiteren Vorteil. Das Basismodell benötigte 63 Completion-Tokens für seine Antwort, das feinabgestimmte Modell hingegen 29, also weniger als die Hälfte. Output-Tokens beeinflussen sowohl die Antwortzeit als auch die Rechenkosten an einem stark genutzten Endpunkt – daher stellt ihre Halbierung eine erhebliche Einsparung dar und kein Rundungsfehler.

Ausprobieren an Ihrem eigenen Text

Weil der Adapter zusammen mit dem Repository geliefert wird, funktioniert das Skript try_it.py direkt nach dem Cloning. Durch Angabe von --compare werden sowohl das Basismodell als auch das angepasste Modell geladen, sodass Sie den Unterschied an einem von Ihnen verfassten Ticket sehen können:

.venv/bin/python try_it.py \
--compare "I was charged twice for my Pro plan and nobody has replied in a week"

TICKET       "I was charged twice for my Pro plan and nobody has replied in a week"

before       { "category": "Billing & Support", "priority": "High", "needs_human": true,
                 "tags": ["Duplicate Charge","Account Inquiry","Support Ticket","Pro Plan"] }
               INVALID -> category, priority, tags   (0.42s)

after        {"category":"billing","priority":"medium","needs_human":true,
                "tags":["double_charge","email_change"]}
               VALID   (0.24s)

Die Basislösung scheitert bei der Validierung in drei Feldern; die angepasste Lösung besteht die Überprüfung und ist außerdem schneller. Entfernen Sie --compare, um nur die feinabgestimmte Lösung zu erhalten, oder lassen Sie den Ticket-Text weg, um eine interaktive Anfrage zu bekommen.

Drei Möglichkeiten, das Modell auszuführen

Sie können den Adapter getrennt halten, ihn in die Basisgewichte integrieren oder in ein anderes Format umwandeln. Die Integration ist für die Bereitstellung am robustesten.

Integration des Adapters

LoRA stellt die Gewichtsaktualisierung als ein niedrig-rangiges Produkt BA dar, das bei jeder Vorwärtsberechnung zu den eingefrorenen Gewichten W hinzugefügt wird. Bei der Integration erfolgt die Addition W + BA nur einmal, und es werden normale Gewichte geschrieben, wodurch Sie einen einzigen, selbstständigen Modellordner erhalten:

python -m mlx_lm fuse \
  --model mlx-community/Qwen3.5-2B-MLX-4bit \
  --adapter-path adapters/triage-2b \
  --save-path fused/triage-2b

Dies hat 3,6 Sekunden in Anspruch genommen und 1,0 GB Ausgabe erzeugt. Der nächste Schritt ist nicht optional: das gefusionierte Modell bewerten, bevor man ihm vertraut.

fused/triage-2b   fully valid: 40/40 (100%)   median latency 0.26s
adapter           fully valid: 40/40 (100%)   median latency 0.33s

Die Qualität bleibt identisch, und das gefusionierte Modell ist messbar schneller, da die zusätzlichen Matrixmultiplikationen pro Schicht verschwunden sind. Der Grund für eine erneute Bewertung liegt darin, dass die Fusion arithmetische Operationen beinhaltet, und fehlerhafte Arithmetik unbemerkt zu Fehlern führt. Ein defekter „Fuse“ erzeugt dennoch eine Reihe von anscheinend vernünftigen Dateien, die anschließend unsinnige Ergebnisse liefern. Nur eine Bewertung kann die beiden Fälle voneinander unterscheiden.

Verfügbar machen

mlx_lm server stellt das gefusionierte Modell über HTTP zur Verfügung. Die Option --chat-template-args deaktiviert das Nachdenken auf Serverebene, was aus unten genannten Gründen wichtig ist:

python -m mlx_lm server --model fused/triage-2b --port 8082 \
       --chat-template-args '{"enable_thinking":false}'

Eine einfache curl-Anfrage an den Endpunkt für Chat-Completions bestätigt, dass das Modell in dem während des Trainings verwendeten Format antwortet. Die Temperatur liegt bei null, um eine deterministische Ausgabe zu erzielen, und es wird derselbe Systemprompt verwendet wie während des Trainings:

curl -s -X POST http://127.0.0.1:8082/v1/chat/completions \
 -H 'Content-Type: application/json' -d '{
 "messages":[
  {"role":"system","content":"You are a support triage engine. Reply with one JSON object and nothing else, with keys: category, priority, needs_human, tags."},
  {"role":"user","content":"Production is down for all our users. The app crashes every time I open the dashboard screen."}],
 "max_tokens":120,"temperature":0}'


{"category": "bug", "priority": "urgent", "needs_human": false,
 "tags": ["crash", "desktop"]}

Die Antwort nutzte 29 Completions-Tokens. Da der Endpunkt OpenAI-kompatibel ist, kann bestehender Code, der für die OpenAI-API geschrieben wurde, ihn durch Änderung nur der Basis-URL verwenden.

Aufruf des Endpunkts aus Anwendungscode

Die Integration passt in eine Funktion, die ausschließlich die Standardbibliothek von Python verwendet. Die Version in client_example.py des Repositoriums importiert den Systemprompt sowie die Validierungs-Hilfsfunktionen aus einem gemeinsamen schema-Modul, sendet die Anfrage und weigert sich, etwas zurückzugeben, was sie nicht validieren kann:

import json, urllib.request
from schema import SYSTEM_PROMPT, validate, extract_json

ENDPOINT = "http://127.0.0.1:8082/v1/chat/completions"

def triage(ticket_text, timeout=60):
    payload = {
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},   # MUST match training
            {"role": "user",   "content": ticket_text},
        ],
        "max_tokens": 160, "temperature": 0,
    }
    req = urllib.request.Request(ENDPOINT, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        body = json.load(r)

    msg = body["choices"][0]["message"]
    content = msg.get("content")
    if not content:                                  # thinking left no answer
        raise RuntimeError(f"no content; finish_reason={body['choices'][0]['finish_reason']}")

    record = extract_json(content)
    errs = validate(record) if record is not None else ["unparseable"]
    if errs:                                         # never trust it blindly
        raise ValueError(f"invalid record: {errs} -> {content!r}")
    return record

Beim Ausführen anhand von zwei Tickets werden saubere Dictionarys zurückgegeben:

I was charged twice for my Pro subscription this month.
  -> {'category': 'billing', 'priority': 'medium', 'needs_human': True,
      'tags': ['double_charge', 'invoice']}

Production is down for all our users, the dashboard crashes on load.
  -> {'category': 'bug', 'priority': 'urgent', 'needs_human': False,
      'tags': ['crash', 'desktop']}

Drei Elemente in dieser Funktion sind absichtlich vorhanden, und jedes von ihnen schützt vor einem Fehler, der im nächsten Abschnitt beschrieben wird:

  • SYSTEM_PROMPT stammt aus einer Import-Datei und nicht von einer Kopie. Schon eine einzige Zeichenunterschiede im Vergleich zu den Trainingsdaten führen dazu, dass das Modell aus der ursprünglichen Verteilung herausfällt.
  • Die Überprüfung auf leeres content. Wenn das Modell seinen gesamten Speicher für das Logikverständnis verwendet, gibt es keine Antwort mehr zur Auswertung.
  • validate() wird bei jeder Antwort ausgeführt. Ein fein abgestimmtes Modell neigt stark dazu – aber das ist keine Garantie. Eine perfekte Punktzahl im Testset sagt nichts Definitives über die nächste Anfrage aus, daher muss im Code festgelegt werden, was passiert, wenn ein Eintrag fehlschlägt.

Drei Fehler, die niemals einen Fehler auslösen

Niemand der folgenden Fälle wirft einen Fehler aus. Jeder liefert eine zuverlässige, gut strukturierte, aber falsche Antwort.

Die Adapter-Flagge, die still ignoriert wird

Der offensichtliche Shortcut besteht darin, das Fusionsverfahren zu überspringen und den Adapter direkt an den Server weiterzuleiten:

python -m mlx_lm server --model <base> --adapter-path adapters/triage-2b

Mit mlx-lm 0.31.3, der hier verwendeten Version, wurde das Basismodell bereitgestellt. Es gab weder eine Warnung, noch einen Eintrag im Logfile und keinen Fehler. Der Endpunkt startete normal und antwortete mit "category": "Production", "priority": "Critical" sowie einer Reihe von vier Tags im Title-Case-Format: Das Verhalten des untrainierten Modells blieb unverändert. Da es keinen Vergleichswert gab, wäre die natürliche Schlussfolgerung gewesen, dass die Feinabstimmung fehlgeschlagen ist. Spätere Versionen könnten sich anders verhalten, daher sollte man nachprüfen statt zu vermuten.

Eine schnelle Methode, dies festzustellen, dauert nur Sekunden: Senden Sie eine Anfrage, deren richtige Antwort Sie bereits kennen. Eine Antwort im eigenen Wortschatz des Adapters bedeutet, dass der Adapter aktiv ist; eine Antwort, die dem Basismodell ähnelt, bedeutet, dass er es nicht ist. Der oben bewertete gefügte Weg vermeidet die Frage ganz.

Logik, die das gesamte Budget verbraucht

Viele neuere kleine Modelle denken vor dem Beantworten nach. Senden Sie eine JSON-Anfrage mit einer 120-Token-Grenze, während die Denkfunktion aktiviert ist – die Antwort kann dann so aussehen:

{
   "choices":
   [
    {
      "finish_reason":"length",
      "message":{
        "role": "assistant",
        "reasoning":"Thinking Process:\n\n1. **Analyze the Request:** ..."
      }
    }
   ]
}

Es gibt kein content-Feld. Jeder Token wurde für das Denken verwendet, die Generierung stoppte mitten im Gedanken mit finish_reason: „length“, und ein Client, der response.choices[0].message.content liest, stößt entweder auf einen KeyError oder, noch schlimmer, erhält einen leeren String, den er als gültige leere Antwort betrachtet.

Deaktivieren Sie das Nachdenken auf dem Server mit --chat-template-args '{"enable_thinking":false}' oder per Anfrage mit "chat_template_kwargs": {"enable_thinking": false}. Ohne Nachdenken wird dieselbe Anfrage in 29 Tokens abgeschlossen.

Ein Systemprompt, der sich vom Training unterscheidet

Durch das Training wurde dem Adapter beigebracht, unter **genau einem Systemprompt** zu antworten. Ändern Sie diesen Prompt, fällt die Anfrage außerhalb dessen, was es gelernt hat, und das meiste der erlernten Verhaltensweisen verschwindet. Hier ist dasselbe feinabgestimmte Modell mit einem allgemeinen Prompt, der einen hilfsbereiten Assistenten bittet, das Ticket zu kategorisieren:

This is a **Critical Production Incident** (or a **Major Service Level Incident**).

Here is the breakdown of why this categorization applies:

*   **Severity Level: Critical / P0**
    *   **Impact:** Total system outage affecting all users.

Das Ergebnis ist ein Markdown-Artikel ohne jegliches JSON. Das Modell ist nicht defekt; es wurde mit einer Frage konfrontiert, für die es nie trainiert wurde. Halten Sie eine einzige Definition des Prompts bei, die sowohl vom Datengenerator als auch vom Client verwendet wird, und importieren Sie sie überall.

Zwei weitere Fallstricke: die Modellliste und Ihr eigenes Framework

GET /v1/models listet alle Modelle im lokalen Cache auf, nicht das derzeit geladene. Betrachten Sie dies eher als Liste des Caches denn als Überprüfung des Zustands: Es kann Ihnen mitteilen, dass der Server aktiv ist, aber nicht, welche Gewichte antworten.

Prüfen Sie daher zuerst das Bewertungssystem, bevor Sie die Gewichte dafür verantwortlich machen. In diesem Projekt entschied der Bewertungsalgorithmus darüber, das Denken zu deaktivieren, indem er nach "qwen" im Name des Modells suchte. Das funktionierte bei mlx-community/Qwen3.5-2B-MLX-4bit, doch die gefusionierte Version befindet sich bei fused/triage-2b; dadurch blieb das Denken aktiv, ohne dass jemand es bemerkte, und das gefusionierte Modell erzielte 82 % statt 100 %. Die Gewichte waren in Ordnung – der Fehler lag beim Bewertungsalgorithmus. Wenn die Punktzahl unerwartet sinkt, verdächtigen Sie zunächst das Bewertungssystem und verlassen Sie sich niemals auf die Dateinamen zur Entscheidungsfindung.

Was das 40/40 nicht beweist

Die perfekte Punktzahl existiert tatsächlich, doch man muss genau definieren, worauf sie sich bezieht: Sie umfasst außerhalb des Trainingsdatensatzes liegende Beispiele, die vomselben Generator erstellt wurden, der auch den Trainingsdatensatz erzeugt hat. Das Modell generalisiert zwar, aber nur auf neue synthetische Beispiele derselben Art.

Ein paar realistische, unübersichtliche Tickets erzählen eine andere Geschichte. Sechs wurden geprüft. Vier bestanden die strukturelle Überprüfung, doch mehrere davon waren dennoch mit hoher Sicherheit fehlerhaft:

  • Ein Beschwerdebrief in Großbuchstaben, in dem stand, dass Bestellungen nicht versandt werden konnten und alles kaputt sei, landete in account anstelle von bug.
  • Ein Dankesbrief, der eine Verbesserung des Dashboards lobte, wurde in feature_request eingestellt, da das Schema keine „kein Ticket“-Option bietet und das Modell eine Auswahl treffen muss.
  • Eine GDPR-Löschanfrage wurde zu einer how_to-Anfrage mit needs_human: false, wodurch ein rechtlicher Fristtermin an eine Person vorbeigeleitet wurde.

Die letzte Fallstudie betrifft einen Daten-Mangel, keinen Modellfehler. Im generierten Datensatz wird needs_human vollständig durch category bestimmt:

account {True: 125}   billing {True: 137}
bug {False: 153}      how_to {False: 115}      feature_request {False: 110}

Daher lernte das Modell eine fünfzeilige Abfrage-Tabelle anstelle einer Entscheidungsmatrix, und keine Menge an Training kann ein Label korrigieren, das niemals unabhängig war. Man entdeckt dies nur durch Tests unter Abweichungen von der Normalverteilung – betrachten Sie daher einen zurückgehaltenen Wert als das Minimum, was man beanspruchen kann, und nicht als Maximum. Für den Produktivbetrieb sollten einige hundert echte Tickets gelabelt werden, needs_human sollte unabhängig von der Kategorie variieren, und ein „keine Maßnahmen erforderlich“-Label sollte eingeführt werden.

Jenseits von Support-Tickets

Nichts in diesem Workflow ist speziell auf Tickets ausgerichtet. Er eignet sich überall dort, wo unstrukturierte Texte und eine feste Satz von Labels vorhanden sind:

  • Bewerbungsunterlagen in Kategorien wie Hierarchieebene, Berufserfahrung und Fähigkeiten.
  • Rechnungen in Kategorien wie Lieferant, Währung und Postenart.
  • Protokollzeilen in Kategorien wie Dienstleistung, Schweregrad und Incident-Typ.
  • Bewertungen in Sentiment, erwähnte Eigenschaften und Defekttypen aufteilen.
  • Nur zwei Dateien müssen geändert werden: schema.py, in der die zulässigen Werte, die Anfrage sowie die validate()-Funktion definiert sind, und make_data.py, die Ihre Beispiele erzeugt. Alle hier gezeigten Befehle funktionieren dann unverändert.

    Vor dem Feintunen des nächsten Modells

    Probieren Sie zunächst die eingeschränkte Dekodierung aus. GBNF-Grammatiken in llama.cpp oder Bibliotheken wie xgrammar zwingen die erzeugte Ausgabe dazu, einem Schema zu entsprechen, wodurch eine strukturell fehlerhafte Ausgabe unabhängig davon unmöglich wird, ob das Modell feinabgestimmt wurde oder nicht. Allein die Anwendung einer Grammatik hätte hier ohne Training eine Schema-Gültigkeit von 100 % erzielt. Die Feinabstimmung hat dennoch ihren Platz: Eine Grammatik kann zwar die Struktur vorgeben, aber nicht die Bedeutung – und das Training lehrte dem Modell die richtige Kategorie und reduzierte gleichzeitig die Anzahl der Tokens um die Hälfte. Wenn jedoch fehlerhaftes JSON Ihr einziges Problem ist, wenden Sie sich vor einem Trainingsschritt lieber an eine Grammatik.

    Zählen Sie die Kosten pro Anfrage und nicht pro Trainingslauf. Ein Trainingslauf dauert in der Regel etwa fünf Minuten und findet nur einmal statt. Der Tokenverbrauch wiederholt sich bei jeder Aufrufung, solange der Dienst verfügbar ist – daher führt eine Reduzierung von 63 auf 29 Token zu einer stetig wachsenden Ersparnis. Wenn Sie dies mit einer gehosteten API vergleichen, erläutert die Analyse in „Fine-Tune oder API aufrufen“ die Zahlen für einen ähnlichen Pipeline.

    Betrachten Sie GGUF als den fragilen dritten Weg. Eine Konvertierung in GGUF macht das Modell portabel für llama.cpp oder Ollama, doch die Tools können problemlos abschließen und dennoch Gewichte hinterlassen, die unbrauchbare Ergebnisse liefern. Erstellen Sie nach jedem Konvertierungs Schritt ein Beispiel für eine Textfortsetzung – das Vorhandensein einer GGUF-Datei sagt nichts darüber aus, ob alles korrekt funktioniert.

    Kernpunkte

    • Die Zahl, die jedem späteren Ergebnis Bedeutung verleiht, ist die Basislinie. Messen Sie vor dem Training und erneut nach jeder Transformation wie Fusion oder Konvertierung.
    • Fusionierte Gewichte waren genauso präzise wie der Adapter und schneller zur Bereitstellung; der unfusionierte --adapter-path-Weg lieferte stumm das Basismodell der getesteten Version aus.
    • Schützen Sie jede Antwort im Code: Importieren Sie die genaue Trainingsanweisung, überprüfen Sie auf fehlendes content und validieren Sie die Datensätze.
    • Eine perfekte Auswertung auf ungenutzten Daten umfasst nur Daten wie das Trainingsset. Testen Sie mit unstrukturierten echten Eingaben und beheben Sie Label-Leckagen in den Daten, anstatt zu erwarten, dass das Training sie überwindet.