同一个功能,AI 前后生成了三份实现,怎么发现和合并

2026-07-28

内容截至 2026-07。文中命令基于 git 与 Python 3 标准库,各参数的具体行为以你所用版本的官方文档为准;不同代码助手产品的能力边界差异较大,涉及产品行为的部分以官方最新说明为准。

多数人把重复实现归因成「模型记不住我之前写过什么」,这个归因会把你带上错误的修法:去写更长的说明、去塞更多文件进上下文。更常见的真实原因是,你的任务描述里从来没有「先找现有实现」这一步,而仓库本身也不具备让它找得到的条件。 代码助手不会主动怀疑轮子已经存在,它默认你要的是新代码;只要没人要求它检索,它就直接写。于是三个月下来,金额格式化在 utils/ 有一份、在组件目录里有一份、在某个 hook 里还内联了一份,三份的舍入行为还不一样。

这篇只管一件事:同一功能长出多份实现之后,怎么按顺序查出来、怎么合并掉、怎么让它别再长。它和另外两篇是分工关系——一次任务改动面失控怎么收讲的是单次任务里手伸太长,属于事中控制;AI 项目的技术债讲的是选型层面的代价,属于决策层面;本篇处理的是跨会话、跨周次积累出来的语义级重复,是清理和预防。

一、先分因:三份实现是怎么长出来的

不要一上来就搜重复代码。先判断重复是哪种来路,因为来路不同,处置动作完全不同——有的该合并,有的该先改设计,有的根本不该动。

命名漂移。同一个语义,三次生成用了三个名字:formatPricepriceToTextrenderAmount。搜函数名搜不到,搜关键字(toFixedIntl.NumberFormat、货币符号)才能撞上。这类占我见过的重复里最大一块,因为模型每次都在给「这次的语境」起名,而不是在延续仓库的命名习惯。

位置漂移。仓库里同时有 utils/helpers/lib/common/,模型没有依据判断该放哪,就近新建。你会发现三份实现代码几乎一样,只是所在目录不同。这不是模型的问题,是目录语义本身没有唯一答案。

并行会话。你开了几个工作树同时跑不同任务,两个任务都需要同一个小工具函数,各写一份。这种重复往前追溯到 git 历史,会看到两个新增几乎同时落地,互相不知道对方存在。并发引起的其他连带问题见多会话并发改同一份代码

接口分裂。已有实现签名不好用(参数顺序别扭、返回值带副作用、耦合了某个全局配置),模型不敢改老的,就写个新的绕过去。这类重复最危险,因为两份都在被真实调用,行为还有细微差异。它也是唯一一种「重复其实是在向你反馈设计问题」的情况。

再补一种伪重复:测试里为了断言方便,把业务逻辑照抄了一遍。它看起来像重复,但合并掉往往会让测试失去独立性。判断标准是——如果生产实现写错了,这份抄写会不会一起错?会,就是真重复;不会,就留着。

二、判别表:现象到动作

现象大概率成因怎么验证处置动作
三份代码几乎逐行相同,只是文件路径不同位置漂移git log --diff-filter=A --name-only 看新增时间是否分散在不同周定一个唯一落点目录并写进仓库约定,其余两份改成薄壳再删
名字完全不同、实现思路也不同,但输入输出等价命名漂移用关键字族而非函数名搜;对可疑函数跑同一组输入比对输出保留边界处理最全的那份,其余按调用点逐个替换
两份实现新增时间挨得很近,作者/分支不同并行会话git log --since--name-status 对齐时间线立刻合并,不要等;同时把共享层的改动收成串行
老实现还在被调用,新实现只在新代码里用,两者行为有差异接口分裂对两份跑同一批边界输入(空值、负数、超大值、多语言)先补测试固定住老行为,再决定改老的还是废老的
只有测试文件里存在第二份伪重复假设生产实现引入一个错误,看这份抄写是否也跟着错一起错就合并,不会错就保留
搜出来一堆相似但语义确实不同(不同币种、不同精度要求)需求本来就不同逐份找出它的调用方,问清业务规则不合并,改成参数化的同一族函数,或干脆各自留着并加注释说明差异

