1. 单卡 16GB 显存跑 DeepSeek-R1-Distill-Llama-8B LoRA 微调到底卡在哪DeepSeek-R1-Distill-Llama-8B 是把 R1 的推理数据蒸馏进 Llama 3.1 8B 之后得到的稠密模型参数量 80 亿保留了 R1 那种「先想再答」的思维链风格但体积小到可以塞进一张消费级显卡。它适合谁适合手上有单张 16GB 显存卡、想拿垂直领域数据医疗、法律、客服工单做一次 LoRA 微调、又不想碰多卡分布式的人。你要做的事很具体把 Hugging Face 上的基座拉下来用 Unsloth 做 4-bit 量化加载挂上 LoRA 适配器喂几百条带思维链的样本跑出一个能本地加载的 checkpoint。真正卡人的地方不在训练脚本而在三个环节。第一是显存8B 模型如果按 fp16 全量加载光权重就 16GB还没算激活值和优化器状态单卡直接爆。第二是加载速度原生 transformers 加载 8B 模型在普通机器上要几分钟反复调试时非常磨人。第三是依赖版本Unsloth 对 torch、transformers、trl、peft 的版本很敏感装错一个就报ImportError或者训练中途CUDA error。我试过在一张 3090 上从零搭这套环境第一次因为 trl 版本太新SFTTrainer的dataset_text_field参数行为变了训练直接读不到文本列第二次是 xformers 和 torch 版本对不上加载模型时报undefined symbol。这些坑后面会逐个给排查动作。这一篇的目标很明确给你一份能直接复制的 conda 依赖清单、一段 Unsloth 初始化脚本、一套训练参数以及一个用 loss 曲线判断「这次微调到底有没有生效」的验证动作。跑完之后你手上会有一个DeepSeek-R1-Medical-COT目录里面是合并好的 16bit 模型能直接拿去做推理。需要说明的是微调不是必须从零训练。LoRA 只训练一小部分低秩矩阵基座权重冻结所以 16GB 显存足够。你真正要控制的是max_seq_length、per_device_train_batch_size和gradient_accumulation_steps这三个量它们共同决定显存峰值。下面从环境开始一步步来。2. 用 TaoToken 打通 Hugging Face 拉取与 API 调试的前置准备在动手写训练脚本之前先把「模型从哪来、调试请求往哪发」这两件事理顺。DeepSeek-R1-Distill-Llama-8B 的权重托管在 Hugging Face国内直连拉取经常断流尤其是 8B 这种几个 GB 的分片文件。一个稳妥的做法是先把访问链路配好再让huggingface_hub去下载。TaoToken 在这里的角色是提供一个统一的 API 入口方便你在训练前后做模型对话验证、管理密钥而不用在多个平台之间来回切。先注册并拿到 API Key。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 完成账号后进入控制台创建密钥。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 密钥管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。创建完把 Key 复制出来形如sk-xxxx后面配置环境变量要用。API 的基础地址是 https://taotoken.net/api 注意这个地址不带任何查询参数直接作为base_url使用。如果你习惯用 OpenAI SDK可以这样初始化from openai import OpenAI client OpenAI( api_keysk-你的密钥, base_urlhttps://taotoken.net/api, ) resp client.chat.completions.create( modeldeepseek-r1, messages[{role: user, content: 用一句话解释什么是 LoRA 微调}], ) print(resp.choices[0].message.content)这段代码的作用是在你正式开训之前先确认 API 链路是通的顺便验证deepseek-r1这个模型 ID 能不能正常返回。如果这里就报 401说明 Key 没配对如果报连接超时说明网络层还没通先解决这个再往下走。对于要长期跑编码任务或者 Agent 的场景可以看下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合那种「训练脚本写完还要反复调、调完还要接进项目」的连续工作流。如果你只是想快速验证模型输出用模型对话页更直接https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面列了各语言 SDK 的调用示例和参数说明遇到model字段填什么、max_tokens上限多少这类问题翻文档比猜快。另外如果你用 Claude Code 做辅助开发它的接入配置在 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite 里面写了 Base URL、Key、Model ID 三件套怎么填。把 Key 写进环境变量避免硬编码进脚本export TAOTOKEN_API_KEYsk-你的密钥 export HF_TOKENhf_你的HuggingFace令牌HF_TOKEN是 Hugging Face 的访问令牌去 https://huggingface.co/settings/tokens 创建一个 read 权限的就够下载公开模型和数据集其实不需要但有些 gated 仓库会校验。两个变量都配好之后后面的脚本直接os.environ.get读取不用在代码里留明文。3. 可复制的 conda 环境与 Unsloth 初始化配置这一节给的是能直接粘贴运行的完整配置。先建环境再装依赖最后跑初始化脚本。顺序不能乱因为 Unsloth 的安装脚本会检测 torch 版本并决定装哪个 CUDA 轮子。3.1 conda 环境依赖清单# environment.yaml name: deepseek-r1-ft channels: - pytorch - nvidia - conda-forge dependencies: - python3.10 - pip23.3 - pytorch2.4.0 - pytorch-cuda12.1 - cudatoolkit12.1 - pip: - transformers4.44.2 - trl0.9.6 - peft0.12.0 - accelerate0.33.0 - datasets2.20.0 - bitsandbytes0.43.3 - xformers0.0.27.post2 - wandb0.17.5 - huggingface_hub0.24.6 - sentencepiece0.2.0 - protobuf4.25.4用conda env create -f environment.yaml创建然后conda activate deepseek-r1-ft。这里锁死版本是有原因的trl0.9.6的SFTTrainer还支持dataset_text_field直接传列名0.10 之后部分参数改名transformers4.44.2和peft0.12.0是 Unsloth 官方测试过的组合bitsandbytes0.43.3负责 4-bit 量化加载版本低了会在load_in_4bitTrue时报no kernel image。装完基础依赖后再装 Unsloth 本体。官方推荐从源码装因为 PyPI 上的版本更新滞后pip install unsloth pip install --force-reinstall --no-cache-dir --no-deps \ githttps://github.com/unslothai/unsloth.git--no-deps是关键它防止 pip 把前面锁好的 torch、transformers 又升级一遍。装完用下面这段验证import torch, transformers, trl, peft, unsloth print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) print(transformers:, transformers.__version__) print(unsloth:, unsloth.__version__)正常输出里cuda available应该是Trueunsloth版本号形如2024.x。如果cuda available是False说明 conda 装的 pytorch-cuda 和驱动不匹配先nvidia-smi看驱动版本再决定换哪个 cuda 版本。3.2 Unsloth 初始化脚本下面这段是加载模型和 tokenizer 的核心配置路径和参数都按单卡 16GB 场景调过# init_model.py import os import torch from unsloth import FastLanguageModel max_seq_length 2048 dtype None # 自动选择 bf16 或 fp16 load_in_4bit True # 4-bit 量化显存占用降到约 6GB model, tokenizer FastLanguageModel.from_pretrained( model_nameunsloth/DeepSeek-R1-Distill-Llama-8B, max_seq_lengthmax_seq_length, dtypedtype, load_in_4bitload_in_4bit, tokenos.environ.get(HF_TOKEN), ) model FastLanguageModel.get_peft_model( model, r16, target_modules[ q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj, ], lora_alpha16, lora_dropout0, biasnone, use_gradient_checkpointingunsloth, random_state3407, use_rsloraFalse, loftq_configNone, ) print(trainable params:, sum(p.numel() for p in model.parameters() if p.requires_grad))r16是 LoRA 的秩越大拟合能力越强但显存和过拟合风险也上升16 是 8B 模型单卡场景的常用值。target_modules覆盖了注意力层和 MLP 层的全部线性投影这是 Unsloth 推荐的全量挂载方式。use_gradient_checkpointingunsloth是它自己的优化实现比原生 checkpointing 省显存且不掉速。跑完打印出的可训练参数量大概在 4000 万到 5000 万之间占总参数的 0.5% 左右这就是 LoRA 省显存的根本原因。3.3 训练参数配置# train_args.py from transformers import TrainingArguments from trl import SFTTrainer from unsloth import is_bfloat16_supported trainer SFTTrainer( modelmodel, tokenizertokenizer, train_datasetdataset, dataset_text_fieldtext, max_seq_lengthmax_seq_length, dataset_num_proc2, argsTrainingArguments( per_device_train_batch_size2, gradient_accumulation_steps4, warmup_steps5, max_steps60, learning_rate2e-4, fp16not is_bfloat16_supported(), bf16is_bfloat16_supported(), logging_steps10, optimadamw_8bit, weight_decay0.01, lr_scheduler_typelinear, seed3407, output_diroutputs, report_towandb, ), )per_device_train_batch_size2配合gradient_accumulation_steps4等效 batch size 是 8。max_steps60是快速验证用的正式训练应该换成num_train_epochs1或 2。optimadamw_8bit把优化器状态也量化了进一步压显存。bf16在 Ampere 及以上架构3090、4090、A100自动启用T4 这种 Turing 卡会回落到 fp16。4. 数据格式化、启动训练与 loss 曲线验证请求配置就绪后把数据喂进去。这里用 FreedomIntelligence/medical-o1-reasoning-SFT 数据集它每条样本包含Question、Complex_CoT、Response三列正好对应「问题 思维链 答案」的结构。4.1 提示模板与数据映射# prepare_data.py from datasets import load_dataset train_prompt_style Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request. Before answering, think carefully about the question and create a step-by-step chain of thoughts to ensure a logical and accurate response. ### Instruction: You are a medical expert with advanced knowledge in clinical reasoning, diagnostics, and treatment planning. Please answer the following medical question. ### Question: {} ### Response: think {} /think {} EOS_TOKEN tokenizer.eos_token def formatting_prompts_func(examples): inputs examples[Question] cots examples[Complex_CoT] outputs examples[Response] texts [] for q, cot, out in zip(inputs, cots, outputs): text train_prompt_style.format(q, cot, out) EOS_TOKEN texts.append(text) return {text: texts} dataset load_dataset( FreedomIntelligence/medical-o1-reasoning-SFT, en, splittrain[:500], ) dataset dataset.map(formatting_prompts_func, batchedTrue) print(dataset[0][text][:300])splittrain[:500]只取前 500 条够跑通流程。formatting_prompts_func把三列拼成一条完整文本末尾追加EOS_TOKEN这是让模型学会「在哪里停」的关键漏掉它会导致推理时输出停不下来。打印出来的前 300 字符应该能看到### Instruction:和think标签。4.2 启动训练# run_train.py trainer_stats trainer.train() print(trainer_stats)在 3090 上跑 60 步大约 20 到 25 分钟。训练过程中 wandb 会实时上报 loss。如果你没配 wandb把report_to改成noneloss 会打到控制台。4.3 用 loss 曲线判断微调是否生效训练结束后去 wandb 项目页看train/loss曲线。判断标准有三条第一起始 loss 通常在 1.5 到 2.5 之间如果一开始就是 0.1 以下说明数据格式有问题模型可能没读到text列。第二60 步内 loss 应该从起始值下降到 0.8 到 1.2 区间下降趋势要平滑如果剧烈震荡比如 2.0 → 0.3 → 1.8多半是学习率太高把2e-4降到1e-4重跑。第三最后 10 步的 loss 如果还在明显下降说明max_steps不够正式训练要加步数如果已经走平甚至微升说明开始过拟合该停。除了看曲线还要做一次推理对比。用同一个医学问题分别问微调前和微调后的模型# inference_check.py FastLanguageModel.for_inference(model) question A 61-year-old woman with a long history of involuntary urine loss during activities like coughing or sneezing but no leakage at night undergoes a gynecological exam and Q-tip test. Based on these findings, what would cystometry most likely reveal about her residual volume and detrusor contractions? inputs tokenizer( [train_prompt_style.format(question, , )], return_tensorspt, ).to(cuda) outputs model.generate( input_idsinputs.input_ids, attention_maskinputs.attention_mask, max_new_tokens1200, use_cacheTrue, ) response tokenizer.batch_decode(outputs) print(response[0].split(### Response:)[1])微调前的输出思维链冗长、答案用项目符号罗列微调后思维链更紧凑答案收敛成一段话。这个差异就是 loss 下降在生成质量上的体现。如果微调后输出反而变差检查train_prompt_style和推理时用的模板是否完全一致模板不一致是新手最常见的翻车点。4.4 保存与合并# save_model.py new_model_local DeepSeek-R1-Medical-COT model.save_pretrained(new_model_local) tokenizer.save_pretrained(new_model_local) model.save_pretrained_merged( new_model_local, tokenizer, save_methodmerged_16bit )save_pretrained存的是 LoRA 适配器体积小save_pretrained_merged把适配器合并回基座产出一个完整的 16bit 模型可以直接用 transformers 加载推理不依赖 Unsloth。合并后的目录大概 16GB。5. 本篇常见报错排查401、local proxy failed、reading choices、OAuth训练链路里报错集中在几个固定位置下面按真实报错信息给排查动作。401 Unauthorized出现在login(hf_token)或 API 调用时。先确认HF_TOKEN环境变量有没有生效echo $HF_TOKEN看输出。如果为空说明 export 没在当前 shell 生效重新 source 一下。如果是 TaoToken 的 401检查 Key 有没有复制完整注意别把首尾空格带进去。密钥管理页 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以重新生成旧 Key 立即失效。local proxy failed / Connection errorhuggingface_hub下载模型时报这个通常是网络层没通。先curl -I https://huggingface.co看能不能拿到响应头。如果超时检查系统代理设置是否和当前 shell 冲突。注意不要用任何非正规的网络工具合规的访问方式是通过官方镜像或已配置好的链路。如果只是下载慢可以用HF_ENDPOINT指向镜像站但模型 ID 和文件路径要保持一致。Error reading choices / KeyError: choicesAPI 返回体解析失败。常见原因是model字段填了一个不存在的模型 ID服务端返回的是错误 JSON 而不是标准 completion 结构。去接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 核对可用模型列表把model改成文档里列出的值。另一个原因是resp被当成了 dict 直接下标访问正确写法是resp.choices[0].message.content。OAuth / token 过期Hugging Face 的 token 如果设了过期时间训练中途会突然报权限错误。去 token 设置页确认有效期长期任务建议创建不过期的 read token。如果是 Claude Code 接入场景报 OAuth 相关错误检查 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite 里的三件套是否填全Base URL 填https://taotoken.net/apiKey 填sk-开头的密钥Model ID 填文档里对应的模型名。三者缺一都会走到 OAuth 回退逻辑然后失败。CUDA out of memory训练启动瞬间爆显存。按顺序调先把per_device_train_batch_size从 2 降到 1再把max_seq_length从 2048 降到 1024最后确认load_in_4bitTrue和optimadamw_8bit都生效了。如果还爆说明卡本身小于 16GB考虑换更小的蒸馏版本。ImportError: cannot import name SFTTrainertrl 版本不对。pip show trl看版本不是 0.9.x 就重装pip install trl0.9.6。装完重启 kernelPython 不会热重载已导入的模块。loss 一直是 nan学习率太高或者数据里有空样本。先把learning_rate降到1e-4再检查dataset里有没有text为空的条目用dataset.filter(lambda x: len(x[text]) 10)过滤掉。6. 从本地 checkpoint 到持续迭代把微调接进你的工作流跑通第一个 checkpoint 只是起点。真正有价值的是把「数据 → 训练 → 验证 → 部署」这条链路固定下来下次换一批数据能快速重跑。几个实操建议。第一把train_prompt_style单独抽成一个模块文件训练脚本和推理脚本都从它导入。模板不一致是微调效果打折的头号原因抽出来就不会两边写岔。第二每次训练前用dataset[0][text]打印一条完整样本肉眼确认think标签闭合、EOS_TOKEN在末尾。第三loss 曲线和推理对比要一起看只看 loss 会被过拟合骗过去。第四合并后的 16bit 模型用 vLLM 或 transformers 直接加载做批量推理别再用 Unsloth 的for_inference那是给调试用的。如果你要把微调后的模型接进一个持续运行的 Agent 或者编码辅助流程训练脚本的调试、数据清洗、prompt 迭代这些环节会反复调用模型接口。这种连续工作流用 Coding Plan 更顺手https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。只是临时验证某条样本的输出用模型对话页 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 更快。密钥统一在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理接入细节翻 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后说一个容易忽略的点LoRA 适配器是可以叠加和切换的。你可以为医疗数据训一个适配器为法律数据训另一个推理时按需加载基座只存一份。这样单卡 16GB 的机器能维护多个垂直领域的微调版本切换成本只是加载一个几十 MB 的适配器文件。把每个适配器的训练配置、数据版本、loss 曲线记录在一个表格里下次要复现或者对比时直接查表就行。