Главная / Статьи / Работа с LoRA для локальной настройки: проверка, объединение и избежание скрытых сбоев

Работа с LoRA для локальной настройки: проверка, объединение и избежание скрытых сбоев

Докажите, что адаптер LoRA действительно улучшил небольшую модель, объедините её с другими компонентами и предоставьте через локальный API, совместимый с OpenAI, а также выявите сбои, приводящие к ошибочным результатам.

2570 слов

После завершения тренировки 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" и набором из четырёх тегов с заглавными буквами: поведение ненаучённой модели осталось прежним. Поскольку не было базовых показателей для сравнения, естественным выводом было то, что процедура тонкой настройки провалилась. Более поздние версии могут вести себя иначе, поэтому лучше проверять, чем делать предположения.

Быстрый способ выявить это занимает секунды: отправьте запрос с правильным ответом, который вам уже известен. Ответ на вашем собственном языке означает, что адаптер активен; ответ, похожий на ответ базовой модели, указывает на его отсутствие. Метод слияния, оцененный выше, полностью избегает постановки вопроса.

Мышление, исчерпывающее весь бюджет

Многие современные небольшие модели сначала размышляют, прежде чем отвечать. При включенном режиме размышлений отправьте 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 и верифицируйте данные.
    • Идеальный результат тестирования на отдельных данных охватывает лишь данные вроде набора для обучения. Тестируйте на реальных, «грязных» данных и устраняйте утечку меток в данных, вместо того чтобы ожидать, что обучение сможет это исправить.