欢迎光临
我们一直在努力

AI 写的代码越来越难改:四类退化与能拦住的规则

说明:本文讨论的是 AI 参与编码后代码形态的退化与拦截规则,属于代码质量与工程规范话题,不涉及具体模型版本与价格。AI 领域版本迭代极快,凡涉及版本号、价格、可用性,请以你阅读时的官方页面为准。文中代码为结构示意,请按自己的技术栈调整后再上生产。

一、能跑和能改是两件事

交付变快之后,维护成本被挪到了后面

一个功能从提需求到合入,时间确实短了。但"合入"验证的仍然是一次输入对应一次正确输出。

"能跑"是一次性验收:输入对、测试过、合入,成本当场结清。"能改"是长期成本:三个月后要加个条件,需要读几个文件、动几处调用、重新验证多大范围。它不在合入时结算,而是在每次后续需求里分期付款。

混为一谈的典型表现,是评审时看到新增两百行、单测通过、功能正确就放过。这两百行是新建了模块,还是在三个相似模块旁加了第四个,diff 里看不出来——它只显示"加了什么",不显示"仓库里有什么"。

维度能跑(一次性验收)能改(长期成本)
判定时机 合入前 每一次后续改动
判据 输入输出正确、测试通过 改一个需求要碰几个文件、理解几个分支
谁承担 提交者 下一个接手的人
出问题的表现 报错、结果不对 不敢改、改漏、改出新 bug
成本曲线 平坦 随重复次数上升

三个可观察信号

信号一:同一功能有多个实现。 不是"相似",是同一个语义概念有 N 份代码。判据很硬:你能同时说出两个文件的路径加函数名,它们做同一件事,且对边界输入的处理不一致。

信号二:改一处要改五处。 挑最近一个需求,数它动了几个文件,再数其中几个是"因为同一个概念散落在多处"才被迫一起改的。后者占比高,说明修改半径被结构放大了,跟需求本身无关。

信号三:没人敢删某段代码。 特征是没测试、没注释说明用途、搜不到调用方,但也没人敢删。它往往不是死代码,而是"不确定是不是死代码",这个不确定每次都要绕一遍。

本文的边界

不讲两件事。一是不讨论"要不要用 AI 写代码",这取决于团队和任务类型。二是不讨论审查流程怎么组织、门禁怎么设阈值,那是流程设计的话题。

本文只讲代码形态会以哪几种方式退化,以及哪些规则能在合入前拦住它。全文对"退化"的定义统一为——代码仍然能跑、测试仍然通过,但下一次修改的成本比上一次高。

二、四类典型退化

重复实现:同一个工具函数被写三遍

⚠️ 代码待验证

# utils/text.py
def slugify(s: str) –> str:
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")

# api/helpers.py
def to_slug(s: str) –> str:
return re.sub(r"\\s+", "-", s.strip().lower())

# jobs/common.py
def slug(s: str) –> str:
out = re.sub(r"[^\\w]+", "-", s.lower())
return out.strip("-") or "untitled"

三个版本有三处差异:连续分隔符(第二个会留下 a–b)、非 ASCII(第三个的 \\w 保留中文,前两个丢弃)、空结果(只有第三个给默认值)。同一个标题从三个入口进来得到三种 key,下游按 key 去重就会漏。

症状:同一个 bug 修了三次还没干净;或者修了一次,另外两个入口过两周又冒出来。

怎么在 diff 里认出来:新增函数与已有函数不同名但同义(slug / to_slug / slugify),参数表几乎一样。重点看新建文件的第一个函数,重复实现多数出现在那里,因为那是补全最顺手的位置。

修复成本:合并半小时,成本在行为对齐。先把三个版本对同一批输入的差异列成表,按最严格的一版收敛,再补一条覆盖差异点的测试。跳过这步直接替换,会改掉"某个入口原本保留中文"这类隐性行为,通常等线上脏数据出现才发现。

绕开抽象:SQL 拼在业务代码里

需求是给订单列表加一个按租户过滤的条件。已有 OrderRepository.list_by_tenant(),但新代码在 service 层直接写了 session.execute(text("SELECT * FROM orders WHERE tenant_id = :t"))。

它能跑,而且跑得对。问题在于仓储层统一做的几件事——软删过滤、分页上限、字段脱敏、租户校验——这段 SQL 一件都没做。三个月后有人给仓储层加了"已归档订单不返回",这里不会跟着变。

同类形态还有:绕过配置层直接 os.environ["DB_URL"],绕过 HTTP 封装直接 requests.get,绕过日志封装直接 print。共同点是目标模块就在旁边,但用起来比绕过去多一步。

