资讯动态

大模型LoRA微调数据工程:反常识问答思维链SFT与DPO

发布时间:2026/10/26 5:35:42 来源:尧图企业网站定制
项目标题里带着“弱智吧”三个字很多人第一反应是图一乐。但我把它当正经事做了一遍拿这类反常识问答改造成微调数据喂给 InternLM 系列的小尺寸模型看能不能把模型的“逻辑警觉性”拉起来。结论先放这儿——有用但坑比想象中多尤其是数据改写环节稍不注意就把陷阱“修好了”训完等于白干。这篇把我从数据采集、清洗、改写、schema 设计到 XTuner 配置、训练参数计算、评测集搭建、问题排查的完整链路摊开写一遍。适合手上已经跑通过一次 LoRA 微调、想认真做数据工程的同学如果你连conversation字段怎么填都还没搞清楚建议先把基础微调流程走一遍再回来。全文的模型统一用 InternLM2 7B 这一档做示例显存不够的可以换 1.8B思路完全一样。1. 为什么拿反常识问答做微调数据整体设计与思路拆解1.1 这类数据的本质不是段子是逻辑陷阱样本库网上那种以“弱智吧”风格著称的中文问答表面看是玩梗拆开看其实是一批高质量的逻辑谬误样本。它们的共性结构是问题本身带着一个错误前提、一个循环定义、或者一处语言歧义而回答者的乐趣就在于识破它。我把手头数据过了一遍大致能归成六类这个分类后面直接决定了训练样本的输出结构所以值得先花时间做。类别陷阱机制构造示例自拟模型常见错误因果倒置把结果当成原因“为什么每次下雨前乌云都来得这么准时”顺着“乌云会预报”去编机制循环定义用自身解释自身“不会说话的鹦鹉是不是因为不会讲话”复述问题当答案前提虚构预设一个不存在的实体“我家那台只会制冷的空调为什么不制热”直接给维修建议语言歧义一词多义或断句不同“他说他马上到为什么马一直没来”把双关当真事分析量纲混淆单位或数量级错配“一公斤棉花和一公斤铁哪个更重”凭直觉答铁统计谬误样本偏差、幸存者偏差“我认识的司机都不系安全带也没事”举反例但不指出逻辑看清这个表你就明白这类数据真正的训练价值不在“答案多刁钻”而在“先检查前提”这个动作。通用指令数据训出来的模型有个通病无论问题多离谱它都默认题面成立然后一本正经往下推。我实测过一个没微调过的 7B 模型问它“为什么陨石总落在陨石坑里”它能给你写出三百字关于引力场的解释全程没意识到因果关系是反的。这就是我要解决的问题。1.2 三条数据构造路线我为什么选“思维链 SFT 偏好对补强”动手前我把可行方案列了三档每条都小规模跑过一轮最后才定下来。先看对比路线数据形态构造成本实测效果主要问题A 纯答案 SFT问题 → 一句结论低半天能凑齐陷阱识别率提升有限模型只学会“抬杠”这个姿态没学到推理过程B 思维链 SFT问题 → 前提检查 → 分析 → 结论高要逐条改写提升明显且能迁移到没见过的题型改写质量不稳定容易把陷阱写没了C 偏好对 DPOchosen / rejected 成对中负样本可自动生成对“顺着陷阱胡说”的压制很直接需要一定量级且依赖基座模型本身的分辨能力路线 A 我先试的用 800 条纯答案数据训了两个 epoch结果很尴尬模型确实开始说“这个问题有问题”了但它变成了一种条件反射——只要句式像反问它就回一句“前提不成立”哪怕问题本身完全正常。这就是典型的姿态学习而非能力学习数据里没有推理过程模型只能学到表面模式。路线 B 是我最终的主力方案。核心思路是把回答拆成固定三段【前提检查】先把题面里的断言逐条列出来并判断真伪【分析】给出正确的因果或事实链条【结论】用一句话收束。固定结构的好处有两个一是模型学的是“先验证再回答”的流程不是一个口头禅二是评测的时候可以直接做字符串匹配统计“前提检查段是否出现且判断正确”省掉大量人工。路线 C 不是替代是补强。我拿基座模型对训练题自己生成回答把那些“顺着陷阱编”的输出收集起来当 rejected配合人工写好的 chosen 做 DPO。这一步是后加的因为 SFT 训完之后模型虽然会检查前提了但偶尔还是会在分析段里滑回错误逻辑DPO 能把这块压下去。1.3 数据配比与能力保持的取舍这里有个必须提前想清楚的问题反常识数据占比多少合适。我的第一版数据是纯陷阱题2500 条训完模型确实变敏锐了但副作用也很明显——你问它“今天天气怎么样”它先来一句“您的问题不包含可验证前提”日常对话直接废掉。调整后的配比是这样陷阱数据 2200 条通用指令数据 8000 条左右比例约 1:3.6。通用数据我直接用了现成的开源中文指令集覆盖问答、写作、摘要、代码几类目的是把模型原本的输出分布锚住。训练时用同一条流水线混合打乱不要分阶段训分阶段容易在第二阶段把第一阶段学的东西冲掉。还有一点是 epoch 的控制。陷阱数据量小很容易过拟合我的经验是 3 个 epoch 以内观察验证集 loss 开始回升就停。数据量再往上堆收益递减得很快我试过扩到 6000 条指标只涨了不到两个点但清洗和改写的时间翻了三倍不划算。2. 数据构造的核心细节解析与实操要点2.1 原始帖子的采集与清洗口径原始数据我拿到的是一批“问题标题 高赞回复”的配对文本格式很脏。清洗这一步我定了六条硬口径每条都有明确的取舍理由不是拍脑袋只保留问答配对完整、且回答赞数过阈值的条目。理由是低赞回复里玩梗、跑题、纯情绪输出的比例极高这些内容训进去只会污染输出风格。丢弃纯谐音梗和纯地域梗。这类内容没有可迁移的逻辑结构模型学不到东西还可能带出不合适的表述。丢弃含人身攻击、隐私信息、具体机构名称的条目。这条不用解释数据合规是底线我宁可少几百条。长度过滤问题 5 到 120 字回答 10 到 500 字。太短没有信息量太长往往是抖机灵抖出一篇小作文改写成本高。近似去重。同一个梗会被反复提问我用 SimHash 做了一遍汉明距离阈值设在 3去掉的重复条目大概占三成。保留原始分类标签。这一步是为后面的分层采样和评测留接口别嫌麻烦后期做错误分析时会感谢自己。清洗完之后的数据量级要有心理准备我手上这批原始条目过万走完上面六条只剩 2100 条左右能用可用率不到四分之一。这是正常水平别指望拿到就能直接用。2.2 从帖子到训练样本字段设计与 system 模板数据格式我直接对齐 XTuner 的conversation结构省掉一层转换。单条样本长这样{ id: trap_000731, category: causal_inversion, difficulty: 2, conversation: [ { system: 你是一个严谨的中文助手。回答任何问题前先判断题面中的前提是否成立、是否存在歧义或逻辑陷阱。若前提有问题先明确指出再给出正确解释若问题正常正常作答不要强行找茬。, input: 为什么陨石总是精准地落在陨石坑里, output: 【前提检查】题面隐含的断言是“陨石坑先于陨石存在并且对陨石有吸引力”。这个前提不成立。\n【分析】陨石坑并不是预先存在的目标它是陨石撞击地表后形成的痕迹。我们之所以看到“陨石在坑里”是因为命名逻辑反过来——先有撞击才有坑。这属于把结果误当成原因。\n【结论】不是陨石瞄准了坑而是坑由陨石撞击产生因果关系被颠倒了。 } ] }system 模板这段是我改过四版才定下来的。最早只写了“你是一个严谨的助手”结果模型把所有问题都当陷阱处理连“11 等于几”都要先质疑前提。后来加上“若问题正常正常作答不要强行找茬”这句过度激活的问题明显缓解。模板里那几个关键词——先判断前提、指出歧义、不要强行找茬——建议原样保留我做过删词对比去掉任何一个都会让某一类错误率上升。difficulty字段是我自己加的1 到 3 分主要看陷阱藏得深不深。分层采样的时候按 3:4:3 取避免简单题占满训练集导致模型只学会识别明显句式。2.3 答案改写把抖机灵变成可学习的推理链这是整个流程里最费功夫、也最容易翻车的一步。原始回复大多是一句话抖机灵比如“因为坑是陨石砸出来的啊”信息量够但不成体系直接拿来训模型学不到结构。我的做法是用一个大模型做批量改写再人工抽检。改写 prompt 的关键是给足约束我的版本大致包含四块一是任务说明要求输出必须严格包含【前提检查】【分析】【结论】三段二是 few-shot 示例给两条不同类别的完整样例让模型对齐格式三是禁止修改原问题的硬约束这一条至关重要四是禁止虚构事实分析段只能基于常识和逻辑不许编造数据。这里踩过一个很典型的坑值得单独说改写模型会不自觉地“修好”陷阱。比如原问题是“我家的猫不会叫是不是因为它不会说话”本意是循环定义改写模型觉得这问题太傻直接输出成了“猫不会说话是因为它只能发出喵声这是物种差异”陷阱被抹平了训练样本变成了一道普通常识题。我第一批 500 条数据里人工抽检发现这类“陷阱丢失”的比例接近 18%等于白训。解决办法是加一道自动校验把改写前后的input做语义相似度比对低于阈值的打回重写同时人工抽检比例提到 10%重点看循环定义和语言歧义这两类它们最容易被改坏。校验脚本的核心逻辑不复杂用句向量算余弦相似度就行关键是这个环节不能省。2.4 负样本构造与训练集/评测集防泄漏DPO 用的负样本我没有人工写而是让基座模型自己对训练题生成回答把“顺着陷阱往下编”的那些挑出来。理由是人工写的负样本是“我猜模型会怎么错”而模型自己生成的才是“它真的怎么错”分布对得上。具体做法是每道题采样 4 条回答温度设 0.7然后按规则筛没出现“前提”相关表述的、直接把题面当真的、结论与事实相反的都收进 rejected 池。防泄漏这块必须强调。这类数据的特殊性在于同一个梗会以不同措辞反复出现你要是随机切分训练集和测试集模型在测试集上表现会虚高得离谱。我第一版就吃了这个亏评测准确率 87%高兴了半天后来发现测试集里有一半的题在训练集里有近义变体。正确做法是按“梗”分组切分先对问题文本做聚类把语义相近的放进同一个组然后整组整组地划分到训练侧或测试侧绝不拆开。聚类用句向量加层次聚类就行阈值我设在 0.82调了两轮。切完训练集约 1800 条留出 200 条做 held-out另外单独构造 200 条人工变体做泛化测试这部分和训练数据完全不重叠。3. 实操过程与核心环节实现3.1 环境准备与依赖版本锁定环境我用 conda 隔离Python 3.10PyTorch 2.1 系列CUDA 12.1。版本一定要锁死我换过一次 torch 小版本flash-attn 直接编译失败折腾了半天。基础步骤conda create -n trapft python3.10 -y conda activate trapft pip install torch2.1.2 torchvision0.16.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers4.37.2 datasets2.16.1 accelerate0.26.1 git clone https://github.com/InternLM/xtuner.git cd xtuner pip install -e .装完跑一句验证确认 xtuner 能正常导入、CUDA 可见python -c import xtuner, torch; print(xtuner.__version__, torch.cuda.is_available(), torch.cuda.device_count())输出里True和卡数对得上再往下走。显存方面7B 做 QLoRA 4bitcutoff_len设 2048、batch_size设 1单卡 24G 够用实测峰值占用在 19G 左右如果换全参微调7B 至少要有 80G 以上的卡个人玩家别碰。3.2 数据脚本清洗、去重、格式转换一条龙数据脚本我拆成三段写方便单独调试。第一段做清洗和去重核心是 SimHashimport re, hashlib from itertools import combinations def simhash(text: str, bits: int 64) - int: tokens re.findall(r[\u4e00-\u9fa5]|[a-zA-Z0-9], text) vec [0] * bits for tok in tokens: h int(hashlib.md5(tok.encode()).hexdigest(), 16) for i in range(bits): vec[i] 1 if (h i) 1 else -1 out 0 for i in range(bits): if vec[i] 0: out | (1 i) return out def hamming(a: int, b: int) - int: return bin(a ^ b).count(1)去重时不要两两比对O(n²) 在万级数据上会卡住。我的做法是按 SimHash 的高 16 位分桶只在桶内比较速度提升一个数量级。第二段做分类打标我用了关键词规则加人工复核规则大概覆盖七成剩下三成靠人工总比纯人工快。第三段做格式转换和校验输出 JSONL。校验项包括conversation是列表且长度大于 0、每条包含system/input/output三个键、output里必须出现三个段落标记、总 token 数不超过cutoff_len。校验不过的直接写进bad_cases.jsonl方便回头查。import json def build_sample(raw: dict, rewritten: str) - dict: return { id: raw[id], category: raw[category], difficulty: raw.get(difficulty, 2), conversation: [{ system: SYSTEM_PROMPT, input: raw[question], output: rewritten, }], } def validate(sample: dict, cutoff_len: int 2048) - bool: conv sample.get(conversation) if not isinstance(conv, list) or not conv: return False turn conv[0] if not all(k in turn for k in (system, input, output)): return False if 【前提检查】 not in turn[output]: return False if len(turn[input]) len(turn[output]) cutoff_len * 1.5: return False return True最后那个长度判断我用的是字符数粗估中文大概 1 字符对应 1 到 1.5 个 token故意留了余量防止训练时被截断。3.3 训练配置与关键参数计算配置文件我在官方示例基础上改关键字段如下# internlm2_7b_trap_qlora.py 关键片段 max_length 2048 pack_to_max_length True batch_size 1 accumulative_counts 16 max_epochs 3 lr 2e-4 weight_decay 0.01 warmup_ratio 0.03 lora dict( typeLoraConfig, r64, lora_alpha32, lora_dropout0.05, biasnone, task_typeCAUSAL_LM, target_modules[q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj], )参数背后的逻辑我逐个说清楚。max_length定 2048 是因为我统计过数据长度分布95 分位在 700 token 左右留足余量又不浪费显存。pack_to_max_length打开是必要的我的样本普遍偏短不 pack 的话一条 300 token 的样本也要占一整次前向吞吐浪费严重pack 之后同样 batch 下有效样本数能翻两三倍。lr设 2e-4 是 LoRA 的常规区间全参微调一般在 1e-5 到 2e-5两者差一个量级别搞混。r64配alpha32等效缩放系数是 0.5这个搭配在 7B 上我试过 r16、32、64 三档64 的效果最好再往上收益不明显且显存上涨。accumulative_counts16意味着等效 batch 是 16学习率可以按这个批量给单卡跑起来大概每 40 到 50 步一次参数更新。显存估算这块给个参考7B 模型 4bit 量化后权重约占 4.5GLoRA 参数和优化器状态加起来不到 1G剩下的大头是激活值随cutoff_len线性增长。2048 长度下单条样本的激活值大约 12 到 14G加上框架开销24G 卡占用 19G 左右是正常的。如果你的卡刚好 16G把cutoff_len降到 1536或者开梯度检查点能压到 14G 以内。3.4 训练启动与日志观察启动命令xtuner train ./configs/internlm2_7b_trap_qlora.py \ --work-dir ./work_dirs/trap_v1 \ --deepspeed deepspeed_zero2单卡跑其实用不上 deepspeed多卡才需要。日志重点看三个数loss、grad_norm、学习率。loss 从 2.0 附近起步前 200 步快速下降到 1.2 左右是正常节奏如果 500 步之后还在 2.0 以上不动先查数据格式八成是output字段没被正确 mask 或者 system 段被截断了。grad_norm长期大于 10 说明学习率偏高或者数据里有异常样本我遇到过一次最后定位到某条数据的output里混进了超长 URLtoken 化之后产生了一堆罕见 token梯度直接炸了。学习率曲线应该是先 warmup 再余弦下降如果中途出现锯齿检查是不是把warmup_ratio设得太小。训练完成后要转成 HuggingFace 格式才能评测xtuner convert pth_to_hf ./configs/internlm2_7b_trap_qlora.py \ ./work_dirs/trap_v1/iter_1200.pth ./hf_trap_v1 xtuner convert merge ./base_model ./hf_trap_v1 ./merged_trap_v1 --max-shard-size 2GB3.5 评测集搭建与效果验证评测集我分四块每块测的东西不一样混在一起看总数会掩盖问题评测集规模来源看什么指标held-out 原始题200与训练同分布按梗分组隔离陷阱识别率、三段结构完整率人工变体200同类别但换措辞泛化能力干扰题100正常问题无陷阱误报率不该说“前提不成立”通用能力100常识、简单计算、开放式问答能力退化程度判分我用规则加裁判模型两层。规则层很直接output里有没有【前提检查】段、该段有没有出现否定性判断词“不成立”“存在歧义”“并无此前提”等。裁判层用一个更大的模型按 rubric 打分rubric 写清楚三个维度前提判断是否正确、分析是否逻辑自洽、结论是否回答问题。裁判模型会有偏差所以我在 200 条 held-out 上做了人工标注做校准发现它偏向给长回答高分于是在 prompt 里加了“长度不计入评分”。实测对比数据是这样基座模型在 held-out 上的陷阱识别率约 31%纯答案 SFT 版本涨到 52%思维链 SFT 版本到 74%再加 DPO 补强后到 79%。误报率方面基座 8%纯答案版本飙到 27%就是前面说的变杠精思维链版本回落到 11%DPO 后 9%。通用能力用同一套 100 题测思维链版本比基座掉 4 个点左右加上通用数据混合之后差距缩到 1 个点以内。4. 常见问题与排查技巧实录4.1 训练侧问题速查表训练阶段出问题最耗时间因为一轮跑完才知道错所以我把能提前查的都做成清单。下面这张表是我自己积累的按出现频率排序现象最可能原因排查动作处理方式loss 前 500 步几乎不动output 未参与 loss 计算 / 数据字段名不匹配打印一条 tokenize 后的 label看非 -100 的位置核对conversation键名与配置文件里的prompt_templateloss 掉到 0.3 以下且验证集回升过拟合数据量小 epoch 太多对比每轮验证集 loss减 epoch加 dropout或补通用数据grad_norm 持续大于 10学习率过高 / 存在异常长样本按 token 长度排序看 Top10 样本剔除超长样本lr 降一半重跑显存 OOMcutoff_len 过大 / 未开梯度检查点看报错时的 step 与 batch降 cutoff_len开 gradient_checkpointing训完推理输出为空或重复转换时 chat template 不匹配用相同 template 手工构造 prompt 测一次重做 convert确认 template 与训练一致多卡训练卡住deepspeed 配置与卡数不匹配看日志是否停在初始化单卡先跑通再上多卡其中第一条我踩过两次。XTuner 的数据管线对字段名比较敏感你写成conversations多一个 s它不会报错只是默默把整条样本当成纯文本做自回归loss 照样下降但学的是“复述输入”训完模型只会把你问题重说一遍。判断方法很简单训练前手动跑一次 dataset 的__getitem__把 labels 打出来看一眼该是 -100 的位置是不是 -100。4.2 数据侧的高频坑数据问题比训练问题更隐蔽因为它不报错。我总结下来有四类第一类是截断。cutoff_len设小了output后半段被切掉模型学到的就是“说到一半停住”。这类问题的表现是推理时输出结构总是缺最后一段【结论】。解法是统计全量数据的 token 长度分布取 99 分位往上留 20% 余量别拍脑袋定 1024。第二类是重复。同一条数据在文件里出现多次loss 会异常低看起来训练很顺利实际是记住了。除了 SimHash 去重我还会在训练前跑一遍完全一致的 md5 去重两层保险。第三类是system 丢失。有些转换脚本在处理多轮时会丢掉 system 字段模型就学不到“先检查前提”这个指令。校验脚本里必须加一条turn.get(system)非空且长度大于 20。第四类是全角半角混用。中文标点混着英文标点模型输出会跟着乱评测时字符串匹配也会失败。统一转全角或者统一转半角都行关键是全量一致我是在清洗阶段一次性做了转换。4.3 效果侧的三个典型症状与对策训完发现效果不对先别急着调参按症状定位更快。症状一模型变成杠精。表现是不管问什么都先来一句“您的问题前提存疑”。根因基本是训练数据里陷阱样本占比过高或者 system 模板里没有“正常问题正常回答”的约束。对策是把陷阱数据比例降到 30% 以下同时在通用数据里混入一部分普通问答让模型见到“正常问题该怎么答”的样本。症状二只对训练过的题型有效。换一批同类别但措辞不同的题识别率断崖式下跌。这说明模型学的是句式模板不是逻辑。对策是补充变体数据同一道题至少造三种不同问法训练时随机选一种另外把 difficulty 分层采样做起来别让简单题占满。症状三通用能力明显退化。表现是闲聊变得生硬、写作任务质量下降。这个问题的本质是数据分布偏移LoRA 虽然参数量小但输出风格还是会被带偏。对策是提高通用数据比例我在 1:3.6 这个配比下退化控制在 1 个点以内再激进就压不住了。还有一个容易被忽略的点评测本身可能不准。我最早用关键词匹配判分只要输出里有“不成立”就算识别成功结果模型学会了无脑输出这三个字分数虚高。后来改成要求“前提检查段必须明确指出具体哪条断言有问题”配合裁判模型打分才把水分挤掉。5. 迭代方向与几条踩坑后的实测体会5.1 数据规模的天花板比想象中来得早我一开始的想法是数据越多越好从 2100 条一路补到 6000 条中间还扩了类别。结果指标曲线是这样的2100 条时 held-out 识别率 74%3500 条 76%6000 条 77%。边际收益掉得非常快但清洗和改写的人力投入是线性增长的。原因不难理解——这类数据的类别就那六种同类别内部的逻辑结构高度重复模型见够一定数量之后就饱和了再多的样本只是在强化同一个模式。真正带来提升的不是量是类别覆盖的均衡度。我做过一次对比把 2100 条按类别均匀采样到 1500 条和原来不均衡的 2100 条比前者在人工变体集上反而高 3 个点。所以如果你的数据里某一类占了七成先做类别均衡比闷头加数据有用得多。5.2 改写环节值得投入一半以上的时间整个流程里如果只让我优化一个环节我选改写。原始回复的抖机灵内容直接拿来训模型学到的只是“用俏皮话回应”而经过结构化改写的样本模型学到的是“先验证再回答”的流程这两者的迁移能力差了一个量级。我在改写上投入的时间大概占总时长的六成回过头看是值的。具体到操作层面有两条经验一是改写 prompt 里必须给至少两个不同类别的 few-shot只给一个模型会过拟合到那个类别二是改写后一定要做“陷阱是否还在”的校验我用的方法是让裁判模型判断“改写后的 input 是否仍然包含原问题里的逻辑问题”判断为否则打回。这一步加进去之后陷阱丢失率从 18% 降到了 4% 以下。5.3 后续还能往哪扩这套数据构造方法本身是可迁移的不限于反常识问答。我现在在试两个方向一是把这套“前提检查”结构套到数学应用题上很多题目的错误来自对题意的误读结构是一样的二是把 DPO 那部分做扎实目前负样本只是简单筛了“顺着陷阱编”的输出如果按错误类型细分比如“错误识别前提”“识别对了但分析错”“分析对了结论错”做成多档偏好对压制效果应该会更精确。另外一个我还没做但值得试的是多轮版本。现在全是单轮问答如果构造成“用户抛出陷阱 → 模型指出问题 → 用户反驳说我就想知道答案 → 模型坚持并解释”能测出模型在压力下会不会滑回去。这类数据构造起来更麻烦但更接近真实对话场景模型学到的鲁棒性也更实在。

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

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

免费获取报价 →
↑