简介:面向希望在本地完成DeepSeek模型训练的零基础AI爱好者,尤其是仅掌握JavaScript或对Python略有了解的学习者,这份PDF教程以清晰的操作路径,帮助读者从零开始完成环境搭建与微调准备。资源包含1个PDF文件,压缩包大小约1.93MB,内容凝练,便于对照学习。目前已有269人学习下载。教程覆盖Ollama本地部署DeepSeek的具体方法、训练文本数据的准备规范、Python环境及torch/transformers/datasets三大依赖库的安装要点,并通过VSCode等编辑器展示加载预训练模型和分词器的代码示例,同时给出D:\ollama\fine_tune_deepseek等目录结构建议,帮助读者快速搭建工作目录。整体以步骤驱动,配有进度判断和常见问题说明,适合希望低成本入门大模型微调、同时又缺乏深度学习背景的读者按图索骥,逐步掌握本地训练的基础流程。
1. 本地训练 DeepSeek 的边界:671B 训不动,24G 卡能训什么
提到 deepseek 本地模型训练,很多人第一反应是去啃那 671B 参数的 MoE 巨兽。我先把结论摆在这:DeepSeek-V3/R1 本体不是给本地训练的,单卡 24G 连加载都费劲,真正适合本地练的是 R1-Distill 系列(1.5B/7B/14B/32B)这类稠密小模型。
本地模型训练解决的核心诉求是两件事:私有数据不出内网,以及拿到一个在特定领域不套话、不空转的定制模型。把企业内部文档、客服话术、领域问答揉进一个 7B 模型的 LoRA 适配器,训练完显卡上只多出几百 MB 权重,部署和迭代完全自主。
这条路适合手上有一张 12G 以上显存 GPU 的工程师和学生。下面从显存账、数据、训练、避坑到部署,按我踩过的顺序展开。
2. 显存账本与训练路线:为什么全参微调 7B 需要 112GB
本地模型训练的第一步不是写代码,是把显存账算清楚。很多人拿着 4090 就想全参微调 DeepSeek 7B,真跑起来才发现优化器状态比权重本身还吃显存,OOM 只是结果,不是原因。
2.1 全参训练的显存构成:权重之外有个 4 倍隐形开销
全参微调 7B 模型,在混合精度(FP16 权重 + FP32 优化器)下,每个参数实际要占 16 字节。拆开看是这样:
| 组件 | 单参数占用 | 7B 参数合计 |
|---|---|---|
| FP16 权重 | 2 字节 | 14GB |
| FP32 主权重 | 4 字节 | 28GB |
| FP16 梯度 | 2 字节 | 14GB |
| Adam 一阶动量 m | 4 字节 | 28GB |
| Adam 二阶动量 v | 4 字节 | 28GB |
| 合计 | 16 字节 | 约 112GB |
这还没算前向传播的激活值和临时张量,加上去轻松奔着 130GB 走。A100 80G 单卡都吃紧,更不用说 24G 的消费卡。所以全参微调这条路,天然是 A100/H100 集群或者 DeepSpeed 多卡的活儿,个人开发者在自己的机器上别碰。
LoRA 不一样。冻结基座权重,只训练注入的低秩适配器,可训练参数只有模型的 0.1% 到 0.5%。7B 模型的 LoRA 适配器参数量在 7M 量级,优化器状态可以忽略不计,显存大头落在基座权重和前向激活上。FP16 基座要 14GB,QLoRA 用 NF4 4bit 量化基座只要 3.5GB,24G 卡甚至 12G 卡都能挤出空间。
2.2 LoRA 还是 QLoRA:一张对比表做决定
训练路线选择不是玄学,直接看显存预算:
| 方案 | 7B 模型训练峰值显存 | 适合设备 | 训练速度 | 效果 |
|---|---|---|---|---|
| 全参微调 | 120GB 以上 | 多卡 A100/H100 | 慢 | 数据量大时最好 |
| LoRA(FP16/BF16) | 20-24GB | 4090 / 3090 | 快 | 好 |
| QLoRA(NF4) | 8-12GB | 3060 12G 及以上 | 中 | 接近 LoRA |
所谓“QLoRA 效果差”的传言,在指令微调这种任务上并不成立。量化误差主要影响的是基座知识表达,而 LoRA 学的是任务格式和领域语气,两者叠加之后,QLoRA 和 LoRA 的差距通常在可接受范围内。我一般这样选:显存够 24G 优先 LoRA,显存 12-16G 就老老实实 QLoRA,省下的空间还能把序列长度拉长。
多卡用户注意:单卡别上 DeepSpeed ZeRO,那纯属把 CPU offload 的换页速度拖下水。两张 24G 卡以上才值得开 ZeRO Stage 2,配合 gradient checkpointing 用;ZeRO Stage 3 是给 13B 以上模型准备的。
2.3 动手前先做三件事:显卡、依赖、模型目录体检
训练前我固定会跑一遍下面的环境检查,避免训练中途才发现驱动或者库版本不对:
# 1. 显卡和驱动 nvidia-smi # 2. PyTorch 是否认卡 python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))" # 3. 训练相关库的版本 python -m pip list | grep -Ei "transformers|peft|accelerate|trl|datasets|bitsandbytes"显卡体检看三样:显存容量、驱动版本、CUDA 版本。PyTorch 那句能输出True和显卡名说明内核编译没问题。库版本记录好,后面出问题第一件事就是对版本,尤其是trl和peft,这两个库 API 变得快,网上抄来的脚本大概率不是给你的版本写的。
模型也要提前落盘。把deepseek-ai/DeepSeek-R1-Distill-Qwen-7B这类模型下到本地目录,后续训练断网也能反复跑。内网机下载困难的话,直接在能上网的机器上下好整个模型目录,整个目录拷贝到内网机器就能from_pretrained加载,模型目录是自包含的,不用装额外东西。
3. 数据与 tokenizer 对齐:决定训练效果的是预处理不是算力
训练脚本是流水线,数据才是发动机。DeepSeek 本地模型训练最常见的翻车不是显存不够,而是数据格式没对齐,模型在垃圾上练了十几个小时。
3.1 指令数据长什么样:用 chat_template,不要手拼字符串
R1-Distill 系列是对话模型,训练数据必须走它自己的对话模板。最省事的方式是准备 JSONL,每条样本一个 system/user/assistant 三元组:
{"system": "你是某产品的售后助手,回答要简洁,不能编造政策。", "user": "商品发错地址了怎么处理?", "assistant": "在订单页点退货,选择“地址错误”,系统会免费补发一次。如果已经签收,联系在线客服登记补发。"} {"system": "", "user": "退款多久到账?", "assistant": "审核通过后 1-3 个工作日原路退回。"}然后取一小批样本打印 tokenize 之后的结果,确认模板拼接正确:
import json from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-R1-Distill-Qwen-7B") row = {"system": "你是客服助手", "user": "退款多久到账?", "assistant": "1-3 个工作日。"} msgs = [ {"role": "system", "content": row["system"]}, {"role": "user", "content": row["user"]}, {"role": "assistant", "content": row["assistant"]}, ] print(tok.apply_chat_template(msgs, tokenize=False))关键在apply_chat_template。它读的是模型目录里的tokenizer_config.json中预设的 chat_template,训练和推理都用同一个函数,就不会出现“训练正常、推理乱码”的错位。手拼"User: ... Assistant: ..."在旧模型上能跑,在 DeepSeek 上大概率把格式学歪。
3.2 最小可复用的 QLoRA 训练脚本
下面是我在 24G 卡上跑 7B QLoRA 的完整脚本,直接保存为train_lora.py就能用:
# train_lora.py import torch from datasets import load_dataset from transformers import ( AutoModelForCausalLM, AutoTokenizer, TrainingArguments, BitsAndBytesConfig, ) from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model from trl import SFTTrainer BASE = "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B" OUT = "./deepseek_local_lora" DATA = "train.jsonl" # NF4 4bit 量化基座,double quant 进一步省显存 bnb = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, ) tok = AutoTokenizer.from_pretrained(BASE) # Qwen 系列 pad_token 默认是 None,必须补上,否则 attention mask 全废 if tok.pad_token is None: tok.pad_token = tok.eos_token def to_chat(row): msgs = [ {"role": "system", "content": row.get("system") or "你是一个靠谱的 AI 助手。"}, {"role": "user", "content": row["user"]}, {"role": "assistant", "content": row["assistant"]}, ] return {"text": tok.apply_chat_template(msgs, tokenize=False)} ds = load_dataset("json", data_files=DATA)["train"].map(to_chat) model = AutoModelForCausalLM.from_pretrained( BASE, quantization_config=bnb, device_map="auto", torch_dtype=torch.bfloat16, ) # 4bit 下必须先把 layernorm / embed 层转回 fp32,不然 loss 漂移 model = prepare_model_for_kbit_training(model) lora = LoraConfig( r=8, lora_alpha=16, target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora) model.print_trainable_parameters() # 必须看到 trainable params 占比,而不是 0 args = TrainingArguments( output_dir=OUT, num_train_epochs=3, per_device_train_batch_size=2, gradient_accumulation_steps=8, # 等效 batch = 2 * 8 = 16 learning_rate=2e-4, warmup_ratio=0.03, lr_scheduler_type="cosine", bf16=True, # Ampere 及以上用 bf16,比 fp16 稳 optim="paged_adamw_8bit", # 4bit 训练标配,显存溢出时自动分页 logging_steps=10, save_steps=200, save_total_limit=2, remove_unused_columns=False, ) tr = SFTTrainer( model=model, args=args, train_dataset=ds, tokenizer=tok, max_seq_length=1024, dataset_text_field="text", packing=False, ) tr.train()参数说明都写在注释里,单独挑三个容易踩的讲:
target_modules必须和模型结构匹配。Qwen 架构的注意力层叫q_proj/k_proj/v_proj/o_proj,MLP 层叫gate_proj/up_proj/down_proj。如果这里打错成c_attn这类旧 GPT-2 命名,训练照样跑,但所有层权重都没挂上 LoRA,训完等于白训。训练开始前那句print_trainable_parameters()就是用来确认这点的,trainable 参数占比应该在 0.1% 到 0.5% 之间。
optim="paged_adamw_8bit"是 bitsandbytes 提供的分页优化器。4bit 量化下显存吃紧是常事,它能像操作系统换页一样把优化器状态临时挪到 CPU 内存,救急很管用。代价是偶尔慢一点。
bf16=True是基于 Ampere 架构显卡的推荐选择。bf16 的指数范围和 fp32 一样大,训练中不容易出现梯度下溢。如果你的卡是 Turing 架构(比如 2080Ti),不支持 bf16,就把这里改bf16=False, fp16=True。
3.3 预处理三参数:序列长度、packing 和数据清洗
max_seq_length不要一刀切设 2048。设太大,短样本 padding 全是废算力;设太小,长回答被截断,模型学的是半截句子。我的经验是先看数据集的 token 长度分布,把 95% 样本能覆盖到的长度作为上限,一般 512 到 1024 够用。
packing是 SFTTrainer 的吞吐开关。打开后会把多段短样本拼到接近max_seq_length再训练,训练速度能提升一大截。代价是样本衔接处会产生轻微的语义噪声,对下游效果影响很小。如果你的数据长短差异极大,推荐开packing=True省时间;如果样本本身长度均匀,关掉它更干净。
数据清洗别忘了:先做精确去重,把完全相同的样本删掉;样本量少于 1000 条时 LoRA 很容易过拟合,宁可多花两天攒数据也不要拿几百条硬训。另外,system字段不要传空字符串,apply_chat_template遇到空内容可能会直接丢掉这个 role,导致前面的提示词设计全部失效。
4. 起跑、监控与断点续训:看懂 loss 曲线再谈调参
训练一旦启动,你就是个看监控的人。别只盯着屏幕上滚动的 loss 数字,要建立一套自己的判断体系。
4.1 正确的启动姿势:日志重定向和显存观察
训练时间长,终端会被 nohup 占用,所以正确姿势是把日志写文件,再开另一个终端盯:
nohup python train_lora.py > train.log 2>&1 & tail -f train.lognohup让进程在终端断开后继续跑,2>&1把 stderr 也塞进日志。尾随日志的同时,另开窗口周期性看显存:
nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv首次运行会先加载基座模型和做 4bit 量化,这个阶段 GPU 利用率低、时间偏长,不要急着断定卡死了。判断是否真的在跑,看日志里epoch和step有没有在推进,比看显存占用靠谱。
4.2 loss 曲线的四种形态,对应四种问题
loss 曲线要结合任务难度看,但形态规律是通用的。健康的曲线:从 1.x 平滑降到 0.3 到 0.5(取决于数据量和任务复杂度),中间偶尔小波动,整体向下。
不健康的形态:
第一种,loss 长时间钉在一个值附近纹丝不动。先查数据:打印一条apply_chat_template之后的结果,看是不是空串或者只有 system 没有 assistant。再查 LoRA:print_trainable_parameters()确认适配器真的挂上了。最后查学习率,太低也会这样。
第二种,loss 中途跳高再慢慢恢复。这往往是遇到了坏样本——超长样本被截断后,answer 后半段被切掉,留下的全是没闭合的半句话。我的做法是预处理阶段按 token 数先把超长样本筛掉,而不是交给 truncation 硬截。
第三种,loss 出现 NaN。大概率是 fp16 训练时梯度下溢,切 bf16,或者把学习率从 2e-4 降到 5e-5,再给 TrainingArguments 加一行gradient_clip_norm=1.0。
第四种,loss 下降很快但验证集上明显过拟合。说明数据量配不上训练轮数,把num_train_epochs从 3 降到 1,或者把 LoRA 的r从 8 降到 4。
4.3 断点续训:训练机重启不是世界末日
save_steps=200会让训练器每 200 步存一个 checkpoint 目录,里面是 LoRA 适配器权重和优化器状态。机器断电或者你想改学习率继续跑,用一行代码续上:
# 续训入口,放到 train_lora.py 末尾并传 checkpoint 路径 tr.train(resume_from_checkpoint="./deepseek_local_lora/checkpoint-400")续训后前几十步 loss 出现抖动是正常的,因为数据加载顺序变了,优化器状态也要重新适应。真正要检查的是两件事:save_total_limit=2有没有设,不设的话磁盘会被一堆 checkpoint 塞满;续训前确认 checkpoint 目录里有trainer_state.json,这个文件记录了 global step 和学习率调度位置,丢了它等于从头训。
想看更细的训练过程,在TrainingArguments里加report_to=["tensorboard"], logging_dir="./runs",然后本地跑tensorboard --logdir ./runs,loss、学习率、梯度范数都在里面。
5. DeepSeek 本地训练避坑:五个翻车现场与事后复盘
这部分是血泪经验汇总。每个坑我都亲手踩过,现象、原因、解决一次性给全。
5.1 显存明明够,loss 却一步不降
现象:QLoRA 训练 7B,24G 卡只用了 13G,跑到 300 步 loss 稳定在 1.1 左右不再下降。
原因:两个最多见。一是target_modules名字和模型结构对不上,LoRA 适配器挂在空层上,print_trainable_parameters()输出占比为 0 或小得离谱;二是数据预处理时apply_chat_template返回的是空字符串,模型在空白样本上学不到任何结构。
解决:训练启动后第一件事就是看model.print_trainable_parameters()的输出,trainable 参数必须是几百万量级而不是 0。再打印一条ds[0]["text"],确认模板里有实际内容。两个都正常就把学习率拉回 2e-4 左右,不要自作主张降到 1e-5。
5.2 微调完变成复读机:连续输出“好的好的好的”
现象:eval loss 挺好看,但生成时模型只会重复“好的好的好的……”或者疯狂换行。
原因:训练数据里有大量内容完全相同的 assistant 文本,模型把“重复”当成了高频模式。另一个原因是 LoRA 配置太激进,r=64, lora_alpha=128时适配器权重过度覆盖了基座,把模型原有的表达多样性压没了。
解决:先做精确去重,再检查 LoRA 参数,指令微调用r=8, lora_alpha=16起步就够。训练步数并不是越多越好,500 到 1000 步已经能让 7B 模型学会一套新话术,再往上就是过拟合的开始。
5.3 断点续训后 loss 忽高忽低
现象:从 checkpoint-400 续训,前 100 步 loss 从 0.31 弹到 0.9,之后才慢慢降回去。
原因:一个是数据加载顺序随随机种子变化,正常现象;另一个常见原因是paged_adamw_8bit的优化器状态在部分旧版 PyTorch 下保存不完整,恢复时优化器状态是空的,相当于带着一套新优化器续跑。
解决:给 PyTorch 升到 2.4 以上,transformers 升到 4.46 以上再续训。如果版本没法动,续训后前 100 步的波动就当看不见,不要因为它就砍学习率或者重训。
5.4 训练 loss 正常,本地生成却一塌糊涂
现象:训练和验证 loss 都降到 0.3 附近,但推理时中文输出变成乱码或者重复感叹号。
原因:训练时开了packing=True,样本拼接处的 token 被模型当成了上下文的一部分,学出了一些奇怪的跨样本依赖。另一个更隐蔽的原因:推理时代码手拼了"User:" / "Assistant:"模板,和训练时的apply_chat_template不是同一个格式。
解决:推理代码统一用tokenizer.apply_chat_template(msgs, tokenize=False)生成 prompt,不要手拼。packing 造成的噪声一般不大,如果解码质量特别差,把packing=False重训一次对比。
5.5 多轮对话训练后 system 提示失效
现象:模型第一轮回答正常,第二轮开始完全不理会 system 里的约束,自说自话。
原因:训练数据里大量样本的 system 字段是空字符串,apply_chat_template在遇到空 system 时直接丢弃了该 role,模型在训练中根本没见过 system 内容,自然学不会遵守。
解决:数据清洗时把空 system 统一替换成默认值,不要传空串。同时保证训练集里至少 30% 的样本带非空且有信息量的 system 内容,否则模型学不到 system 和多轮 context 的关联。
6. 验证与部署收尾:把 LoRA 合并成能调用的本地 API
训练产物不能只是磁盘上几个 checkpoint,得变成能对外服务的模型。DeepSeek 本地模型的最后一公里是合并、部署、验证三步。
6.1 合并 LoRA 与导出:避免每次推理都顶着 adapter 加载
LoRA 适配器单独加载需要同时读基座和 adapter,每次都要走一遍 PeftModel 封装,麻烦且容易在路径上出错。训练完成后我习惯先合并再推理:
# merge_lora.py from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer base_id = "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B" adapter = "./deepseek_local_lora/checkpoint-600" tok = AutoTokenizer.from_pretrained(base_id) model = AutoModelForCausalLM.from_pretrained(base_id, torch_dtype=torch.bfloat16, device_map="auto") peft = PeftModel.from_pretrained(model, adapter) merged = peft.merge_and_unload() merged.save_pretrained("./merged", safe_serialization=True) tok.save_pretrained("./merged")合并后 base 和 adapter 深度融合成一个完整模型,产物体积约等于基础权重的体积(7B 的 bf16 约 13-14GB),adapter 那几百 MB 已经溶解进去。safe_serialization 保证输出为 safetensors 格式,加载更快更安全。
6.2 用 vLLM 部署:把本地模型变成 OpenAI 兼容 API
合并完直接上 vLLM,一步到位的 DeepSeek 本地化部署:
vllm serve ./merged \ --served-model-name deepseek-local \ --port 8000 \ --gpu-memory-utilization 0.85 \ --max-model-len 4096--gpu-memory-utilization留点余量给运行时,不要设 0.99;--max-model-len 4096防止长文本请求把 KV cache 撑爆。服务起来后测试:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-local","messages":[{"role":"user","content":"退款多久到账?"}],"max_tokens":256}'VLLM 启动时会读取tokenizer_config.json里的 chat_template,线上模板和训练时完全一致。这套接口和 OpenAI SDK 兼容,Python 里只要把base_url指向http://localhost:8000/v1,业务代码不用改就能接入。
6.3 三组对照验证训练是否真的生效
最后验证比 loss 重要。我会固定 20 条业务真实问题,分别问三个模型:原版基座、合并后模型、合并后模型加 few-shot 示例。人工盲测打分,看信息正确率和格式正确率,而不是看模型有没有背下训练数据。
我踩过的教训是:早期训练只盯 eval loss,loss 降下来就以为自己成功了,上线才发现模型把训练数据里的句式记住了,但没真正学会领域知识。后来养成习惯,每次 merge 后先跑这 20 条预发集,过了再更新服务。这个习惯让我少走了很多弯路,也希望帮到你。
本文还有配套的精品资源,点击获取