表里最后一行经常被忽略。把语义不同的实现强行合并,会造出一个塞满 if 分支的万能函数,比原来三份更难维护。

三、发现:按成本从低到高排,别一步到位

第一步查 git,不查代码。 新增文件的时间分布几乎能直接告诉你成因:

# 近三个月新增的文件,按提交倒序
git log --diff-filter=A --since="3 months ago" --name-only --pretty=format:"%h %ad %s" --date=short

如果同名语义的文件分散在很多次提交里,是命名或位置漂移;如果集中在相邻两三天,大概是并行会话撞车。

第二步搜关键字族,不搜函数名。 函数名不可靠,实现里的特征串可靠。挑三到五个「这个功能绕不开的调用」当搜索锚点:

# 以金额格式化为例,搜实现特征而不是函数名
git grep -n -E "toFixed\(|Intl\.NumberFormat|round\(.*100"

第三步做相似度粗筛。 不需要专门工具,标准库够用,但有三处细节不注意就会白跑一遍。下面这段先把函数解析成语法树,抹掉标识符和字符串字面量,再两两算相似度:

import ast
import difflib
import pathlib
import re

MIN_LEN = 200        # 结构串长度下限,不是源码行数;一行转发函数大概在一百多
LEN_RATIO = 0.7      # 长度差太多的一对,相似度不可能过阈值,直接不算
THRESHOLD = 0.85

def shape(node):
    """抹掉标识符与字符串字面量,只留结构——这样命名漂移才拦得住。"""
    dump = ast.dump(node, annotate_fields=False)
    return re.sub(r"'[^']*'", "'_'", dump)

funcs = []
for path in pathlib.Path("src").rglob("*.py"):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    for node in ast.walk(tree):
        # AsyncFunctionDef 不是 FunctionDef 的子类,漏写就查不到 async 实现
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            s = shape(node)
            if len(s) >= MIN_LEN:
                funcs.append((f"{path}:{node.name}", s))

funcs.sort(key=lambda x: len(x[1]))
for i in range(len(funcs)):
    for j in range(i + 1, len(funcs)):
        if len(funcs[i][1]) < len(funcs[j][1]) * LEN_RATIO:
            break        # 已按长度排序,后面只会更长,不必再比
        # autojunk 默认对长序列启用启发式,会把高频字符当噪声,相似度被压低
        ratio = difflib.SequenceMatcher(None, funcs[i][1], funcs[j][1],
                                        autojunk=False).ratio()
        if ratio > THRESHOLD:
            print(round(ratio, 3), funcs[i][0], "<->", funcs[j][0])

三处细节值得单独说,因为它们各自对应一类漏检或一次白等:

一是必须抹掉标识符ast.dump 的输出里带着函数名、参数名、变量名和字符串常量,而本文第一类重复恰恰是命名漂移。不做替换时,两份逻辑完全一样、只是变量名不同的实现相似度会被拉低,函数体越短掉得越狠(短序列里标识符占的比重更大)——你最想抓的那类重复反而最容易漏掉。抹掉之后,结构完全一致的两份会直接落在 1.0,只差一两个字面量的会落在 0.99 上下,和噪声拉开明显距离。数字字面量这里没抹,因为精度、进制、魔数上的差异往往正是你要看的行为差异,抹掉反而会把语义不同的实现判成同一份。

二是这个双循环是 O(n²),不是几秒钟能跑完的量级。SequenceMatcher 在几百字符的序列上单对大约几毫秒,一千个候选函数是接近五十万对,不做预筛就是几十分钟起步,中途你还看不到任何进度。所以长度预筛不是可选优化,是能不能跑完的前提:按结构串长度排序后,长度比低于 0.7 的直接 break,通常能砍掉大部分组合。真跑之前先 print(len(funcs)) 看候选量,量大就先按目录分批跑。

