☰
反常识语料构造:用语义陷阱数据微调InternLM提升抗诱导能力
2026/10/26 13:57:44 网站建设 项目流程

1. 反常识语料为什么值得进微调数据池

前阵子在一个小群里聊数据构造,有人甩过来一句"拿弱智吧的帖子训模型,这不是明摆着把模型带沟里吗"。这个反应非常正常,因为大部分人对这类语料的第一印象就是段子、玩梗、没有信息密度。但我自己真正把几千条帖子扒下来逐条读过之后,结论完全相反:这批文本是整个互联网上少有的、天然带有"语义陷阱"的高密度对抗样本。

弱智吧这个社区的核心玩法是反逻辑问答——提问者用一个看似正常的句式,埋一个谐音、双关或者字面歧义,答案是出人意料的。比如"什么东西越洗越脏",字面看是在问清洁,实际考的是"洗"这个动作的对象能否是水本身。这类题目的结构非常稳定:一个诱导性的前提 + 一个需要跳出常规语义框架的答案。这恰好是大模型最容易栽跟头的地方,模型靠统计先验补全答案,先验越强,被带偏的概率越高。

所以这篇东西讲的不是"怎么用段子逗模型玩",而是一个正经的数据构造实验:把这种非标准语料,加工成可以喂进 InternLM 做微调的高质量指令数据,然后看模型在"抗诱导"这件事上到底有没有变化。适合两类人看——正在做小规模指令微调、手上数据不够但你想要"难样本"的;以及想搞清楚数据构造这条链路每一环具体在干什么的。哪怕你手上的语料跟这个完全无关,清洗、改写、构造偏好对、泄漏检测这套流程也是通用的。

1.1 弱智吧语料的真实分布:三类题、两种噪声

我把扒下来的样本按答案的生成机制分了三类,这个分类直接决定了后面每类数据该怎么加工,不能一锅炖。

第一类是谐音双关型。答案的正确性完全依赖读音重合,比如某个问句的字面意思和答案之间唯一的桥梁是发音。这类样本对模型的意义在于考察它能不能在语义路径走不通的时候切换到语音路径。

第二类是字面歧义型。题干里的某个词在常识语境下有一个默认含义,但在答案里被强行拉回到字面义。举个例子,"什么门永远关不上"这种题,考的是把"门"从一个物理构件扩展到一个抽象概念。模型如果只在物理语义空间里搜,就会一直往"坏了的门""没装门框的门"上靠。

第三类是纯粹的语言游戏型,靠断句、词性转换、量词错配制造荒诞感。这类题严格来说没有"唯一正确答案",价值最低,但在构造负样本的时候特别好用——因为它能让模型产生大量"看起来合理其实跑偏"的输出。

噪声主要两种。一种是水楼,纯表情、"哈哈哈"、"楼上说得对"这类,占原始抓取量的六成以上,必须清掉。另一种更隐蔽,是**"梗中梗"**,答案引用了社区内部的特定上下文,脱离那个语境完全无法理解。这种样本如果直接进训练集,等于给模型灌入一批无法从题干推导出答案的样本,会污染它对"可解性"的判断。

1.2 这个实验真正想验证的假设

我在动手之前给自己定了三个可证伪的问题,没有这三个问题,做完了也没法判断成败。

假设一:在少量高质量反诱导样本上做 LoRA 微调,能提升模型在同类陷阱题上的正确率,且不明显损伤通用能力。

假设二:带思维链的样本比纯答案样本效果更好,但收益存在上限,样本量超过某个点之后,模型会开始模仿"解释的格式"而不是"解释的逻辑"。

假设三:偏好对(chosen / rejected)数据的收益,在陷阱题这个场景下比 SFT 更明显,因为模型缺的不是知识,是"不要顺着题干往下滑"的习惯。

这三个假设里,第二个是最容易被忽略的。很多人做 CoT 数据只看到了"推理过程更完整"这一面,没注意到小数据微调里模型学格式的速度远快于学逻辑的速度。

