AI 写代码最让人担心的,未必是它写错了,而是它在正确地重复造轮子。
一个页面需要 Loading,AI 写一个;另一个页面需要类似状态,AI 又写一个。今天生成一个日期格式化函数,下周换了个命名再生成一个。单看每次提交,这些代码都能运行,甚至 Review 时也挑不出明显问题。但几个月后,仓库里可能已经同时存在三四套 Snackbar、异常转换、DTO 映射和分页逻辑。
最近了解了 Deslop 之后,我对这个问题有了一个更具体的认识:治理 AI 重复代码,重点不应只是"提交后发现",而应该是"生成前查询"。
AI 为什么特别容易制造重复代码
人类开发者在长期维护一个项目时,会逐渐形成对代码库的记忆:某个工具函数放在哪里、某种错误状态已经封装过、哪个 Repository 有相似实现。AI 则更依赖当前上下文。只要相关文件没有进入上下文窗口,它就很容易把"没看到"理解成"并不存在"。
例如,仓库中可能已经有两个作用接近的函数:
dart
String formatUserLabel(User user) {
return '${user.firstName} ${user.lastName}';
}
String buildAuthorName(Author author) {
return '${author.firstName} ${author.lastName}';
}
函数名、参数类型和变量名都不一样,文本搜索很难直接判断它们是否重复。更麻烦的是,这两个实现一旦开始独立演进,后续处理空字符串、国际化姓名顺序或特殊字符时,很可能只修复其中一个。
Flutter 项目尤其容易出现这种情况。声明式 Widget 树中存在大量稳定骨架,Loading、Error、Empty、SafeArea、Dialog、BottomSheet 等结构会频繁出现;Repository 层又常见重试、异常转换、分页和 DTO 映射。每一段可能只有十几行,但生成次数多了,重复会像利息一样累积。
Deslop 的思路:比较语法结构,而不是表面文本
传统的文本查重对变量重命名、格式化变化和少量语句调整非常敏感。Deslop 选择先用 Tree-sitter 把 Dart 代码解析成 AST,再对语法树进行标准化。
标准化大致会做三件事:
- 将标识符统一替换为占位符;
- 将字符串、数字等常量替换为统一字面量;
- 忽略注释、空格等不影响语法结构的信息。
经过处理后,前面的两个函数都会呈现出相近的结构:函数声明、单个参数、字符串插值以及两次属性访问。也就是说,Deslop 关注的是代码的"骨架",而不是作者用了什么命名。
在标准化之后,它会组合多种信号寻找不同级别的重复。
| 重复类型 | 典型特征 | 主要检测方式 |
|---|---|---|
| Type-1 | 文本和结构几乎完全一致 | AST 子树指纹 |
| Type-2 | 变量名、函数名或常量不同 | 标准化 AST 后计算指纹 |
| Type-3 | 增删了少量语句,但主体相似 | k-gram、MinHash 与 LSH |
| Type-4 | 写法差异很大,但行为接近 | 可选的代码向量 |
对于完全或高度一致的子树,Deslop 使用 Merkle Hash 自底向上计算结构指纹。只要节点类型和子节点顺序一致,就能得到相同结果。
对于"改了一点"的代码,它会把 AST 节点序列切成 k-gram,再用 MinHash 压缩特征,并通过 LSH 快速召回候选。这样的设计不是直接宣布两段代码相同,而是先从大仓库中筛出值得进一步比较的少量候选。
语法差异更大时,还可以启用 Embedding,用向量相似度补充结构检测。不过语义向量并非默认开启,因为它的成本、稳定性和误报都需要额外权衡。
最终,结构匹配、Token 相似度和向量相似度会融合成候选分数,再通过聚类把相关代码归到同一组。这里有一个容易忽略的细节:如果 A 像 B、B 像 C,三者可能进入同一组,但 A 和 C 未必足够相似。因此,聚类结果是调查线索,不是自动重构指令。
真正重要的变化:从"提交后扫描"前移到"写代码前查询"
如果 Deslop 只在 CI 中运行,它仍然只是一个更懂 Dart 的静态分析工具。它最值得关注的能力,是通过 MCP 给 Coding Agent 提供查询入口。
理想的生成流程应该变成:
text
理解需求
↓
准备新增函数或类
↓
调用 find-similar 查询仓库索引
↓
阅读相似实现及其上下文
↓
选择复用、扩展,或确认后新建
↓
执行测试并提交
这相当于给 Agent 增加了一层"仓库记忆"。LLM 负责理解需求,结构检索负责回答"项目里是否已经有类似实现"。相比让模型先盲目 grep,再凭文件名和片段猜测,AST、Token 与可选向量信号更稳定,也更适合处理改名后的重复。
但安装 MCP 并不意味着 Agent 会主动使用它。团队仍然需要把调用时机写进规则,例如:创建新的函数、类、Parser、Route、Fixture 或 ViewModel 之前,先执行相似代码查询。工具解决的是能力问题,规则解决的是执行问题。
一套可落地的团队工作流
我更倾向于把重复治理拆成四层,而不是期待一个工具完成所有事情。
1. 建立基线
第一次在仓库中运行 CLI,先了解重复代码的存量和分布。不要急着一次性清零,而是优先处理体积大、出现次数多、维护频率高的重复组。排名的意义,是让团队先拿到真实收益。
2. 在开发过程中提供即时反馈
通过编辑器扩展或 LSP 增量更新结果,让开发者在当前文件附近就能看到相似实现。增量分析比每次扫描整个项目更适合日常开发,也能减少等待带来的使用阻力。
3. 把查询接入 Agent 生成循环
在项目的 Agent 规则中明确"先查后写",并要求 Agent 读取候选代码的完整上下文。分数较高时优先复用或抽取;处于中间区间时需要人工判断;相似度较低时再继续创建。阈值只能帮助排序,不能代替架构决策。
4. 用 CI 控制趋势
CI 更适合做长期门禁:生成报告、记录重复率变化,并在超过团队阈值时阻止继续恶化。与其要求历史重复立刻归零,不如先确保新增代码不会持续抬高基线。
可以把决策简化成下面这张表:
| 发现的关系 | 建议动作 |
|---|---|
| 同一领域、行为一致、变化原因相同 | 优先复用或抽取 |
| 结构相似,但上下文和职责不同 | 阅读调用链后再判断 |
| 跨平台、跨包或跨领域边界 | 通常保留独立实现 |
| 测试代码为了可读性而展开 | 谨慎抽象 |
| 性能敏感路径的专用实现 | 先验证性能再合并 |
重复并不天然等于坏设计
结构检测给出的只是证据,而不是结论。
价格格式化和重量格式化现在都可能只是 value.toStringAsFixed(2),但它们的变化原因完全不同:价格未来可能涉及币种和精度,重量则可能涉及单位换算。此时抽成一个通用的 formatNumber(),看似消除了重复,实际上抹掉了领域语义。
Flutter 项目中还有一些合理重复:iOS 与 Android 的平台实现需要隔离;多个页面当前拥有相同 Widget 骨架,但交互正在分化;不同 package 需要独立发布;测试中的 Arrange/Act/Assert 刻意展开以保证单个用例可读;性能关键循环也可能为了避免额外分发而保留专用实现。
判断是否应该合并,可以问三个问题:
- 两段代码是否属于同一个业务概念?
- 它们未来是否会因为同一种原因发生变化?
- 抽象之后,依赖关系是否比现在更清晰?
只有三个答案都偏向"是",重构才大概率有价值。
AI 编程需要的不只是更强的模型
Deslop 带来的启发并不限于 Flutter。随着生成代码的速度上升,工程系统必须补上模型缺失的全局视野。
一个成熟的 AI Coding 流程,不能只有 Prompt 和生成按钮,还需要仓库检索、规则约束、静态分析、测试验证和人工决策。模型擅长快速给出局部实现,工具擅长提供稳定的全局证据,开发者则负责判断领域边界和长期演进方向。
所以,解决 AI 重复代码的关键不是在 Review 阶段反复提醒"不要重复",而是改变生成路径:在新代码出现之前,先让 Agent 知道仓库里已经有什么。
当"先查再写"成为默认动作,AI 才不只是一个写得更快的代码生成器,而会逐渐成为真正理解项目约束的协作者。