什么是LoRA微调
与LoRA微调对应的是全量微调,即对整个模型所有的参数根据输入的文本进行微调更新。这个模型所有的参数需要巨量的算力,只有大型计算机群可以实现。
LoRA微调是目前社区最主流的模型微调方式,通过训练两个低阶矩阵,只需调整非常少的参数量,能够实现近似全量微调的效果,实现在普通家用消费级显卡上也能进行模型的微调工作。
设备准备
教程基于的显卡是RTX 5070,12GB显存。由于不同型号的显卡其显存与支持的量化精度皆有所不同,因此有条件的读者可在自己的家用级显卡上进行LoRA微调,亦或者可以尝试使用阿里云提供的免费设备上进行微调学习。本教程全程无广,笔者会在下面简单讲述使用阿里云的免费设备可能遇到的问题和处理方向。在此之后,本教程将不会涉及阿里云的设备相关信息。
使用阿里云人工智能平台PAI(非广)
阿里云首月会赠送一定的免费额度,其免费额度允许租用三种特定的实例,我们需要租赁的是交互式建模(DSW)实例,笔者尝试过使用图中选择的实例——规格英伟达A10显卡,虽然显存有24 GB,但由于其不支持更低的精度,最终训练效果反而不如笔者的RTX 5070显卡,因此故放弃。
另外有一个非常重要的一点。在阿里云人工智能平台PAI创建实例的时候,如果选择了ModelScope开头的镜像,其所有的连接Hugging Face平台下载模型的行为都会被拦截,必须在阿里云的ModelScope平台下载模型。由于这是两个不同的平台,所以Hugging Face平台上的模型ModelScope平台不一定有,因此读者得提早做好准备,要么就不使用ModelScope开头的镜像,要么就先在ModelScope平台选择好需要微调的模型,不然会遇到无法连接Hugging Face下载模型的窘境。
准备工作
所有神经网络、人工智能都需要的第一步操作,安装较新的显卡驱动版本。
笔者使用的Python版本是3.12.13。由于环境安装不是本教程的重点,因此用户可以尝试使用下方的方式快速配置环境,但由于每一个人所使用的显卡和对应的版本不同,若出现意外情况,建议使用DeepSeek等大模型辅助环境安装。
1. 克隆仓库
git clone https://github.com/BigheadFishZJML/qwopus3.5-9b-fanren-lora
cd qwopus3.5-9b-fanren-lora
2. 创建虚拟环境
# 方式 A:conda
conda create -n fanren python=3.12 -y
conda activate fanren
# 方式 B:venv
python -m venv venv
venv\Scripts\activate # Windows
source venv/bin/activate # Linux / macOS
3. 安装依赖(注意顺序!)
unsloth 会强制拉取 CPU 版 PyTorch,必须先装 CUDA 版再装 unsloth:
# 1. 先装 CUDA 版 PyTorch
pip install torch==2.13.0+cu132 --index-url https://download.pytorch.org/whl/cu132 --force-reinstall
pip install torchvision --index-url https://download.pytorch.org/whl/cu132
# 2. 再装 bitsandbytes
pip install bitsandbytes
# 3. 最后装 unsloth,禁止自动拉依赖
pip install unsloth==2026.7.1 --no-deps
pip install unsloth-zoo==2026.7.1 --no-deps
# 4. 其余依赖正常安装
pip install transformers trl datasets peft
第一步——创建训练集
首先,我们需要准备训练使用的训练集,这部分的代码在split_novel.py中实现。
这一步需要读者自行在网上准备一个凡人修仙传的txt文本,建议命名为 凡人修仙传.txt
项目使用 Unsloth + TRL SFTTrainer 进行 LoRA 微调,因此训练集支持以下三种输入结构:
格式 1:instruction-input-output
{
"instruction": "你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情...",
"input": "韩立站在七玄门的演武场上,周围站满了前来参加入门测试的少年...",
"output": "这是他第一次面对这么多人,而且这些人都是和他差不多大的同龄人..."
}
格式 2:question-answer
{
"question": "什么是筑基?",
"answer": "筑基是修仙者将体内真气凝结为液态真元的过程..."
}
格式 3:纯文本
{
"text": "韩立盘膝坐在洞府之中,双手掐诀..."
}
笔者这里采用了第一种格式,但作为一个续写模型,其实使用第二种更好,但是由于项目一开始使用了第一种格式,那么就先暂时不动了。为什么理论上使用第二种格式更好呢?因为这个模型进行微调之后,我们设计它就只是一个用于扩写的模型。那它就不需要再有一个永远都是重复的内容的instruction了。这个instruction会占用一点上下文,对于这种不大的模型,能节约一点上下文当然是节约一点更好。
首先导入需要用到的包:
import json
import argparse
import os
from typing import List, Dict
接下来我们写一个方法用于加载文本,因为我的文本是UTF-8(你的也很可能是的),因此指定文本编码为UTF-8
这个方法输入一个文本文件的路径,返回文本文件的字符串形式:
def load_text(file_path: str) -> str:
"""读取文本文件,支持UTF-8编码"""
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
有了文本,我们还需要写一个方法将文本切割成训练样本,
训练样本应该是窗口滑动的,这样能最大限度地利用我们的文本,创建出更多的训练样本,让模型进行学习,首先让我们先理解一下,什么是窗口滑动?
作为计算机领域一个比较常见的术语,窗口滑动其实很好理解,对于格式 1的样本,我们教模型输入instruction和input的内容,要求返回output的内容,而窗口滑动就是本样本的input的内容和output的内容,相对下一个样本的input的内容和output的内容,向后滑动多少字符(步长),以下我举一个例子:
样本 1:
{
"instruction": "你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情...",
"input": "ABCDEFG",
"output": "HIJKLMN"
}
样本 2:
{
"instruction": "你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情...",
"input": "DEFGHIJ",
"output": "KLMN......"
}
那么这里的滑动窗口步长就是3,因为本样本的input的内容和output的内容,相对下一个样本的input的内容和output的内容,向后滑动了3个字符。在本教程中,我们可以设置滑动步长等于input的长度,这样就可以正好做到不重复,即以下例子:
样本 1:
{
"instruction": "你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情...",
"input": "ABCDEFG",
"output": "HIJKLMN"
}
样本 2:
{
"instruction": "你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情...",
"input": "HIJKLMN",
"output": "OPQ RST......"
}
理解了样本的组织形式,我们来实现这个方法:
def split_text(text: str,
input_len: int,
output_len: int,
step: int,
instruction: str) -> List[Dict[str, str]]:
"""
将文本切割成训练样本。
- input_len: 每个样本的输入(前文)长度(字符数)
- output_len: 每个样本的输出(续写)长度(字符数)
- step: 窗口滑动步长(字符数)
- instruction: 固定的指令文本
"""
total_len = len(text)
samples = []
start = 0
while start + input_len + output_len <= total_len:
input_text = text[start: start + input_len]
output_text = text[start + input_len: start + input_len + output_len]
samples.append({
"instruction": instruction,
"input": input_text,
"output": output_text
})
start += step
return samples
这个方法最后会返回一个样本集,非常不错!
现在终于准备好需要用到的方法了,现在再让我们写一个main()方法,用于调用我们的这些工具:
def main():
parser = argparse.ArgumentParser(description="将小说TXT切割为JSON训练数据")
# 允许通过命令调用脚本的时候,手动设置文本长度、文件路径等设置值
parser.add_argument("--input", "-i", default="凡人修仙传.txt",
help="输入TXT文件路径(默认: 凡人修仙传.txt)")
parser.add_argument("--output", "-o", default="fan_ren.json",
help="输出JSON文件路径(默认: fan_ren.json)")
parser.add_argument("--input-len", type=int, default=380,
help="输入文本长度,字符数(默认: 380)")
parser.add_argument("--output-len", type=int, default=380,
help="输出文本长度,字符数(默认: 380)")
parser.add_argument("--step", type=int, default=None,
help="滑动步长,默认等于 input-len(不重叠)")
parser.add_argument("--instruction", type=str,
default="你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情,要求文风一致,逻辑连贯。",
help="固定的指令文本")
args = parser.parse_args()
# 如果未指定step,则默认不重叠,即步长等于input_len
if args.step is None:
args.step = args.input_len
print(f"读取文件: {args.input}")
text = load_text(args.input)
total_len = len(text)
print(f"文本总长度: {total_len} 字符")
if total_len < args.input_len + args.output_len:
print("错误:文本长度不足一个样本,请减小input-len或output-len")
return
samples = split_text(text, args.input_len, args.output_len, args.step, args.instruction)
print(f"生成样本数: {len(samples)}")
# 写入JSON文件,保证中文不转义
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(samples, f, ensure_ascii=False, indent=2)
print(f"数据已保存至: {args.output}")
if len(samples) > 0:
print("示例样本(前100个字符的input):")
print(samples[0]['input'][:100] + "...")
print("示例样本(前100个字符的output):")
print(samples[0]['output'][:100] + "...")
在这里我设置的输入文本的长度和输出文本的长度,都是380个字符,这是笔者在这里折中采用的一个长度。对于模型的训练,其输入的文本越长,它在训练中所占用的显存就要越多,而往往并不是样本越长越好,如果样本很长,其样本数量就要被迫减少,不一定有助于样本的微调。在这个项目中,我们希望实现的是一个凡人修仙传风格的续写模型,由于其基座模型不是一个大模型,我们不会对其长输出有过高的期望。因此,与其通过长文本进行训练,不如通过较短的样本进行微调,使其输出在300~500字符之间的质量更高。
最后,让我们来确认入口方法,创建训练集的代码就写完了:
if __name__ == "__main__":
main()
现在让我们来尝试运行这个脚本。别忘了准备好 凡人修仙传.txt:
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>python split_novel.py
读取文件: 凡人修仙传.txt
文本总长度: 7614083 字符
生成样本数: 20036
数据已保存至: fan_ren.json
示例样本(前100个字符的input):
内容简介
一个普通的山村穷小子,韩立,在自己叔叔的引荐下,走出山野,跨入了一个江湖小门派,成为了一名长老的记名弟子,一个阴谋,让他懵懵懂懂的开始修仙,从此,飘渺无踪的仙路出现在了他的脚下,破解门派...
示例样本(前100个字符的output):
双眼,直直望着茅草和烂泥糊成的黑屋顶,身上盖着的旧棉被,已呈深黄色,看不出原来的本来面目,还若有若无的散发着淡淡的霉味。
在他身边紧挨着的另一人,是二哥韩铸,酣睡的十分香甜,从他身上不时传来轻重不...
成功了!可以看到我们生成的样本集已经放在了fan_ren.json,接下来,我们就可以用这个样本集对模型进行微调了。
第二步——编写微调脚本
终于准备好了样本集,现在进入最重要的一步,编写LoRA微调的脚本。
在本项目中,笔者选择的模型是zjml/Qwen3.5-9B-Text-Only-abliterated。这个模型是笔者在Qwen3.5-9B-abliterated模型的基础上剥离视觉塔,反正都是本地运行的小模型,自然选择abliterated版本,我可不想模型写着写着罢工。至于为什么要使用剥离视觉塔的模型,由于本项目希望实现的是凡人修仙传风格的文本续写模型,自然无需图片的识别能力。虽然笔者估计9B的模型及图片识别能力,可能较难以胜任日常工作。另外,笔者亲测使用剥离视觉塔的模型,可以在训练中节约大概0.9 GB的显存,并且能略微提高训练速度。
本项目采用Unsloth 核心库,Unsloth 是“支持 LoRA 的,且把它优化到极致的工具”。其重写了模型底层计算时的一些关键运算逻辑。PyTorch 默认的计算方式比较“规矩”且耗显存,而 Unsloth 团队为 Llama、Qwen、Mistral 等主流模型手写了极其精简的“自定义内核(Custom Kernels)“,使在消费级显卡上训练大模型成为可能。
1. 导入包
首先导入需要使用的包,在这个脚本的编写中,笔者遇到了编码的问题。根据笔者的测试,必须要把强制UTF-8编码写在所有第三方包之前,否则实际运行的时候,会因为编码报错:
import sys
# Windows 上强制 UTF-8 模式,必须在 import 任何第三方库之前
if sys.flags.utf8_mode == 0:
import os
os.environ["PYTHONUTF8"] = "1"
os.execv(sys.executable, [sys.executable] + sys.argv)
else:
import os
import time
import json
import logging
import traceback
from datetime import datetime
from pathlib import Path
2. 配置日志
本文在导入unsloth等重量级库前,先实现了日志部分的内容,在导入torch包后先输出日志,可以方便的看到使用者安装的torch版本是否正确,如果输出显示torch版本是CPU版本,则可快速定位到torch版本的问题,否则,之后unsloth包导入时,由于需要带GPU的torch版本而报错,则需要在一大段报错中寻找原因了。
现在,让我们来实现日志部分:
# ===================== 日志系统 =====================
LOG_DIR = Path("./logs")
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / f"train_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-7s | %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger(__name__)
def log_section(title: str):
msg = f"{'='*60}\n {title}\n{'='*60}"
log.info(msg)
def log_system_info():
import torch
log.info(f"Python: {sys.version}")
log.info(f"PyTorch: {torch.__version__} | CUDA: {torch.version.cuda}")
gpu_name = torch.cuda.get_device_name(0)
log.info(f"GPU: {gpu_name}")
mem = torch.cuda.get_device_properties(0).total_memory / 1024**3
log.info(f"VRAM 总量: {mem:.1f} GB")
log.info(f"日志文件: {LOG_FILE.resolve()}")
for k in ["PYTHONUTF8", "CUDA_VISIBLE_DEVICES"]:
log.info(f" env {k} = {os.environ.get(k, '(未设置)')}")
return gpu_name, mem
接下来我们调用log_system_info()方法,看看我们的环境是否正常:
# ===== 先记录环境,再导入重量级库 =====
import torch
gpu_name, vram_gb = log_system_info()
让我们来运行一下:
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>11:45:42 | INFO | Python: 3.12.13 | packaged by Anaconda, Inc. | (main, Mar 19 2026, 20:16:45) [MSC v.1942 64 bit (AMD64)]
11:45:42 | INFO | PyTorch: 2.13.0+cu132 | CUDA: 13.2
11:45:42 | INFO | GPU: NVIDIA GeForce RTX 5070
11:45:42 | INFO | VRAM 总量: 11.9 GB
11:45:42 | INFO | 日志文件: D:\Data\Python\qwopus3.5-9b-fanren-lora\logs\train_20260724_114535.log
11:45:42 | INFO | env PYTHONUTF8 = 1
11:45:42 | INFO | env CUDA_VISIBLE_DEVICES = (未设置)
可以看到,笔者的PyTorch版本是带GPU的版本,读者朋友们如果发现这里没有+cuxxx,则需要注意了,很可能是安装的PyTorch版本不对导致的,由于unsloth是需要带GPU的版本PyTorch的,如果PyTorch版本不对,之后导入包会报错。
现在我们把剩余的包导入项目:
import datasets as hf_datasets
from unsloth import FastLanguageModel
from trl import SFTTrainer, SFTConfig
from transformers import TrainerCallback, EarlyStoppingCallback
3. 配置全局配置
关于第三方包的导入终于做完了,现在我们来配置一些全局参数。
# ===================== 全局配置 =====================
# 优先使用本地模型,不存在则从 HuggingFace 自动下载
LOCAL_MODEL_DIR = str(Path(__file__).resolve().parent / "model/Qwen3.5-9B-Text-Only-abliterated")
HF_MODEL_ID = "zjml/Qwen3.5-9B-Text-Only-abliterated"
MODEL_ID = LOCAL_MODEL_DIR if Path(LOCAL_MODEL_DIR).exists() else HF_MODEL_ID
LORA_SAVE_DIR = "./lora/qwen3.5-9b-text-lora-checkpoint"
JSON_DATA_PATH = "./fan_ren.json"
接下来需要配置的选取参数就与模型的微调息息相关了,我待会会逐一做出解释:
# ---- 根据 GPU 自动选择配置 ----
IS_V100 = "V100" in gpu_name or "Tesla V100" in gpu_name
if IS_V100:
if vram_gb >= 30:
# V100 32GB: 长上下文 + 大 batch
MAX_SEQ_LENGTH = 2048
TRAIN_BATCH_SIZE = 2
GRADIENT_ACCUMULATION_STEPS = 4 # effective batch = 8
else:
# V100 16GB: 中等上下文
MAX_SEQ_LENGTH = 1536
TRAIN_BATCH_SIZE = 1
GRADIENT_ACCUMULATION_STEPS = 4 # effective batch = 4
else:
# RTX 5070 / 其他 12GB 卡
MAX_SEQ_LENGTH = 1280
TRAIN_BATCH_SIZE = 1
GRADIENT_ACCUMULATION_STEPS = 4
LEARNING_RATE = 2e-4
NUM_TRAIN_EPOCHS = 3
SAVE_STEPS = 500
VAL_SPLIT_RATIO = 0.05 # 5% 做验证集
EARLY_STOPPING_PATIENCE = 3 # eval_loss 连续 3 次不降则停
LORA_DROPOUT = 0.05 # 轻微正则化防过拟合
MAX_SEQ_LENGTH:即模型的输入字符数,记得我们刚刚在形成样本集的时候设置的字符数吗?当时我们设置的输入文本长度和输出文本长度都是380,再加上一段introduction,在使用5070显卡时,我们将模型的输入字符数设置为1280,基本正好能够覆盖我们所有的样本的总字符长度。如果这个值小于样本的总字符数,样本将会被截断。当然也是可以进行训练的,不过这就造成了样本资源的浪费,所以样本集在设置输入文本长度和输出文本长度时,与我们现在设置的模型的输入字符数是息息相关的。
TRAIN_BATCH_SIZE:即训练批次大小,模型一次只“消化”1条数据,就更新一次思考方向。如果显存充足,设大一点(如4或8)训练会更稳定,但显存占用会翻倍。这一条与下一条GRADIENT_ACCUMULATION_STEPS配置也是息息相关的。
GRADIENT_ACCUMULATION_STEPS:即梯度累积步数,虽然一次只看1条数据(训练批次大小=1),但我把4次“看数据”算出来的梯度攒在一起,凑够了再统一更新一次模型。这样实际等效的批次大小 = 1 × 4 = 4,相当于用“多花点时间”换取了“大批次训练的稳定性”,同时节省了显存。
LEARNING_RATE:即学习率,模型每次更新参数时迈的“步子”有多大。2e-4(即 0.0002)是LoRA微调的标准黄金值。
NUM_TRAIN_EPOCHS:即训练轮数,相当于数据集可以重复使用多少遍,这里设置为了3遍,相当于对于模型来说,我们的训练集大小增加到了3倍,但是之后我们还会设置早停,与早停配合起来使用,我们的训练集大小是绰绰有余的。对于小数据集(几千条),3-5轮合适;对于大数据集(几万条),1-2轮就够了。轮数太多会把模型“背下来”导致过拟合。
SAVE_STEPS:即保存检查点,每更新500步,自动把当前的模型权重存一份到硬盘上,防止训练中断导致白跑。
VAL_SPLIT_RATIO:即验证集比例,把数据集的5%抽出来作为测试,这部分的答案模型永远看不见,只用来检测它学没学懂。
EARLY_STOPPING_PATIENCE:即早停耐心值,如果“验证集Loss”连续3次都没有变好(没下降),直接按下暂停键,终止训练,防止模型过拟合。
LORA_DROPOUT:即LoRA随机失活率,在LoRA插件里,随机“扔掉”5%的神经元连接,不让它们参与本次更新,防止模型“死记硬背”训练数据。
4. 开始主流程
终于配置好了!接下来我们来写微调训练的主流程。
我们先写一个方法用于清理GPU显存碎片,否则根据笔者的实测,其显存占用会越来越高。一旦触发了自动卸载,PyTorch会将显存放不下的数据移动到内存中,这会导致训练速度的严重变慢,导致原本只需数10小时的训练需要延长到数天乃至数周,这是无法让人接受的。
# ===================== 主流程 =====================
class ClearCacheCallback(TrainerCallback):
"""每次 checkpoint 保存后清理 GPU 显存碎片"""
def on_save(self, args, state, control, **kwargs):
torch.cuda.empty_cache()
log.info(" 🧹 GPU cache 已清理")
接下来我们要开始写main()方法,在写main()方法之前,我们需要理清楚三个东西,分别是4-bit量化、fp16和bf16,我们通过一个流程来理解这三者的关系:
当你在RTX 5070上运行这段代码,执行一次推理时,流程是这样的:
- 加载(存放):模型权重以 4-bit 格式加载到显存。(占用约原本1/8的空间,所以你的RTX 5070才能跑动大模型)
- 计算前(解压):GPU计算单元要算一个矩阵乘法。它从显存中取出4-bit的权重。
- 计算中(运算):GPU内部临时把这些4-bit数字映射(反量化)成 BF16 格式,然后以 BF16 的精度进行乘法加法运算。
- 计算后(存回):算完得到的结果(也是 BF16 ),GPU会再次把它压缩回4-bit,存回显存里,以便腾出空间给下一层。
因为 V100 的硬件电路物理上不支持 BF16 的计算指令。因此需配置其使用 FP16 格式。
5. 加载模型
现在我们开始写main()方法,我们先要把基座模型加载好,基座模型如果存放在本地,则优先从本地调取模型,否则设置其从Hugging Face下载模型,并且我们需要根据不同的显卡配置,使用不同的数据格式:
log_section("STEP 1: 加载模型 (4-bit 量化)")
source = "本地" if MODEL_ID == LOCAL_MODEL_DIR else "HuggingFace"
log.info(f" 模型来源: {source} ({MODEL_ID})")
# V100 不支持 bf16,使用 fp16
compute_dtype = torch.float16 if IS_V100 else torch.bfloat16
dtype_name = "fp16" if IS_V100 else "bf16"
log.info(f" 计算精度: {dtype_name}({'V100 不支持 bf16' if IS_V100 else '自动选择'})")
配置好加载模型的参数之后,我们开始写加载模型和分词器的代码:
t0 = time.time()
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_ID,
max_seq_length=MAX_SEQ_LENGTH,
dtype=compute_dtype,
load_in_4bit=True,
)
elapsed = time.time() - t0
log.info(f"✅ 模型加载成功!耗时 {elapsed:.1f}s")
log.info(f" GPU 显存占用: {torch.cuda.memory_allocated(0)/1024**3:.1f} GB")
我们现在来运行一下,看看能不能正常把模型加载出来。由于笔者的模型是直接下载存放到了本地,因此这一步比较快,如果读者没有提前将模型下载到本地,程序需要自行从Hugging Face上下载模型,可能较慢。建议读者先自行将模型下载到本地,防止这一步时间过长。
为了页面看起来比较简洁,笔者删除了一些不影响后续步骤的报错。这一步笔者大概等待了1~2分钟,由于设备的不同,等待时间也可能有所不同,但估计不可能大于10分钟:
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>python ./train.py
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>15:18:20 | INFO | Python: 3.12.13 | packaged by Anaconda, Inc. | (main, Mar 19 2026, 20:16:45) [MSC v.1942 64 bit (AMD64)]
15:18:20 | INFO | PyTorch: 2.13.0+cu132 | CUDA: 13.2
15:18:20 | INFO | GPU: NVIDIA GeForce RTX 5070
15:18:20 | INFO | VRAM 总量: 11.9 GB
15:18:20 | INFO | 日志文件: D:\Data\Python\qwopus3.5-9b-fanren-lora\logs\train_20260725_151814.log
15:18:20 | INFO | env PYTHONUTF8 = 1
15:18:20 | INFO | env CUDA_VISIBLE_DEVICES = (未设置)
🦥 Unsloth: Will patch your computer to enable 2x faster free finetuning.
🦥 Unsloth Zoo will now patch everything to make training faster!
<string>:1: FutureWarning: torch._dynamo.config.inline_inbuilt_nn_modules is deprecated and does not do anything, inline_inbuilt_nn_modules is always True. It will be removed in a future version of PyTorch.
15:19:19 | INFO | ============================================================
环境诊断
============================================================
15:19:19 | INFO | ============================================================
STEP 1: 加载模型 (4-bit 量化)
============================================================
15:19:19 | INFO | 模型来源: 本地 (D:\Data\Python\qwopus3.5-9b-fanren-lora\model\Qwen3.5-9B-Text-Only-abliterated)
15:19:19 | INFO | 计算精度: bf16(自动选择)
15:19:20 | INFO | HTTP Request: GET https://huggingface.co/api/agent-harnesses "HTTP/1.1 200 OK"
15:19:20 | INFO | HTTP Request: GET https://huggingface.co/api/whoami-v2 "HTTP/1.1 200 OK"
15:19:20 | INFO | HTTP Request: GET https://huggingface.co/api/whoami-v2 "HTTP/1.1 200 OK"
15:19:23 | INFO | HTTP Request: GET https://huggingface.co/api/whoami-v2 "HTTP/1.1 200 OK"
==((====))== Unsloth 2026.7.1: Fast Qwen3_5 patching. Transformers: 5.13.1.
\\ /| NVIDIA GeForce RTX 5070. Num GPUs = 1. Max memory: 11.94 GB. Platform: Windows.
O^O/ \_/ \ Torch: 2.13.0+cu132. CUDA: 12.0. CUDA Toolkit: 13.2. Triton: 3.7.1
\ / Bfloat16 = TRUE. FA [Xformers = None. FA2 = False]
"-____-" Free license: http://github.com/unslothai/unsloth
Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!
The fast path is not available because one of the required library is not installed. Falling back to torch implementation. To install follow https://github.com/fla-org/flash-linear-attention#installation and https://github.com/Dao-AILab/causal-conv1d
Loading weights: 0%|▎ | 2/427 [00:03<11:33, 1.63s/it]D:\Data\Anacondea\envs\lora\Lib\site-packages\bitsandbytes\backends\cuda\ops.py:213: FutureWarning: _check_is_size will be removed in a future PyTorch release along with guard_size_oblivious. Use _check(i >= 0) instead.
torch._check_is_size(blocksize)
Loading weights: 100%|███████████████████████████████████████████████████████████████| 427/427 [00:14<00:00, 29.34it/s]
The tokenizer you are loading from 'D:\Data\Python\qwopus3.5-9b-fanren-lora\model\Qwen3.5-9B-Text-Only-abliterated' with an incorrect regex pattern: https://huggingface.co/mistralai/Mistral-Small-3.1-24B-Instruct-2503/discussions/84#69121093e8b480e709447d5e. This will lead to incorrect tokenization. You should set the `fix_mistral_regex=True` flag when loading this tokenizer to fix this issue.
15:19:43 | INFO | ✅ 模型加载成功!耗时 23.7s
15:19:43 | INFO | GPU 显存占用: 7.2 GB
6. 配置LoRA参数
接下来我们要配置LoRA微调的参数,这一步比较枯燥,不感兴趣的读者可以直接使用我配置好的。
r=16:LoRA 低秩矩阵的秩,为什为十六是一个非常通用的平衡值。
target_modules=[ … ]:指定要对模型的哪些线性层(Linear Layers)应用 LoRA 适配器。只在这些层插入可训练的 LoRA 参数,其他层保持冻结。
lora_alpha=16:LoRA 的缩放系数的缩放分母,缩放系数 = lora_alpha / r 。
如果缩放系数 >1 :微调的新知识会被放大,模型变化剧烈,适应新任务更快,但容易破坏原有能力(灾难性遗忘)。
如果缩放系数 =1 :LoRA 的影响按原始比例加入,最稳健,既学新东西,又不怎么破坏旧知识。
如果缩放系数 <1 :微调影响被削弱,训练更平滑、更保守,适合数据量很小或防止过拟合的场景。
当前缩放系数 = 16 / 16 = 1,比较稳妥。
lora_dropout=LORA_DROPOUT:在训练时随机丢弃一部分神经元,防止过拟合。
bias=”none”:指定如何处理模型中的偏置项(Bias)参数,”none”(不训练任何偏置)、”all”(训练所有偏置)、”lora_only”(只训练 LoRA 层内新增的偏置),如果设置为训练偏置项,需要消耗额外的显存,但对最终任务精度几乎没有作用。
use_gradient_checkpointing=”unsloth”:梯度检查点,有选择地丢弃大部分中间激活值,只保留少数“检查点”(Checkpoints)。在反向传播时,从最近的检查点开始,重新计算所需的激活值。这是一种经典的内存优化技术,能显著降低显存占用,但会因重新计算而增加训练时间。设置为”unsloth”时,能在极小的速度损耗下(仅约 +1.9% 的时间开销)换来了巨大的显存收益。
random_state=3407:LoRA 权重初始化的随机种子,确保实验结果可复现。每次运行代码,LoRA 的初始状态都一样。
use_rslora=False:是否使用 RS-LoRA,标准 LoRA 缩放是 alpha / r,RS-LoRA 改为 alpha / sqrt(r)。当 r 变化时,RS-LoRA 能保持更新量方差更稳定,便于跨不同 r 值进行超参迁移。由于我们的项目缩放为1,因此开不开启此功能影响不大。
loftq_config=None:不开启LoFTQ(Low-rank Factorization with Quantization),避免项目初始化配置变得更加复杂。如果要配置LoFTQ,就更加复杂了,我们先不管这里。
讲解完这些参数,接下来我们开始配置LoRA:
# ===================== STEP 2: LoRA =====================
log_section("STEP 2: 配置 LoRA")
model = FastLanguageModel.get_peft_model(
model,
r=16,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_alpha=16,
lora_dropout=LORA_DROPOUT,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=3407,
use_rslora=False,
loftq_config=None,
)
log.info("✅ LoRA 配置完成")
model.print_trainable_parameters()
7. 准备训练集和样本集
接下来我们把训练集,也就是我们的样本准备好,先写一个方法加载训练集:
# ===================== STEP 3: 数据 =====================
log_section("STEP 3: 加载数据集")
def load_json_data(file_path):
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
再写一个方法,把训练集中的每一个样本转化为模型训练所需的“提示词模板”:
def convert_to_unsloth_format(example):
if all(k in example for k in ["instruction", "input", "output"]):
text = (
f"### Instruction:\n{example['instruction']}\n\n"
f"### Input:\n{example['input']}\n\n"
f"### Response:\n{example['output']}"
)
elif all(k in example for k in ["question", "answer"]):
text = f"### Question:\n{example['question']}\n\n### Answer:\n{example['answer']}"
elif "text" in example:
text = example["text"]
else:
text = str(example)
return {"text": text}
接下来调用这两个方法:
raw_data = load_json_data(JSON_DATA_PATH)
log.info(f" 原始条目数: {len(raw_data)}")
converted_data = [convert_to_unsloth_format(item) for item in raw_data]
我们再用一个方法,把converted_data转化为Hugging Face 的 Dataset 对象。这样做的好处是能利用 Hugging Face 生态的 map、shuffle 等高效数据处理方法。
dataset = hf_datasets.Dataset.from_list(converted_data)
接下来我们把样本集拆分为训练集和测试集两个部分:
# train/val 拆分
split_dataset = dataset.train_test_split(test_size=VAL_SPLIT_RATIO, seed=3407)
train_dataset = split_dataset["train"]
val_dataset = split_dataset["test"]
现在训练集和测试题就准备完毕了,我们写个日志记录一下:
log.info(f"✅ 数据集加载完成")
log.info(f" 训练集: {len(train_dataset)} 条 | 验证集: {len(val_dataset)} 条")
sample = train_dataset[0]["text"][:200].replace("\n", "\\n")
log.info(f" 首条预览: {sample}...")
让我们运行一下,试试效果,为了页面的简洁,笔者就只保留这个部分的日志输出了:
16:44:45 | INFO | ============================================================
STEP 3: 加载数据集
============================================================
16:44:45 | INFO | 原始条目数: 20036
16:44:45 | INFO | ✅ 数据集加载完成
16:44:45 | INFO | 训练集: 19034 条 | 验证集: 1002 条
16:44:45 | INFO | 首条预览: ### Instruction:\n你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情,要求文风一致,逻辑连贯。\n\n### Input:\n纷纷小心起来。\n 不说这幻阵是否难破,万一那位阴罗宗的大长老,在幻阵中突然打开杀戒,他们又有谁能够单独抵挡。自然迟疑了起来。\n 看到这种情形,花天奇脸上露出一丝讥笑,当即不再理会其他人,一转身冲门下弟子吩咐道:“不用你们守在这里了。这点...
8. 配置训练
接下来就是重头戏了,我们需要配置训练的参数,这一步比刚刚的配置更加单调,如果不想看的话,可以直接使用我配置好的配置项。
model=model:没什么好说的,传入基座模型。
tokenizer=tokenizer:传入分词器,刚刚和模型一起拿到了,直接传入。
train_dataset=train_dataset:传入训练集。
eval_dataset=val_dataset:传入验证集。训练器在每次评估时计算验证集损失,用来监控是否过拟合
。
callbacks=[ClearCacheCache, EarlyStoppingCallback(…)]:Callback 回调函数,传入ClearCacheCache方法,每次训练步骤结束后主动清理 GPU 显存碎片。EarlyStoppingCallback是早停的方法,我们设置了EARLY_STOPPING_PATIENCE = 3,连续 3 次评估都没有下降,就提前终止训练。可以省去无效训练时间。
max_seq_length=MAX_SEQ_LENGTH:模型能处理的最大 Token 长度。我们之前设置为了1280。
per_device_train_batch_size=TRAIN_BATCH_SIZE,每张 GPU 上的批处理大小(Batch Size)。这是实际加载到单卡显存中的样本数量。由于我们有一张显卡,因此传入TRAIN_BATCH_SIZE,也就是之前设置好的1,同时处理一个样本。
gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS:梯度累积步数,我们之前设置为了4,每4次更新一次权重。
warmup_steps=10:学习率预热步数。由于我们是做LoRA微调,只需设置较短的预热次数,如果是进行全量微调,则需要调高这个值。
num_train_epochs=NUM_TRAIN_EPOCHS:训练轮数,我们之前设置了3,因此样本集最多可以用3轮。
learning_rate=LEARNING_RATE:优化器的学习率。LoRA 微调通常设为 1e-4 到 5e-4 之间,我们之前设置为了2e-4。
fp16=IS_V100和bf16=not IS_V100:配置是启用 BF16 还是 FP16。
logging_steps=10:每隔 10 步在控制台或日志文件输出一次当前的 Loss 和学习率。
logging_dir=str(LOG_DIR):TensorBoard 或日志文件存放的目录,我们设置起将日志文件存放在本地,方便日后的调试。
save_steps=SAVE_STEPS:每隔多少训练步数保存一次检查点。
save_total_limit=2:最多只保留最近的 2 个检查点。旧的检查点会被自动删除,节约硬盘空间。
eval_steps=SAVE_STEPS:每隔多少步用验证集做一次验证。这里设置得和 save_steps 完全一样,意味着每次保存时顺便做验证。
save_strategy=”steps”和eval_strategy=”steps”:保存策略和验证策略按“步数”执行,”no”:训练期间不保存和验证任何中间模型。只有训练结束才有最终模型。”epoch”:每个 Epoch(完整遍历一次数据集)结束后保存和验证一次模型。”steps”:每隔固定的 save_steps 和 eval_strategy 步数保存一次模型。
load_best_model_at_end=True:训练结束后,自动从所有检查点中加载验证集表现最好的那个模型,而不是加载最后一步的模型。防止你因为后面过拟合而选到差的模型。
metric_for_best_model=”eval_loss”:用什么指标判定“最好”?用验证集损失(eval_loss)。
greater_is_better=False:因为损失(Loss)是越小越好,所以填 False。
output_dir=LORA_SAVE_DIR:训练好的模型权重(LoRA 适配器)和中间检查点保存的文件夹路径。
optim=”adamw_8bit”:优化器类型。adamw_8bit 是 bitsandbytes 库提供的 8-bit 量化版 AdamW,能将优化器状态占用的显存减少 75%,显著降低显存的使用。
seed=3407:固定所有随机操作的种子,确保每次运行结果可复现。
dataset_num_proc=1:数据预处理时使用的 CPU 进程数。在将原始文本转换成模型可用的 input_ids 之前,启动多少个独立的CPU进程来并行处理数据集。设为 1 是为了避免多进程带来的死锁或内存暴增问题。建议当样本量几十万条以上,再考虑把这个值设置为2或4,并且谨慎提高,积极进行测试。
report_to=”none”:关闭向外部实验追踪平台的报告。这里我们不弄得太复杂,直接关闭。
remove_unused_columns=True:自动删除数据集中模型不需要的列(如原始的 text 字段),只保留 input_ids 和 attention_mask 传给模型。如果不删除,模型会因为收到了多余的键值而报错。当我们把包含 {“text”: “### Instruction:\n…”} 的数据集传给 SFTTrainer 时,它在执行 trainer.train() 之前,会自动触发一个内部的 _prepare_dataset() 方法。这个方法会做三件事:自动分词(生成 input_ids)、自动生成掩码(生成 attention_mask)、自动复制标签(生成 labels)。当 SFTTrainer 在内存中生成了 input_ids, attention_mask, labels 这三个新列后,数据集里同时还有原始的 text 列。此时,remove_unused_columns=True 生效,它检查模型的 forward() 签名,发现只需要 input_ids, attention_mask, labels,于是果断将 text 列从数据批次中剔除。这样,最终进入 GPU 的批次里就只有干净的数值张量,不会因为多余的 text 字符串而报错。
dataloader_pin_memory=False:是否将数据预加载到 CPU 的固定内存(Page-locked Memory)以加速传输。由于我们不是在做计算机视觉任务或者训练一个规模很小的模型,数据传输不是瓶颈,因此无需设置为True,否则会额外占用大量内存。
终于介绍完了训练的参数,现在我们将这些参数运用起来:
# ===================== STEP 4: Trainer =====================
log_section("STEP 4: 配置 Trainer")
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_dataset,
eval_dataset=val_dataset,
callbacks=[ClearCacheCallback,
EarlyStoppingCallback(early_stopping_patience=EARLY_STOPPING_PATIENCE)],
args=SFTConfig(
max_seq_length=MAX_SEQ_LENGTH,
per_device_train_batch_size=TRAIN_BATCH_SIZE,
gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS,
warmup_steps=10,
num_train_epochs=NUM_TRAIN_EPOCHS,
learning_rate=LEARNING_RATE,
fp16=IS_V100, # V100 用 fp16,其他关
bf16=not IS_V100, # 非 V100 用 bf16
logging_steps=10,
logging_dir=str(LOG_DIR),
save_steps=SAVE_STEPS,
save_total_limit=2,
eval_steps=SAVE_STEPS, # 与 save 同频做验证
save_strategy="steps",
eval_strategy="steps",
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
output_dir=LORA_SAVE_DIR,
optim="adamw_8bit",
seed=3407,
dataset_num_proc=1,
report_to="none",
remove_unused_columns=True,
dataloader_pin_memory=False,
),
)
log.info("✅ Trainer 配置完成")
log.info(f" 精度: {'fp16' if IS_V100 else 'bf16'} | eval 每 {SAVE_STEPS} steps")
9. 开始训练
准备工作都做完了,现在我们写开始运行训练的代码,先写一些日志:
# ===================== STEP 5: 训练 =====================
log_section("STEP 5: 开始训练")
eff_batch = TRAIN_BATCH_SIZE * GRADIENT_ACCUMULATION_STEPS
log.info(f" GPU={gpu_name} VRAM={vram_gb:.0f}GB dtype={'fp16' if IS_V100 else 'bf16'}")
log.info(f" epochs={NUM_TRAIN_EPOCHS} batch_size={TRAIN_BATCH_SIZE} grad_accum={GRADIENT_ACCUMULATION_STEPS} effective_batch={eff_batch}")
log.info(f" max_seq_length={MAX_SEQ_LENGTH} lr={LEARNING_RATE}")
现在我们来写一些代码,用来实现断点后启动,继续训练。因为我们刚刚配置了检查点,因此我们只需要去找有没有检查点文件,就可以知道是从头开始训练还是从检查点继续训练了:
resume = False
if Path(LORA_SAVE_DIR).exists():
checkpoints = sorted(Path(LORA_SAVE_DIR).glob("checkpoint-*"))
if checkpoints:
resume = True
log.info(f"📂 从 checkpoint 恢复: {checkpoints[-1].name}")
if not resume:
log.info("🆕 首次训练,从头开始")
最后再写一个trainer.train()方法,开启训练:
t_train = time.time()
trainer.train(resume_from_checkpoint=resume)
log.info(f"✅ 训练完成!总耗时 {(time.time() - t_train) / 60:.1f} 分钟")
让我们跑起来看看效果:
02:15:28 | ERROR | 💥 脚本异常退出,详见日志: D:\Data\Python\qwopus3.5-9b-fanren-lora\logs\train_20260726_021306.log
奇怪,居然报错了,这是怎么回事?在笔者之前的测试中,这个代码是能正常运行,并且已经把模型跑出来了的,为什么这个时候突然报错了呢?
10. 奇怪的报错
报错整体比较长。这里笔者求助了Deepseek分析这个问题,大概原因是Unsloth 从HuggingFace 拿模型的时候,方法的对接出了一个问题,拿不到*args变量,由于这里不是本文的重点,因此就不在这里花更多的时间阐述了,笔者让Deepseek写了一段代码作为补丁,放在导入包和全局配置中间:
# ── Monkey-patch: 修复 Unsloth 编译器与 force_accelerate_hooks 的兼容性 ──
# force_accelerate_hooks 把 forward 替换为 wrapped(*args, **kwargs),但没有设
# __wrapped__ 属性,导致 inspect.signature 丢失原始显式参数名(变成 *args)。
# Unsloth 编译器用这个错误的签名生成编译缓存,产出的 forward 方法签名头使用
# 显式参数名而调用体写 *args,引发 NameError。
# 本补丁仅添加 __wrapped__ 引用,不改变 wrapper 的任何运行时行为。
import transformers.integrations.accelerate as _hf_accelerate
_original_force_accelerate_hooks = _hf_accelerate.force_accelerate_hooks
def _patched_force_accelerate_hooks(child_module_name):
_original_decorator = _original_force_accelerate_hooks(child_module_name)
def _decorator(forward_func):
wrapped_fn = _original_decorator(forward_func)
wrapped_fn.__wrapped__ = forward_func # 让 inspect.signature 穿透到原始签名
return wrapped_fn
return _decorator
_hf_accelerate.force_accelerate_hooks = _patched_force_accelerate_hooks
# ── 补丁结束 ──
11. 开始训练
我们再一次启动,先要求powershell进入UTF-8编码,再在运行脚本,可以减少报错:
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>chcp 65001
Active code page: 65001
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>python ./train.py
大概运行了两三分钟,能看到已经在输出进度了:
(lora) D:\Data\Python\qwopus3.5-9b-fanren-lora>Unsloth: Double buffering enabled (parallel H2D + compute) for backward pass.
0%| | 6/14277 [00:58<19:26:32, 4.90s/it]
刚开始训练速度会稍微较慢,之后会达到比较平稳的速度。由于最终大概率会触发早停,因此实际训练时间会比预计训练时间略短一些。但先别急着训练,我们先把保存模型的代码写好。
12. 保存模型
由于我们只是对模型进行了微调,因此只需要保存微调产生的LoRA 轻量化权重和分词器。至于为什么需要保存分词器,只是因为Hugging Face推荐我们这么做,那我们就顺手保存了,并且在上传我们的微调模型的时候也顺手把它上传上去,实际上作用不大,我们在接下来的输出脚本中也不会使用到。
因此,我们来完成保存模型部分的代码:
# ===================== STEP 6: 保存 =====================
log_section("STEP 6: 保存模型")
# 保存LoRA 轻量化权重
model.save_pretrained(LORA_SAVE_DIR)
# 保存分词器
tokenizer.save_pretrained(LORA_SAVE_DIR)
log.info(f"🎉 LoRA 适配器已保存至: {LORA_SAVE_DIR}")
log.info(f" 完整日志: {LOG_FILE.resolve()}")
13. 运行入口
最后,我们来写一个运行入口,并且输出一些日志,方便我们的调试:
# ===================== 入口 =====================
if __name__ == "__main__":
log_section("环境诊断")
try:
main()
except Exception:
log.error("\n" + traceback.format_exc())
log.error(f"💥 脚本异常退出,详见日志: {LOG_FILE.resolve()}")
sys.exit(1)
14. 开始训练
接下来就到了最耗费时间的步骤。运行我们的train.py,开始我们的训练,这个步骤可能消耗1~2天。当然读者们也可以直接使用笔者已经训练好的模型,节约时间和电费~
第三步——编写运行模型的脚本
长达两天的时间,我们的电脑消耗了5~6块的电费之后,终于将微调跑出来了。由于我们设置了LORA_SAVE_DIR = "./lora/qwen3.5-9b-text-lora-checkpoint",因此得去文件夹下找到我们的微调文件,那么如何使用我们的微调模型进行写作呢?我们需要实现一个简单的输出脚本,也就是项目中的inference.py。项目中的inference.py。事实上,内嵌了一些调试用的预设文本。我们现在不需要实现这些。./lora/qwen3.5-9b-text-lora-checkpoint
1. 导入包
按照惯例,先导个包,并且要求编码为UTF-8:
import sys
import os
# Windows UTF-8 模式强制(同 train.py)
if sys.flags.utf8_mode == 0:
os.environ["PYTHONUTF8"] = "1"
os.execv(sys.executable, [sys.executable] + sys.argv)
import argparse
import torch
import time
from pathlib import Path
from unsloth import FastLanguageModel
from transformers import TextStreamer
2. 打个补丁
之后和train.py一样,为Unsloth 从HuggingFace 打一个补丁,这个地方我们暂时不去理解:
# ── Monkey-patch: 修复 Unsloth 编译器与 force_accelerate_hooks 的兼容性 ──
# 详见 train.py 中同名补丁的注释。
import transformers.integrations.accelerate as _hf_accelerate
_original_force_accelerate_hooks = _hf_accelerate.force_accelerate_hooks
def _patched_force_accelerate_hooks(child_module_name):
_original_decorator = _original_force_accelerate_hooks(child_module_name)
def _decorator(forward_func):
wrapped_fn = _original_decorator(forward_func)
wrapped_fn.__wrapped__ = forward_func
return wrapped_fn
return _decorator
_hf_accelerate.force_accelerate_hooks = _patched_force_accelerate_hooks
# ── 补丁结束 ──
3. 设置随机种子
对于输出,我们肯定希望模型的每次输出都是不一样的,因此我们设置其种子值是随机的:
# 随机种子:每次运行生成不同结果
# 设置PyTorch在CPU上的随机操作的种子值
torch.manual_seed(int(time.time() * 1000) % (2**31))
# 设置PyTorch在GPU上的随机操作的种子值
if torch.cuda.is_available():
torch.cuda.manual_seed_all(int(time.time() * 1000) % (2**31))
4. 设置基座模型
和train.py一样,我们需要设置基座模型的位置,如果没有的话自动从Hugging Face上下载,如果有的话则直接使用本地模型:
# 优先使用本地模型,不存在则从 HuggingFace 自动下载
LOCAL_MODEL_DIR = str(Path(__file__).resolve().parent / "model/Qwen3.5-9B-Text-Only-abliterated")
HF_MODEL_ID = "zjml/Qwen3.5-9B-Text-Only-abliterated"
MODEL_ID = LOCAL_MODEL_DIR if Path(LOCAL_MODEL_DIR).exists() else HF_MODEL_ID
def load_model(lora_path: str):
"""加载基座模型 + LoRA 适配器"""
print(f"加载基座模型: {MODEL_ID}")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_ID,
max_seq_length=1280,
dtype=torch.bfloat16,
load_in_4bit=True,
)
print(f"加载 LoRA 适配器: {lora_path}")
model.load_adapter(lora_path)
model.to("cuda") # 确保 LoRA 权重移到 GPU
FastLanguageModel.for_inference(model)
print("✅ 模型就绪")
return model, tokenizer
5. 撰写提示词模板
接下来,我们来写一段提示词模板。还是老生常谈的问题,我们在训练的时候更推荐其实是使用格式 2:question-answer,这样就不需要有Instruction部分占用上下文了。不过既然模型已经训练出来了,我们就用格式1来写一段提示词模板,方便之后的代码中直接替换input部分来实现上下文的续写:
PROMPT_TEMPLATE = """### Instruction:
你是仙侠小说《凡人修仙传》风格的作家,请根据以下前文内容,续写接下来的剧情,要求文风一致,逻辑连贯。
### Input:
{input_text}
### Response:
"""
6. 加载基座模型和分词器
现在,我们写一个方法来加载基座模型和分词器,先加载基座模型,再加载LoRA适配器,之后确保整个组合模型(基座 + LoRA)完整地搬运到 GPU 显存,最后开启推理优化模式:
def load_model(lora_path: str):
"""加载基座模型 + LoRA 适配器"""
print(f"加载基座模型: {MODEL_ID}")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=MODEL_ID,
max_seq_length=1280,
dtype=torch.bfloat16,
load_in_4bit=True,
)
print(f"加载 LoRA 适配器: {lora_path}")
model.load_adapter(lora_path)
model.to("cuda") # 确保 LoRA 权重移到 GPU
# 开启推理优化模式
FastLanguageModel.for_inference(model)
print("✅ 模型就绪")
return model, tokenizer
7. 输出方法
现在我们写一个方法来实现流式输出。对于语言模型,我们首先先要将输入的文本通过分词器转化为模型能够读懂的语言:
def generate(model, tokenizer, prompt: str, max_tokens: int, temperature: float, top_p: float):
"""流式生成文本"""
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
然后打开流式输出开关:
streamer = TextStreamer(tokenizer, skip_prompt=True)
接下来就是调用model.generate()方法,并且把诸如温度等数值传入进去,由于这个方法在执行中会自己打印流式输出,所以我们无需再接收其返回的数据,直接调用这个方法就行:
print("\n--- 生成结果 ---")
_ = model.generate(
**inputs,
max_new_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
do_sample=True,
repetition_penalty=1.1,
streamer=streamer,
)
print("\n--- 生成完毕 ---")
8. 实现main()方法
最后我们来实现main()方法,将刚刚准备的方法都用起来:
这个地方有一点需要注意,如果读者是在Hugging Face上面通过git下载的笔者已经训练好的模型,请把你的模型所在的目录设置好,也就是_default_lora = “lora/qwen3.5-9b-text-lora-checkpoint”需要改为_default_lora = “lora/zjml/Qwen3.5-9B-Fanren-LoRA”,请读者通过实际情况进行修改,当然也可以通过命令传入:
def main():
parser = argparse.ArgumentParser(description="qwopus3.5-9b-fanren-lora 推理脚本")
# 智能检测 LoRA 路径:HF clone 则在当前目录,本地开发则在 lora 子目录
_default_lora = "."
if not Path("adapter_model.safetensors").exists():
_default_lora = "lora/qwen3.5-9b-text-lora-checkpoint"
parser.add_argument("--lora", "-l", default=_default_lora,
help=f"LoRA 适配器路径 (默认: {_default_lora})")
parser.add_argument("--input", "-i", default=None,
help="前文内容")
parser.add_argument("--max-tokens", "-n", type=int, default=512,
help="最大生成 token 数 (默认: 512)")
parser.add_argument("--temperature", type=float, default=0.8,
help="温度参数,越高越有创造性 (默认按案例预设)")
parser.add_argument("--top-p", type=float, default=0.9,
help="top_p 采样 (默认: 0.9)")
args = parser.parse_args()
temp = args.temperature
tokens = args.max_tokens
print(f" 温度={temp} tokens={tokens}")
model, tokenizer = load_model(args.lora)
prompt = PROMPT_TEMPLATE.format(input_text=args.input)
generate(model, tokenizer, prompt, tokens, temp, args.top_p)
最后,再设置一个程序入口:
if __name__ == "__main__":
main()
9. 执行续写
终于到了检验成果的时候了,我们通过一个简单的命令,让模型为我们的输入做一个续写:
python inference2.py -i "韩立独自行走在乱星海的夜空之下——"
等待一段时间,可以看到模型已经成功地进行流式输出了:
倒让原本一心想要进入里面取宝的韩立,心中一动,颇有兴趣的仔细打量起此山来。
第一千一百零七章 天都尸火与天罡血雷
此山虽然高大异常,但明显是被人用莫大神通强行抬升到半空中一般,底部仍深埋地下数百丈的样子。山体表面闪动着淡淡灵光,并有一层凝厚的乳白色冰霜覆盖其上,竟将整座巨山冰封了起来。
如此做的好处自然是防止里面的宝物被他人窥视,同时也可避免一些有心人的破坏。不过若是因此真能将那些宝物护住,这些化神修士也就没有冒奇险进入其中的意思了。
“这就是北极元光的寒力?”韩立正想的时候,耳边却传来了大衍神君的声音:“不错,看来老夫当初所言不假。此地的确没有什么北极元光存在,只是不知为何,这里全都被一种极寒之力封印住了。”
“是吗?那晚辈这就见识一下了。”韩立神色一动,单手一掐诀,顿时身上青光一闪,一层青濛濛的光华涌出。
“不!”
“什 么?”
韩立的话音才刚落,就听得大衍神君一声惊呼。韩立心中一凛,急忙定睛望去,结果瞳孔骤然间一缩。
只见在他身前不远处,一只青色大手凭空出现,闪电般的向下一抓,就将一只白色小鼎一把捞在了手中。
而小鼎四周的紫色火焰,在青色大手方一接触的瞬间,立刻被冻结成一块紫色冰块。整只大手也瞬间变得晶莹闪烁,寒气逼人。
韩立心中一惊,不及多想的口中一声大喝。
一股黑气从丹田处飞窜而出,随即化为一条黑色小蛟冲向了那只青色大手。结果在一接触下,立刻被冻成了另一块巨大冰块。
韩立脸色有些难看,目中闪过一丝诧异之色。
他自从修成了煞丹后,可从未出现过这种情形。难道这北极元光具有什么特殊神通不成?
心念急转间,韩立神识一动下,周身浮现的青色灵纹,同时放出刺目的灵芒。
各色符文交织之下,韩立身形略一模糊,蓦然从原地
--- 生成完毕 ---
可以看到,作为续写的话,模型还是比较成功的,至少语句没有什么问题。并且也能够正确地使用凡人修仙传中的角色名称,其写作风格也与凡人修仙传非常相似,这就是微调所实现的效果。
结语
到现在,相信各位读者已经能够独立实现模型的微调了。模型的微调在实际使用中有着很大的潜力,对于一个小模型,经过微调之后,也能在其特定的领域展现出很强的实际效果,并且能大大地减少算力与显存的使用。与提示词工程不同,模型微调在更深层次中对模型的参数做出了改变,并且相比提示词工程,我们无需担心模型由于长上下文中的注意力机制或者遗忘机制,导致模型的输出偏离我们的预期,微调的小模型在消费级的家用计算机中也能有较好的输出表现。
并且经过LoRA 和Unsloth 项目的顶尖工程师的调教,在家用级显卡上也能对较大参数的模型进行微调,无需我们独立重新训练一个新的模型。对于家用消费级显卡,我们是完全不可能进行全量微调或者独立训练一个9B参数的模型,但我们却可以对其进行微调。换句话说,我们无需教会模型说话的能力,但我们却可以对其说话的风格做出一些细小的改变。另外,模型微调只需使用较小的文本样本量,普通用户较难收集几十G甚至几TB的优质文本数据用于教会模型说话,而模型微调往往只需要使用几百甚至几十MB的文本便可以进行微调,其难度下降了数个数量级。
以上就是本文的全部内容了,写作不易,还望大家多多支持,并且可以在评论区进行讨论,感兴趣的朋友可以通过hnoycy@mymailbox.cn 与笔者取得联系,共同进步!