1.3 直接拿段子当训练集会翻车的三个原因

我早期偷懒试过一次,把清洗后的问答对直接拼成"问题:xxx 答案:xxx",不做任何改写就开训。结果很典型,值得单独说。

第一个问题是指令格式缺失。模型学到的分布是"看到一句话后面接一句抖机灵",而不是"收到一个任务并完成它"。你后面用标准指令去问,它反而答不准了。这就是典型的格式不对齐。

第二个问题是答案多解。同一个题目在评论区可能有五六个不同角度的回答,全都有道理。如果不过滤直接混进去,同一道题在训练集里对应多个不同答案,模型会学到"这类题可以随便答",熵被抬高,输出稳定性下降。

第三个问题最坑:网络梗泄漏。这批题目里相当一部分在网上流传极广,基座模型在预训练阶段早就见过。你拿它做训练集,模型表现变好,但你根本分不清是微调起了作用,还是它只是"背出了原文"。这个坑后面第 5 节我会专门讲怎么检测。

2. 从社区文本到JSONL:清洗与结构化还原

数据构造这件事,八成的功夫花在清洗上,只有两成在模型侧。我这次的结构是四步:原始帖子采集 → 楼层结构还原 → 质量过滤 → 结构化输出。每一步都有具体的判断规则,靠"感觉"筛数据最后一定会翻车。

先说清楚一点,采集环节我只取了公开可访问的页面文本,且只保留问答正文,不保留用户标识、发布时间、地区等任何字段。个人信息不入库,这是底线,不是可选项。

2.1 楼层结构还原成问答对的核心规则

原始的帖子结构是"一个标题句 + 若干楼层回复"。标题句一般是题目,回复里混杂着正确答案、错误猜测、玩梗、水楼。要从这一堆里还原出干净的"题干-答案"对,我用了三条规则。

规则一:答案锚定在得赞最高的非水楼回复上。这个规则很朴素但很有效。社区的自净能力在这个场景下比任何启发式算法都准。我试过纯按文本相似度挑答案,效果差得多。

规则二:题干必须满足"可独立成句"。很多标题是残缺的,比如只有一个名词短语,必须结合正文才能理解。这类样本我直接丢弃,因为脱离上下文的题干喂给模型,它学不到任何模式。

规则三:答案长度控制在 2 到 60 字之间。太短的是"哈哈哈"或者单个词,没有解析价值;太长的一般是长篇科普或者辩论,已经偏离了"陷阱题"的形态,会污染数据分布。

这里有个细节值得说:归一化必须做在分词之前。中文社区文本里全角半角混用、繁体简体混用、夹杂大量无意义符号的情况非常普遍。我遇到过一条题目,因为里面有个全角问号,导致后面按标点切分时完全错位,整批数据的问答对都串了行。这种 bug 排查起来非常费劲,因为数据是批量处理的,你看到的是一千条里有一条错,但实际错位造成的影响是相邻两条一起被污染。

2.2 用脚本做第一轮机械过滤

清洗脚本我是用 Python 写的,核心逻辑其实很简单,就是把一堆规则串起来,逐条打分,过阈值才留下。下面这段是骨架,实际跑的时候我在每个 filter 里都打了日志,方便回头看是哪条规则杀掉的样本。

import re, json, hashlib def is_water(text: str) -> bool: t = text.strip() if len(t) < 4: return True # 纯符号 / 纯表情名义字符 if not re.search(r'[\u4e00-\u9fa5a-zA-Z0-9]', t): return True water_pat = ['哈哈哈', '楼上', '沙发', '顶', '楼主', '正解', '笑死'] return any(p in t for p in water_pat) def norm(text: str) -> str: t = text.strip() t = re.sub(r'\s+', '', t) # 全角转半角 t = ''.join( chr(ord(c) - 0xFEE0) if 0xFF01 <= ord(c) <= 0xFF5E else c for c in t ) t = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。?!、:;()]', '', t) return t def sim_key(text: str) -> str: # 简易去重指纹:去掉标点后取前 16 字符 raw = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', '', text) return hashlib.md5(raw[:16].encode()).hexdigest() def build(records): seen, out = set(), [] for r in records: q, a = norm(r.get('title', '')), norm(r.get('answer', '')) if is_water(q) or is_water(a): continue if not (8 <= len(q) <= 40) or not (2 <= len(a) <= 60): continue key = sim_key(q) if key in seen: continue seen.add(key) out.append({'instruction': q, 'output': a, 'src': 'trap_qa'}) return out

