说明:本文讨论的是 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——整个仓库只用了一个符号,而包在依赖树里有几十个模块。三看新增依赖的理由,写"方便"的基本可以打回。
修复成本:装的时候一行,拆的时候一轮。移除要走完整回归,因为它可能被间接依赖着。这个不对称正是依赖膨胀难收的原因:引入的决策成本和移除的决策成本差一个数量级。
| 重复实现 | 同一 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 本,附每本适合的阶段
资料是我自己整理的,放在下面这个码上,扫码即可获取:



添加时备注「大模型」,优先通过。
拿到之后建议先看学习路线图那一份,先定位自己在哪个阶段,再决定学什么,比一上来就啃框架效率高得多。


