简介:这份PDF资料面向AI零基础、仅掌握JavaScript或略懂PHP、Python的初学者,系统讲解如何在本地对DeepSeek-R1:1.5B模型进行微调训练。内容从Ollama本地部署、模型文件路径确认,到训练数据准备、Python环境搭建、torch/transformers/datasets三大库安装,再到加载预训练模型与分词器、数据预处理,形成一条完整可跟随的实践链路。资源包共1个PDF文件,大小约1.93MB,以图文步骤形式呈现,便于边看边操作。目前已有270人学习下载。读者可借此掌握本地模型微调的目录结构设计、代码编写与运行验证方法,理解模型加载成功与数据预处理成功的判断依据,并获得依赖库安装缓慢时的耐心等待与排错思路,适合希望低门槛入门AI模型训练的实践者参考。
1. 从 Ollama 拉下来的模型,到底能不能接着训?
很多人第一次在本地跑通ollama run deepseek-r1:1.5b之后,都会冒出一个念头:这模型能不能拿我自己的数据再喂一喂?尤其是手头只有一堆.txt语料、平时主要写 JavaScript、顶多翻过几页 PHP 和 Python 的从业者,最关心的不是论文里的 scaling law,而是「我这点数据能不能让它说话更像我的业务」。答案是能,但路径和你想的不太一样。
Ollama 负责的是推理侧的本地部署,它把模型权重、量化格式、运行环境打包成一个开箱即用的黑匣子;而微调是训练侧的事,需要把权重从 Ollama 的模型目录里「请」出来,交给 PyTorch 和 Hugging Face 那套工具链。这两件事共用同一份模型文件,但操作入口完全不同。这篇笔记就按「部署 → 取权重 → 装环境 → 写脚本 → 训练 → 验证」的顺序拆一遍,目标读者是只会 JavaScript、Python 几乎零基础、但愿意照着敲命令的人。全程在 Windows 上操作,模型放在D:\ollama\models,工作目录放在D:\ollama\fine_tune_deepseek,不涉及任何云端算力,纯本地跑。
需要先打一个预防针:deepseek-r1:1.5b这个体量,在消费级显卡上做全量微调基本不现实,显存会直接爆掉。常见做法是走 LoRA 或者只做小学习率的轻量微调,甚至退一步只做数据格式对齐。这篇笔记会把每一步的边界讲清楚,哪些参数能改、哪些改了会翻车、日志里出现什么字样说明该停手,都会落到具体命令和代码上。
2. 把 Ollama 模型目录变成 Hugging Face 能读的权重
2.1 先搞清楚 Ollama 的模型文件长什么样
Ollama 在 Windows 上默认把模型放在C:\Users\<用户名>\.ollama\models,但安装时可以指定到别的盘。你如果按项目里的做法装到了D:\ollama\models,那目录结构大致是这样:
D:\ollama\models\ ├── blobs\ │ ├── sha256-xxxxx │ └── sha256-yyyyy └── manifests\ └── registry.ollama.ai\ └── library\ └── deepseek-r1\ └── 1.5bblobs里是真正的权重分片和配置,manifests里是索引文件,记录了这个模型由哪些 blob 组成、用什么模板、量化类型是什么。这里有个关键点:Ollama 拉下来的模型通常是 GGUF 或它自己封装的量化格式,而transformers的AutoModelForCausalLM.from_pretrained()默认读的是 PyTorch 的pytorch_model.bin或model.safetensors。两者不是一回事。
所以直接写model_name = "D:/ollama/models/deepseek-r1:1.5b"然后调from_pretrained,大概率会报OSError: Can't load config或者找不到权重文件。这不是路径写错了,是格式不匹配。
2.2 两条可行的取权重路线
路线一:从 Hugging Face 重新拉一份同规格的原始权重。deepseek-r1:1.5b对应的基座模型在 Hugging Face 上有公开仓库,用transformers直接按模型名加载即可。这条路最省事,缺点是下载量不小,网络慢的时候要等很久。
路线二:用转换脚本把 Ollama 的 blob 转成 PyTorch 格式。Ollama 的 manifest 里记录了每个 blob 的 media type,如果是application/vnd.ollama.image.model,那它就是 GGUF 格式的权重。可以用llama.cpp自带的转换脚本先转成 GGUF 再转 PyTorch,但这条链路对零基础的人不友好,中间任何一步版本对不上都会卡住。
我一般会建议走路线一,因为微调本身就需要一个干净的、未量化的基座,量化过的权重再训效果会打折扣。下面这段代码就是按路线一写的,把模型名换成 Hugging Face 上的实际仓库名即可:
# load_base.py # 作用:从 Hugging Face 拉取与 deepseek-r1:1.5b 同规格的基座权重 # 注意:首次运行会下载数 GB 文件,确保 D 盘有足够空间 from transformers import AutoTokenizer, AutoModelForCausalLM # 这里的模型名需要替换为 Hugging Face 上对应的仓库标识 # 不要直接写本地 Ollama 目录,格式不兼容 model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype="auto", # 自动选择 fp16 或 fp32,省显存 device_map="auto" # 有 GPU 就上 GPU,没有就 CPU ) print("Model and tokenizer loaded successfully!") print("Model dtype:", model.dtype) print("Model device:", next(model.parameters()).device)逻辑说明:torch_dtype="auto"让 transformers 根据当前硬件自动选精度,有 CUDA 且支持 fp16 时会用半精度,显存占用直接减半。device_map="auto"会把模型层自动分配到可用设备上,单卡场景下就是全部放 GPU,显存不够时会往 CPU 上溢,但速度会掉得很厉害。
参数说明:如果你只有 CPU,把device_map去掉,加载完后手动model.to("cpu")。如果显存小于 6GB,建议加load_in_8bit=True,但这需要额外装bitsandbytes,Windows 上编译经常出问题,新手先别碰。
2.3 验证加载是否真的成功
运行python load_base.py,看到Model and tokenizer loaded successfully!只说明没抛异常,不代表权重完整。再补一句检查:
# 检查模型参数量和第一层权重的形状 total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params / 1e6:.2f}M") print("First layer shape:", model.model.embed_tokens.weight.shape)1.5B 的模型,参数量应该在 1500M 上下,embedding 层的词表大小通常在 15 万左右。如果打印出来是 0M 或者形状明显不对,说明加载的是空壳,回去检查模型名和网络。
3. 数据预处理:把 .txt 喂成模型能吃的 token
3.1 语料格式和目录约定
项目里说每行可以是一个训练样本,这个说法在纯文本继续预训练场景下成立,但要注意两点:一是每行不要太短,低于 10 个字符的样本对模型几乎没有信息量;二是编码必须是 UTF-8,Windows 记事本默认可能是 GBK,读进来会乱码。
目录按项目里的约定放:
D:\ollama\fine_tune_deepseek\ ├── data\ │ ├── file1.txt │ ├── file2.txt │ └── file3.txt └── fine_tune.py3.2 读取和 tokenize 的完整脚本
# data_prep.py # 作用:读取 data 目录下所有 .txt,转成模型输入 import os from transformers import AutoTokenizer data_dir = "D:/ollama/fine_tune_deepseek/data" model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B" tokenizer = AutoTokenizer.from_pretrained(model_name) # 如果 tokenizer 没有 pad_token,用 eos_token 顶上 # 否则 padding="max_length" 会直接报错 if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token texts = [] for filename in os.listdir(data_dir): if filename.endswith(".txt"): filepath = os.path.join(data_dir, filename) with open(filepath, "r", encoding="utf-8") as f: for line in f: line = line.strip() if len(line) >= 10: # 过滤过短样本 texts.append(line) print(f"Loaded {len(texts)} samples") inputs = tokenizer( texts, return_tensors="pt", max_length=512, truncation=True, padding="max_length" ) print("Input shape:", inputs["input_ids"].shape) print("Data preprocessed successfully!")逻辑说明:os.listdir遍历目录,endswith(".txt")只挑文本文件。逐行读取而不是f.read()整篇读入,是为了让每个样本独立,避免一篇长文被截断后语义不完整。len(line) >= 10这个阈值可以按你的语料调整,目的是过滤掉「你好」「好的」这类无信息样本。
参数说明:max_length=512是单样本最大 token 数,1.5B 模型通常支持 2048 甚至更长,但微调时设太大显存吃不消,512 是消费级显卡的稳妥值。truncation=True表示超长截断,padding="max_length"表示短样本补齐到 512,这样 batch 里所有样本长度一致,训练时不会因为动态长度反复重编译。
3.3 一个容易被忽略的坑:labels 从哪来
上面这段代码只产出了input_ids和attention_mask,没有labels。因果语言模型的训练目标是预测下一个 token,labels通常就是input_ids本身,由 Trainer 内部做 shift。但如果你直接把inputs["input_ids"]当train_dataset传进去,Trainer 会把它当成一个普通张量,取不到labels字段,训练时 loss 会是 None。
正确做法是构造一个 Dataset 对象,把input_ids和attention_mask都放进去,并让labels等于input_ids:
from datasets import Dataset dataset = Dataset.from_dict({ "input_ids": inputs["input_ids"], "attention_mask": inputs["attention_mask"], "labels": inputs["input_ids"].clone() # 因果 LM 的标签就是输入本身 }) print(dataset)这一步不做,后面trainer.train()会直接抛ValueError: Trainer: No labels found,很多人卡在这里以为是数据问题,其实是结构没对上。
4. 微调参数怎么设:Trainer 和 TrainingArguments 的实操配置
4.1 最小可运行的训练脚本
# train.py # 作用:用 Trainer API 对基座模型做轻量微调 import os from transformers import ( AutoTokenizer, AutoModelForCausalLM, Trainer, TrainingArguments ) from datasets import Dataset model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B" data_dir = "D:/ollama/fine_tune_deepseek/data" output_dir = "D:/ollama/fine_tune_deepseek/fine_tuned_model" log_dir = "D:/ollama/fine_tune_deepseek/logs" tokenizer = AutoTokenizer.from_pretrained(model_name) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype="auto", device_map="auto" ) # 读取数据 texts = [] for filename in os.listdir(data_dir): if filename.endswith(".txt"): with open(os.path.join(data_dir, filename), "r", encoding="utf-8") as f: for line in f: line = line.strip() if len(line) >= 10: texts.append(line) inputs = tokenizer( texts, return_tensors="pt", max_length=512, truncation=True, padding="max_length" ) dataset = Dataset.from_dict({ "input_ids": inputs["input_ids"], "attention_mask": inputs["attention_mask"], "labels": inputs["input_ids"].clone() }) # 训练参数 training_args = TrainingArguments( output_dir=output_dir, logging_dir=log_dir, num_train_epochs=3, # 小数据集 3 轮足够,多了会过拟合 per_device_train_batch_size=1, # 显存小于 8GB 就保持 1 gradient_accumulation_steps=8, # 等效 batch size = 1 * 8 = 8 learning_rate=2e-5, # 微调常用量级,别超过 5e-5 warmup_steps=20, logging_steps=10, save_steps=100, save_total_limit=2, # 只保留最近两个 checkpoint,省磁盘 fp16=True, # 有 NVIDIA GPU 就开,AMD 或 CPU 关掉 report_to="tensorboard" ) trainer = Trainer( model=model, args=training_args, train_dataset=dataset ) trainer.train() trainer.save_model(output_dir) tokenizer.save_pretrained(output_dir) print(f"Fine-tuned model saved to {output_dir}")逻辑说明:gradient_accumulation_steps=8是在显存不够时模拟大 batch 的标准手段,梯度会累积 8 次再更新一次参数,等效 batch size 变成 8,训练稳定性比单样本好很多。save_total_limit=2防止 checkpoint 把 D 盘塞满,1.5B 模型每个 checkpoint 大约 3GB,不限制的话跑几轮就几十 GB 出去了。
参数说明:learning_rate=2e-5是微调的甜点区,再大容易把预训练学到的知识冲掉,再小则几乎看不出变化。num_train_epochs=3针对几千条样本的规模,如果你的.txt只有几百行,跑 1 轮就够,跑多了模型会把你的语料背下来,生成时只会复读。
4.2 用 TensorBoard 看 loss 曲线
训练启动后,另开一个终端:
cd D:\ollama\fine_tune_deepseek tensorboard --logdir=./logs浏览器打开http://localhost:6006,重点看train/loss这条线。正常情况是从 2.0 左右缓慢下降到 1.0 以下,如果前 50 步就掉到 0.1,说明学习率太大或者数据重复度太高;如果 200 步还在 2.5 以上横盘,检查labels是不是没对上,或者数据里全是乱码。
4.3 显存不够时的降级方案
如果fp16=True加上 batch size 1 还是 OOM,按这个顺序降:
| 调整项 | 改法 | 代价 |
|---|---|---|
| 序列长度 | max_length从 512 降到 256 | 长样本被截断,语义可能不完整 |
| 精度 | fp16改bf16(需 GPU 支持) | 老卡不支持,会报错 |
| 量化加载 | 加load_in_8bit=True | 需装 bitsandbytes,Windows 编译易失败 |
| 模型层冻结 | 只训最后 4 层 | 效果打折,但显存立省一半 |
冻结层的写法是在Trainer之前加:
# 只训练最后 4 层,其余层冻结 for name, param in model.named_parameters(): if "layers" in name: layer_idx = int(name.split("layers.")[1].split(".")[0]) if layer_idx < 24: # 假设总共 28 层,冻结前 24 层 param.requires_grad = False层数需要根据实际模型结构打印model确认,不同版本的层命名可能不一样。
5. 避坑排查:训练过程中最常见的五类翻车
5.1 现象:from_pretrained报OSError: Can't load config for 'D:/ollama/models/deepseek-r1:1.5b'
原因:Ollama 的模型目录里没有config.json,只有 manifest 和 blob,transformers 不认识这种结构。
解决:不要指向 Ollama 目录,改用 Hugging Face 上的同规格仓库名,或者先用转换脚本把 blob 转成标准格式。转换脚本涉及 GGUF 解析,零基础不建议自己写,直接用公开权重最稳。
5.2 现象:训练时 loss 一直是nan
原因:学习率太大,或者数据里有空样本导致 attention mask 全零,softmax 时出现除零。
解决:先把learning_rate降到5e-6试一轮;同时在数据读取阶段加一行if len(line) == 0: continue,确保没有空行进入 tokenizer。如果还不行,检查fp16是否在你的显卡上不稳定,换成bf16或直接关掉。
5.3 现象:pip install torch卡在下载,进度条几小时不动
原因:默认源在国外,带宽受限。项目正文里提到 22.2 KB/s 的速度,204MB 要下两个多小时,这是常态。
解决:换国内镜像源,命令改成:
pip install torch transformers datasets -i https://pypi.tuna.tsinghua.edu.cn/simple如果 torch 的 CUDA 版本需要匹配显卡驱动,先去 PyTorch 官网查对应命令,再在命令末尾加-i镜像参数。注意镜像源不一定同步所有版本,如果报No matching distribution,去掉镜像用默认源重试。
5.4 现象:训练跑完了,但生成结果和微调前一模一样
原因:学习率太小、训练轮数太少,或者数据量本身不足以让权重发生可观测的偏移。1.5B 模型在几百条样本上微调,变化本来就微弱。
解决:先把num_train_epochs提到 5,learning_rate提到5e-5,跑一轮看 loss 有没有明显下降。如果 loss 降了但生成没变,说明模型把你的数据「记住」了但没有泛化,这时候要检查数据质量,而不是继续加轮数。
5.5 现象:保存后的模型用 Ollama 加载不了
原因:save_pretrained存的是 PyTorch 格式,Ollama 只认 GGUF 或它自己的封装格式。
解决:微调完的模型要回到 Ollama 用,需要先转 GGUF,再写 Modelfile 导入。转换用llama.cpp的convert_hf_to_gguf.py,导入用ollama create。这条链路是另一个话题,但心里要有数:微调产物和 Ollama 推理产物之间隔着一层格式转换,不是存完就能直接ollama run。
6. 验证微调效果:三个比「看起来像」更靠谱的检查手段
训练跑完,最忌讳的就是看一眼生成结果觉得「好像还行」就收工。下面三个检查按顺序做,能过滤掉大部分自欺欺人的情况。
第一个检查是困惑度对比。在留出的验证集上分别算基座模型和微调模型的 perplexity,如果微调后反而升高,说明过拟合了,回去减轮数。代码不复杂:
import torch from transformers import AutoTokenizer, AutoModelForCausalLM def compute_ppl(model_path, texts): tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype="auto", device_map="auto") model.eval() total_loss = 0 with torch.no_grad(): for text in texts: inputs = tokenizer(text, return_tensors="pt").to(model.device) outputs = model(**inputs, labels=inputs["input_ids"]) total_loss += outputs.loss.item() return torch.exp(torch.tensor(total_loss / len(texts))).item() # 准备 20 条没参与训练的文本做验证 val_texts = ["验证文本1", "验证文本2"] # 替换成你的实际留出数据 print("Base PPL:", compute_ppl("deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B", val_texts)) print("Tuned PPL:", compute_ppl("D:/ollama/fine_tune_deepseek/fine_tuned_model", val_texts))第二个检查是固定 prompt 的生成对比。选 5 个你业务里真实会问的问题,分别用基座和微调模型生成,把输出并排贴出来看。重点不是「哪个更好」,而是「微调后的输出有没有带上你语料的风格特征」。如果风格没变,说明数据没有真正影响模型。
第三个检查是灾难性遗忘测试。用几个通用问题(比如「解释一下什么是递归」)问微调后的模型,如果它连通用问题都答得乱七八糟,说明学习率太大,把预训练知识冲掉了。这时候要么降学习率重训,要么在训练数据里混入一部分通用语料做回放。
我自己的习惯是,每次微调完先跑这三个检查,任何一个不过关就不往 Ollama 里导。格式转换和导入那一步很费时间,与其导进去发现不对再回滚,不如在 PyTorch 阶段就把问题拦住。从那以后我每次动学习率和轮数之前,都会先把验证集 perplexity 的基线记下来,改完参数第一件事就是对比这个数,比看生成文本靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取