Обробка LoRA для фін-тюнінгу локально: перевірка, об’єднання та уникнення прихованих помилок
Доведіть, що адаптер LoRA справді покращив невелику модель, об’єднайте її та розмістіть за локальним API, сумісним з OpenAI, а також виявляйте помилки, які призводять до неправильних результатів.
Завершення процесу навчання LoRA залишає у вас невеликий файл адаптера — приблизно 11 МБ у цьому випадку — та майже нічого більше. Файл сам по собі не є результатом: поки доопрацьовану модель не оцінять за тим самим базовим критерієм та не розмістять у місці, де програма зможе до неї звернутися, у вас є лише надія. У цьому посібнику розглядається адаптер для обробки заявок підтримки користувачів для моделі з 2 мільярдами параметрів, його оцінювання, об’єднання з окремими вагами моделі, розміщення його за допомогою локального кінцевого пункту, сумісного з OpenAI, а також описуються способи виникнення помилок, які призводять до правдоподібних, коректно сформованих, але неправильних відповідей без жодного повідомлення про помилку.
Усе, що показано, працює з репозиторієм finetune-demo, який містить навчений адаптер, тож ви можете слідувати крок за кроком, не тренуючи нічого самостійно. Ключовий результат: у цьому завданні кількість повністю коректних відповідей моделі зросла з нуля з 40 можливих до 40 з 40.
Оцінювання адаптера порівняно з базовою моделлю
Єдині справедливі зміни показників стосуються саме однієї змінної. При оцінці використовується той самий скрипт, ті самі 40 зразків даних та та сама температура, що й у базовому виконанні з ненавченим моделлю; єдине доповнення — це прапорець --adapter, який вказує на навчені ваги. Прапорець --no-think вимикає режим міркувань моделі, тож вона відповідає безпосередньо.
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:
Розділ errors by rule порожній, і саме це є суттю: кожна з 40 відповідей відповідала всім правилам перевірки. Медіанний час виконання становив 0,33 секунди на зразок даних.
Якщо порівнювати з базовим варіантом, різниця є значною. Ненавчена модель завжди генерувала зрозумілий JSON, але ніколи не використовувала необхідний словниковий запас для категорії, пріоритету чи тегів:
| | 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 |
Той самий зразок даних, який використовувався для демонстрації базового рівня, показує причину. До навчання модель створювала такі мітки, як "IT Support" та мітки у великих літерах; після цього вона почала використовувати значення у маленьких літерах згідно зі схемою:
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"]}
У цьому прикладі є ще одна перевага. Базова модель використовувала 63 токени для завершення у своїй відповіді, тоді як налаштована модель — 29, що менше половини. Кількість токенів вихідних даних впливає як на час відповіді, так і на вартість обробки на завантаженому сервері, тож її зменшення на половину є значною економією, а не помилкою округлення.
Спробуйте це з власним текстом
Оскільки адаптер постачається разом із репозиторієм, скрипт try_it.py працює відразу після клонування. Передача параметра --compare завантажує як базову, так і адаптовану моделі, щоб ви могли побачити різницю на зразку даних, який ви самі написали:
.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)
Базова відповідь не пройшла перевірку за трьома полями; адаптована відповідь пройшла перевірку та ще й швидша. Видаліть --compare, щоб отримати лише адаптовану відповідь, або пропустіть текст заявки, щоб отримати інтерактивне запитання.
Три способи запуску моделі
Ви можете зберегти адаптер окремо, об’єднати його з базовими вагами або перетворити на інший формат. Об’єднання є найбільш надійним варіантом для роботи в сервісі.
Об’єднання адаптера
LoRA представляє оновлення ваг у вигляді продукту низького рангу BA, який додається до заморожених ваг W під час кожного проходження моделі. При об’єднанні операція W + BA виконується лише один раз, і записуються звичайні ваги, що дає вам один самодостатній каталог моделі:
python -m mlx_lm fuse \
--model mlx-community/Qwen3.5-2B-MLX-4bit \
--adapter-path adapters/triage-2b \
--save-path fused/triage-2b
Це зайняло 3,6 секунди та створило 1,0 ГБ результату. Наступний крок є обов’язковим: оцінити об’єднану модель перед тим, як їй довіряти.
fused/triage-2b fully valid: 40/40 (100%) median latency 0.26s
adapter fully valid: 40/40 (100%) median latency 0.33s
Якість залишається ідентичною, а об’єднана модель працює значно швидше, оскільки зникли додаткові множення матриць у кожному шарі. Причина для повторної оцінки полягає у тому, що процес об’єднання є арифметичним, а неправильні обчислення проходять без помітних наслідків. Пошкоджене об’єднання все одно створює каталог файлів, які виглядають правдоподібно, але потім генерують безглуздий контент. Лише оцінка дозволяє розрізнити ці два варіанти.
Надання послуг
mlx_lm server розгортає об’єднану модель через HTTP. Параметр --chat-template-args вимикає процес міркувань на рівні сервера, що має значення з причин, описаних нижче:
python -m mlx_lm server --model fused/triage-2b --port 8082 \
--chat-template-args '{"enable_thinking":false}'
Звичайний запит curl до кінцевої точки доповнення повідомлень підтверджує, що модель відповідає у навченому форматі. Значення параметра temperature дорівнює нулю для детермінованого результату, а системний запит — це той самий, що використовувався під час навчання:
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"]}
У відповіді було використано 29 токенів для доповнення. Оскільки кінцева точка сумісна з OpenAI, існуючий код, написаний для API OpenAI, може її використовувати, змінивши лише базову URL.
Виклик кінцевої точки з коду додатку
Інтеграція реалізована в одній функції з використанням лише стандартної бібліотеки Python. Версія у файлі client_example.py репозиторію імпортує системний запит та допоміжні функції для перевірки з спільного модуля schema, надсилає запит та відмовляється повертати щось, що не може перевірити:
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
При виконанні на двох замовленнях повертаються чисті словники:
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']}
У цій функції три елементи є навмисно, і кожен з них запобігає помилці, описаній у наступному розділі:
SYSTEM_PROMPTпоходить від імпорту, а не є копією. Навіть різниця у один символ порівняно з даними навчання виводить модель за межі розподілу.- Перевірка порожнього
content. Якщо модель витрачає всі свої ресурси на міркування, не залишається відповіді для обробки. validate()виконується для кожної відповіді. Уточнена модель має сильну тенденцію до правильних результатів, але це не гарантія. Ідеальний результат у тестовому наборі нічого не гарантує щодо наступного запиту, тому у коді потрібно визначити, що робити, якщо запис не пройшов перевірку.
Три види помилок, які ніколи не викликають помилки
Жоден з наведених варіантів не генерує помилок. Кожен з них повертає впевнену, добре структуровану, але неправильну відповідь.
Прапорець адаптера, який ігнорується мовчки
Очевидним способом є пропуск процедури об’єднання та безпосереднє передавання адаптера на сервер:
python -m mlx_lm server --model <base> --adapter-path adapters/triage-2b
З використанням mlx-lm 0.31.3, версії, яка використовується тут, це передавало базову модель. Не було жодних попереджень, жодної запису в журналі та жодних помилок. Ендпоїнт запустився нормально та відповів "category": "Production", "priority": "Critical" та набором чотирьох тегів у форматі Title-Case: поведінка ненавченої моделі залишилася незмінною. Без базових показників для порівняння природним висновком було б те, що процедура налаштування зазнала невдачі. Більш пізні версії можуть поводитися інакше, тому краще перевіряти, ніж припускати.
Швидкий спосіб виявити це займає кілька секунд: надішліть запит, правильну відповідь на який ви вже знаєте. Відповідь у вашому власному словнику маркерів означає, що адаптер активний; відповідь, схожа на базову модель, означає, що він неактивний. Об’єднаний шлях, оцінений вище, повністю уникає цього запитання.
Міркування, які споживають весь бюджет
Багато сучасних невеликих моделей спочатку міркують, перш ніж відповісти. Запросіть JSON із обмеженням у 120 токенів, коли увімкнено процес міркувань, і відповідь може виглядати так:
{
"choices":
[
{
"finish_reason":"length",
"message":{
"role": "assistant",
"reasoning":"Thinking Process:\n\n1. **Analyze the Request:** ..."
}
}
]
}
Тут немає поля content. Кожен токен пішов на міркування, генерація зупинилась із кодом finish_reason: „length“ на середині процесу міркувань, а клієнт, який читає response.choices[0].message.content, або отримує помилку KeyError, або, що гірше, порожню стрічку, яку сприймає як законну порожню відповідь.
Вимкніть функцію мислення на сервері за допомогою --chat-template-args '{"enable_thinking":false}' або за окремим запитом — "chat_template_kwargs": {"enable_thinking": false}. При вимкненому мисленні той самий запит обробляється за 29 токенів.
Системний запит, який відрізняється від процесу навчання
Під час навчання адаптера навчили відповідати за допомогою саме одного системного запиту. Якщо змінити цей запит, запит виходить за межі того, що адаптер бачив під час навчання, і більша частина засвоєної поведінки зникає. Ось той самий деталізовано налаштований модель, якому надано загальний запит про те, щоб корисний асистент класифікував заявку:
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.
Результатом є есе у форматі Markdown без жодного JSON. Модель не пошкоджена; їй було поставлено запит, на який її ніколи не тренували. Зберігайте єдине визначення запиту, яке буде спільним для генератора даних та клієнта, та імпортуйте його всюди.
Ще дві пастки: список моделей та ваш власний інструментарій
GET /v1/models виводить список усіх моделей у локальній кеш-пам’яті, а не тієї, яка зараз завантажена. Вважайте це списком кешу, а не перевіркою стану: він може показати, що сервер працює, але не вказати, які ваги відповідають.
Перевірте також інструментарій оцінювання, перш ніж звинувачувати ваги. У цьому проєкті оцінювач вирішував, чи вимкнути функцію мислення, шукаючи "qwen" у назві моделі. Це спрацювало для mlx-community/Qwen3.5-2B-MLX-4bit, але об’єднана копія знаходиться у fused/triage-2b, тому функція мислення залишилась увімкненою, і ніхто цього не помітив; крім того, об’єднана модель набрала 82% замість 100%. Ваги були у порядку; проблема була в оцінювачі. Коли оцінка несподівано падає, спочатку перевірте інструментарій оцінювання, і ніколи не базуйте свої рішення на назві файлу.
Що не доводить показник 40/40
Ідеальний бал справді існує, але потрібно бути точним щодо його обсягу: він стосується запитів, створених саме тим генератором, який сформував набір для навчання. Модель дійсно здатна до узагальнення, але лише на нові синтетичні приклади такого типу.
Кілька реалістичних, складних заявок розповідають іншу історію. Було перевірено шість з них. Чотири пройшли структурну перевірку, але кілька з них все одно були помилковими з повною впевненістю:
- Скарга, написана повністю великими літерами про те, що замовлення не можна відправити та все зламано, потрапила до категорії
accountзамістьbug. - Лист подяки з похвалою за виправлення на панелі керування було додано до категорії
feature_request, оскільки схема не передбачає опції „не заявка“, і модель змушена обрати одну з категорій. - Запит на видалення згідно з GDPR став записом у категорії
how_toіз позначкоюneeds_human: false, що відвело законний термін виконання від людини.
Останній випадок — це дефект у даних, а не в моделі. У створеному наборі даних значення needs_human повністю визначається значенням category:
account {True: 125} billing {True: 137}
bug {False: 153} how_to {False: 115} feature_request {False: 110}
Таким чином модель навчилася використовувати таблицю пошуку з п’ятьма рядками замість прийняття рішення, і жодна кількість тренувань не зможе виправити мітку, яка ніколи не була незалежною. Це можна виявити лише шляхом тестування на випадки, що не належать до звичайних сценаріїв, тому вважайте значення, які залишилися без оцінки, мінімумом, який можна претендувати, а не максимумом. Для продакшн-використання позначте кілька сотень реальних заявок, дозвольте значенню needs_human змінюватися незалежно від категорії та додайте мітку „жодних дій“.
Поза межами заявок на підтримку
Ніщо в цьому конвеєрі обробки не є специфічним для заявок. Він підходить будь-де, де є неструктурований текст та фіксований набір міток:
- Резюме — на рівень кваліфікації, кількість років досвіду та теги навичок.
- Рахунки-фактури — на постачальника, валюту та категорії статей.
- Рядки журналу — на сервіс, ступінь серйозності та тип інциденту.
Потрібно змінити лише два файли: schema.py, у якому зберігаються дозволені значення, запит та функція validate(), а також make_data.py, який створює приклади. Усі команди, наведені тут, будуть працювати без змін.
Перш ніж налаштовувати наступну модель
Спочатку спробуйте обмежений декодування. Граматики GBNF у llama.cpp або бібліотеках на кшталт xgrammar змушують генерований вихід відповідати певній схемі, що унеможливлює появу структурно некоректного вихідного тексту, незалежно від того, чи піддавався модель додатковій налаштуванню. Лише застосування граматики дозволило б досягти 100% відповідності схемі без жодних тренувань. Проте додаткова налаштування все одно мають своє значення: граматика може забезпечити правильну структуру, але не значення, а тренування допомогли моделі зрозуміти правильну категорію та водночас скоротити кількість токенів удвічі. Але якщо ваша єдина проблема — некоректний JSON, спочатку скористайтеся граматикою, перш ніж починати тренування.
Рахуйте витрати за кожен запит, а не за кожну процедуру навчання. Процедура навчання триває приблизно п’ять хвилин один раз. Витрати на токени повторюються з кожним викликом, доки існує сервіс, тому зниження кількості токенів з 63 до 29 — це економія, яка постійно зростає. Якщо ви порівнюєте це з хостованим API, у статті fine-tune or call the API наведено розрахунки для подібної системи.
Вважайте GGUF крихким третім варіантом. Перетворення у формат GGUF робить модель сумісною з llama.cpp чи Ollama, проте інструменти можуть завершити роботу без проблем, залишивши вам ваги, які генерують некоректний результат. Створюйте зразок завершення тексту після кожного кроку перетворення; наявність файлу GGUF нічого не говорить про те, чи працює він правильно.
Основні висновки
- Число, яке надає сенсу кожному наступному результату, — це базовий показник. Вимірюйте його перед навчанням та знову після кожної трансформації, такої як об’єднання чи конвертація.
- Об’єднані ваги були настільки ж точними, як і адаптер, та працювали швидше; механізм
--adapter-pathбез змін використовував базову модель версії, що тестувалась. - Захищайте кожну відповідь у коді: імпортуйте саме той запит для навчання, перевіряйте наявність поля
contentта підтверджуйте коректність даних. - Ідеальний показник, отриманий на окремих даних, стосується лише даних типу навчального набору. Тестуйте на реальних, складних вхідних даних та усувайте проблеми з мітками в даних, замість того щоб сподіватися, що навчання їх вирішить.