首页 / 文章 / 在本地运行LoRA微调:验证、融合并避免隐性故障

在本地运行LoRA微调:验证、融合并避免隐性故障

证明LoRA适配器确实提升了小型模型的性能,将其融合并在本地兼容OpenAI的API后提供服务,同时检测那些会输出错误结果的故障情况。

2570 词

完成LoRA训练后,你只会得到一个体积较小的适配器文件,此例中约为11 MB,除此之外并无其他内容。文件本身并不代表结果:只有当微调后的模型经过与基准模型的对比测试,并能通过应用程序调用时,才能视为真正有用的结果。本指南以一个20亿参数模型的支持工单分类适配器为例,对其进行测试、将其融合为独立的权重文件、通过本地兼容OpenAI的接口提供服务,同时分析了那些会在不出现任何错误提示的情况下返回看似合理但实际错误的答案的故障原因。

所有演示内容均来自finetune-demo仓库,该仓库已包含训练好的适配器,因此无需自行进行任何训练即可跟随操作。关键成果是:在该任务中,模型的完全正确答案数从40个中的0个提升到了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 GB的输出结果。接下来的步骤并非可选:在信任该融合模型之前,必须先对其进行评分。

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,原本为OpenAI API编写的代码只需更改基础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.

最终得到的是一个完全不含 JSON 的 Markdown 文章。模型本身没有问题,只是被问到了它从未训练过的问题。应保持提示语的单一定义,由数据生成器和客户端共同使用,并在所有地方导入该定义。

另外两个陷阱:模型列表与自定义工具

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的值独立于类别变化,并引入“无需处理”这一标签。

超越支持工单的应用

该流程中的任何部分都并非专为工单设计,只要存在非结构化文本和固定的标签集,它就能适用:

  • 简历可被转换为职级、工作经验年限及技能标签。
  • 发票可被转换为供应商、货币类型及明细类别。
  • 日志记录可被转换为服务类型、严重程度及事件类别。
  • 评论需被解析为情感倾向、提及的特征以及缺陷类型。
  • 只需修改两个文件:包含允许值、提示语及validate()函数的schema.py,以及用于生成示例数据的make_data.py。此处列出的所有命令均无需更改即可继续使用。

    在微调下一个模型之前

    请先尝试受限解码。 llama.cpp 中的 GBNF 语法或 xgrammar 等库能够强制生成的输出符合特定结构,因此无论模型是否经过微调,都不可能出现结构错误的结果。仅使用语法的话,在无需训练的情况下就能将结构合规性提升到 100%。不过微调依然有其必要性:语法只能确保结构正确,无法决定含义;而训练则能让模型掌握正确的分类方式,同时还将Token数量减少一半。但如果你的问题仅仅是格式错误的 JSON,那么在开始训练之前先使用语法处理即可。

    应按每次请求计算成本,而非每次训练运行。单次训练运行大约需要五分钟时间。只要该服务仍在运行,每次调用都会产生token费用,因此将token使用量从63减少到29所带来的节省会持续增加。如果您正在将其与托管API进行比较,“微调还是调用API:文档提取流程的成本分析”一文详细介绍了类似流程的数值情况。

    将GGUF视为较为不稳定的第三种选择。虽然通过GGUF格式转换后模型可以适配到llama.cpp或Ollama中,但这些工具可能会顺利完成转换,却生成无效的权重文件。应在每次转换后生成样本输出;仅有GGUF文件的存在并不能证明模型运行是否正常。

    核心要点

    • 为后续所有结果赋予意义的数值即为基准值。需在训练前进行测量,且在每次融合或转换等操作后再次测量。
    • 经过融合处理的权重与适配器的精度相当,且加载速度更快;而未进行融合处理的--adapter-path路径则会直接使用测试时所用的基础模型版本进行无声加载。
    • 应在代码中对每一条响应进行防护:导入精确的训练提示,检查是否缺少content字段,并验证数据记录的正确性。
    • 完美的留出数据测试结果仅适用于类似训练集这样的数据。应在复杂的真实输入上进行测试,解决数据中的标签泄露问题,而非指望训练过程能自动克服这一问题。