从 monorepo 里挑微调样本,我是怎么把重复样板代码挡在门外的

我在做一个代码微调项目时发现,从 monorepo 里捞样本最坑的不是“怎么选”,而是“怎么别选”——重复的样板代码像蟑螂一样,你看到一只的时候,暗处已经藏了一窝。这篇文章把我用过的几招盘一遍,重点讲怎么在源头就把这些重复货挡在门外。

先说结论:用“文件指纹 + 路径聚类 + diff 信号”三步走,比任何单一去重算法都管用

我一开始也迷信 MinHash、SimHash 这类文本去重,结果在 monorepo 里栽了跟头。原因很简单:monorepo 里重复的不一定是完全一样的代码,而是结构相同、变量名微调、注释换皮的“近亲样本”。而且 monorepo 的目录结构本身就是强信号——同一个 package 下的 20 个 CRUD 文件,对模型来说就是 20 份重复样板。

后来我把筛选逻辑改成三层过滤,重复样本直接从 34% 降到了 4% 左右(我们 repo 里 1.2 万个 .ts/.tsx 文件,筛完剩 800 多个候选,人工复审后留了 500 多)。

第一层:AST 结构指纹,把“换皮代码”打回原形

文本哈希(MD5、SHA)只能抓完全相同的文件,但 monorepo 里多的是“复制粘贴后改了两个变量名”的文件。我的做法是:

  1. typescript 编译器 API 把每个文件解析成 AST;
  2. 把 AST 里所有标识符(Identifier)替换成占位符 ID,所有字符串字面量替换成 STR,所有数字替换成 NUM
  3. 删掉注释和空行;
  4. 对处理后的 AST 做一次结构序列化,算 SHA-256。

这样 getUserByIdgetProductById 在指纹层面就是同一个东西。我在一个 400 多个文件的服务目录里跑了一下,结构指纹把 400 个文件压成了 37 个去重簇,其中最大的一个簇里有 63 个文件——全是同一套 controller/service/router 模板生成的。

// 简化版 AST 指纹提取
import ts from 'typescript';
import crypto from 'crypto';

function astFingerprint(source: string): string {
  const sf = ts.createSourceFile('x.ts', source, ts.ScriptTarget.Latest, true);
  
  function normalize(node: ts.Node): string {
    if (ts.isIdentifier(node)) return 'ID';
    if (ts.isStringLiteral(node)) return 'STR';
    if (ts.isNumericLiteral(node)) return 'NUM';
    
    const children = node.getChildren(sf)
      .filter(c => !ts.isToken(c) || c.kind === ts.SyntaxKind.OpenBraceToken || c.kind === ts.SyntaxKind.CloseBraceToken)
      .map(normalize)
      .join('|');
    
    return `${ts.SyntaxKind[node.kind]}:${children}`;
  }
  
  const normalized = normalize(sf);
  return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
}

这套指纹的局限是对“结构相似但逻辑不同”的代码不敏感——比如两个都是 if (x) { doA() } else { doB() } 但 A 和 B 完全不同,指纹会判它们相同。所以它只能当第一层粗筛,不能当最终标准。

第二层:路径聚类,让目录结构替你干活

monorepo 的目录名经常直接暴露样板代码。我的规则很粗暴但好用:

  • 同一目录下指纹相同的文件超过 3 个,只保留 1 个。如果 packages/api/src/controllers/ 里有 8 个文件指纹撞了,那这 8 个就是同一个模板生成的,留一个最“干净”的(注释少、依赖少、行数适中)。
  • 匹配“生成器目录”模式直接跳过。比如 __generated__/generated/dist/build/coverage/,这些目录里的代码不是人写的,喂给模型等于教它吃土。
  • 测试文件默认降权*.test.ts*.spec.ts__tests__/ 里的代码信号较低——大量 mock、断言样板,而且和业务逻辑混在一起会稀释样本质量。我不是完全排除,而是给它们一个 0.3 的权重系数。

路径聚类的一个额外好处是:它能抓到 AST 指纹漏掉的“同目录近亲”。比如两个文件结构相似但不完全相同(一个多了一层 try-catch),AST 指纹可能不同,但路径聚类会告诉你“这目录下 15 个文件全是同一类东西”。