这里最容易犯的错是去重指纹设计得太粗。我第一版用的是原句哈希,结果发现同一个题目会被改一两个字重新发一遍,哈希完全挡不住。后来改成"去标点后取前 16 字",召回率立刻上去了。但也不能取整句,因为有些题目的后半段是变体,取整句会漏掉重复。这个长度是试出来的,没有理论依据,你也可以按自己的数据分布调。

2.3 去重之外,还要做一次"梗泄漏"预筛

这一步我在第 2 节里提前说一下,因为它属于数据构造阶段的工作,不是评测阶段才做的。

具体做法很土但管用:把基座模型(未微调)直接拿来跑一遍全量题目,让它输出答案,然后用关键词匹配 + 人工抽查判断对错。凡是基座模型能直接答对的,标记为leaked=True。

这批被标记的样本有两个用途。一是不能进测试集,否则评测结果毫无意义。二是可以进训练集,但权重降低,因为它们对模型来说太简单,属于已经"会做"的题,练了没增量。

我第一次跑这个预筛的时候,基座模型的直接正确率是 34%。这个数字比我想的高不少,说明网上流传度高的题目确实已经被预训练语料吃进去了。如果你不做这步,微调完看到正确率从 34% 涨到 60%,会误以为数据构造很成功,实际上可能只是训练集和测试集撞了。

3. 三条数据构造主线:指令对、思维链、偏好对

清洗完得到大约 2400 条干净的问答对,接下来才是真正花心思的部分。我把数据拆成三条线并行构造,因为它们训练目标和数据形态完全不同,混在一起训反而互相干扰。

数据线形态规模训练方式主要作用
指令对instruction / output~1800SFT建立"收到任务就作答"的格式
思维链instruction / reasoning / output~600SFT(同批)教会模型先拆题再答
偏好对prompt / chosen / rejected~900DPO纠正"顺着题干滑"的倾向

3.1 指令化改写:把题目变成模型认识的任务

原始数据最大的问题是"没有任务感"。一句"什么东西越洗越脏",模型看到它可能以为是闲聊,也可能以为是接龙。改写的目的就是给它一个明确的角色和一个明确的输出契约。

我用了三类模板,随机分配,避免模型把某一个固定句式当成触发条件:

{"instruction": "下面是一个有陷阱的问题,请给出正确答案,只回答问题本身。\n问题:什么东西越洗越脏?", "output": "水。"} {"instruction": "回答下面的问题。注意题干可能存在语义误导,不要被字面意思带走。\n问题:什么东西越洗越脏?", "output": "水,因为洗东西的时候水本身会变脏。"} {"instruction": "请判断题目中的关键词是否被偷换了含义,然后给出答案。\n问题:什么东西越洗越脏?", "output": "关键词是“洗”,正常语境下“洗”的对象是物品,这里把水也当成被洗的对象,偷换了语义关系。答案:水。"}

三个模板的答案长度差异很大,这是故意的。只给短答案,模型会变得惜字如金;只给长答案,模型会变得啰嗦。混着给,它才能学会按指令里隐含的长度要求调整输出。

这里踩过一个坑:模板里的提示语不能写得太"剧透"。我一开始在提示里直接写了"这是一道脑筋急转弯",结果模型学到的是"看到'脑筋急转弯'这四个字才开始找陷阱",普通问句它照样栽。后来把提示语改成中性描述,让它必须自己判断题目类型,效果才对。

