4-bit量化+LoRA+SFT:24G显存部署医疗推理大模型实战
上个季度接了一个医疗推理场景的项目需要把开源大模型部署到一台只有 24G 显存的服务器上做病历主诉的鉴别诊断思路生成、检查建议输出这类任务。最开始我试过直接对 Llama4Scout 做全量微调结果模型还没训练半天显存就告急训练时间更是不可接受。后来把路线改成了“4-bit 量化 LoRA 低秩适配 SFT 有监督微调”这套轻量化适配方案总算是把训练成本和推理开销都拉到了一个可以接受的范围。这篇文章就是这次完整落地过程的复盘从环境准备到量化、微调、导出部署把每一步的关键决策和踩坑都记录下来。1. 为什么非要做“4-bit量化 LoRA 微调”这条路线1.1 现实约束全量微调在医疗场景里不划算医疗推理任务和普通闲聊对话差别很大。普通对话模型答得稍微泛一点没关系但病历推理要求模型输出结构化的鉴别诊断思路、明确的检查建议还要遵循一定的医学表达习惯。直接拿通用权重跑能感觉到模型“懂一点”医疗术语但逻辑链路经常是散的结论和建议也没有章法。所以必须要做领域适配。适配的第一直觉是全量微调。我把 Llama4Scout 在 fp16 下加载试跑光模型权重就占了接近 24G加上优化器状态、梯度、激活值训练阶段 24G 的卡根本装不下。就算勉强用小 batch 跑一台卡要训练好几天成本完全不可控。这个现实让我放弃了全量微调的路线开始认真考虑参数高效微调。1.2 三种技术的分工与组合逻辑这套方案里的三个技术点各司其职4-bit 量化解决“模型能不能塞进显存”的问题。把 Llama4Scout 的权重从 16bit 压到 4bit模型体积直接缩小到原来的四分之一左右加载后显存占用从接近 24G 降到了大约 7-8G给后续的训练和推理留出了充足空间。LoRA 低秩适配解决“训练参数太多”的问题。冻结量化后的底座权重只训练注入的低秩适配矩阵可训练参数通常只有原始模型的百分之零点几。SFT 有监督微调解决“模型不会按医疗推理格式输出”的问题。用整理好的医疗推理数据集对 LoRA 适配层做监督微调让模型学会在给定病历信息后按指定模板输出推理链路。打个不太严谨但好理解的比方4-bit 量化相当于把一间仓库里的货全部换成迷你型号腾出大量货架空间LoRA 是在仓库墙面上装了几个可拆卸的挂钩不用动整间仓库就能挂上新货物SFT 就是往这些挂钩上挂经过验证的标准货品。三者叠加效果是所有环节一起完成的。2. JupyterLab 环境准备依赖版本、显存检查与模型加载验证2.1 依赖安装与版本匹配很多人在环境这步就翻车了。我直接用 conda 建了一个干净的 Python 3.10 环境然后装核心依赖组。注意版本不是越新越好而是相互之间要兼容。pip install torch2.3.0 --index-url https://download.pytorch.org/whl/cu121 pip install transformers4.44.2 pip install accelerate0.33.0 pip install peft0.12.0 pip install bitsandbytes0.43.3 pip install datasets2.20.0 pip install trl0.9.4 pip install jupyterlab这个组合是我反复试过的相对稳定版本。transformers 4.44 以上对 4-bit 量化加载的支持已经很完善peft 0.12 对 QLoRA 场景的合并和加载都正常trl 0.9.4 里的 SFTTrainer 接口稳定。如果版本配不好最常见的报错是bitsandbytes无法加载 4-bit 模型或者是peft在注入 LoRA 时报类型不匹配。JupyterLab 在这个流程里的最大价值是可以逐步验证加载一段模型、看一眼显存、再决定下一步。不像在命令行脚本里一跑到底出问题还要从头再来。我在 JupyterLab 里把每个阶段拆成了独立 cell方便随时调整。2.2 加载前的显存与 CUDA 检查加载模型前我先在 notebook 里执行了一次环境检查import torch import transformers import bitsandbytes as bnb print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) print(gpu count:, torch.cuda.device_count()) print(gpu name:, torch.cuda.get_device_name(0)) print(gpu memory:, torch.cuda.get_device_properties(0).total_memory / 1024**3, GB) print(transformers:, transformers.__version__) print(bitsandbytes:, bnb.__version__)我这边显卡是常见的数据中心卡显存 24GCUDA 兼容性没问题。如果你用的是较老的显卡要确认计算能力在 8.0 以上否则 4-bit 的某些 kernel 跑不起来。还有一点很多人忽略bitsandbytes 在 0.43 版本对部分新显卡支持不完整遇到奇怪的 kernel 报错先排除驱动问题再检查显卡和库的匹配情况。2.3 JupyterLab 特有的执行注意事项在 Jupyter 里跑大模型和脚本有个巨大差异内核常驻显存不会自动释放。你在一个 cell 里加载了一个 16bit 模型后面换了 4bit 模型重新加载之前那个模型的显存并不会因为变量被覆盖而回收。所以我在做对比实验时养成了一个习惯每次换模型加载策略先“重启内核”再只运行对应的加载 cell。另外多个 cell 都会打印模型结构和参数名称输出特别长。我给关键输出加上了print标记或者直接用%%capture把过长的输出收掉避免 notebook 被刷爆。3. 4-bit 量化NF4 数据类型与 Double Quantization 的实际参数3.1 NF4 为什么比 FP4 稳4-bit 量化不是简单地把 fp16 截断成 4bit。bitsandbytes 提供了两种 4bit 量化类型一个是fp4一个是nf4。我直接选了nf4。NF4NormalFloat4是 QLoRA 研究里提出的一种基于信息论的分位数数据类型。它把 4bit 能表示的 16 个数值按照标准正态分布的分位数来设置而不是均匀分布。这样做的好处是神经网络权重在训练之后通常符合近似正态分布NF4 把更多的数值精度分配给了分布中心附近的权重而 FP4 用的是均匀量化在权重值集中在零点附近时精度损失更大。放到医疗场景里这个选择的实际感受是用 NF4 量化后模型原始推理能力在常规问句上几乎没有可感知的退化而 FP4 在某些术语频繁出现的句子上偶尔会输出不自然的词序。虽然 LoRA 微调能纠正一部分问题但底座量化带来的信息损失是无法完全修复的所以一开始就选信息保留更好的 NF4。3.2 双重量化省下的那 0.4GB除了数据类型还有一个经常被忽略的参数bnb_4bit_use_double_quantTrue。双重量化Double Quantization是对量化常数值再做一次量化。可以这样理解4-bit 量化不是把每个权重直接变成 4bit 存下来而是把一段权重按照一个缩放常数进行缩放后再量化。这个缩放常数本身也要额外存储如果不做处理每个小块都要多存一个浮点数积少成多也是一笔开销。Double Quantization 就是把这些缩放常数再量化一次用更低精度去存它们。实际跑下来开启双重量化能在模型权重占用上再省出约 0.4GB。看上去不多但对于显存捉襟见肘的部署环境这 0.4GB 可能正好是能不能塞进一张卡的区别。3.3 量化加载代码与生效验证加载 4-bit 模型的代码用transformers的BitsAndBytesConfigimport torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig quant_config BitsAndBytesConfig( load_in_4bitTrue, bnb_4bit_quant_typenf4, bnb_4bit_use_double_quantTrue, bnb_4bit_compute_dtypetorch.bfloat16, ) model AutoModelForCausalLM.from_pretrained( model_path/Llama4Scout, quantization_configquant_config, device_mapauto, torch_dtypetorch.bfloat16, trust_remote_codeTrue, ) tokenizer AutoTokenizer.from_pretrained(model_path/Llama4Scout, trust_remote_codeTrue)注意bnb_4bit_compute_dtype我用了bfloat16。这意味着权重以 4bit 存储但计算过程中会反量化成 bf16 来做矩阵乘法。bf16 的动态范围比 fp16 大对医疗文本里可能出现的长文本、极小数值的梯度传递更友好。加载完成后我习惯先验证模型是真的量化了而不是默默用了 16bit。方法很简单print(model.memory_stats if hasattr(model, memory_stats) else no memory_stats) # 或者直接查看模型内部参数 for name, param in model.named_parameters(): if param.requires_grad: print(name, param.dtype, param.shape) break正常情况下模型第一层参数显示是torch.float32的元数据4-bit 量化后的参数以特殊格式存储而device上的内存占用应该在 8G 以内。我这边实际占用是 7.6G 左右符合预期。4. LoRA 注入target_modules、秩与 alpha 的确定过程4.1 低秩适配在量化模型上起什么作用LoRA 的核心思路很简单不修改原始权重矩阵 W而是在 W 旁边加一个低秩的更新矩阵 ΔW BA。其中 B 是 m×r 矩阵A 是 r×n 矩阵r 远小于 m 和 n。训练时只更新 B 和 A原始 W 完全冻结。在 4-bit 量化 LoRA 的框架下这还有另外一层好处LoRA 的参数以更高精度通常是 fp32 或 bf16保存相当于在低精度底座之上加了一条高精度的“补丁通道”。模型主干的容量被压缩了但适配层的容量不受量化的影响。这也解释了为什么 4-bit 量化之后做 LoRA 微调效果往往能接近甚至超过在 16bit 底座上直接做 LoRA。4.2 面向 Llama4Scout 应该选哪些层注入LoRA 效果最大的影响因素之一就是target_modules的选择。Llama4Scout 是标准的 decoder-only 结构注意力部分包含 q_proj、k_proj、v_proj、o_proj 四个投影矩阵MLP 部分则包含 gate_proj、up_proj、down_proj 三个矩阵。我第一版实验只注入了q_proj和v_proj因为这是很多入门教程的默认选择。跑了 2000 步之后发现模型在训练集上的 loss 掉得很慢生成结果在“检查建议”这种需要强知识关联的内容上依旧经常跑偏。后来把target_modules扩展到全部七个投影矩阵同样的训练步数下 loss 下降明显更快生成质量也有肉眼可见的提升。原因不复杂医疗推理需要模型同时把握上下文的关键信息注意力矩阵还要把医学知识映射到输出词汇MLP 矩阵。如果只调注意力矩阵的一部分MLP 层里的知识映射还是冻结的旧容量适配能力就受限。LoRA 配置如下from peft import LoraConfig, get_peft_model lora_config LoraConfig( r16, lora_alpha32, target_modules[ q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj ], lora_dropout0.05, biasnone, task_typeCAUSAL_LM, ) peft_model get_peft_model(model, lora_config) # 查看可训练参数量 peft_model.print_trainable_parameters()4.3 秩 r 和 alpha 怎么定为什么建议 16/32秩 r 决定了 LoRA 适配矩阵的宽度。r 太小比如 4可学习的表达能力不够模型记不住医疗推理中复杂的关系r 太大比如 64训练参数变多虽然不至于像全量微调那样夸张但显存和过拟合风险都会上升。我用了 r16alpha32这个组合在多数开源微调实践中被验证是比较稳的甜点值。alpha 是 LoRA 的缩放系数实际缩放比例是 alpha / r。32/16 2相当于让适配矩阵的更新强度是基础更新幅度的两倍在领域数据量不大几千条的情况下能更快收敛。跑出来的可训练参数占比大约是 0.3% 左右原始模型参数有几十亿LoRA 只引入了一千多万可训练参数。这个量级意味着即使我在训练中把学习率调得偏激进也不容易对底座造成灾难性破坏实验成本低很多。5. SFT 训练医疗推理数据集的处理与训练配方5.1 医疗推理数据的采集与脱敏处理有监督微调的前提是有高质量数据。我这次的医疗推理数据集来自合作单位的脱敏门诊记录每一条都经过了严格的隐私过滤移除了患者姓名、身份证号、联系方式、医院内部编号等敏感字段只保留病历描述中的症状、体征、既往史、检查结果等医学信息。从原始记录到训练样本我做了两步转换第一步把非结构化的病历文本重写为规范的主诉段落。这一步是人工规则结合的因为原始病历常有口语化表述和冗余信息直接丢给模型反而会学坏。第二步为每条病历生成包含“鉴别诊断思路 建议检查项目 处理建议”的推理结构。生成过程参考了多个来源的医学内容最后由有医学背景的同事做了复核。整个过程最终得到约 5000 条可用样本。有效样本量不用贪多关键是覆盖的场景要全有常见病的典型病历也有诊断不明确、需要进一步检查的疑难病历。5.2 指令格式让模型学会“按模板说话”医疗推理的输出必须稳定不能一次一个格式。我在训练数据里统一使用了带标记的指令模板这里有一条病历主诉{症状描述包括起病时间、部位、性质、加重缓解因素} 请你完成以下医疗推理任务 1. 列出 2-3 个可能的鉴别诊断方向并说明依据 2. 给出下一步最需要的检查项目 3. 给出初步处理建议。 回答格式 鉴别诊断列表 检查建议列表 处理建议文本这个模板的效果在训练过程中很明显模型很快学会了用固定的三段式结构输出。推理链路是否完全正确另说至少不再出现把建议写成大段散文的情况。5.3 训练超参数与关键绑定关系训练超参数我按照下面的配置跑整体效果和稳定性都比较理想参数数值说明per_device_train_batch_size24-bit 量化下较大 batch 容易 OOMgradient_accumulation_steps8等效 batch 16learning_rate2e-4LoRA 常用学习率比全量微调高一个数量级num_train_epochs35000 条数据下 3 个 epoch 足够max_seq_length1536病历文本加输出模板的总长度optimpaged_adamw_8bit减少优化器显存占用lr_scheduler_typecosine收敛更平滑warmup_ratio0.03前 3% 步数预热bf16True减少精度损失旧显卡可换 fp16值得多说一句的是paged_adamw_8bit。这个优化器把优化器状态分页存储在 CPU 内存中用到时再换入显存。在 4-bit 量化 LoRA 场景里这是“显存压缩三件套”的最后一件不开它24G 卡跑这个配置会非常紧张。5.4 JupyterLab 里的实际训练流程数据处理好之后我直接在 notebook 里用 trl 的SFTTrainer拉起训练from datasets import load_dataset from trl import SFTTrainer from transformers import TrainingArguments dataset load_dataset(json, data_filesmedical_reasoning_sft.json, splittrain) def formatting_func(example): text example[text] return text training_args TrainingArguments( output_dir./llama4scout_lora_med, per_device_train_batch_size2, gradient_accumulation_steps8, learning_rate2e-4, num_train_epochs3, bf16True, optimpaged_adamw_8bit, logging_steps10, save_steps200, save_total_limit2, warmup_ratio0.03, lr_scheduler_typecosine, remove_unused_columnsFalse, ) trainer SFTTrainer( modelpeft_model, train_datasetdataset, formatting_funcformatting_func, max_seq_length1536, tokenizertokenizer, argstraining_args, ) trainer.train()训练过程中我一直在 notebook 里盯着 loss 曲线。第一轮 loss 从 1.6 左右快速降到 0.7 附近第二三轮下降趋缓最后停在 0.4 左右。这个 loss 数值本身没有绝对参考意义重要的是 curve 是否平滑如果发生突然跳变多半是数据里有格式异常或者 tokenizer 出了问题。另一个实用经验JupyterLab 里跑长训练一定要设置save_steps。我每 200 步保存一次 checkpoint这样即使内核崩了也能从最近的 checkpoint 恢复不用从头再来。这是我在一次 3 小时训练因为内核内存问题中断之后学到的教训。6. 效果验证与轻量化导出6.1 微调前与微调后的对比训练完成后我用验证集里新写的一批病历做了对比测试。同一个输入微调前的模型可以生成相关的内容但结构松散鉴别诊断常常只给一个名字没有任何依据说明检查建议也经常是“建议完善相关检查”这种空话。微调后的模型稳定输出三段式结构鉴别诊断方向基本符合医学常识检查建议能落到具体项目上。最明显的进步在“依据说明”这一部分。微调前的模型好像知道答案但说不清楚为什么微调后它能引用病历里的具体线索来支撑诊断方向比如“结合患者右上腹疼痛放射至右肩背部需要考虑胆囊相关疾病”。这种推理链路的完整性对实际医疗场景的可解释性非常重要。6.2 合并 LoRA 与轻量化导出训练产物有两种主流使用方式第一种独立保存 LoRA adapter推理时先加载量化底座再挂载 adapter。第二种把 LoRA 权重合并进模型再导出为 GGUF 或 AWQ 等更轻量的格式。我两种都试了。独立保存 LoRA 的优点是灵活底座不变可以针对不同科室各训一个 adapter切换方便。但推理框架里需要额外处理挂载流程对部署环境要求高一些。最终部署我选择了合并导出。合并的代码很直接from peft import PeftModel base_model AutoModelForCausalLM.from_pretrained( model_path/Llama4Scout, torch_dtypetorch.bfloat16, device_mapauto, ) lora_model PeftModel.from_pretrained(base_model, ./llama4scout_lora_med/checkpoint-600) merged_model lora_model.merge_and_unload() merged_model.save_pretrained(./llama4scout_med_merged) tokenizer.save_pretrained(./llama4scout_med_merged)合并后的模型是 16bit 权重显存占用比训练时大一些但推理阶段不需要优化器状态单卡 24G 仍然可以顺畅加载。6.3 显存与推理速度的实际记录我用导出后的模型在 20 条病历上跑了生成测试记录了关键数字模型加载后显存占用16.2GB单条病历推理平均耗时2.3 秒输出长度约 200-300 token推理时峰值显存17.8GB这个负载水平对单卡部署非常友好。如果还想进一步压缩到更小的显存设备可以再把合并模型转成 GGUF 并做 6bit 或 5bit 量化但这会引入另一轮精度损失我暂时没有做。7. 这次实操里踩得最疼的几个坑7.1 pad_token 缺失导致的输出崩坏这是我这次踩的第一个大坑。模型自带的 tokenizer 没有明确的 pad_token我在 SFT 训练时没有专门设置结果训练过程没报错但推理阶段生成文本在达到 max_length 时频繁出现截断错乱。解决办法是在训练前显式设置if tokenizer.pad_token is None: tokenizer.pad_token tokenizer.eos_token看似只是一行代码但不设置的话数据 collator 在 padding 时可能会用不正确的 token导致注意力掩码和标签对不齐模型学到的内容就是错位的。这个坑特别隐蔽因为训练 loss 看起来还是正常下降的。7.2 JupyterLab 内核显存累积我在加载量化模型、测试推理之后再继续训练几轮操作下来显存飙到了 20G 以上明明加载量化模型只占 8G莫名其妙就 OOM 了。原因是 Jupyter 内核不重启旧的模型对象没有真正释放显存。后来养成习惯切换模型阶段先重启内核清理输出再重新运行。另外用torch.cuda.empty_cache()也不能完全替代重启它只能清理缓存不能回收仍被 Python 对象引用的显存块。7.3 梯度检查点与 4-bit 量化之间的兼容训练到一半遇到过一次显存突然飙升的报错。排查下来是gradient_checkpointing和 4-bit 量化的兼容性问题。bitsandbytes 的某些 kernel 在梯度检查点下会因重计算时的反量化路径不同产生额外内存开销。我的解决方法是启用梯度检查点之后再执行一次model.enable_input_require_grads()并在量化加载时保留部分参数为浮点精度。如果你是新手最简单的做法是先把 gradient_checkpointing 关掉优先保证训练稳定。7.4 BF16 支持问题bnb_4bit_compute_dtypetorch.bfloat16和训练参数的bf16True都是按新卡设计的。如果显卡计算能力低于 8.0bf16 可能不支持或者性能极差训练直接报数据类型错误。我换了一台较老的卡测试时把所有 bf16 改回 fp16 才正常。这一点在开始环境准备时就要确认否则可能浪费一整天在报错排查上。回过头看这套“4-bit 量化 LoRA SFT”的流程在医疗推理场景里完全跑得通而且性价比很高。一张 24G 卡从训练到部署一条龙全搞定。如果数据集质量继续提升、或者换成参数量更大的底座这套流程的可扩展空间也很大。希望这篇复盘能帮到正在做类似任务的朋友少走几步弯路。