用MacBook进行LLM简单人类指令微调
目标:在MacBook Pro上微调大模型,使其能完成一些特定小微知识库的问题回答。
比方说针对知识点:
3.2 不同隐私法对收集、使用或披露特定类型个人数据所需的同意类型规定差异显著:
(1) 部分法律明确规定必须获得"选择同意"(opt in)后方可收集、使用或披露特定数据。"选择同意"意味着未经明确许可不得处理数据。
(2) 其他法律要求提供"选择退出"(opt out)机制,即默认可处理数据,除非个人明确拒绝。"选择退出"意味着未经明确反对即可处理数据。
(3) 个人可随时撤销同意。
fine-tune模型后,使其能在用户提问(prompt)"什么是选择同意(opt in)"或类似问题时,回答“部分法律明确规定必须获得'选择同意'(opt in)后方可收集、使用或披露特定数据。'选择同意'意味着未经明确许可不得处理数据”。
硬件:chip: Apple M4 Pro, Memory 24GB
OS: MacOS 15.6 (24G84)
历程:
一、使用GPT2变种
因为刚学完经典教程 <Build a Large Language Model (From Scratch)>, 这本书是以GPT2为范式来进行大模型的搭建,使用,微调教学的。最后一章就是阐述如何进行遵循人类指令的微调。我恰好计划用书中的代码现学现用,小试牛刀。
1. 最开始想用标准GPT2模型,如·`gpt2-medium (355M)`,使用中文数据指令集来训练它,如`alpaca_gpt4_data_zh`。发现这是个不可能完成的任务,首先M4 chip的内存就受不了,报错退出,因为`alpaca_gpt4_data_zh`的很多指令token数量远超GPT2通常设定的1024。况且GPT2模型比较小,设计之初以英文为主,对中文支持有限,
2. 转而选用在GPT2上预训练好中文的模型,在AI大模型(包括腾讯元宝,chatGPT等)的建议下,第一个试用的是`IDEA-CCNL/Wenzhong-GPT2-110M`。使用`Hugging Face`的模块进行下载(加载),fine tune的脚本大部份沿用经典教程,少部份代码做适配,包括`vocab_size`要使`model.config`和`tokenizer`的配置一致。结果发现,它太差了,无法用于中文微调。在`Hugging Face`上,这种两年前的模型,下载量和like数量都很少的,可以直接跳过。
3. 改用清华大学的`THUDM/chatglm-6b`。这个还是腾讯混元推荐的模型。发现安装依赖环境都存在困难,`sentencepiece` 和 `transformers`的版本有冲突,按照`Hugging Face`上的软件依赖安装方法,在MacOS上无法运行。也是两年前的模型了,弃用。
二、使用Qwen3
还是在AI的建议下尝试使用Qwen. 为什么没有一开始使用Qwen是因为原以为Qwen作为最新兴起的模型,和DeepSeek类似,会很大,占用很多的计算机资源,而我只需要微调小微人类指令集,也只有MacBook, 没有GPU。上网一查才知道,Qwen3有各种形式和尺寸的模型,覆盖了各种应用场景,包括专门在MacBook上使用的MLX系列。MLX 是 Apple 推出的机器学习框架,支持 Metal 加速,适合 M 系列芯片。
因为我们的微调指令集很小,所以选择了一个最小的模型,`Qwen/Qwen3-0.6B-MLX-4bit`. 4-bit 量化是一种 模型压缩技术,它将原本以 高精度(如 FP32 或 BF16)存储的模型权重,压缩(量化)为低精度(如 INT4 / 4-bit 整数)表示,可以大幅减少存储空间,内存需求,并提升推理速度。
直接使用`Hugging Face`官网上的例程即可以实现下载,加载和推理。Qwen/Qwen3-0.6B-MLX-4bit
缺省下载在本地`.cache/huggingface/hub/models--Qwen--Qwen3-0.6B-MLX-4bit`, 占用磁盘空间317M。
这里发现了该模型的一个bug,即`enable_thinking=True`失效,在GitHub上提了个issue, [[Bug]: enable_thinking=False doesn't work in mlx_lm #1575],两天后该问题被Alibaba的工程师解决,更新了版本。重新下载后问题消失。这里要给Alibaba工程师点赞。
现在要开始微调了。如何微调呢?肯定是不能用经典教程里的GPT2的脚本,尽管两者都是Decoder-only架构。根据GitHub上Qwen3主页的README, `We advise you to use training frameworks, including Axolotl, UnSloth, Swift, Llama-Factory, etc., to finetune your models with SFT, DPO, GRPO, etc.`。我选择Swift框架,它属于ModelScope社区开发的。
进入SWIFT Github主页,根据README里安装环境,看到`Using Python`区域的pseudocode, 点击链接 [here]进入到详细脚本页面,`examples/notebook/qwen2_5-self-cognition/self-cognition-sft.ipynb`, 是在Qwen2.5时创建的demo脚本,也可以应用于3.0.
在运行的时候报错,因为这个框架只支持全量(全精度)版本,不支持4-bit量化,
在AI(chatGPT)的建议下,转向MLX社区。[MLX LM]是Github上用于在Apple芯片平台支持MLX架构上运行的大模型推理和微调python包。mlx_lm.lora — 这个模块包含使用LoRA和全权重微调,取决于使用时命令行的参数,如--fine-tune-type full表示全参数微调。 使用Lora进行微调的脚本很简单,
mlx_lm.lora \--model Qwen/Qwen3-0.6B-MLX-4bit \--train \--data ./data \--fine-tune-type lora \--batch-size 4 \--iters 1000 \--adapter-path ./qwen3-0.6b-lora-out
这里 (1)--data, 是指用于训练的数据的访问地址,里面应该包含`train.jsonl`和`valid.jsonl`。(2) --iters, 1次iteration训练batch-size个案例(train.jsonl里的一条训练数据),假设有400条训练数据,1000次iters对应 1000/(400/4) = 10. (3) --adapter-path, 当使用Lora进行微调时,并不改变模型原有的参数,而是植入小的,低阶的,可训练的适配器(矩阵)在特定层(通常是attention layer)。当训练完成后,进行推理时,输出结果是原模型的输出和这个适配器输出的叠加。这个adapter-path是这个低阶适配器的保存地址。通过这种方式,可以大大减少微调的工作量。
这里还要谈谈训练数据的准备,我这个项目的任务比较简单,就是根据用户针对知识点的提问输出回答。一条训练数据的格式如下:
{"prompt": "什么是选择同意(opt in)?", "completion": "部分法律明确规定必须获得'选择同意'(opt in)后方可收集、使用或披露特定数据。'选择同意'意味着未经明确许可不得处理数据"}
其实就是prompt和completion两部分。
因为我的数据集很小,只有70多条,那么valid数据就不能再直接从里面分出一部分了,我的初始valid.jsonl就是把train.jsonl里的部分训练数据的prompt重新组织一下语言,或者换一种提问的角度,completion根据新的问法来组织回答语句。
因为这个模型很小,且采用4bit量化,又是使用Lora,训练数据简单且短,所以运行上面的脚本进行微调训练的时候,内存占用量只有1G多一点,
Loading pretrained model
Fetching 7 files: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 7/7 [00:00<00:00, 76260.07it/s]
Loading datasets
Training
Trainable parameters: 0.110% (0.655M/596.050M)
Starting training..., iters: 1000
Calculating loss...: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████| 25/25 [00:01<00:00, 17.72it/s]
Iter 1: Val loss 6.340, Val took 1.412s
Iter 10: Train loss 4.890, Learning Rate 1.000e-05, It/sec 9.413, Tokens/sec 1522.059, Trained Tokens 1617, Peak mem 1.357 GB
Iter 20: Train loss 3.260, Learning Rate 1.000e-05, It/sec 10.914, Tokens/sec 1700.343, Trained Tokens 3175, Peak mem 1.357 GB
Iter 30: Train loss 2.666, Learning Rate 1.000e-05, It/sec 10.916, Tokens/sec 1575.109, Trained Tokens 4618, Peak mem 1.357 GB
Iter 40: Train loss 2.641, Learning Rate 1.000e-05, It/sec 9.469, Tokens/sec 1555.684, Trained Tokens 6261, Peak mem 1.360 GB
Iter 50: Train loss 2.657, Learning Rate 1.000e-05, It/sec 9.524, Tokens/sec 1718.063, Trained Tokens 8065, Peak mem 1.360 GB
Iter 60: Train loss 2.281, Learning Rate 1.000e-05, It/sec 9.523, Tokens/sec 1500.811, Trained Tokens 9641, Peak mem 1.360 GB
Iter 70: Train loss 2.416, Learning Rate 1.000e-05, It/sec 10.845, Tokens/sec 1748.139, Trained Tokens 11253, Peak mem 1.360 GB
计算速度非常快,10个iters都不到1s。Training loss下降得也很猛,到最后只有零点几。但是val loss还是有两点多。
微调完后,用如下脚本可以快速进行推理,生成答案:
mlx_lm.generate \--model Qwen/Qwen3-0.6B-MLX-4bit \--adapter-path ./qwen3-0.6b-lora-out \--prompt "我们为什么需要保护个人数据?"
发现用train.jsonl里面的prompt,输出回答很完美,但是用valid.jsonl里的prompt, 输出与预期相差甚远。说明模型只是机械地一字不差地记住了train.jsonl的内容(overfit),并没有真正理解并产生泛化能力。
咨询了AI,这是由于训练数据过少,且iters过多导致的。解决方案是扩大训练数据,用不同的方式进行提问。我还是借助了腾讯元宝,来对我的训练数据的prompt进行改写,即换一种遣词造句的方式,意思和内容不变。completion不变。一开始,我把整个train.jsonl(70多条)上传到腾讯元宝,然后它一段一段地输出改写的内容。前面一些条还准确,到了后面产生幻觉,把不知哪里的内容(可能是以前训练数据的内容,有点接近的)当成prompt输出。然后我改变策略,每次直接向对话框贴10条训练数据,包含了prompt和completion. 它回答50条改写的prompt,加上不变的completion. 即每条产生5条改写。最后生成300多条训练数据,我全部直接复制黏贴在train.jsonl里,而原train.jsonl的内容,就复制到val.jsonl中。
再次用上面的脚本进行微调,同样很快完成,train.jsonl后面的loss收敛到零点几,val.jsonl的loss也只有一点几。再写一个python脚本程序进行人与模型的对话,发现如果用train.jsonl里的prompt进行提问,输出几乎复刻completion的内容,如果用valid.jsonl, 大部份情况下也能回答与期望一致,少数情况下会偏题。80分是有的。作为一个小试牛刀的项目,精度要求不太高的话,也就可以收工了。
下面贴上人与微调后模型对话的python脚本。
from mlx_lm import load, generate
from transformers import AutoModelForCausalLM, AutoTokenizer
import osdef generate_response(model, tokenizer, prompt):if tokenizer.chat_template is not None:messages=[{"role":"user", "content":prompt}]prompt = tokenizer.apply_chat_template(messages,add_generation_prompt=True,enable_thinking=False)response = generate(model,tokenizer,prompt=prompt,verbose=False,max_tokens=1024)print(response)def main():model,tokenizer = load(path_or_hf_repo="Qwen/Qwen3-0.6B-MLX-4bit",adapter_path='./qwen3-0.6b-lora-out')#prompt = "Hello, please introduce yourself and tell me what you can do./no_think"prompt ="你好,请介绍你自己并告诉我你可以做什么。"generate_response(model=model,tokenizer=tokenizer,prompt=prompt)while True:#Get user inputuser_input = input("\nYou: ").strip()if not user_input:continue#Check for quit commandif 'exit' in user_input.lower():print('Goodbye')breakgenerate_response(model=model,tokenizer=tokenizer,prompt=user_input)if __name__ == "__main__":main()
经验总结
1. LLM发展速度很快,最好是用最新的模型,一般会有适应各种不同场景的需求的不同版本
2. LLM作为一个新东西,AI,无论是国内的腾讯元宝,DeepSeek, 还是chatGPT, 都无法对各种模型的使用情况,适用场景,包括API,做一个准确的描述和评估。AI的回答仅仅作为参考,还是要靠自己去摸索。这和传统编程语言直接出模版例程完全不同。