Dostarczanie LoRA do drobnej optymalizacji lokalnie: weryfikacja, łączenie i unikanie ukrytych błędów
Udowodnij, że adapter LoRA rzeczywiście poprawił mały model, połącz go i udostępnij za lokalną API kompatybilną z OpenAI, a także wykryj błędy powodujące dostarczanie pewnych, błędnych wyników.
Zakończenie sesji treningowej LoRA pozostawia nam mały plik adaptera, w tym przypadku około 11 MB, i niewiele więcej. Plik sam w sobie nie jest wynikiem – dopóki zoptymalizowany model nie zostanie oceniony w stosunku do tego samego punktu odniesienia i nie zostanie udostępniony w miejscu, gdzie aplikacja może do niego uzyskać dostęp, mamy jedynie nadzieję. Ten przewodnik obejmuje adaptator do triażu zgłoszeń technicznych dla modelu o 2 miliardach parametrów, jego pomiar, połączenie z samodzielnymi wagami modelu, udostępnienie go za lokalnym punktem końcowym kompatybilnym z OpenAI oraz omówienie sposobów, w których model może zwracać wiarygodne, poprawnie sformatowane, ale błędne odpowiedzi bez żadnego komunikatu o błędzie.
Wszystko, co pokazano, działa z repozytorium finetune-demo, które zawiera już wytrenowany adapter, więc możesz śledzić proces bez konieczności samodzielnego trenowania czegokolwiek. Wynik: w tym zadaniu model z zero pełnie poprawnych odpowiedzi na 40 przeszedł na 40 na 40.
Pomiar adaptera w stosunku do punktu odniesienia
Jedyna sprawiedliwa mierzalność polega na zmianie dokładnie jednej zmiennych. Ocena wykorzystuje ten sam skrypt, te same 40 próbek zachowanych na później oraz tę samą temperaturę co w bazowym testie z nieprzeszkolonym modelem; jedyną dodatkową cechą jest flaga --adapter wskazująca na przeszkolone wagi. Flaga --no-think wyłącza tryb rozumowania modelu, dzięki czemu odpowiada on bezpośrednio.
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:
Sekcja errors by rule jest pusta, co właśnie stanowi sedno sprawy: każda z 40 odpowiedzi spełniała wszystkie reguły walidacji. Mediana czasu reakcji wynosiła 0,33 sekundy na każdą próbkę.
W porównaniu z bazowym modelem różnica jest wyraźna. Nieprzeszkolony model zawsze generował możliwy do przetworzenia JSON, ale nigdy nie używał wymaganego słownictwa dotyczącego kategorii, priorytetu czy tagów:
| | 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 |
To samo zgłoszenie użyte do pokazania stanu wyjściowego wyjaśnia powód. Przed szkoleniem model wymyślał etykiety takie jak "IT Support" oraz tagi w formacie Title-Case; później używał wartości małych liter z schematu domu:
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"]}
W tym przykładzie istnieje jeszcze jeden zaleta. Model podstawowy użył 63 tokenów do uzupełnienia swojej odpowiedzi, natomiast model dostosowany – 29, czyli mniej niż połowa. Tokeny wyjściowe wpływają zarówno na czas odpowiedzi, jak i koszty obliczeń w intensywnie używanym punkcie końcowym, więc ich zmniejszenie o połowę stanowi znaczącą oszczędność, a nie błąd zaokrąglenia.
Próba na własnym tekście
Ponieważ adapter jest dostarczany wraz z repozytorium, skrypt try_it.py działa od razu po klonowaniu. Podanie parametru --compare załadowuje zarówno model podstawowy, jak i dostosowany, dzięki czemu można zobaczyć różnicę na tekście, który sam napisałeś:
.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)
Podstawowa odpowiedź nie przechodzi walidacji w trzech polach; dostosowana odpowiedź przekracza tę walidację i jest także szybsza. Usuń --compare, aby otrzymać tylko dostosowaną odpowiedź, lub pomiń tekst biletu, aby uzyskać interaktywne pytanie.
Trzy sposoby uruchomienia modelu
Możesz zachować adapter oddzielnie, połączyć go z podstawowymi wagami lub przekonwertować na inny format. Połączenie jest najbardziej niezawodne pod kątem dostarczania modelu.
Połączenie adaptera
LoRA reprezentuje aktualizację wag jako iloczyn niskorankowy BA, który jest dodawany do zamrożonych wag W przy każdym przepływie danych. Połączenie wykonuje dodanie W + BA tylko raz i zapisuje zwykłe wagi, dając ci pojedynczy, samodzielny katalog modelu:
python -m mlx_lm fuse \
--model mlx-community/Qwen3.5-2B-MLX-4bit \
--adapter-path adapters/triage-2b \
--save-path fused/triage-2b
To zajęło 3,6 sekundy i dało 1,0 GB wyników. Kolejny krok nie jest opcjonalny: należy ocenić połączony model przed zaufaniem mu.
fused/triage-2b fully valid: 40/40 (100%) median latency 0.26s
adapter fully valid: 40/40 (100%) median latency 0.33s
Jakość jest identyczna, a połączony model jest wyraźnie szybszy, ponieważ zniknęły dodatkowe mnożenia macierzy na każdej warstwie. Powodem ponownej oceny jest to, że łączenie modeli polega na operacjach arytmetycznych, a błędne obliczenia zachowują się bezobjawowo. Uszkodzony model nadal tworzy katalog plików wyglądających przekonująco, które następnie generują bezsensowne wyniki. Tylko ocena pozwala odróżnić te dwa przypadki.
Dostarczanie modelu
mlx_lm server udostępnia połączony model przez HTTP. Opcja --chat-template-args wyłącza proces myślenia na poziomie serwera, co ma znaczenie z powodów opisanych poniżej:
python -m mlx_lm server --model fused/triage-2b --port 8082 \
--chat-template-args '{"enable_thinking":false}'
Zwykła prośba curl do punktu końcowego kompletowania wiadomości potwierdza, że model odpowiada w ustandaryzowanym formacie. Temperatura wynosi zero, co zapewnia deterministyczny wynik, a prompt systemowy to ten sam, który był używany podczas szkolenia:
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"]}
Odpowiedź składała się z 29 tokenów kompletujących. Ponieważ punkt końcowy jest zgodny z OpenAI, istniejący kod napisany pod API OpenAI może go używać, zmieniając jedynie adres URL bazowy.
Wywoływanie punktu końcowego z kodu aplikacji
Integracja mieści się w jednej funkcji, wykorzystującej wyłącznie standardową bibliotekę Pythona. Wersja znajdująca się w pliku client_example.py w repozytorium importuje prompt systemowy oraz narzędzia walidacyjne z wspólnego modułu schema, wysyła prośbę i odmawia zwracania czegokolwiek, co nie da się zweryfikować:
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
Przy uruchomieniu na dwóch ticketach zwraca czyste słowniki:
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']}
Trzy elementy w tej funkcji znajdują się tam celowo, a każdy z nich chroni przed błędem opisanym w następnej sekcji:
SYSTEM_PROMPTpochodzi z importu, a nie z kopiowania. Nawet różnica jednego znaku w porównaniu z treningowymi danymi powoduje, że model pracuje poza swoją domeną rozkładu.- Sprawdzenie pustego
content. Jeśli model wykorzysta cały swój budżet na rozumowanie, nie ma żadnej odpowiedzi do przetworzenia. validate()jest wykonywany przy każdej odpowiedzi. Model dostosowany pod konkretne zadanie ma silną tendencję do poprawnego działania, ale to nie jest gwarancja. Doskonała ocena w zestawie testowym nic nie mówi na pewno o kolejnej prośbie, dlatego w kodzie należy określić, co się stanie, gdy dane zawiodą.
Trzy błędy, które nigdy nie powodują błędu
Żaden z nich nie wywołuje błędu. Każdy z nich zwraca pewną, dobrze skonstruowaną, ale błędną odpowiedź.
Oczywistym sposobem obejścia jest pominięcie procesu łączenia i przekazanie adaptera bezpośrednio do serwera:
python -m mlx_lm server --model <base> --adapter-path adapters/triage-2b
Przy użyciu mlx-lm 0.31.3, wersji użytej tutaj, serwer obsługiwał model bazowy. Nie było żadnego ostrzeżenia, żadnej linii w dzienniku ani błędu. Koniec punktu dostępu uruchomił się normalnie i odpowiedział wartościami "category": "Production", "priority": "Critical" oraz zbiorem czterech tagów w formacie Title-Case: zachowanie modelu niewyuczonygo pozostało niezmienione. Bez wartości bazowej do porównania naturalnym wnioskiem byłoby, że dostosowanie modelu nie powiodło się. Nowsze wersje mogą zachowywać się inaczej, dlatego lepiej sprawdzić niż zakładać.
Szybki sposób na wykrycie tego trwa zaledwie kilka sekund: wysyłaj żądanie, którego poprawną odpowiedź już znasz. Odpowiedź w twojej własnej bazie słów oznacza, że adapter jest aktywny; odpowiedź przypominająca model bazowy oznacza, że nie jest. Metoda fuzji, opisana powyżej, całkowicie unika zadawania tego pytania.
Rozumowanie, które zużywa cały budżet
Wiele nowszych małych modeli najpierw rozumuje, zanim odpowie. Wyślij żądanie w formacie JSON z limitem 120 tokenów, gdy rozumowanie jest aktywne – odpowiedź może wyglądać w ten sposób:
{
"choices":
[
{
"finish_reason":"length",
"message":{
"role": "assistant",
"reasoning":"Thinking Process:\n\n1. **Analyze the Request:** ..."
}
}
]
}
Brak pola content. Wszystkie tokeny zostały przeznaczone na rozumowanie, generowanie zatrzymało się z kodem finish_reason: „length” w trakcie przetwarzania, a klient, który próbuje odczytać response.choices[0].message.content, napotka błąd KeyError lub, co gorsza, otrzyma pustą ścieżkę, którą potraktuje jako prawidłową pustą odpowiedź.
Wyłącz funkcję rozumowania na serwerze za pomocą --chat-template-args '{"enable_thinking":false}' lub na żądanie za pomocą "chat_template_kwargs": {"enable_thinking": false}. Przy wyłączonym rozumowaniu ta sama prośba jest realizowana w 29 tokenach.
Wskazówka systemowa różniąca się od tej używanej podczas szkolenia
Szkolenie nauczyło adaptera odpowiadania pod wpływem **dokładnie jednej wskazówki systemowej**. Jeśli zmienimy tę wskazówkę, prośba wykracza poza to, co adapter widział podczas szkolenia, i większość nabytego zachowania znika. Oto ten sam dostrojony model otrzymujący ogólną prośbę o pomoc w sklasyfikowaniu zgłoszenia:
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.
Wynikiem jest tekst w formacie Markdown, w którym w ogóle nie ma żadnego JSON. Model nie jest uszkodzony; postawiono mu pytanie, na które nigdy nie był szkoleny. Należy zachować jedną definicję promptu, wspólną dla generatora danych i klienta, oraz importować ją we wszystkich miejscach.
Dwie dodatkowe pułapki: lista modeli i własne narzędzia
GET /v1/models wyświetla wszystkie modele z lokalnego cache’u, a nie ten aktualnie załadowany. Traktuj to raczej jako listę z cache’u niż test sprawdzający stan serwera: może potwierdzić, że serwer jest aktywny, ale nie pokazać, które wagi odpowiadają.
Zanim zaczniesz winić wagi, sprawdź również narzędzie oceny. W tym projekcie narzędzie to decydowało o wyłączeniu funkcji myślenia poprzez wyszukiwanie "qwen" w nazwie modelu. To zadziałało w przypadku mlx-community/Qwen3.5-2B-MLX-4bit, ale zintegrowana wersja znajduje się w katalogu fused/triage-2b, więc funkcja myślenia pozostała włączona, a nikt tego nie zauważył; w rezultacie zintegrowany model uzyskał 82% punktów zamiast 100%. Wagi były w porządku – winą był element oceny. Gdy wynik spada niespodziewanie, najpierw sprawdź narzędzie oceny i nigdy nie opieraj zachowania modelu na nazwie pliku.
Czego nie dowodzi wynik 40/40
Idealny wynik rzeczywiście istnieje, ale trzeba być precyzyjnym co do jego zakresu: obejmuje on przykłady utworzone przez ten sam generator, który stworzył zbiór treningowy. Model potrafi generalizować, ale tylko na nowe syntetyczne przykłady tego samego typu.
Nieliczne realistyczne, skomplikowane zgłoszenia opowiadają inną historię. Rozpatrzono sześć z nich. Cztery przeszły weryfikację strukturalną, a kilka z nich nadal było błędnych z pełną pewnością:
- Skarga napisana wyłącznie wielkimi literami, w której stwierdzano, że zamówienia nie mogą zostać wysłane i że wszystko jest zepsute, trafiła do kategorii
accountzamiastbug. - Wiadomość podziękowawcza chwaląca poprawkę w panelu sterowania została umieszczona w kategorii
feature_request, ponieważ schemat nie oferuje opcji „to nie jest zgłoszenie”, a model musi wybrać jedną z kategorii. - Zgłoszenie o usunięciu zgodnie z GDPR stało się wpisem typu
how_toz ustawieniemneeds_human: false, co spowodowało, że termin prawny nie został przekazany osobie odpowiedzialnej.
Ostatni przypadek to wada danych, a nie wada modelu. W utworzonym zbiorze danych wartość needs_human jest w pełni określana przez wartość category:
account {True: 125} billing {True: 137}
bug {False: 153} how_to {False: 115} feature_request {False: 110}
Zatem model nauczył się tabeli wyszukiwania o pięciu wierszach zamiast podejmowania decyzji, a żadne ilości treningu nie mogą naprawić etykiety, która nigdy nie była niezależna. Można to odkryć jedynie poprzez testy na danych pochodzących z innych zbiorów, dlatego traktuj wynik z grupy danych niebędącej częścią treningu jako minimalną wartość, jaką można uznać, a nie maksymalną. Do użycia w produkcji oznacz kilkaset rzeczywistych zgłoszeń, niech needs_human zmienia się niezależnie od kategorii, i wprowadź etykietę „brak działań”.
Poza zgłoszeniami obsługi klienta
Żadna z elementów tego procesu nie jest specyficzna dla zgłoszeń. Pasuje wszędzie tam, gdzie masz tekst nieustrukturyzowany i ustalony zestaw etykiet:
- CV – na poziom zaawansowania, lata doświadczenia i tagi umiejętności.
- Rachunki – na dostawcę, walutę i kategorie pozycji.
- Liniowe zapisy z logów – na usługę, stopień powagi i typ incydentu.
Trzeba zmienić tylko dwa pliki: schema.py, który zawiera dozwolone wartości, prompt oraz funkcję validate(), oraz make_data.py, który generuje przykłady. Wszystkie tutaj pokazane polecenia będą więc działać bez zmian.
Zanim dostosujesz następny model
Najpierw spróbuj ograniczonego dekodowania. Gramatyki GBNF w llama.cpp lub bibliotekach takich jak xgrammar zmuszają wygenerowany wynik do dostosowania się do określonego schematu, co uniemożliwia powstanie strukturalnie błędnego wyniku, niezależnie od tego, czy model był dopracowywany, czy nie. Samo zastosowanie gramatyki dałoby tu 100% poprawności schematu bez żadnego szkolenia. Dopracowywanie modelu nadal ma swoje znaczenie: gramatyka może narzucić określoną strukturę, ale nie znaczenie, a szkolenie nauczyło model odpowiedniej kategorii, jednocześnie zmniejszając liczbę tokenów o połowę. Jeśli jednak jedynym problemem jest błędny JSON, zastosuj gramatykę przed przeprowadzeniem szkolenia.
Licz koszt na zapytanie, a nie na jedną sesję szkolenia. Sesja szkolenia trwa zazwyczaj około pięciu minut, tylko raz. Wydatek na tokeny powtarza się przy każdym wywołaniu, dopóki usługa istnieje, więc zmiana z 63 na 29 tokenów to oszczędność, która stale rośnie. Jeśli porównujesz to z API hostowanym, analiza w fine-tune or call the API przedstawia liczby dla podobnego pipeline’u.
Traktuj GGUF jako delikatną trzecią opcję. Konwersja do formatu GGUF umożliwia przeniesienie modelu do llama.cpp lub Ollamy, jednak narzędzia te mogą zakończyć pracę bez żadnych problemów, pozostawiając ci wagi, które generują błędne wyniki. Twórz przykładowe wyniki po każdym kroku konwersji; sama obecność pliku GGUF nic nie dowodzi na temat poprawnego działania modelu.
Główne wnioski
- Liczba, która nadaje sens każdemu późniejszemu wynikowi, to punkt odniesienia. Należy ją zmierzyć przed treningiem oraz ponownie po każdej transformacji, takiej jak fuzja lub konwersja.
- Masy po fuzji były tak samo dokładne jak adapter i szybciej się uruchamiały; ścieżka
--adapter-pathbez fuzji w tle używała modelu bazowego z przetestowanej wersji. - Zabezpiecz każdą odpowiedź w kodzie: imporuj dokładny prompt treningowy, sprawdź obecność pola
contentoraz zweryfikuj dane. - Doskonały wynik uzyskany na niezależnych danych dotyczy jedynie zbiorów takich jak zestaw treningowy. Testuj na rzeczywistych, zmiennych danych i napraw problem wycieku etykiet w danych, zamiast oczekiwać, że trening to rozwiąże.
Literatura pokrewna
- Diagnozowanie problemów wyjściowych LLM: Kiedy używać promptów, zdobywania danych lub dopracowywania modelu — Metoda oparta na objawach, która pomaga zdecydować, czy słaba funkcja sztucznej inteligencji wymaga lepszego promptu, warstwy zdobywania danych lub dopracowywania modelu, oraz dlaczego szkolenie modelu na faktach przynosi odwrotne efekty.
- Dopracowywać model czy używać API? Koszty procesu wyodrębniania dokumentów — Przykładowy model kosztowy dla procesu przetwarzania dokumentów pokazuje, dlaczego kierowanie zapytań do modelu jest tańsze niż jego dopracowywanie, oraz w jakich przypadkach dokładność schematu lub wymogi dotyczące przechowywania danych w UE uzasadniają posiadanie własnego modelu.