3.2 思维链样本的写法与"解释膨胀"风险

思维链数据的收益是真的,但写法很讲究。我见过不少 CoT 数据集,推理过程写得像教科书,一二三四列得整整齐齐,结果模型学完之后输出全是这种格式化的废话,真正需要它推理的时候反而更慢更不准。

我的写法是模仿人的真实思考路径,允许有反复和排除:

指令:什么东西越洗越脏? 推理:先看“洗”这个动作。正常情况洗东西,物品变干净,水变脏。题目说“什么东西越洗越脏”,如果按正常逻辑,被洗的东西应该变干净,所以它问的对象不是被洗的那个,而是洗的过程里承担脏东西的那个。那就是水。 答案:水。

注意这段推理里有一句"如果按正常逻辑……所以不是",这是排除过程,我认为这是整条 CoT 里最有价值的部分。模型缺的不是知识,是"先假设再证伪"的习惯。

提示:CoT 样本的规模一定要控制住。我实测下来,CoT 占比超过总 SFT 数据的三成之后,模型在普通问答上的平均输出长度会明显上升,而那些场景不需要长输出。这个副作用在小模型上尤其明显。

另外还有个很隐蔽的问题:CoT 数据会放大幻觉。模型一旦学会"先解释再回答",在遇到它其实不会的题目时,它会倾向于编一段看似合理的推理来支撑一个错误答案,而不是直接说"不确定"。我在评估阶段专门统计了"自信错误"的比例,微调 CoT 之后这个比例确实上升了几个点。这是必须接受的代价,但你要知道它的存在。

3.3 偏好对构造:错误答案从哪来

偏好对数据是整个实验里我认为收益最高的一环。核心思路很直接:模型缺的不是"知道答案",是"不要在题干设定的框架里打转"。所以 rejected 样本要专门挑那种"顺着题干合理推演但结论错误"的回答。

rejected 有四个来源,我按优先级排:

  1. 基座模型的真实错误输出。最有价值,因为这就是模型当前的坏习惯,DPO 直接纠偏。
  2. 人工构造的"合理误导"答案。比如把题干的错误前提当真,认真回答一个不存在的东西。
  3. 正确答案但推理过程错。这类样本很妙,答案对但路径错,DPO 会让模型学会重视过程。
  4. 答非所问的敷衍回答。用来压制"不确定就糊弄"的倾向。

chosen 侧我倾向于用简洁答案 + 一句关键解释,不用完整 CoT。原因是 DPO 训练时 chosen 和 rejected 的长度差异如果太大,模型会学到"长的一定是好的"这个错误信号。

{ "prompt": "请回答问题:什么东西越洗越脏?", "chosen": "水。因为洗的过程中污渍转移到水里,水本身变脏了。", "rejected": "越洗越脏的东西通常是抹布、拖把这类清洁工具,因为它们反复接触污渍,如果不及时更换会积累细菌。" }

上面这个 rejected 就是典型的"顺着题干滑"。它读起来非常合理,甚至比正确答案更像正经回答,但完全没识别出题目在玩语义游戏。这种"高质量的错误"才是 DPO 真正需要的负样本,随手编一个胡言乱语当 rejected,训练效果差得远。

4. InternLM微调的实操配置

数据准备好之后进训练。我用的是 InternLM 系列的开源基座,规模选的中等档位,原因很简单:太小了学不动这种需要"跳出分布"的能力,太大了我手上的算力跑不动全参数,只能上 LoRA,而 LoRA 在大模型上的容量又不够覆盖这种偏行为模式的改变。中等规模是性价比最合适的区间。

4.1 数据配比与基座选择

单纯用这 2400 条陷阱题训,模型一定会跑偏。通用能力是靠海量数据撑起来的,你拿两千条窄分布数据去覆盖它,等于用一杯水冲淡一缸水,冲不动还搅浑了。