三是两个阈值都要按你的环境校准,不要照抄MIN_LEN 量的是结构串长度,不是源码行数,而 ast.dump 的输出格式在不同 Python 版本间有出入(比如新版本会多带一些默认为空的字段),同一个一行函数在不同版本上的长度并不一样。校准办法很直接:先只 print 出每个函数的名字和 len(shape(node)),看一眼你想排除的那类小函数落在什么区间,再把下限定在它上面。相似度阈值同理——拿一对你已经确知重复的函数跑一下看得多少分,再把阈值放低一点点。0.85 只是起点,太低会淹在噪声里,同一种循环加判断的骨架,业务上毫无关系的函数也能撞到 0.8 以上。

其他语言同理:把 ast 换成对应的解析器,拿不到语法树就退化成「去掉空白、注释,把标识符统一替换成单字符」后的文本比对,效果会差些但方向不变。粗筛的目标不是精确,是把全仓库缩到几十对候选,交给后面两步。

第四步查调用点,这一步决定合并顺序。 每份实现被谁引用、引用了多少处,直接决定谁当主实现、改起来多疼:

# -w 加词边界,否则 formatPriceList 这类同前缀函数会混进来
git grep -n -w "formatPrice" -- "*.ts" "*.tsx" | awk -F: '{print $1}' | sort | uniq -c | sort -rn

看的不是总数而是分布:引用集中在两三个文件里,替换是半小时的活;散在三十个文件里,就得按目录分批,并且要先想清楚中间状态怎么保持可发布。注意这里数出来的行也包含定义处和 import 语句,看量级和分布足够,不必去掉。

第五步才让 AI 参与。 把上面筛出来的候选清单交给它做语义比对,让它逐对回答三个问题:输入输出是否等价、边界行为差在哪、能否用同一签名覆盖。这一步它比人快,前提是候选清单由你提供——直接问「帮我找出仓库里的重复代码」,在稍大的仓库上得到的答案不可信,原因见大仓库上下文不足怎么办

四、合并:选主、过渡、删除,一步一个提交

选主实现看四条。 被测试覆盖的优先;被最多调用点依赖的优先;边界处理最全的优先(空值、极端值、时区、多语言);外部依赖最少的优先。四条冲突时以第一条为准——有测试的那份,你至少知道它现在的行为是什么。

合并前先补测试。 顺序不能反。把主实现和待废实现的行为差异写成断言,包括你打算保留的和打算改掉的。没有这层,替换调用点时任何行为变化都会静默流到生产。

过渡用薄壳,不要一次性删。 把待废实现的函数体换成对主实现的转发,行为差异在薄壳里做适配,然后加一行注释标记它是过渡层。这样调用点可以分批改,每批独立验证。全部改完再删薄壳。

一次提交只做一件事。 拆成「补测试」「加薄壳」「替换调用点(可以按目录分几次)」「删薄壳」。混在一个大提交里,出问题你没法二分定位。这类重构交给 AI 时要明确限定改动面,否则很容易顺手重排周边代码,那就变成了另一个问题。

替换调用点适合交给 AI,删除决策不适合。 机械替换它做得又快又准;但「这份能不能删」依赖运行时信息(有没有反射调用、动态导入、配置里写的字符串路径),它看不全。删之前自己确认一遍动态引用:

# Python 侧的反射与动态导入
git grep -n -E "importlib|__import__|getattr\(|globals\(\)\[" -- src
# JS/TS 侧的动态导入与拼接 require
git grep -n -E "import\(|require\(.*\+" -- src

这两条只能提高信心,不能证明没人用。类型注解里的 import('x') 也会命中,属于正常噪声,逐条看一眼就行。真正查不到的是配置文件、数据库或环境变量里存着的函数名与模块路径——这部分只能靠你知道项目有没有这种机制,光靠搜代码搜不出来。

五、什么情况下别再折腾

止损点一:合并成本超过重写成本。 如果三份实现的行为差异你花了半天还没理清,说明这个功能的规则本身没定清楚。这时候正确动作是先把规则写下来(输入范围、舍入方式、异常时返回什么),然后基于规则重写一份,三份全废。理规则比对代码便宜。

