Выкананне настройкі 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 ГБ рэзультата. Наступны крок не ўмоżliвены: прызначыце оцэнку з’едыненаму модэлю прытым, як толькі пачнёте яму даваць доверлеўнасць.
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 да канцэнтра пункту чату паўтарыльна падтверджвае, што модэль адпавядае у навучаным формате. Тэмпература стаўіцца нулем для детерміністычнага выходу, а системны прыказ вярнюецца той, які быў выкарыстоўваны падчас навучэння:
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, можа яго выкарыстоўваць, зменіўшы толькі базовую адресу.
Выклік канцэнтра пункту з коду аплікацыі
Інтэграцыя ўместнаецца ў адну функцыю, выкарыстоўваючы толькі стандартную бібліятэку 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, і прабавіце даннэ працэсу валідазыі. - Ідеальны рэзультат, атрыманы на незалежных дадзеннях, паводлівае толькі для дадзенаў у формате навчальнага набору. Тэстуйце на рэальных, неупорядкованых данніх і вылечыце проблему вытэкання метак у дадзеннях, а не сподзіваецеся, што навчанне яе падкорыць.