症状:同一个约束在十个地方各写一遍,改约束时漏掉两三处。

怎么在 diff 里认出来:看新增代码的 import。diff 里出现本该只属于底层模块的 import(数据库驱动、os、requests),而文件在上层目录,基本就是绕开。其次搜关键字:execute(、text("、os.environ 出现在 services/ 和 api/ 下。

修复成本:中。多数情况不是懒,而是抽象层缺一个方法——原来那个签名不满足新需求。所以先补抽象,再改调用点,最后加一条"禁止跨层 import"的 lint 防回归。只改调用点不补抽象,下一个人还会再绕一次。

注释与实现不符:注释说重试三次,代码里是一次

注释写着"失败重试三次,指数退避",代码是 for attempt in range(1)——只跑一次,退避参数从头到尾没被读过。写的人可能本来打算重试,为了先跑通把循环改小了,注释没跟着改。

杀伤力在于测试发现不了:一次调用成功时重试逻辑本来就不执行,测试全绿。

另一种高频形态是 docstring 与异常行为不符:写 Returns None if not found,实现是 raise KeyError。下游按 None 判空,第一次遇到不存在的记录就抛 500。

症状:读代码的人按注释做决策,然后在某个边界条件下被推翻。

怎么在 diff 里认出来:两种可疑组合——注释行变了、代码行没变;或者代码行变了、同块注释一行没动。前者容易发现,后者才是主流。可操作的判据是:注释里出现数字、次数、顺序、单位,就当成可执行断言,代码里必须能指到对应那一行,指不到就是错的。

修复成本:改字符的成本是零,风险却不低。错的注释比没有注释更贵,因为它会让人跳过验证。规则定得很简单:改动行为必须同时改注释,两者在同一个 commit 里。

依赖膨胀:为一个字符串函数引入一个大包

为了把 1234567 格式化成 1.2 MB,引入一个通用格式化库;为了一个 debounce,引入一整套工具函数集合;为了一个日期解析,引入一个带时区数据库的包。

症状:安装体积和 CI 安装时间一起涨,本地偶尔因为传递依赖冲突装不上。

怎么在 diff 里认出来:三个便宜的看法。一看 lockfile 增量行数——轻量包几十行,带传递依赖的几百行。二看 import——整个仓库只用了一个符号,而包在依赖树里有几十个模块。三看新增依赖的理由,写"方便"的基本可以打回。

修复成本:装的时候一行,拆的时候一轮。移除要走完整回归,因为它可能被间接依赖着。这个不对称正是依赖膨胀难收的原因:引入的决策成本和移除的决策成本差一个数量级。

退化类型线上症状在 diff 里怎么认修复成本
重复实现 同一 bug 反复出现,修一处漏两处 新建文件开头的同义函数,参数表雷同 低,但行为对齐要额外一轮
绕开抽象 统一约束在个别路径上失效 上层目录出现底层模块的 import 中,需先补抽象再改调用点
注释与实现不符 边界条件下行为与文档相反 注释与代码只改了一边 低改动,高风险
依赖膨胀 安装变慢、传递依赖冲突 lockfile 增量大、只用一个符号 引入便宜、移除昂贵

三、为什么会长成这样

补全的局部最优

生成侧看到的世界是"当前上下文里的这些文件"。它不知道 utils/text.py 里已经有一个 slugify,因为那个文件没被打开,也没被检索进来。在它能看到的范围里,新建一个函数成本最低——不需要理解已有实现的行为差异,也不需要判断该放哪一层。

它每次都给出局部最优,而局部最优累积起来就是全局最差。

抽象在上下文之外

仓库一大,抽象层就飘出上下文窗口。两种表现:抽象层文件太大(一个 utils 模块几十个函数),检索命中后被截断,只看到前十几个;或者命名与调用点的用词对不上(抽象叫 normalize_identifier,需求里说的是"生成短链"),根本命中不了。

第二种是词汇问题:生成侧按需求里的词去找,找不到就新建,同一个概念慢慢长出两套叫法。

"先跑通"成为默认验收

当验收标准只有"跑通",所有结构问题都被判为不阻塞。没人反对"先跑通",因为在单次交付的视角下它确实最优。问题在于"先"字没有兑现——临时方案一旦合入就成了既有实现,下一次生成会把它当可复用的东西继续引用。

评审只看 diff 不看调用方

单个 diff 常常是合理的:新增一个工具函数,逻辑清晰,有测试。问题出在它与仓库其余部分的关系上,而 diff 恰好不展示这层关系。评审者只在本页内判断,重复实现永远看不出来。

退化类型生成侧成因拦截侧手段
重复实现 已有实现不在上下文里,新建比检索便宜 先搜后写;重复代码检测;结构自检清单
绕开抽象 抽象层文件过大被截断,或命名与需求用词不一致 跨层 import 检查;影响面检查;规则文件
注释与实现不符 改代码时注释不在同一段上下文,未被一并改写 数量词断言规则;同一 commit 检查
依赖膨胀 单函数需求被映射到"有个库能直接做" 依赖白名单;体积阈值;替代方案说明

四、评审侧能拦的结构性规则

三个新增,单独过一遍

评审先不看风格,只看三类新增:新增文件、新增依赖、新增抽象层。每一类都代表一次结构决策,一旦合入很难回退。

对每一类只问一句:为什么不能放在已有位置。答得出来(原模块职责确实不同、依赖确实没有替代),过;答不出来,回去改。这条检查把"我加了个东西"变成了"我判断过不该加在别处",判断过程写进 PR 描述,评审者不用翻仓库也能看到提交者搜过没。

影响面:这个改动动了谁

改公开函数、改数据结构、改接口返回,这三类必须先数调用方。不是估,是数。

判据定得具体一些:改函数签名时,PR 描述里要写调用方数量;超过五个就拆两步——先加带默认值的新参数,发一版,再删旧参数。这样每一步的回归范围都可控。

反过来,只动私有函数、只在一个文件内生效的改动,写一句"仅文件内调用"即可。

重复实现:先搜后写

新写函数之前先搜三样:同名(含常见前后缀)、同签名(参数个数与类型)、同用途(按业务词汇搜,不按函数名搜)。

⚠️ 代码待验证

# 1. 按名字搜:同义实现往往只差前后缀
rg -n "def (to_|make_|get_)?slug" –type py

# 2. 按签名搜:参数与返回类型一致,基本就是同一个东西
rg -n "def \\w+\\(.*str.*\\)\\s*->\\s*str" –type py

# 3. 按调用点反查:改公开函数前先数调用方
rg -n "slugify\\(" –type py -c

第三条在改签名时尤其有用,-c 按文件给出命中次数,一眼看出这个函数被多少文件依赖。搜完把结果写进 PR 描述,一行就够:"已搜 slug / to_slug,仓库内无同类实现。"这不是自证清白,是告诉评审者该看哪里。

PR 描述里的结构自检清单

把上面几条固化成表,提交时逐行填,空着的行就是没检查的行。

检查项填什么不通过时怎么办
新增文件 文件路径 + 为什么不放已有模块 放回已有模块,或说明职责差异
新增依赖 包名 + 用途 + 不引入的替代方案 能本地实现就本地实现
新增抽象层 层名 + 谁会用它 只有一个调用方就先不抽象
调用方影响 改动的公开函数 + 调用方数量 超过五个拆两步走
重复实现 搜过的关键词 + 结论 命中就复用,不复用要写理由
注释同步 本次是否改了行为 改了行为就同步改注释
修改半径 本次动了几处 + 其中几处因概念分散 超过一半就记一笔债

这张表的价值不在"填",在于填不出来时你会停下来想一下。

五、工具侧能拦的卡点

评审靠人,人会累、会赶时间,能机器判的尽量交给机器。这一节四类卡点都能在 CI 里跑出二值结论。

重复代码检测:阈值与作用范围

两种做法。按 token 序列比对:源码切成 token 做滑动窗口哈希,窗口相同即为重复。不依赖解析器,但重命名后仍会命中,也会跨语言误判(Python 与 SQL 的字符串字面量长得一样)。按语法树比对:解析成 AST,对子树做哈希,更准,但每种语言都要有解析器。

起步阶段用 token 方案就够,重点是两个参数:最小 token 数和相似度阈值。前者控制颗粒度,设太小会把 if not x: return None 这类惯用写法全抓出来,设太大则漏掉真正的重复块。阈值从 0.85 起比较稳,低于这个值多半是"同一类写法"而非"同一段逻辑"。

作用范围必须限定在本次改动涉及的文件,否则第一次上线会一次性报出几百条历史重复,没人会看第二眼。

误报怎么处理:不调阈值去压,而是把合理重复的片段登记进忽略清单并写明原因(比如"分属两个服务的协议编解码,需要独立演进")。清单进版本库、跟着评审;新出现的重复不许直接进清单——进清单等于永久豁免。

依赖白名单与体积阈值

新增依赖必须过三道:包名在白名单内或经人工确认、PR 描述里写了用途与替代方案、增量体积在阈值内。

体积按 lockfile 的增量行数算,不按安装后的字节数——行数在任何 CI 环境都能算出来,字节数会受平台和版本影响。阈值起步可以设两百行左右,超过就要求人工确认。确认不用做成流程节点,在 PR 里回一句"确认引入,原因是 X"就够。

复杂度与嵌套深度:只对新改动生效

这两个指标如果对存量全量跑,第一天就有几百个文件超阈值,然后所有人学会忽略这条检查。

正确做法是增量:只对本次 diff 里改动过的函数计算。存量问题不阻塞任何人,新增问题一定会被看到。实现上不用自己写,拿 diff 的文件行号范围去过滤静态分析结果即可。

嵌套深度比圈复杂度更好用。圈复杂度高的函数有时确实无法拆分(一个大 switch),而嵌套超过四层的函数几乎总能靠提前 return 拆开。

类型检查与 lint 要分两级

所有规则都设成"阻断",团队会想办法绕过;都设成"提示",等于没设。

阻断级:跨层 import、service 层出现裸 SQL、config 之外读环境变量、未在依赖清单里的 import、类型检查的硬错误。

提示级:函数长度、参数个数、注释中的数量词与实现不符、新增文件的命名风格。

分级标准只有一条:这条规则能不能给出确定的错,而不是"可能不好"。跨层 import 是确定的错;函数长两百行只是可能不好。

⚠️ 代码待验证

structural_checks:
duplicate_code:
min_tokens: 60
similarity: 0.85
scope: changed_files
on_hit: block
dependencies:
whitelist: .ci/deps–allow.txt
lockfile_growth_lines: 200
require_reason: true
on_hit: block
complexity:
max_cyclomatic: 15
max_nesting: 4
scope: changed_files
on_hit: warn
lint:
block_rules:
– no–cross–layer–import
– no–raw–sql–in–service
– no–env–read–outside–config
warn_rules:
– max–function–lines
– max–params

最关键的是两处 scope: changed_files:它决定这套检查是"每天报一堆历史问题"还是"每次只报新增问题"。

完整版资料清单:本文用到的结构自检清单与 CI 规则模板都整理在里面了,扫码即可获取:

配套资料获取

六、重构节奏与债的记账

每 N 次功能改动,做一次结构回看

每完成五到八次功能改动,安排一次结构回看。时间盒九十分钟,范围只限本周期新增和改动的文件,只做三件事——合并这期间长出来的重复实现、收敛绕开的抽象点、删掉确认没人用的代码。

不要在这时候做大重构。结构回看的目的是阻止恶化,不是改善存量。一旦允许它动存量,就会变成"顺手重构",然后拖期、被砍,连回看这个动作本身也被砍掉。

九十分钟到了就停,没做完的记成债——这比做了多少更重要。

债要登记,不登记等于没欠

口头说"这个以后要改"的债,一周内会忘干净。登记只要四个字段,写在仓库里的一个 json 文件里,跟代码一起评审。

⚠️ 代码待验证

{
"id": "debt-20261014-order-filter",
"location": "services/order_service.py:142",
"why": "仓储层缺按租户与状态分页的方法,先直连数据库交付",
"fix_cost": "4h,涉及 3 个调用方",
"triggers": ["touch_same_file", "second_duplicate"],
"triggered_count": 0,
"escalate_after": 2
}

四个字段里最容易被省掉的是 why,而它最重要。半年后看到这段绕开抽象的代码,只写"待重构",没人知道能不能直接改回去——是抽象层缺方法,还是因为性能,还是当时有临时需求。原因写清楚,修复方案基本就自明了。

fix_cost 要写具体,"4h,涉及 3 个调用方"比"中等"有用,它决定这笔债什么时候值得还。

三种触发条件,两次就升级

一、下次改同一文件。 任何人下次动这个文件,先还债再改需求。这条最有效,因为改的人正好有上下文,还债的边际成本最低。

二、出现第二个重复实现。 第一次重复可以登记为债,第二次出现就是信号:说明这个概念还会被继续复制。这时不能再挂账,必须当次合并。

三、有人因为读不懂而出错。 包括改错、理解偏差、照着注释做出错误判断。这条必须当天处理——补注释或重构,不能"下次再说",因为下一次出错的人还是读不懂。

超过两次触发仍未处理的债,升级:不再挂在债表里,直接排进迭代计划。两次不是科学取值,是给一个明确、不会无限拖延的上限。

七、度量与团队约定

四个指标,够用

指标多了一定没人看,四个足够覆盖前面四类退化。

指标定义怎么读
重复率 重复块行数 / 总行数,按目录统计 看季度趋势,不看单点
新增依赖数 单次迭代新增的直接依赖条数 配合"用途已写"比例一起看
绕过次数 lint 命中的跨层调用数量 按目录分布,比总数有用
修改半径 一次需求改动的文件数中位数 用中位数,均值会被大需求拉偏

指标只做趋势,不做考核

这四个数一旦进了考核,就会以意想不到的方式被优化:重复率高了,就把重复代码拆成两份看起来不一样的;修改半径大了,就把一个需求拆成三个 PR 提交;绕过次数多了,就把 lint 规则关掉。

正确用法是季度看一次方向:重复率是不是在涨、修改半径是不是在变大。涨了就翻那段时间的 PR,看是哪类改动带起来的,而不是去问责某个人。

约定写成规则文件,放进仓库

规则写在文档系统里,人和生成侧都读不到。写成仓库根目录下的一个文件,两边都能读:人评审时对照,生成侧在上下文里看到就直接遵守。

⚠️ 代码待验证

# .ai/rules.yaml
naming:
slug: utils/text.py::slugify # 此类转换全仓库只有一个实现
layers:
allowed:
api: [service]
service: [repository]
repository: [db]
forbidden:
– "service 内直接出现 execute("
– "config 之外出现 os.environ"
comments:
– "注释中的次数、单位、顺序必须能在代码里指到对应行"
dependencies:
– "新增依赖需在 PR 描述里写用途与替代方案"
– "单函数可替代的不引入依赖"
structure:
– "新增文件、新增依赖、新增抽象层需各自说明为什么不能复用"
– "改公开函数签名前先数调用方,超过五个拆两步"

这份文件有两个作用:对人是评审依据,不必每次口头解释同一套规则;对生成侧是约束条件,进了上下文就能少走一部分弯路。条目要少而硬,超过二十条就会变成没人读的摆设。

完整版资料清单:本文用到的结构自检清单与 CI 规则模板都整理在里面了,扫码即可获取:

配套资料获取

附表 A:关键取舍一览

工程取舍原因依据章节
用"能改"而非"能跑"做质量判据 一次性验收不反映后续修改成本 第一章
用修改半径做基线 可从版本记录直接数出,不需额外埋点 第一章
退化定义为"能跑但更难改" 把结构问题和功能缺陷分开处理 第一章
绕开抽象先补抽象再改调用 只改调用点,下一个人还会再绕 第二章
注释中的数量词视为断言 错的注释会让人跳过验证 第二章
依赖按 lockfile 增量判定 行数在任何环境都能算,字节数不能 第二章、第五章
把成因归到上下文缺失 指向可改的检索与命名,而非模型能力 第三章
调用方超过五个拆两步 控制单次回归范围 第四章
结构回看设时间盒且不碰存量 允许顺手重构就会拖期直到被砍 第六章
债登记必须写原因 没有原因,修复方案无法判断 第六章
两次触发即升级排期 无限挂账等于不做 第六章
指标只做趋势不做考核 一考核就会被优化指标本身 第七章
规则文件放仓库根目录 人和生成侧读同一份,避免两套标准 第七章

附表 B:术语速查表

术语一句话解释
一次性验收 合入前对单次输入输出的正确性判定
修改半径 完成一次需求需要改动的文件数量
重复实现 同一语义概念在仓库中存在多份独立代码
绕开抽象 越过既定分层,直接调用底层能力
注释与实现不符 行为已改而注释未同步,二者描述不一致
依赖膨胀 为极小功能引入体积与依赖树远超所需的包
阻断级规则 违反即不合入,判据为确定的错而非可能不好
提示级规则 只汇总不阻塞,用于观察趋势
结构回看 固定时间盒内对新代码做的重复合并与收敛
债登记 记录位置、原因、修复成本与触发条件的条目
触发条件 使一笔债从"可挂账"变为"必须处理"的事件
忽略清单 经确认属合理重复、免于检测的片段列表

写在最后:这篇用到的资料

写这篇文章时,我把几个项目里返工过的代码结构和当时的评审记录都翻了一遍,顺手整理成几份配套的东西:

  • 大模型学习路线图:从 LLM 基础到 Agent 开发,各阶段该学什么、用什么资料
  • 大模型全套教程:按主题分好的视频与文档清单
  • 大模型实战好书:24 本,附每本适合的阶段

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取

大模型全套教程

大模型实战好书

添加时备注「大模型」,优先通过。

拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。

赞(0)
未经允许不得转载:171主机测评 » AI 写的代码越来越难改:四类退化与能拦住的规则
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址