摘要: AI 生成的代码跑得通、能上线,这件事三个月后回头看的人不多。这篇用一组沙箱演练量"可删性":同一个需求、两版样本(AI 风格·多写保险 2023 行 vs 人写规范四十来行),对每个顶层函数做三方判断------静态调用计数(重构者第一眼看到的)、运行时探针(真实执行)、删减真值(删了跑一遍)。结论反直觉:AI 版其实有三个函数删了完全不影响功能,但静态分析显示它们"有调用",没人敢删;剩下六十多个函数静态零调用,按静态判断"能删"删了,模块直接加载失败 。两版输出(合法输入域)一模一样,可删性却天差地别:AI 版确定能删零处,人写版确定能删一处。跑得通不等于删得动。文末给三道可删性闸门。
一、现场:重构两千行,第一刀不知道该砍哪
先说这块功能。还是那个订单整理模块:拿一批订单,校验金额、状态、时间、优惠券、退款,把通过的整理成清单。三个月前它被写出来时跑得很顺,测试是绿的,上线后没有报警、没有工单,日志里也没有异常堆栈。
转折出在重构上。三个月后我打算给这块瘦身------功能没变,但文件已经两千多行了,里面散落着各种"看起来没用"的东西。按以往经验,重构第一步是清死代码:把没人调用的函数找出来删掉,文件能小一圈。
结果第一刀就砍不下去。 我用编辑器搜"这个函数谁调用",搜出来一堆"0 个引用";可这些函数我不敢删------因为我记得它们好像在哪被调过,只是搜不出来。我试着按静态分析的结论删了一个,模块直接启动失败。再换个思路,看那些"有引用"的函数,又发现有几个的引用全在 if(false) 里,删了其实没事,但静态工具显示"被调用",谁敢动?
先把这个标题钉死:"没有一处能删",说的是没有一处能"确定"删了不出事 ------不是真的没有能删的东西(后面量出来其实有三个删了也不影响功能),而是没有一个重构者能放心确认"删了安全"的函数。这个区别是整篇的钥匙,后面组 2 会把它量成数字。
这里要先说清楚:这不是报错。 它不炸、不告警,线上一切正常,监控发现不了。发现它的路径只有一条:有人真的来删它------一旦开始删,就发现删哪块都怕出错,最后一行没动。
演练口径:下面是一次沙箱演练,不是线上生产记录。标题里的"两千行"是这类情况的统称,不是某一次具体生成记录;重构现场和"测试是绿的"这类背景,按典型情况设定;两版代码是我按两种典型生成风格手工构造的对照样本,用来复现"AI 多写保险"和"人写规范"在可删性上的差异。第二到七节里的指标、三方判断和三组对照,都是脚本真实跑出来的。环境:Node v26.13.0,acorn 8.19.0 + acorn-walk 8.3.5(AST 解析与遍历),模块为 ES Module。
二、先排除最容易怀疑的那几个
| 怀疑项 | 检查结果 |
|---|---|
| 是不是真的没死代码 | 不是。两版都有函数删了不影响功能(后面量出来 A 版三个、B 版一个) |
| 是不是 IDE 检测不到调用 | 部分成立。IDE 静态分析只能看到"直接调用",看不到数组/对象映射里的动态调用 |
| 是不是这些函数本来就在用 | 是。六十多个"静态零调用"的函数,运行时逐个在跑 |
| 是不是我看错了/工具坏了 | 不是。用 Node 真实跑过,探针记录了每个函数的执行次数 |
三、先给"能删"下个定义
重构时判断"某段代码能不能删",通常靠静态分析:没有引用就能删。但静态分析有个盲区------它只认"直接调用"这种写法。我把一个函数能不能删拆成三方,合起来才是真相:
- 静态调用计数 :用 acorn 解析源码,数
fn(...)这种直接调用的次数。这是重构者第一眼看到的。 - 运行时探针 :给每个顶层函数体开头插一行计数器,跑一遍用例,看这个函数到底执行没执行。
- 删减真值:把函数定义从源码里抹掉,重新跑用例,看输出变不变、模块能不能加载。这是上帝视角。
三方一对照,函数分成三类:
| 类别 | 静态调用 | 运行时执行 | 删了会怎样 | 重构者的处境 |
|---|---|---|---|---|
| 真死 | 0 | 未执行 | 仍绿 | 敢删,安全 |
| 伪活 | >0(看似被调) | 未执行 | 仍绿 | 不敢删------静态显示"有调用" |
| 灰 | 0(看似没被调) | 执行 | 炸 | 敢删就删错------静态显示"没引用" |
真死 是正常死代码,该删;伪活 是"静态看似被调、实际永不执行"------它删了没事,但静态工具显示有引用,重构者不敢删;灰最阴险------静态零调用,按"没引用就能删"的规则删了,直接炸。
四、两版样本
同一个需求,两种生成风格。A 版(AI 风格·多写保险) :防御性 else 兜底、catch 吞错返默认值、参数默认值、把一件事拆成几十个碎片化 helper(这里是程序化生成六十个 checkXX 来模拟这种碎片化,真实 AI 代码不会这么整齐------八节的局限会说明)、if(false) 引用几个 helper、还有两个 helper 只在 dispatch 映射里被动态调用。B 版(人写规范):单一职责、非法值直接抛错不吞、无冗余兜底,只留一个标注"已废弃"的真死函数。行数:A 版 2023 行,B 版四十来行。
A 版的设计里埋了三类"可删性陷阱",正好对应上面的三类:
- 灰 :
check01~check60全部只出现在CHECKS = [check01, ...]数组里,运行时被validateOrder逐个fn(o)执行------静态零直接调用,但删了模块加载就失败(顶层数组字面量立即求值)。 - 伪活 :
ghostDiscountA/ghostCouponA/ghostRoundingA只在if (false) { ... }块里被引用------静态显示"被调用",运行时永不执行,删了完全不影响输出 。(if(false)是"恒假分支"的极端简化;真实 AI 代码里更常见的是if (cfg && cfg.enabled && helper(o))这类防御性条件包裹的调用------条件在业务约束下恒假,机制一模一样:静态看似可达、运行时不可达。) - 灰 :
legacyFallbackA/legacyRecheckA只在dispatch对象字面量里被引用,运行时经dispatch['legacyFallbackA'](o)动态调用------静态零直接调用,但删了模块加载失败(dispatch顶层立即求值,引用已删除的函数)。
B 版则干净:一个真死函数 legacyUnused(标注已废弃、零引用),其余都是真活。
这些判定具体是怎么跑的? 核心就两个函数。静态调用计数用 acorn 遍历 AST 数 fn(...) 直接调用;删减真值用 acorn ranges 定位函数节点、从源码抹掉、重新加载跑用例。运行时探针则给每个顶层函数体开头插一行计数器,跑一遍看谁执行了:
js
// 静态调用计数:数 fn(...) 直接调用的次数(重构者第一眼看到的就是它)
function countDirectCalls(source, fnName) {
const ast = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
let count = 0;
walk.simple(ast, {
CallExpression(node) {
if (node.callee && node.callee.type === 'Identifier' && node.callee.name === fnName) count++;
},
});
return count;
}
// 删减真值:抹掉函数定义,重新加载跑用例,看输出变不变、模块能不能加载
async function truthDelete(source, fnName, orders, originalOut) {
const newSrc = deleteFunction(source, fnName);
const tmp = path.join(__dirname, `_del_${fnName}.mjs`);
fs.writeFileSync(tmp, newSrc);
try {
const mod = await import(pathToFileURL(tmp).href);
const out = mod.processOrders(orders);
return { ok: true, same: JSON.stringify(out) === JSON.stringify(originalOut) };
} catch (e) {
return { ok: false, same: false, err: e.constructor.name };
} finally { fs.rmSync(tmp, { force: true }); }
}
这里有个坑值得单独说:acorn 的 FunctionDeclaration.start 落在 function 关键字上,不含前面的 export ------直接按节点范围抹掉,会残留一个 export 导致语法错误。我第一次跑的时候组 2 全报 SyntaxError,就是栽在这。修法是删除前向前多扫一段,把 export 一起带走。acorn 的 AST 节点结构说明见官方文档。这两个函数的运行结果,就是下面组 1/2/3 的真实输出。
上面这套判断跑在 A 版 check01 上,出来的三方是这样的(真实输出,node analyze.mjs):
text
check01 三方判断:
静态调用:0(只出现在 CHECKS 数组字面量里,无 fn(...) 直接调用)
运行时执行:9 次(9 个订单,每个都经 validateOrder 数组遍历调用)
删减真值:ReferenceError(CHECKS 顶层立即求值,引用已删除的函数)
静态说"能删",运行时却执行了九次,删了模块直接加载失败------这就是"灰"的定义在单个函数上的样子。
五、三组对照
组 1:基准组------两版功能等价
先用同一批订单跑两版(九个,覆盖退款/取消/待处理各状态),确认它们做的是同一件事(否则"可删性对比"就没意义):
text
=== 组 1:基准组(功能等价验证)===
输入订单数: 9
A 版输出条数: 9 | B 版输出条数: 9
两版输出逐字符相同: 是
A 版输出示例: {"id":"O1","amount":-120,"status":"refund","quantity":1,"coupon":"CPN-0","time":"2026-10-01T10:00:00Z","discount":0.9,"tax":0.06,"channel":"web","weight":0.5,"note":"refund"}
B 版输出示例: {"id":"O1","amount":-120,"status":"refund","quantity":1,"coupon":"CPN-0","time":"2026-10-01T10:00:00Z","discount":0.9,"tax":0.06,"channel":"web","weight":0.5,"note":"refund"}
两版输出逐字符相同。 说明 A 版不是写错了------它当时把那批输入处理出了预期结果;B 版的"干净"也不是靠少做事换来的。两版唯一的差别在代码结构,不在行为。
但要说明白两件事,免得你误读这个"相同"。 第一,这个相同只在合法输入 上成立:A 版对非法输入是静默吞掉(strict 默认 false,校验不过就 continue 跳过),B 版是直接抛错------两版在"怎么处理脏数据"上根本不同,而这正是多写保险的一部分。组 1 只测合法输入,是因为重构者面对的正是"线上跑通的那批数据";但**"功能等价"的结论要限定在合法输入域**,不能说两版在所有输入下都一样。第二,B 版不是 从 A 版重构出来的产物,而是"同一个需求、人从一开始就写对"的对照样本------它证明的是同样功能下"人写代码的可删性",不是"A 版能改写成 B 版"(真要整体重写,那叫推翻重来,不叫重构)。A 版的重构,是在保留现有结构的前提下做增量删减,所以才会撞上"没有一处能删"。
组 2:可删性判断------确定能删 A 版零处,B 版一处
对每个顶层函数跑三方判断:
text
=== 组 2:可删性判断(三方对照)------ A 版(AI 风格·多写保险) ===
可删性候选函数数(不含入口): 66
真值能删(删了仍绿): 3
确定能删(静态零调用+运行时未执行+真值能删): 0
伪活(静态看似被调/运行时未执行/真值能删): 3 个 → ghostDiscountA, ghostCouponA, ghostRoundingA
灰(静态零调用/运行时执行/真值不能删): 62 个 → check01, check02, check03, check04, check05...
=== 组 2:可删性判断(三方对照)------ B 版(人写规范) ===
可删性候选函数数(不含入口): 2
真值能删(删了仍绿): 1
确定能删(静态零调用+运行时未执行+真值能删): 1
确定能删清单: legacyUnused
A 版六十多个候选,确定能删零处。 但它不是没有能删的东西------真值能删的有三个 (三个 ghost),问题是这三个静态看起来"被调用"(在 if(false) 里),重构者不敢删。剩下六十多个灰函数静态零调用,按"没引用就能删"的规则,重构者会判"能删"------可它们运行时全在跑,删了就炸。两头堵死:敢删的删错,能删的不敢删。
B 版两个候选,确定能删一处 (legacyUnused,真死,静态零调用、运行时未执行、删了仍绿);另一个候选是 parseAmount(真活:静态有调用、运行时执行、删了炸),属于"不能删"的那一半。
组 3:删减复现------按静态判断删,看看会怎样
模拟重构者的真实操作:按"静态零调用→能删"的规则删。
text
=== 组 3:删减复现(重构者按静态判断删)===
删 A 版 check01(静态零调用,重构者会判"能删")→ 结果:挂了:ReferenceError ------ 真值不能删,删错
删 B 版 legacyUnused(静态零调用)→ 结果:仍绿(安全,真值能删)
删 A 版的 check01------静态零调用,按规则"能删",删完模块直接加载失败 (CHECKS 数组顶层立即求值,引用已删除的函数 → ReferenceError)。删 B 版的 legacyUnused------同样静态零调用,删完仍绿,功能不受影响。
这就是差距:同样的"静态零调用"线索,在 A 版指向删错,在 B 版指向安全删除。 你信静态分析,A 版会让你删错;你不信,A 版你就一处都不敢删。
把这两个"同样线索、两种结局"摆在一起看:
| 操作 | 静态线索 | 删减结果 | 判定 |
|---|---|---|---|
删 A 版 check01 |
静态零调用(重构者判"能删") | 模块加载失败(ReferenceError) | 删错------运行时在跑 |
删 B 版 legacyUnused |
静态零调用(重构者判"能删") | 仍绿,输出不变 | 安全------真死 |
六、根因:多写保险,把"能删"变成了"不敢删"和"不能删"
A 版为什么删不动?不是它没死代码,而是AI 生成时的"多写保险"把代码从"干净可删"改造成了"两头堵":
- 碎片化 helper → 灰 。AI 倾向把一件事拆成几十个小函数,再用数组/对象映射动态调用。静态分析只认直接调用,这些函数在它眼里"零引用"------但它不知道数组会遍历执行它们。结果:静态说能删,实际删了炸。
- 防御性分支 → 伪活 。AI 爱写"恒假条件分支"(这里用
if(false)作极端简化)、默认值、catch 吞错这类"永不触发的保险分支",分支里的函数静态有调用、运行时永不执行。结果:真能删,但静态说"被调用",不敢删。 - 两者叠加 → 确定能删 = 零 。重构者靠静态分析做决策,灰让他删错一次后就不敢再删;伪活让剩下能删的也下不去手。最后结论就是标题那句:没有一处能删。
B 版为什么能删?人写代码时把"废弃"显式标注出来 (legacyUnused 上面写着"已废弃,等待清理"),而且不用数组映射动态调用来绕静态分析------静态看到的就是真相。真死是干净的、可确认的。
这不是 A 版写得"错"。 组 1 已经证明它跑得和 B 版一样好。这是三个月后才显形的成本------生成时多写的每一层保险,都在重构时变成一道不确定的坎。
七、可删性三道闸(重构 AI 代码前,先过这三关)
第一道闸是删之前,第二道是删之时,第三道是删之后。这三道不是给"这次"用的,是给每次删 AI 代码用的。
闸 1:删之前,数两遍调用------静态一遍,动态一遍。 不能只信 IDE 的"查找引用"。静态分析看不到数组/对象映射里的动态调用,要把整个模块的"引用它的地方"人工过一遍,特别是:函数被塞进数组、被当对象属性、被字符串键 dispatch['xxx'] 调用。边界说明 :这只能降低风险,不能消除------动态调用还可能藏在跨模块、字符串拼接、eval 里,这些地方静态分析彻底失明,得靠闸 2 兜底。
闸 2:删之时,跑一遍全量用例,且用例要覆盖"删掉它的那条路径"。 删完立刻跑测试。但注意:测试绿不等于删对 ------如果测试根本没走到被删函数的那条分支,删了也绿。所以要确认用例覆盖到"这个函数执行的那条路径"。边界说明:覆盖判断本身依赖用例质量,如果原代码没有针对这条路径的用例,需要先补一个"专门触发它"的用例再删。
闸 3:删之后,留一个运行时探针。 删完在"疑似能删"的函数里保留一个计数器,跑一段真实流量,看它到底执行没执行。执行了说明删错了,赶紧回滚;没执行说明真删对了。边界说明:探针需要埋进运行时,线上流量不够覆盖的冷门路径可能测不到,所以这招对"高频路径"可靠、对"低频路径"只能算辅助。
三道闸合起来:静态数两遍(排除灰)→ 全量用例覆盖路径(排除漏测)→ 探针确认未执行(排除误判)。对 A 版这种"多写保险"代码,三道闸都得过;对 B 版,闸 1 静态零调用 + 显式废弃标注,基本就够。
这套判断在沙箱里可复跑:node gen.mjs 生成两版样本(调 STEP_COUNT_A 控制 A 版行数),node analyze.mjs 跑三组对照。想验证某个真实模块:把 analyze.mjs 里的 A/B 路径换成你的文件,同时把入口函数名 processOrders 换成你模块的真实入口名------脚本里入口名是写死的(删减真值拿"入口函数的输出"当基准),但三方判断逻辑本身不依赖具体模块,只依赖"有一个能跑的入口、能对同一批输入输出可比对的结果"。
八、为什么这事值得较真
这篇不是要论证"AI 生成的代码不能用"------组 1 已经说明它功能上没问题。我要说的是另一件事:AI 生成时的那点"多写保险",在当时是零成本的;三个月后重构时,它变成"没有一处能删"的困境,重构成本从"删十分钟"变成"查一下午还没动刀"。
关键数字:A 版六十多个候选函数,确定能删零处;B 版两个候选,确定能删一处。同样跑得通、测试绿、上线的两版代码,可删性一个为零、一个一半。 这就是"能跑"和"能维护"之间的距离。
边界与局限 :这是沙箱演练,两版样本是我手工构造的典型生成风格,不是真实模型采样;A 版 2023 行里 step/check 是程序化重复生成的,真实 AI 代码的碎片化程度可能没那么整齐;"确定能删"的定义(静态零调用+未执行+删了仍绿)是严格口径,实际重构中"疑似能删"的灰色区域可能更大。演练的价值在于把机制讲清楚------多写保险如何制造灰与伪活,而不是给出一个普遍适用的删减比例。