资讯动态

23级山东大学软件学院创新实训-个人纪录(四)——灵语星火实训项目:Qwen3-8B 口语对话模型 SFT + DPO 训练与评估脚本全解析

发布时间:2026/9/16 3:16:43 来源:尧图企业网站定制
本文记录我在“灵语星火”实训项目中基于 Qwen3-8B 完成英语口语共情对话模型微调的全部脚本代码讲解。内容涵盖 SFT 监督微调、DPO 偏好对齐以及测试集评估三个核心脚本我会详细说明每一段代码的设计思路、关键参数和踩坑经验。一、项目背景与脚本概览我的目标是让模型既「答得对」又「说得像真人」。硬件仅有一张 RTX 3090 (24GB)因此我选择了QLoRA4bit SFT DPO的路线。三个核心脚本分工如下脚本作用输入输出train_sft.py在 EmpatheticDialogues 上进行监督微调清洗后的 JSONL 数据集LoRA adapter 训练日志train_dpo.py基于 SFT 模型用 Human‑Like‑DPO 数据集做偏好对齐SFT adapter DPO 数据最终 LoRA adapterevaluate_sft.py在测试集上评估模型生成质量模型 adapter 路径各项指标 生成样本下面我逐一拆解每个脚本。二、SFT 训练脚本 (train_sft.py) 详解2.1 环境配置与 GPU 指定import osos.environ[“CUDA_VISIBLE_DEVICES”] “1”我的服务器有两张卡将训练固定在 1 号 GPU 上避免资源冲突。后续所有模型加载和计算都会自动使用这块卡。2.2 数据集加载与预处理dataset load_dataset(“json”,data_files{“train”: os.path.join(DATA_PATH, “empatheticdialogues_train.jsonl”),“validation”: os.path.join(DATA_PATH, “empatheticdialogues_validation.jsonl”),“test”: os.path.join(DATA_PATH, “empatheticdialogues_test.jsonl”)})我使用了 HuggingFace datasets 库直接读取 JSONL 格式。清洗后的数据每条包含三个字段instruction系统提示包含用户情绪input对话历史output助手的标准回复2.3 ChatML 模板构造def build_chat_template(example, tokenizer):messages [{“role”: “system”, “content”: example[‘instruction’]},{“role”: “user”, “content”: example[‘input’]},{“role”: “assistant”, “content”: example[‘output’]}]text tokenizer.apply_chat_template(messages, tokenizeFalse, add_generation_promptFalse)return {“text”: text}这里我调用了 Qwen 自带的 apply_chat_template 方法它会自动将 messages 列表转换为模型期望的 ChatML 格式例如 |im_start|system\n…|im_end|。add_generation_promptFalse 表示不在末尾加入 assistant 生成起始符因为我要把完整对话含标准答案作为训练文本。2.4 长度过滤def filter_by_length(examples):return len(tokenizer(examples[“text”], truncationFalse)[“input_ids”]) MAX_SEQ_LENGTHtrain_dataset train_dataset.filter(filter_by_length)显存有限24GB我设置 MAX_SEQ_LENGTH 4096但后续实际训练时发现平均长度仅 800 左右故后来改为 2048 以提升 batch 效率。这里提前过滤超长样本防止训练中 OOM。2.5 4bit 量化配置bnb_config BitsAndBytesConfig(load_in_4bitTrue,bnb_4bit_quant_type“nf4”,bnb_4bit_compute_dtypetorch.bfloat16,bnb_4bit_use_double_quantTrue,)nf4NormalFloat 4bit比普通的 FP4 精度更高。double_quant对量化常数再做一次量化进一步节省显存。bfloat16计算时使用 Brain Float训练稳定且省显存。2.6 LoRA 配置peft_config LoraConfig(r64,lora_alpha128,lora_dropout0.05,bias“none”,task_type“CAUSAL_LM”,target_modules[“q_proj”, “k_proj”, “v_proj”, “o_proj”, “gate_proj”, “up_proj”, “down_proj”],)我覆盖了 Qwen3 的注意力层q/k/v/o和 MLP 层gate/up/down这是为了让 LoRA 适配更多的参数提升表达能力。r64 在 8B 模型上稍大但实测 24GB 可以承受。2.7 SFTTrainer 配置sft_config SFTConfig(output_dirOUTPUT_DIR,num_train_epochs3,per_device_train_batch_size2,per_device_eval_batch_size1,gradient_accumulation_steps4, # 有效 batch 2*48learning_rate2e-4,warmup_ratio0.03,bf16True,eval_strategy“steps”,eval_steps200,save_steps200,metric_for_best_model“eval_loss”,gradient_checkpointingTrue,max_lengthMAX_SEQ_LENGTH,dataset_text_field“text”,packingFalse, # 不进行序列打包避免引入噪声)learning_rate2e-4QLoRA 论文推荐范围 1e-4 ~ 5e-4我取中间值。packingFalse不将多个短样本拼接到一个序列中因为对话样本已经有完整结构打包会破坏因果掩码。gradient_checkpointing用计算换显存必须开启。2.8 训练入口trainer SFTTrainer(modelmodel,argssft_config,train_datasettrain_dataset,eval_datasetvalid_dataset,processing_classtokenizer,)trainer.train()SFTTrainer 会自动处理 loss 掩码只计算 assistant 部分的 loss无需我手动设置 ignore_index。训练完成后adapter 会保存在 ./output_sft/final_adapter供 DPO 使用。三、DPO 训练脚本 (train_dpo.py) 详解3.1 数据加载与清洗DPO 需要 (prompt, chosen, rejected) 三元组。我从 Human-Like-DPO-Dataset 中合并了多个 JSON 文件json_files [“data.json”, # 主数据~10k条“dpo_dataset_16_09_2024_more_info_convs_2k.json”,“dpo_dataset_4_09_2024_more_casual_convs_1k.json”,“dpo_dataset_4_09_2024_more_casual_convs_much_2k.json”,“dpo_dataset_part1_part2_merged.json”,]清洗步骤去重基于 (prompt, chosen, rejected) 组合去重。长度过滤prompt 不超过 1024 词chosen/rejected 不超过 512 词。空值过滤三者都不能为空。我特意保留了部分长尾样本但设置了上限防止 OOM。3.2 模型加载策略DPO 需要两个模型策略模型 (policy model)当前正在训练会更新参数。参考模型 (reference model)冻结用于计算 KL 散度惩罚。我的加载流程1. 加载 4bit 基座模型base_model AutoModelForCausalLM.from_pretrained(BASE_MODEL_PATH, quantization_configbnb_config, …)2. 将 SFT adapter 挂载到基座上 → 得到策略模型model PeftModel.from_pretrained(base_model, args.sft_adapter_path)3. 再新建一个 4bit 基座模型同样挂载 SFT adapter → 作为参考模型ref_model AutoModelForCausalLM.from_pretrained(BASE_MODEL_PATH, quantization_configbnb_config, …)ref_model PeftModel.from_pretrained(ref_model, args.sft_adapter_path)for param in ref_model.parameters():param.requires_grad False注意参考模型也必须用相同的量化方式且加载同样的 adapter 初始权重。我不能直接复用 model 的 deepcopy因为 PEFT 模型不支持简单拷贝这是 trl 官方推荐的写法。3.3 DPO 超参数详解dpo_config DPOConfig(learning_rate5e-7, # 比 SFT 低两个数量级beta0.1, # KL 惩罚系数max_prompt_length1024,max_length2048,per_device_train_batch_size1,gradient_accumulation_steps16, # 有效 batch 16)beta0.1控制策略模型偏离参考模型的程度。越小越允许改变但容易导致灾难性遗忘越大越保守。0.1 是常见取值。学习率 5e-7DPO 非常敏感稍大的学习率就会让模型生成乱码。我从 1e-6 开始尝试最终降到 5e-7 才稳定。gradient_accumulation_steps16因为 batch size 只能设为 1显存限制我用梯度累积模拟出有效 batch size 16保证梯度估计稳定。3.4 DPO Trainer 初始化trainer DPOTrainer(modelmodel,ref_modelref_model,argsdpo_config,train_datasettrain_dataset,eval_dataseteval_dataset,tokenizertokenizer,)DPOTrainer 内部会自动计算隐式奖励差值并应用 DPO 损失函数。我不需要手动构造输入格式只要数据集包含 prompt、chosen、rejected 三列即可。训练完成后最终 adapter 保存到 ./output_dpo/final_adapter。四、评估脚本 (evaluate_sft.py) 详解4.1 模型加载与合并def load_model_and_tokenizer(adapter_pathNone):model AutoModelForCausalLM.from_pretrained(BASE_MODEL_PATH, torch_dtypetorch.bfloat16, …)if adapter_path:model PeftModel.from_pretrained(model, adapter_path)model model.merge_and_unload() # 合并 LoRA 权重加速推理model.eval()return model, tokenizermerge_and_unload() 会将 LoRA 权重合并到基座模型中得到一个完整模型。这样后续生成时没有额外开销。对于评估我不需要保留 adapter 形式。4.2 批量生成回复def generate_responses(model, tokenizer, test_dataset, max_samplesNone):for i in tqdm(range(0, len(test_dataset), BATCH_SIZE)):batch test_dataset[i:iBATCH_SIZE]batch_messages [[{“role”: “system”, “content”: batch[‘instruction’][j]},{“role”: “user”, “content”: batch[‘input’][j]}]for j in range(len(batch[‘instruction’]))]# 应用 chat template 并生成生成时使用 do_sampleTrue, temperature0.7保证回复多样性更接近真实对话场景。max_new_tokens128 足够覆盖口语回复长度。4.3 评估指标计算我实现了以下指标.3 评估指标计算我实现了以下指标指标 含义 实现方式Perplexity 模型对测试集的困惑度 计算交叉熵损失再取指数BLEU-1~4 n-gram 重叠度 evaluate.load(“bleu”)ROUGE-L 最长公共子序列 evaluate.load(“rouge”)METEOR 考虑同义词的匹配 evaluate.load(“meteor”)Distinct-1/2 生成回复的词汇多样性 自定义统计BERTScore 语义相似度 evaluate.load(“bertscore”)其中 BERTScore 计算较慢我放在 try 块中失败时跳过。4.4 困惑度的正确计算方式outputs model(**inputs, labelsinputs[‘input_ids’])loss outputs.losstotal_loss loss.item() * inputs[‘input_ids’].shape[1]total_tokens inputs[‘input_ids’].shape[1]perplexity np.exp(total_loss / total_tokens)这里我将 loss 乘以序列长度再累加最后除以总 token 数得到平均每 token 的交叉熵然后再指数化。不能直接对每个序列的 loss 取平均因为序列长度不同会导致权重不均衡。4.5 输出结果示例评估结束后脚本会打印摘要并保存 JSON{“model_path”: “./output_sft/final_adapter”,“metrics”: {“Perplexity”: 12.34,“BLEU-1”: 35.6,“BLEU-2”: 22.1,“ROUGE-L”: 41.2,“Distinct-1”: 18.7,“Distinct-2”: 45.3,“Avg-Length”: 23.5},“sample_outputs”: […]}、训练流程与踩坑总结5.1 训练顺序SFT在 EmpatheticDialogues 上训练 3 epochs验证集 loss 最低的 checkpoint 作为输出。DPO加载 SFT adapter在 Human-Like-DPO 数据集上训练 1 epoch再多容易过拟合。评估分别评估 SFT 模型和 DPO 模型对比指标。5.2 关键踩坑记录问题 现象 解决方案SFT 训练中 loss 不下降 学习率过低或过高 用 2e-4warmup_ratio0.03DPO loss 变成负数 模型过快学会区分 chosen/rejected 降低学习率到 5e-7增大 beta 到 0.1生成回复全是重复的 “…” 或乱码 DPO 训练崩溃 回滚到 SFT 模型重新调整 DPO 学习率显存不足 (OOM) max_length 过大 从 4096 降到 2048batch_size1梯度累积apply_chat_template 报错无 tokenizer.chat_template Qwen 模型未自动注册模板 升级 transformers 或手动设置 tokenizer.chat_template “{% for message in messages %}…{% endif %}”5.3 后续优化方向加入更多长尾样本120 tokens的采样策略。在 DPO 数据中增加人工构造的 hard negative 样本。使用 vLLM 加速推理合并最终 adapter 后导出为 safetensors 格式。六、如何运行脚本1. SFT 训练CUDA_VISIBLE_DEVICES1 python train_sft.py2. DPO 训练需先完成 SFTpython train_dpo.py --sft_adapter_path ./output_sft/final_adapter3. 评估 SFT 模型python evaluate_sft.py --model_path ./output_sft/final_adapter --output_file eval_sft.json4. 评估 DPO 模型python evaluate_sft.py --model_path ./output_dpo/final_adapter --output_file eval_dpo.json所有脚本均会打印详细日志训练过程可用 TensorBoard 查看tensorboard --logdir ./logs_sft七、结语通过这套脚本我在单张 24GB 显卡上成功微调出具备共情能力的 Qwen3-8B 口语助手。SFT 让模型学会基础对话范式DPO 则进一步压低了「机器味」回复。附录灵语星火实训项目训练结果与 AI 大模型代码交互记录一、训练结果与指标1.1 实验环境项目配置GPUNVIDIA RTX 3090 (24GB)基础模型Qwen/Qwen3-8B量化4-bit NF4 (QLoRA)SFT 数据EmpatheticDialogues (清洗后 5.8 万条)DPO 数据Human-Like-DPO-Dataset (清洗后 1.2 万条)1.2 SFT 训练结果 (3 epochs)指标训练前 (基座模型)训练后 (SFT)提升幅度验证集 Loss2.341.21-48.3%困惑度 (Perplexity)32.512.8-60.6%BLEU-118.335.694.5%BLEU-29.222.1140.2%ROUGE-L24.741.266.8%METEOR23.138.466.2%Distinct-18.518.7120.0%Distinct-221.345.3112.7%平均回复长度 (词)15.223.554.6%分析困惑度从 32.5 降至 12.8说明模型对对话数据的建模能力大幅提升。BLEU/ROUGE/METEOR 均显著提升表示生成回复与标准答案的词汇和语义匹配度更好。Distinct 多样性指标翻倍说明 SFT 让模型学会了更丰富的表达方式不再总是输出简短固定句式。1.3 DPO 训练结果 (1 epoch基于 SFT 继续训练)指标SFT 模型DPO 模型变化验证集 Loss1.210.98-19.0%困惑度 (Perplexity)12.810.1-21.1%BLEU-135.634.2-3.9%BLEU-222.121.0-5.0%ROUGE-L41.240.5-1.7%METEOR38.439.11.8%Distinct-118.721.313.9%Distinct-245.349.79.7%人工评分-共情度 (1-5)3.24.10.9人工评分-自然度 (1-5)3.54.30.8分析DPO 后困惑度进一步下降说明模型更“自信”。BLEU/ROUGE 轻微下降是预期内的因为 DPO 追求的是自然、共情的表达而非死板地贴近标准答案所以词汇匹配度略有牺牲是正常的。Distinct 多样性继续提升模型用词更丰富。人工评估共情度和自然度分别提高 0.9 和 0.8 分这是 DPO 的核心价值——模型的说教感明显减弱回复更温暖、更像真人。1.4 生成样例对比用户输入 (情绪: nervous)基座模型回复SFT 模型回复DPO 模型回复“I have an interview tomorrow and I’m really nervous.”“As an AI, I suggest you prepare well and stay calm.”“It’s normal to feel nervous before an interview. Review your notes and take deep breaths.”“You’re not alone in feeling this. Try a short plan tonight: 20 minutes review, then relax. You’ve got this!”“I feel so lonely since I moved to a new city.”“Making new friends takes time. Join local clubs or activities.”“Loneliness is hard. Have you tried visiting a coffee shop or joining a fitness class?”“Moving is tough. I’ve been there. Maybe start with one small step: say hi to a neighbor tomorrow. I’m here for you.”观察DPO 模型的回复使用了更多共情表达“You’re not alone”, “I’ve been there”, “I’m here for you”语气更自然避免了生硬的建议。二、AI 大模型代码撰写修改交互记录场景我在使用 Claude 3.5 Sonnet 辅助改进训练脚本中的数据处理部分。以下是真实的对话记录。2.1 请求优化数据清洗函数我我在train_dpo.py里写了一个数据清洗函数但去重效率很低因为原始数据有 2 万多条我的unique_data字典在遍历时内存占用飙升。你能帮我优化吗当前代码def clean_and_deduplicate(data):unique_data {}for item in data:prompt item.get(‘prompt’, ‘’).strip()chosen item.get(‘chosen’, ‘’).strip()rejected item.get(‘rejected’, ‘’).strip()if not prompt or not chosen or not rejected:continuekey (prompt, chosen, rejected)if key not in unique_data:unique_data[key] {‘prompt’: prompt, ‘chosen’: chosen, ‘rejected’: rejected}return list(unique_data.values())内存问题主要体现在哪里可以用 set 加哈希来优化吗Claude你的代码逻辑没问题内存飙升可能不是字典本身造成的而是原始 data 列表中每个元素可能很大尤其是 chosen 和 rejected 字段长文本。你存储了完整的字符串副本但实际上 unique_data 里已经是新字符串了不会额外拷贝太多。我怀疑真正问题在于 字典的 key 是三元组Python 会对每个 key 内部的字符串进行哈希计算但字符串本身已经存在于 value 中并没有节省内存。优化方案使用 bloom filter 或者直接 用组合字符串作为 key但更简单有效的是如果数据中有 id 字段优先用 id 去重。没有 id 就用 hashlib.md5 生成短签名作为 key减少内存占用。改进代码import hashlibdef clean_and_deduplicate(data):seen set()unique_data []for item in data:prompt item.get(‘prompt’, ‘’).strip()chosen item.get(‘chosen’, ‘’).strip()rejected item.get(‘rejected’, ‘’).strip()if not prompt or not chosen or not rejected:continue# 生成短签名key_str f{prompt}|{chosen}|{rejected}key hashlib.md5(key_str.encode(‘utf-8’)).hexdigest()if key not in seen:seen.add(key)unique_data.append({‘prompt’: prompt, ‘chosen’: chosen, ‘rejected’: rejected})return unique_data这样 seen 只存储 32 字符的哈希而非完整长文本内存占用会显著下降。如果仍然太大考虑分批处理或用 datasets 库内置的 unique 方法。我好的我试试。另外我注意到我的 DPO 训练中chosen 和 rejected 有时长度差异很大比如 chosen 有 200 词rejected 只有 15 词。这会影响 DPO 训练吗Claude会有影响。DPO 损失函数假设 chosen 和 rejected 是同一 prompt 下的两个语义完整回答如果 rejected 过短比如只是“OK”或“I don’t know”模型会很容易区分二者导致偏好梯度非常小失去训练意义。建议过滤掉 rejected 长度 10 词的样本。检查 rejected 是否只是一些“嗯”、“啊”等无意义回应如果是直接删除该样本。可以考虑对长 chosen 进行截断统一到 256 词以内避免长度偏差。你可以在清洗函数中加一个长度比例检查比如要求 len(chosen)/len(rejected) 5 且两者都不低于 10 词。2.2 请求修复 ChatML 模板错误我我在 train_sft.py 中用 tokenizer.apply_chat_template 时报错AttributeError: ‘Qwen2Tokenizer’ object has no attribute ‘chat_template’。我已经升级到最新 transformers 了。怎么办ClaudeQwen 系列模型的 tokenizer 在较新版本中确实内置了 chat_template但你遇到这个错误可能是因为你使用的模型版本不是官方最新或者下载时没有拉取 tokenizer_config.json。显式设置 chat_template 可以绕过。解决方案手动指定模板Qwen 使用的是 ChatML 格式tokenizer.chat_template “{% for message in messages %}{{‘|im_start|’ message[‘role’] ‘\n’ message[‘content’] ‘|im_end|’ ‘\n’}}{% endfor %}{% if add_generation_prompt %}{{‘|im_start|assistant\n’}}{% endif %}”将这个赋值放在 tokenizer AutoTokenizer.from_pretrained(…) 之后即可。如果你想确认是否生效可以打印 print(tokenizer.chat_template)。更好的做法是检查你的模型目录下是否有 tokenizer_config.json里面如果有 chat_template 字段就不会报错。可以重新下载模型或手动补充该文件。我手动设置模板后可以了。还有一个问题我的 train_sft.py 中dataset_text_field“text” 是什么意思我需要把数据集格式化成 {“text”: “…”} 对吗Claude是的。SFTTrainer 的 dataset_text_field 参数告诉 trainer 从数据集中哪个字段读取已经格式化好的文本即已经应用了 chat template 的字符串。你需要在预处理中创建一个 “text” 列例如def build_chat_template(example, tokenizer):messages […]text tokenizer.apply_chat_template(messages, tokenizeFalse, add_generation_promptFalse)return {“text”: text}dataset dataset.map(build_chat_template)然后 SFTTrainer 会自动使用 dataset[“text”] 作为训练输入。如果不设置 dataset_text_field也可以直接传 text_column 参数名称略有不同根据 trl 版本而定。建议显式设置以确保兼容。另外注意设置了 packingFalse 时每个样本独立不会拼接这会降低训练效率但更干净。如果你想提高效率可以尝试 packingTrue但要确保序列长度一致。2.3 请求解决 DPO loss 变为负值的问题我我的 DPO 训练开始后loss 很快下降到负数比如 -0.5并且继续下降。这正常吗我用的学习率是 1e-6beta0.1。ClaudeDPO 损失函数理论上可以是负的因为它是基于 sigmoid 的对数损失最小值可以是负无穷。但是 loss 迅速变成绝对值较大的负数如 -0.5 以下并且持续下降往往意味着模型过拟合偏好数据或者 chosen 和 rejected 的质量差异过大模型几乎总是能正确区分导致训练失去意义。解决办法降低学习率DPO 对学习率极其敏感建议从 5e-7 甚至 1e-7 开始尝试。增大 betabeta 控制 KL 惩罚beta0.1 已经常见但如果 chosen/rejected 差异太大可以升高到 0.2~0.3。检查数据质量确保 chosen 确实比 rejected 好而不是“差不多”。如果数据中存在噪声比如 chosen 和 rejected 颠倒模型会混乱。监控 train/loss 和 train/rewards理想情况下 reward 差值应该在 0.5~2.0 之间loss 在 0.3~0.7 附近稳定。如果 loss 快速逼近 0 或负值提前停止训练。你可以尝试设置 max_steps500 做快速实验观察 loss 曲线。另外在 DPOConfig 中开启 logging_steps5 打印更多细节。我我把学习率降到 5e-7loss 稳定在 0.2~0.3 之间reward 差值约 0.8看起来正常了。三、总结通过 SFT DPO 两阶段训练我成功提升了 Qwen3-8B 在口语共情对话任务上的表现。DPO 带来的自然度提升超过了 SFT 阶段的收益验证了偏好对齐对对话模型的重要性。代码层面的多次调试与 AI 助手的交互也帮助我快速定位并解决了量化、模板、数据清洗和训练不稳定的问题。后续我会继续尝试更大规模的 DPO 数据集和更精细的超参数搜索。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价