第三层:diff 信号,让 Git 历史告诉你哪些是“活代码”

monorepo 里有一类文件很坑:它们看起来是业务代码,实际上三年前就没人动了,而且是复制粘贴的产物。这类文件对微调没有价值,但 AST 指纹和路径聚类都可能漏掉。

我的做法是用 git log 提取三个信号:

  1. 最后修改时间:超过 18 个月没动过的文件,默认降权 0.5。代码风格可能已经过时。
  2. 修改次数:被改过 50 次以上的文件通常是核心业务逻辑——高信号。只改过 1-2 次(创建那几次)的文件大概率是“一次性复制粘贴”。
  3. diff 中“样板行”占比:如果一个文件的历史 diff 里,importexport、类型声明、空行、括号这些样板行占了 70% 以上,说明这个文件大部分内容都是结构性的,没有实质逻辑变化。
# 提取文件修改次数和最后修改时间
git log --format="%ad" --date=short -- <file> | wc -l          # 修改次数
git log -1 --format="%ad" --date=short -- <file>              # 最后修改时间

# 统计 diff 中样板行占比(简化版)
git log -p -- <file> | grep -E "^[+-]" | grep -vE "^[+-]{3}" | \
  grep -cE "^[+-]\s*(import|export|type|interface|\}|\{|\s*//)" 

我把这三个信号组合成一个 0-1 的“活性分数”,低于 0.3 的文件在微调样本里直接出局。效果是:一个 200 多个文件的遗留服务目录里,靠 diff 信号砍掉了 60% 的候选,剩下的 80 个文件里,人工复审发现 70 多个确实是高质量样本。

组合拳比单点方案强在哪

单独用任何一个方法都有明显漏洞:

方法 抓得住 漏掉的
文本哈希 完全相同的文件 一切换皮代码
AST 结构指纹 结构相同的模板代码 结构相似但逻辑不同的代码
路径聚类 同目录批量生成物 散落在不同目录的重复
diff 信号 长期不动的死代码 近期复制的样板

但三层叠加之后,漏网之鱼就很少了。因为重复样板代码通常同时满足多个特征:同目录、结构相同、长期不修改。只要命中其中两个,基本就可以判死刑。

一个我踩过的坑:别把“相似的业务代码”误杀

早期我把 AST 指纹阈值调得太激进,结果把一个目录下 5 个“结构相似但业务语义不同”的文件误杀了。那 5 个文件是同一个开发者写的,风格一致,AST 结构相似度很高,但分别处理订单、支付、退款、对账、结算——这恰恰是微调最需要的“同一风格、不同业务域”的样本。

后来我加了一个规则:如果两个文件指纹相同但路径前缀不同(比如 orders/payments/),保留两个。这个规则保住了大量“同构不同义”的高价值样本。

常见问题

AST 指纹提取对多语言项目怎么办?

我只处理 TypeScript/JavaScript,因为我们的 monorepo 主要是 .ts/.tsx。如果你有多语言,可以按语言分别处理——Python 用 ast 模块,Go 用 go/parser,思路一样:把标识符和字面量归一化后算哈希。但不同语言的“样板特征”不同,比如 Go 的 if err != nil 就是典型样板行,diff 信号里可以单独处理。

去重之后样本太少怎么办?

我们 1.2 万个文件去重后剩 800 个候选,这个量对 LoRA 微调来说其实够用了。如果确实不够,与其放宽去重阈值,不如从其他维度补充样本——比如把同一个 repo 里不同 service 的同类文件(不同目录、相似但不相同的业务代码)都纳入,或者从 commit 历史里挑“bug fix”类 diff 作为负样本。

AST 指纹会不会太慢?

1.2 万个文件跑一遍 AST 解析和指纹计算,在我 2021 年的 M1 MacBook 上大约 3 分钟。完全可接受。如果 repo 更大(10 万+ 文件),可以先用文件大小和扩展名做预过滤,再跑 AST。

直接去重不行吗,为什么还要 diff 信号?

因为 monorepo 里“重复”和“过时”是两个正交问题。diff 信号抓的是“死代码”——它们可能完全没重复,但早已不反映当前代码风格和业务逻辑。微调样本要的是“当前活着的、有代表性的代码”,不是“历史上存在过的代码”。