止损点二:调用点超过你能一次验证的量。 替换到一半发现调用点比预估多出一大截,别硬推。停在当前状态——薄壳转发是可以长期存在的合法状态,比半改半没改的调用点安全得多。剩下的排进后续迭代。

回滚点:只要主实现的行为在替换过程中被改动过,就回滚。 合并重构的红线是行为不变。一旦你为了「兼容那两份」而修改了主实现的逻辑,这次改动就同时包含了重构和行为变更,出问题无法归因。git revert 掉,重新分成两次做:先只搬调用点,再单独提一次行为变更。

换条路:语义不同就别合并。 前面表里说过,不同币种、不同精度、不同业务口径的实现,硬塞进一个函数只会把复杂度从三个文件挪进一个文件的分支里。改成同一族的显式命名函数(各自短小、共享底层原语),比一个万能函数好维护。

还有一种情况直接停手:这段代码半年内会整体下线。 给要删的模块做重构是纯亏损。确认一下路线图再动手。

六、避坑清单

只搜函数名,漏掉一大半。 会踩是因为人的思维习惯是按名字找东西,而模型每次都在重新起名。避法:搜实现特征串(关键 API 调用、魔数、正则),把函数名当最后一道补充。

用 AI 直接扫全仓库找重复,然后信了结果。 会踩是因为它给出的清单看起来很像样,但在超出上下文能覆盖的仓库上它只看到了片段,漏报不会告诉你。避法:粗筛用脚本(确定性、可复现),语义判断才交给它,并且要它给出每一对的判断依据。

先删后测。 会踩是因为删掉之后代码能跑、测试也过——那份实现的调用点可能全在你没覆盖到的路径上。避法:删除前先把它改成只打一行日志再转发的版本(这是第四节薄壳的最后一段用法:调用点已经全部替换完,薄壳留着只为验证真的没人再走这条路),观察一轮完整业务周期,日志一次没出再删。有灰度环境的先在灰度上观察;没有的话,至少让这个日志版本跟着一次完整发布走一遍,别在同一次改动里既加壳又删壳。

把行为差异当 bug 顺手改掉。 会踩是因为看到两份不一致,本能是「统一成对的那个」。但另一份的行为可能正是某个调用方依赖的。避法:差异一律先写成测试记录下来,改不改单独决策、单独提交。

合并完不修约定,两个月后重新长出来。 会踩是因为清理是一次性动作,而生成是持续动作。避法:把「这类工具函数唯一落点在哪、命名前缀是什么、新增前必须先搜哪几个关键字」写进仓库的说明文件,让每次生成都读得到,写法参考仓库说明文件怎么写。约定要具体到目录路径和命名规则,写「注意复用」等于没写。

并行任务共享同一个底层模块。 会踩是因为任务拆分时只看功能边界,没看共享依赖。避法:拆并行任务前先确认它们要碰的公共文件;有重叠的部分自己先写好、串行落地,再放并行任务出去。

给 AI 的任务里不带「先检索」。 会踩是因为你觉得这是常识。避法:把检索写成任务的第一步,并且指定搜什么——「先用这几个关键字搜现有实现,找到就改它、找不到再新建,并说明搜了什么」。带上「说明搜了什么」这一句,你能立刻看出它是不是真搜了。

收束

重复实现的成本不在多出的那几十行代码,在于将来某天你改了一份、漏了两份,而线上表现出的不一致要靠用户投诉才被发现。所以处理顺序永远是:先查 git 定成因,再筛候选定范围,再补测试定行为,最后才动手合并;合并期间行为不变是红线,破了就回滚。

动手前过一遍自检:

  • 我知道这次重复是哪种来路(命名 / 位置 / 并发 / 接口分裂 / 语义本不同)了吗?
  • 候选清单是脚本筛出来的,还是模型说的?
  • 主实现选定的依据是「有测试」还是「看起来干净」?
  • 行为差异是否已经写成断言、并且和这次重构分开提交?
  • 删除前是否确认过动态引用和反射调用?
  • 仓库约定改了吗——不改,两个月后你会再做一遍这件事。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。