死代码与功能开关清理智能体。
删除是编码智能体所有任务里唯一一件「测试全绿什么也证明不了」的活儿,因为测试套件通过的理由,恰恰就是这段代码看起来已死的那个理由:没有东西会执行到它。这一组里其余每一套工作流都是从测试那里买到安全感的;这一套买不到,而一个凭着构建通过就把删除推上去的智能体,是在你的生产系统上跑一场没有量具的实验。信号必须来自别处——在一个完整业务周期上采集到的可达性证据——而删除本身必须以你能倒回去的分阶段方式抵达。
这里那个惯用的安全信号是失效的,而且它悄无声息地失效。
编码智能体赢得信任靠的是一个循环:提出一个改动、跑测试、通过就留下。补丁生成完全依赖它。而对一次删除来说,这个循环在三种从外面看一模一样的情形下都返回绿色:
- 这段代码确实不可达。删除是对的,而测试套件沉默,是因为本来就没有什么可以被弄坏。
- 这段代码只在生产配置下可达。一个区域开关、一个企业版档位、一个客户专属集成、一个每月一号跑的 cron。测试套件从没执行过它,所以它离开时测试套件也察觉不到。
- 智能体把测试连同代码一起删了。构建是同义反复地绿的。这不是假想;这是一个删除 PR 让自己获批最常见的方式。
只有第一种是成功,而构建输出里没有任何东西能把它们区分开。所以第一条规矩是结构性的而非技术性的:一个同时删掉测试文件的删除 PR,已经毁掉了它自己的证据。把删掉的测试单独计数,把这个数字亮在 PR 正文里,并让任何非零值都成为一个必须由人来做的决定。智能体可以提议删掉一个「唯一考察对象就是被删代码」的测试;但它绝不可以把这件事和「这段代码没人用」这个主张放进同一个可评审单元里。
注意这件事对目标函数意味着什么。「减少代码行数」「关掉陈旧开关」「提高覆盖率」全都可度量、对梯度友好,而且全都能靠删错东西来满足——覆盖率尤其如此:你删掉没有测试的代码,它反而会上升,而那恰恰是你最没把握的代码。这在成为工程问题之前,先是一个奖励设计陷阱;目标必须写成你真正想要的那样:被移除的代码,可证明不会被到达,且证明随件附上。
静态可达性是一个下界,而事故就住在那道缝里。
调用图分析、未使用导出检测与死分支分析都确实好用,智能体应该把它们全跑一遍。它们产出的是一份候选清单;而诚实的说法是:静态分析能证明某样东西被到达,却无法证明它不被到达。
那道缝很大,且因语言而异。下面每一条对调用图都是隐形的:
- 经由字符串的分发。反射、依赖注入容器、按类名做键的序列化、ORM 生命周期钩子、模板与路由查找、
getattr、动态导入。引用是存在的——在一个配置文件里、一行数据库记录里,或一个注解里。 - 仓库之外的消费方。一个 public 方法之所以「没人用」,只是相对于你索引过的那些代码而言。仓库导航划定了分析的边界,而在多仓库的版图里,那条边界干的活儿远比任何人承认的要多。
- 由数据而非代码把守的路径。一个只对某一个租户、某一种货币、某一个语言区、某一类单据才执行的分支。
- 失败路径上的一切。重试处理器、兜底、迁移回退脚本、灾备例程、人工对账工具。这些代码在设计上就是死的——它们在生产 trace 里没有引用,恰恰是因为还没出过事;而删掉它们,正是一个组织在三年一遇的那一天、弄丢自己需要的那样东西的方式。
经典范例至今仍是 Knight Capital。2012 年 8 月 1 日,该公司在约四十五分钟内损失约 4.4 亿美元,原因是一个配置开关被复用给了一个新功能,而它原本控制的那段代码——一个叫 Power Peg、自 2003 年起就不再使用的测试例程——在八台服务器中的一台上依然存在、依然可执行。那段死代码不只是杂物;它是一把上了膛的枪,而一次开关复用把它指向了市场。还有一个细节值得记住:把那七台已更新的服务器回滚到上一个版本,反而让旧路径在八台上全部激活,把事情弄得更糟。删除是危险的,不删除也是危险的,而两者之间的差别完全是一个证据问题。
去生产环境买证据,并按业务日历定窗口。
既然测试供不出这个信号,那就得由运行时观测来供。智能体在这里的活儿不是什么精巧的分析,而是为每个候选攒出一份证据包,并且拒绝在没有它的情况下往下走。
- 执行遥测。跑在生产里的覆盖率或性能剖析探针、在入口处自增的一个计数器、一条已有的日志、一个 tracing span。只要是你已经有的、能回答「它跑过吗、上一次是什么时候」的东西。
- 开关求值数据。你的开关平台记录了每一次求值。这是整套工作流里质量最高的证据,而它通常正闲置在一块没人看的仪表盘上。
- 网关与路由日志对应端点,查询日志对应存储过程与报表。
这份证据有两个性质,决定了它到底值不值钱。
窗口必须长过两次使用之间最长的那个合法间隔。「三十天没有流量」是一句关于三十天的陈述。月结、季度报表、年度审计导出、闰日分支、一年一次的监管申报、季节性定价路径——每一个都是活生生的功能,只是占空比比你的观测窗口长。窗口要从业务日历上挑,而不是从一个整数上挑:凡是碰财务或合规的,十三个月是站得住脚的默认值,因为它覆盖一个完整的年度周期,外加它被报出来的那个月。
采样会改变「没有」的含义。一个按 1% 采样的剖析器,在构造上就通常会漏掉一条一个季度才执行几次的路径。来自采样源的「没有证据」,是很弱的「证据表明没有」;智能体必须把采样率与观测结果一并记下,好让评审者能分辨「我们盯着一切、什么都没看到」和「我们偶尔瞥了两眼」。
在开关数据里,有一个区分承载了大部分价值,而智能体往往把它弄反。一个每天被求值一百万次、每次都返回 false 的开关,和一个从来不被求值的开关,不是一回事。前者是一个答案已经尘埃落定的活调用点——可以安全地塌缩成常量。后者意味着调用点早就没了,只剩下开关定义——可以安全地在开关平台里删掉,但它关于任何代码都没告诉你什么。把这两类当成一类,产出的就是对着恰恰错误的那一半做出的、自信满满的删除。
先做开关——那是另一个问题,而且仪表齐全得多。
陈旧开关和死代码常被归在一起,而它们不该。一个陈旧开关有归属人、有创建日期、有一块带求值计数的厂商仪表盘,通常还有一个早已被满足的既定意图。死代码这些都没有。从证据已经存在的地方开工。
机械的清除是一套四步变换,而智能体在中间两步上确实出色:
- 读生产值,不是读代码默认值。这一步就是被跳过的那一步,也正是会把 diff 整个弄反的那一步。源码里写的默认值几乎总是上线前的值——
false——而那个开关早已全量放开、已经返回了八个月的true。一个读代码默认值的智能体,会删掉已上线的功能、留下被放弃的那个。权威的值活在开关平台的生产环境里,智能体必须去取。 - 把求值替换成那个常量,独立成一个提交,别的什么都不改。
- 化简由此产生的分支——塌缩
if (true)、删掉不可达的那一支、去掉因此不再使用的导入与辅助函数。机械、可核验,而且正是值得自动化的那种枯燥。 - 最后在平台里退休那条开关定义,等到没有任何东西再求值它之后。
开关清除的危险从来不在代码里。它在所有其他读同一个开关的东西那里:
- 其他服务。开关键名是横跨你整片版图的一个共享全局命名空间。「本仓库里没有引用」不是要问的那个问题。
- 分析与定向。按开关值做键的实验分组、人群定义与仪表盘会悄悄坏掉,并在一个季度之后被某个根本不知道发生过一次删除的人发现。
- 运行手册与支持工具。「如果客户报告 X,就把
new_checkout关掉」是一项只存在于一个 wiki 页面上的运维依赖。 - 其实是急停开关的那些开关。一个从未被扳动过的开关,在现存的每一种陈旧度启发式眼里,看上去都跟一个没人清理的开关一模一样。它不陈旧。它是利用率为零的保险;而除非急停开关在开关平台里被标记为受保护类别、并且教会智能体一律拒绝它们,否则它每一次都会高置信度地提议删掉它。
把删除做成可倒回的几个阶段,并且让回滚保持纯净。
一次事后证明是错的删除,是在生产里、由一位客户、在距离那次改动无法预测的远处被发现的。这让可逆性成为压倒性的设计考量,也因此主张一条三阶段流水线——智能体的大部分活儿都发生在那些不改变行为的阶段里。
- 阶段一——埋点,不删。给每个候选在入口处加一个计数器或一行结构化日志,然后发版。这很便宜、很安全、可以成批评审,而且它把一个静态的猜测变成了一次测量。然后把 STEP 3 里那个窗口等满。这整篇手册的价值,大半就在于团队愿不愿意在这里等。
- 阶段二——立墓碑。让这条路径大声地被弃用、但仍然可用:用 error 级别记下候选标识,或者返回一个弃用响应头,背后配一个你能扳回去的开关。你现在能查出你的调用方是谁了——是从他们的抱怨里,而不是从你的调用图里——而且你能在几秒内把服务恢复回来。
- 阶段三——删除,装在一个别的什么都不做的提交里。
最后那条约束值得死守。一个删除提交必须是纯删除——不重排格式、不改名、不夹带「反正我都在这儿了」的重构、不升依赖。理由很窄也很实在:对一个凌晨三点、完全没参与过这件事的人来说,git revert 必须是一个真实可选项。一个在同一个提交里既删掉一个模块、又把它的邻居收拾了一遍的智能体,为了给评审者省一次点击,把你最便宜的那条恢复路径拿走了。把它写进评审里强制执行:如果 diff 里除了删导入之外还有新增行,它就不是一个删除 PR。
按成因成批,不按文件成批。一个开关一个 PR、一个子系统一个 PR、一个被移除的功能一个 PR——并附上证据包——这是可评审的。一个四百文件的「移除未使用代码」PR 是一次多绕了几步的橡皮图章,而这正是迁移智能体提出过的同一个批次形状论证。每个 PR 都带三件东西:静态发现、带窗口与采样率的运行时证据,以及所涉任何开关的生产值。
度量进料口,不是度量产出。
汇报指标塑造整个项目,而那几个显而易见的指标主动地有害。「删掉的行数」奖励的是删掉大块的、理解透彻的、低风险的代码,并且忽略那个小而承重的分支。「关掉的开关数」奖励的是关掉容易的那些,留下那个十年历史、被四个服务读取的。两者关于「你是不是更安全了」都一言不发。
四个能说话的数字:
- 已过声明有效期的开关,占在用开关的比例。是比率,不是计数,这样规模增长就没法替你粉饰。
- 移除时开关年龄的中位数。这个数字告诉你这个循环闭合得比它被打开得更快还是更慢。如果它在上升而计数在下降,那你是在移除年轻的、在积攒危险的。
- 每一百次移除对应的事故与回滚数。预期它非零——一个报告为零的项目,要么非常小,要么根本没删掉任何要紧的东西。把它记下来,好让代价和收益并排可见。
- 从立墓碑到删除的前置时间。如果它在缩短,说明有人正被压着赶进度、STEP 5 正在被压缩;而那恰恰是这套工作流开始制造故障的时刻。
然后去修进料口,因为一个对着漏水龙头的清理智能体,是一项永久的人力承诺。一个创建时没有归属人和有效期的开关,本身就是缺陷,而拒掉它很便宜:对一个两者都缺的新开关键让 CI 失败,并让开关平台在到期时通知归属人,而不是等一次清扫把它翻出来。同样的逻辑也适用于代码——一次没有移除日期的弃用,是一次五年后还在那儿的弃用。智能体该站在进料口的分量,不亚于它站在清理队伍里的分量;而这正是CI 修复与漏洞整改最终都落到的那个论点。
在动手造任何东西之前,先花一个下午。拿出你最老的十个功能开关。对每一个写下三件事:它当前的生产值、它过去三十天的求值次数,以及出事时会不会有人去扳它。你通常会发现:有两个早已是没人塌缩掉的常量;有一个是急停开关,而现存的每一种陈旧度启发式都会建议删掉它;还有至少一个,它的生产值与代码默认值相反。那张表既是给你的智能体写的规格,也是它为什么需要一个人在回路里的理由。
相关:灰度与版本化——这套工作流是那条开关生命周期的尾端;爆炸半径——用来量一次错误删除够得到多远;以及生成器—验证器鸿沟——为什么昂贵的那一半是证据包而不是 diff。