别让 AI 把仓库写成“代码平行宇宙”:从 Deslop 看生成前查重

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 刻意展开以保证单个用例可读;性能关键循环也可能为了避免额外分发而保留专用实现。

判断是否应该合并,可以问三个问题:

  1. 两段代码是否属于同一个业务概念?
  2. 它们未来是否会因为同一种原因发生变化?
  3. 抽象之后,依赖关系是否比现在更清晰?

只有三个答案都偏向"是",重构才大概率有价值。

AI 编程需要的不只是更强的模型

Deslop 带来的启发并不限于 Flutter。随着生成代码的速度上升,工程系统必须补上模型缺失的全局视野。

一个成熟的 AI Coding 流程,不能只有 Prompt 和生成按钮,还需要仓库检索、规则约束、静态分析、测试验证和人工决策。模型擅长快速给出局部实现,工具擅长提供稳定的全局证据,开发者则负责判断领域边界和长期演进方向。

所以,解决 AI 重复代码的关键不是在 Review 阶段反复提醒"不要重复",而是改变生成路径:在新代码出现之前,先让 Agent 知道仓库里已经有什么。

当"先查再写"成为默认动作,AI 才不只是一个写得更快的代码生成器,而会逐渐成为真正理解项目约束的协作者。

参考

相关推荐
啵啵啵鱼1 小时前
DS-顺序表
java·数据结构·笔记·后端·链表·obsidian
nice先生的狂想曲1 小时前
javascript高级程序设计(六)——2026.9.5
前端·javascript
回家吃饭去吧1 小时前
AI Agent Function Calling / Tool Use 深度解析
后端
光影少年1 小时前
如何做大型React+RN 项目架构设计、目录规范
前端·react native·react.js
大黄评测1 小时前
Oracle 锁等待、会话阻塞故障复盘:生产事故排查步骤
后端
一位正在转型AI全栈的前端工程师1 小时前
AI 全栈学习之旅 -Week 8:从单 Agent 到多 Agent 协作:LangGraph 实战与记忆持久化
前端·python
用户921080262861 小时前
Promise 和 async/await:从用途到执行机制
前端
大勇前进2 小时前
Oracle 慢 SQL 优化避坑:索引建了为什么依然走全表扫描
后端
良在掘金542232 小时前
匹配服排行榜位置原子交换与锁粒度设计
后端