我的配比是陷阱数据 : 通用指令数据 ≈ 1 : 3。通用数据我用的是公开的通用指令集,覆盖写作、问答、摘要、代码几类,保证模型不会因为只练"抖机灵"而丧失正常表达能力。

这个比例不是拍脑袋定的。我做了个小规模的梯度实验:分别用 1:1、1:3、1:6 训三个 checkpoint,然后在一个 200 条的小规模通用能力测试集上看掉点情况。1:1 的掉点最明显,1:6 的掉点在噪声范围内但陷阱题提升很弱,1:3 是拐点。你手上的数据分布不一样,这个比例一定要自己试,别照抄。

4.2 LoRA 参数:为什么这么设

配置这块我列一下实际用的关键项,附上理由,因为很多人抄配置只抄数字不知道为什么。

参数取值设定理由
lora_rank32数据量小,rank 太高容易过拟合;低于 16 在行为模式改变上容量不够
lora_alpha64保持 alpha = 2 * rank 的常用比例,缩放稳定
lora_dropout0.05小数据必须加 dropout,我试过 0 和 0.05,后者 eval loss 更平
learning_rate2e-4LoRA 常用区间,再高 loss 会震荡
batch_size4(梯度累积 8)显存限制下的折中,等效 batch 32
max_length2048CoT 样本最长约 700 token,2048 留足余量
epochs3第 4 轮开始 eval loss 明显抬头

配置文件长这样,格式是通用的对话微调配置:

# 关键片段,非完整配置 model_name_or_path = "internlm-base" # 替换为你实际使用的基座路径 data_path = "./data/trap_mix.jsonl" max_length = 2048 batch_size = 4 accumulative_counts = 8 max_epochs = 3 lr = 2e-4 weight_decay = 0.01 warmup_ratio = 0.03 lora = dict( type="LoraConfig", r=32, lora_alpha=64, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", )

warmup_ratio这个参数在小数据微调里容易被忽视。我一开始设的 0.1,结果前几十步基本没在学,因为总步数本来就不多,warmup 占掉的比例太大。改成 0.03 之后曲线正常了。

4.3 训练监控:loss 正常但效果不正常的排查

这里我要说一个最折磨人的情况:loss 曲线漂亮得不行,但模型效果没变化,甚至更差。

我第一次训完就是这个状态。train loss 从 1.8 平稳降到 0.6,eval loss 到 0.9 之后开始抬头,典型的过拟合信号。但真正的诊断不能只看 loss,我做了三件事才定位到问题。

第一件事:逐条对比基座和微调模型在同一批 prompt 上的输出。我发现微调后的模型在陷阱题上确实会答对了,但语气变了,变得非常"自信"且句式固定,全是"关键词是X,这里偷换了……答案:Y"这个模板。它学的是模板,不是推理。

第二件事:把训练集里的 CoT 样本全部拿掉重训一版。结果模板化输出消失了,但陷阱题正确率只掉了 4 个点。这说明绝大部分提升来自指令对数据,CoT 的边际收益远没有我想的那么大,反而贡献了主要的副作用。

第三件事:查数据里的重复。我用文本相似度扫了一遍训练集,发现 2400 条里大约有 180 条是同一题目的不同变体,这些变体在训练时被重复采样多次,等于给这批数据加了权。把它们合并之后,过拟合明显缓解。

提示:小数据微调一定要做"去重后统计每题出现次数"。重复样本在小数据集里的加权效应被严重低估,一条重复三次的样本,影响力相当于三条不同样本,但泛化价值只有一条。

最后我把 CoT 占比从 25% 压到 12%,epochs 保持 3,重新训了一版。这版的效果是所有版本里最平衡的,也是后面评估用的模型。

5. 评估设计:怎么判断这批数据真起了作用

评估这块我要多花点篇幅,因为这是整个实验里最容易自欺欺人的环节。你做了一堆工作,主观上非常希望它有效,这时候如果评估设计不严谨,你会不自觉地挑选那些能证明自己有效的指标。

5.1 分维度测试集与难度分层

