承接与场景
上篇用两笔账把路线定了下来:合同审查团队决定走"微调管行为 + RAG 管知识"的混合路线。路线定了,第一篇账其实没算完整——SFT 那 34 美元只是卡费,微调真正的 expensive 项是数据。本篇进入第二道工序:5 万条指令数据从哪来、怎么洗、怎么去重。场景换到电商客服助手(后续多篇沿用业务语境但各自独立成文),要训练的模型任务是:给定客户咨询与工单元信息,输出标准处理话术。所有实验仍是纯 Python 3.12 标准库的确定性模拟——MinHash、规则过滤这些算法本身就是纯集合与字符串运算,和生产实现只差数据规模;真实项目中数据读写用 HF datasets,语义级去重才需要额外模型。
指令数据长什么样,从哪来
一条 SFT 样本的最小形态是"指令 + 期望回答",工程上通常再拆出系统提示(角色与约束)、多轮历史、以及给谁看的元数据(来源、标注员、批次)。构造来源有四条,成本与质量互补:存量工单改写(把真实客服对话整理成规范化 QA,质量最高但要脱敏与改写)、模板生成(业务动作 × 意图 × 语气做笛卡尔积再填槽,覆盖可控但易模板化)、强模型蒸馏(让闭源大模型按规格批量产出,便宜量大但会继承它的口头禅与结构偏好)、人工标注(最贵,只留给评测集与边界案例)。经验配比是蒸馏打底、改写提质、人工只进评测。数量级的判断:格式与话术类行为,几千条就有明显变化;涉及判断流程的,万条起步;这解释了为什么"数据工程"占微调项目一半以上工期——InstructGPT 论文里 1.3 万条标注数据就是核心资产,模型结构只是载体。
数据质量的头号隐形杀手是重复。同一句话换个标点的"孪生样本"在语料里成簇出现时,训练会把梯度预算全砸在少数模式上,模型学会的是复读而不是泛化;更隐蔽的是指令集与评测集近重复——训练见过换皮考题,评测分数虚高,上线即翻车。所以去重不是锦上添花,是数据流水线的第一道闸门。
实验一:MinHash + LSH 的近重复检测与阈值扫描
朴素两两比较是 O(n²),5 万条就是 12.5 亿次集合运算,不可行。工业方案分两步:MinHash 把每条样本的字符 k-gram 集合压缩成固定长度签名(同一哈希族下,签名分量相等的概率恰好等于两个集合的 Jaccard 相似度,这是它的数学根据);LSH 再把签名切成若干"带",任一带完全相同才进候选对——相似度越高的对碰撞概率越大,从而用线性开销捞出候选,再精核。下面用 16 条客服指令跑通全流程,预埋三组近重复(换数字、换同义词、加虚词),扫描四个判定阈值看各自抓到什么:
"""MinHash + LSH 指令集近重复检测与阈值扫描, 纯标准库, 确定性。"""importhashlibimportitertools NUM_HASH=32# MinHash 签名长度BANDS=8# LSH: 8 带 x 4 行ROWS=NUM_HASH//BANDSdefshingles(text,k=3):t=text.replace(" ","")return{t[i:i+k]foriinrange(max(1,len(t)-k+1))}defsignature(grams):return[min(int(hashlib.md5(("%d|%s"%(h,g)).encode()).hexdigest(),16)forgingrams)forhinrange(NUM_HASH)]samples=["用户询问订单何时发货,回复:您的订单已于3月5日发出,预计2天后送达。","用户询问订单何时发货,回复:您的订单已于3月6日发出,预计2天后送达。","客户问发票怎么开,回复:请在订单详情页点击开具发票,电子票当天发送到邮箱。","客户问发票如何开具,回复:请在订单详情页点击开具发票,电子票当天发送到邮箱。","用户投诉快递破损,回复:非常抱歉,请上传破损照片,我们为您优先补发并回收旧件。","用户反馈商品与描述不符,回复:支持7天无理由退货,运费由商家承担。","咨询会员积分规则,回复:每消费1元积1分,积分可在结算页抵扣现金。","用户询问如何修改收货地址,回复:订单未发货前可在详情页自助修改。","用户询问如何修改收货地址,回复:订单未发货前可以在详情页自助修改地址。","客户要求取消订单,回复:未发货订单可直接取消,已发货需拒收后退回。","用户询问售后电话,回复:客服热线400-000-0000,工作日9点到18点。","咨询配送范围,回复:全国大陆地区均可配送,偏远地区时效顺延3天。","用户询问能否到店自提,回复:当前不支持自提,全部为仓配发货。","客户抱怨物流慢,回复:非常抱歉让您久等,已为您催促承运商并补偿10元券。","用户询问支付方式,回复:支持微信、支付宝、银行卡与对公转账。","咨询赠品活动,回复:满299元赠清洁套装,随主单一起发出。",]truth=[{0,1},{2,3},{7,8}]# 预埋三组近重复: 换数字/换同义词/加虚词grams=[shingles(s)forsinsamples]sigs=[signature(g)forgingrams]buckets={}fori,siginenumerate(sigs):forbinrange(BANDS):buckets.setdefault((b,tuple(sig[b*ROWS:(b+1)*ROWS])),set()).add(i)candidates=set()forgroupinbuckets.values():iflen(group)>1:candidates.update(itertools.combinations(sorted(group),2))exact_pairs=[]fori,jinitertools.combinations(range(len(samples)),2):jv=len(grams[i]&grams[j])/len(grams[i]|grams[j])ifjv>=0.6:exact_pairs.append((i,j,jv))in_cand=sum(1fori,j,_inexact_pairsif(i,j)incandidates)print("LSH 候选 %d 对; 穷举字面 Jaccard>=0.6 的有 %d 对, LSH 覆盖 %d 对"%(len(candidates),len(exact_pairs),in_cand))forthin(0.90,0.80,0.70,0.60):pairs=[(i,j)fori,jinsorted(candidates)ifsum(a==bfora,binzip(sigs[i],sigs[j]))/NUM_HASH>=th]groups=[]fori,jinpairs:hit=[gforgingroupsifiingorjing]merged=set().union(*hit)|{i,j}ifhitelse{i,j}groups=[gforgingroupsifgnotinhit]+[merged]detected=sorted(tuple(sorted(g))forgingroups)hit_n=sum(1fortintruthiftuple(sorted(t))indetected)drop=sorted({iforgingroupsforiinsorted(g)[1:]})print("阈值 %.2f -> 重复簇 %s | 命中 %d/%d | 保留 %d/%d"%(th,[list(g)forgindetected],hit_n,len(truth),len(samples)-len(drop),len(samples)))运行输出:
LSH 候选 3 对; 穷举字面 Jaccard>=0.6 的有 3 对, LSH 覆盖 3 对 阈值 0.90 -> 重复簇 [[0, 1]] | 命中 1/3 | 保留 15/16 阈值 0.80 -> 重复簇 [[0, 1]] | 命中 1/3 | 保留 15/16 阈值 0.70 -> 重复簇 [[0, 1]] | 命中 1/3 | 保留 15/16 阈值 0.60 -> 重复簇 [[0, 1], [2, 3], [7, 8]] | 命中 3/3 | 保留 13/16三行结论。第一行验证 LSH 召回完整:所有字面相似的配对全部进了候选,没有漏网——带宽参数(8 带 4 行)决定了这个召回-开销折中。第二,阈值从 0.90 降到 0.60 之间,只有"换数字"组被抓到:换同义词、加虚词的样本字符 3-gram 重合度被改动位置打断,估计 Jaccard 只有 0.688,卡在 0.70 阈值下。这就是字面去重的天花板:对改写型重复(蒸馏数据尤其常见,同一条指令让大模型"换种说法再写三遍"产生的正是这种)必须把阈值压到 0.6 附近,而阈值一低,正常样本被误杀的风险又上升——同一批语料上,0.8 阈值是常配的起点,高重复风险的蒸馏源单独配 0.6。第三,要真正解决语义重复,得把 shingle 换成 embedding 近邻,那是第五篇数据构造里"去重要看语义还是看字面"的伏笔。
清洗:规则瀑布要可归因
去重之后是清洗。清洗的核心工程原则:每条规则单独可归因、按成本从低到高排序、能脱敏的不丢弃。手机号、身份证号这类敏感信息,整条删除等于把真实场景从分布里挖掉一块,正确做法是正则替换成占位符后保留。下面模拟一条 240 样本的指令集,预埋六类缺陷,跑五道规则并统计每道规则剔除了哪些类别——真实项目里没有 flaw 标签,这份"标准答案"是为了检验规则本身抓得准不准、有没有互相抢活:
"""SFT 指令集质量过滤流水线: 规则瀑布 + 敏感脱敏, 种子确定性。"""importrandomimportrefromcollectionsimportCounter rng=random.Random(2024)INTENTS=["查询订单","退款进度","发票开具","物流异常","修改地址","会员权益"]flaws=(["short"]*14+["trunc"]*12+["rep"]*10+["mismatch"]*10+["moji"]*8+["phone"]*24+["clean"]*162)rng.shuffle(flaws)samples=[]fori,flawinenumerate(flaws):intent=INTENTS[i%len(INTENTS)]prompt="客户咨询:%s,客户等级%d,请给出标准处理话术与注意事项。"%(intent,rng.randint(1,5))answer=("回复:关于%s,已为您核实。处理结论:工单 #%d 已提交后台,""预计 1 个工作日内同步结果,请留意站内信。"%(intent,100000+i))ifflaw=="short":prompt,answer="%s?"%intent,"已处理。"elifflaw=="trunc":answer=answer[:24]elifflaw=="rep":answer="抱歉给您带来不便。"*4elifflaw=="mismatch":answer="您好,已收到反馈,我们会尽快跟进相关事宜。"elifflaw=="moji":answer="%s ×§ø∆?#@!%%&*()_+|~;:×§ø∆?#@!%%&*()_+|~;:。"%intentelifflaw=="phone":prompt+=" 回电号码 138%08d。"%rng.randint(1,99999999)samples.append({"i":i,"flaw":flaw,"intent":intent,"prompt":prompt,"answer":answer})defbigram_uniq(s):returnlen({s[a:a+2]forainrange(len(s)-1)})/max(1,len(s)-1)defzh_ratio(s):returnlen(re.findall(r"[\u4e00-\u9fff]",s))/max(1,len(s.replace(" ","")))rules=[("长度不足(整条<45字)",lambdax:len(x["prompt"])+len(x["answer"])<45),("疑似截断(结尾无句读)",lambdax:notre.search(r"[。!?]$",x["answer"])),("高重复(2-gram 独特率<0.5)",lambdax:bigram_uniq(x["answer"])<0.5),("回答与意图不匹配",lambdax:x["intent"]notinx["answer"]),("中文占比过低(乱码)",lambdax:zh_ratio(x["prompt"]+x["answer"])<0.60),]print("原始 %d 条, 缺陷注入: %s"%(len(samples),dict(Counter(flaws))))pool=samplesforname,badinrules:dropped=[xforxinpoolifbad(x)]pool=[xforxinpoolifnotbad(x)]print("规则「%s」剔除 %2d 条 %s -> 存活 %d"%(name,len(dropped),dict(Counter(x["flaw"]forxindropped)),len(pool)))phone=re.compile(r"1[3-9]\d{9}")masked=[xforxinpoolifphone.search(x["prompt"])]forxinmasked:x["prompt"]=phone.sub("[已脱敏手机号]",x["prompt"])print("敏感信息: %d 条命中手机号 -> 脱敏改写后保留(整条丢弃会损失场景覆盖)"%len(masked))print("存活集意图分布:"," ".join("%s=%d"%(k,v)fork,vinsorted(Counter(x["intent"]forxinpool).items(),key=lambdakv:-kv[1])))print("最终可用率 %.1f%% (%d/%d), 其中 clean=%d, phone 脱敏=%d"%(100*len(pool)/len(samples),len(pool),len(samples),sum(1forxinpoolifx["flaw"]=="clean"),len(masked)))运行输出:
原始 240 条, 缺陷注入: {'trunc': 12, 'clean': 162, 'phone': 24, 'short': 14, 'moji': 8, 'mismatch': 10, 'rep': 10} 规则「长度不足(整条<45字)」剔除 14 条 {'short': 14} -> 存活 226 规则「疑似截断(结尾无句读)」剔除 12 条 {'trunc': 12} -> 存活 214 规则「高重复(2-gram 独特率<0.5)」剔除 10 条 {'rep': 10} -> 存活 204 规则「回答与意图不匹配」剔除 10 条 {'mismatch': 10} -> 存活 194 规则「中文占比过低(乱码)」剔除 8 条 {'moji': 8} -> 存活 186 敏感信息: 24 条命中手机号 -> 脱敏改写后保留(整条丢弃会损失场景覆盖) 存活集意图分布: 退款进度=32 会员权益=32 修改地址=32 查询订单=31 物流异常=30 发票开具=29 最终可用率 77.5% (186/240), 其中 clean=162, phone 脱敏=24这份瀑布有两个值得抄走的细节。其一,五道规则各抓各的缺陷、零误伤——真实语料上做不到这么干净,所以剔除统计必须逐规则留存,一旦发现"回答与意图不匹配"误杀了大量正常样本(中文意图词未必出现在话术里),要么改规则要么换成分词重合度,瀑布日志是唯一能发现这种事的证据。其二,77.5% 的可用率是健康的:业内清洗 30% 上下的损耗很正常,如果剔除率只有 5%,大概率是规则太松,而不是数据太好。最后看存活集的意图分布——六个意图 29~32 条基本均衡,这是配比工作的成果;若清洗把某个意图几乎杀光(缺陷样本恰好集中在它身上),存活分布会立刻失衡,模型上线后那个意图就成了盲区。
常见坑
- 训练评测手拉手:清洗前先切出评测集并从训练侧剔除其近重复,否则第六篇的评测分数全是幻觉。切分要按"会话/工单"整块切,同一客户当天的连续对话拆到两侧等于泄漏。
- 模板复读机:蒸馏或模板生成的数据不检查多样性,模型会学到固定起首语("您好,已收到反馈"刷屏)。统计存活集的 n-gram 开头分布,比字面去重更进一步。
- 过度拒绝样本:安全对齐语料里的"抱歉无法提供"混进业务指令集,模型客服场景一言不合就拒答。缺陷数据同理——把"回答错误"的样本直接丢掉可以,留着改正前先隔离批次。
- 只有一条流水线的幻觉:数据每次更新都要能重跑同一套规则并 diff 出增减,靠人手在 Excel 里筛的流水线在第二次重训时必然失控。
- 长度分布失衡:只保留长回复样本,模型学会车轱辘话;截断规则也别一刀切,业务里合法短句(“已为您登记”)要能被白名单救回。
数据流水线清单
- 采集时即打元数据:来源(改写/模板/蒸馏/人工)、批次、标注者,事后归因全靠它
- MinHash 签名入库,阈值 0.8 起步,蒸馏源单独 0.6;语义级去重留给 embedding
- 规则瀑布按成本排序:长度 → 截断 → 重复 → 匹配 → 乱码,逐规则记录剔除归因
- 敏感信息一律脱敏保留,不整条丢弃;脱敏函数版本化,占位符全局统一
- 每轮产出三张报表:去重簇大小、规则瀑布、意图/长度分布,随数据版本号归档
数据备好了,下一篇进入训练本身:《LLM 微调实战(3):LoRA 实战:rank、alpha 与显存开销的关系》——为什么只训 1% 的参数也能改变模型行为,rank 和 alpha 各管什么,显存账怎么算,用形状与数值手算给你看。
参考来源
- Training language models to follow instructions with human feedback (InstructGPT):https://arxiv.org/abs/2203.02155
- LIMA: Less Is More for Alignment:https://arxiv.org/abs/2305.11206
- Deduplicating Training Data Makes Language Models Better:https://arxiv.org/abs/2107.06499
- Wikipedia: MinHash:https://en.wikipedia.org/wiki/MinHash
- Self-Instruct: Aligning Language Models with Self-Generated Instructions:https://arxiv.org/abs/2212.10560
¥9.9 付费专栏《Python 自动化接单实战:从脚本到第一单》共 10 篇、已完结不再更新,一次拿走:点此直达