测试集我单独构造了 300 条,和训练集严格无重叠。构造方式是从清洗后的池子里随机抽,但抽样前先用基座模型测一遍,按答对与否分成两档。

难档(基座答错)150 条,易档(基座答对)150 条。分开统计的意义在于:如果一个模型只在易档上提升,那它很可能是在拟合格式而不是在学能力;只有在难档上也有稳定提升,才能说明它真的获得了某种"识别陷阱"的能力。

测试维度我拆成四个,每个维度单独算分:

维度考察点样本数判定方式
语义陷阱识别能否识别题干中的关键词偷换90答案关键词匹配 + 人工复核
双关谐音理解能否走通语音路径70答案完全匹配
抗过度推理会不会给出冗长但错误的解释80答案正确且长度 < 40 字
拒答合理性遇到无解题目能否说不确定60人工判定

第四个维度是我特意加的。因为反逻辑题目里有一部分本身就设计成了无解或者多解,模型如果强行给一个答案,其实是一种更严重的失败。我观察到微调之后模型在这个维度上反而略有退步,因为它被训练成了"总要给个答案"。

5.2 泄漏检测与基座对照

前面提过,这批题目网上流传度很高。我在测试集构造时做了一次严格的泄漏检查,流程是这样的:

  1. 用基座模型跑测试集,记录答案。
  2. 对答对的样本,人工看是否属于"直接背出答案"(表现为答案与网络流传版本逐字相同)。
  3. 对判定为泄漏的样本,从测试集中剔除。

剔完之后 300 条剩下 236 条。这个损耗率接近 20%,说明如果不做这步,你的评测结果会虚高一大截。

另外必须有的对照是基座模型基线。所有指标都是相对于基座的增量,绝对值意义不大。因为同一批数据换个基座,绝对值可能差十几个点。

对照结果大致是这样(数字做了归一化处理,只反映趋势):

指标基座SFT(仅指令对)SFT(指令对+少量CoT)
语义陷阱识别34%58%63%
双关谐音理解41%52%55%
抗过度推理61%57%54%
拒答合理性48%43%40%
通用能力(对照集)基线-1.2%-2.1%

这张表里最值得注意的是后两行。抗过度推理和拒答合理性都掉了,也就是说,模型变"敢答"了,但也变"不肯认怂"了。这是陷阱题微调的必然代价,因为训练信号本身就在鼓励它给出确定答案。如果你的应用场景里"不知道就说不知道"比"答对率"更重要,那这套数据的配比必须往回调。

5.3 人工盲评与"模板化"观察

自动指标永远有盲区,我抽了 100 条做人工盲评。做法是把基座和微调模型的回答打乱顺序,去掉来源标记,让两个人独立打分,只看"这个回答是否真的理解了题目的陷阱"。

盲评结果和自动指标基本吻合,但暴露了一个自动指标看不见的问题:微调模型有 12% 的回答是"正确但套模板"。具体表现是,即使题目根本没有语义偷换,它也会硬套一句"关键词是X,这里偷换了含义"。这种"无差别识别陷阱"其实是一种新的失败模式,比答错更难被发现,因为自动评分只匹配答案。

我在实际操作中的体会是,这类语料做微调,最大的收益不在于把正确率从 34% 提到 63%,而在于你能通过这个实验看清楚模型的失败模式到底长什么样。它不会告诉你"我不知道",它会给你一个结构完整、语气笃定、逻辑上差一步的答案。这个认知比指标提升有价值得多。

如果你也想在自己的场景里复现这套流程,我给一个最后的小建议:先别急着训,先花半天时间把 200 条样本逐条读一遍。读的过程里你会发现大量自动清洗脚本抓不到的规律,比如某类题目的答案分布极度集中、某类题干其实是同一个模板批量生成的。这些东西写不进代码,但对后面每一步的决策影响都很大。我这次实验里最关键的几个判断,包括 CoT 占比和配比调整,都是读样本读出来的,不是跑实验跑出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询