Skip to content
Wen's Blog

如何解决长期 AI Coding 产生的代码屎山?

最近我在整理自己的 Coding Agent Skills 时,研究了一个挺有意思的项目:simplify-codebase (opens in a new window)。它从 DeepSeek Harness 的 simplification 实践里提炼出了一套更通用的方法。

一开始我只是把它理解成普通的“代码重构”:找重复代码、删掉没用的 abstraction、把复杂函数收一收。后来越看越觉得,它真正想解决的其实是另一个问题,而且正好也是我长期使用 AI Coding 后越来越明显地遇到的问题:AI 写出来的代码通常不是错的,甚至非常“规范”,但它会一点一点把项目写成屎山。

这种屎山和传统印象不太一样。它不是几千行代码塞在一个文件里,不是变量叫 a1a2,也不是满屏复制粘贴。恰恰相反,它通常长得非常专业:Interface 有了,Dependency Injection 有了,Repository 有了,Adapter 有了,Factory 有了,Strategy 有了,Mock 有了,RetryPolicy 也抽出来了,连 Clock 都可以注入。

单独 review 每一个 PR,好像都挑不出什么大问题,但半年之后再看,很容易冒出一个疑问:这玩意到底为什么需要这么复杂?

AI 特别擅长写“正确但没必要”的代码

假设原来有这么一个函数:

Future<User> loadUser() async {
final response = await api.getUser();
return parseUser(response);
}

让 Coding Agent 给它补测试,很可能最后变成:

Future<User> loadUser({
ApiClient? apiClient,
UserParser? parser,
RetryPolicy? retryPolicy,
Clock? clock,
Logger? logger,
Metrics? metrics,
}) async {
final client = apiClient ?? ApiClient.instance;
final userParser = parser ?? DefaultUserParser();
final retry = retryPolicy ?? DefaultRetryPolicy();
// ...
}

这样当然很好测试。测试可以注入 FakeApiClientFakeClockFakeRetryPolicyFakeParser,甚至可以精确验证第一次失败、等待多久、第二次重试、parser 被调用几次、logger 收到什么、metrics 上报了什么。Coverage 也会很好看。

问题是,真实生产代码到底需不需要这些 variability?如果整个应用永远只有一个 ApiClient,永远只有一个 parser,retry policy 也从来不会动态替换,那么这些注入点的主要消费者其实只是测试。为了让测试更容易控制内部行为,我们反过来改变了生产代码本身的设计,这就是我现在越来越警惕的一类东西:Test-induced Architecture

测试方便,不等于生产抽象合理

测试当然重要,但“方便测试”并不能自动证明一个 production abstraction 应该存在。

一个正常的 Seam 往往来自真实系统边界,例如 HTTP、Database、File System、Clock、Process、External Service、Platform API。这些地方确实存在不同的 ownership、failure mode 或运行环境,把它们隔离出来通常是合理的。另一类 Seam 则只是因为“测试的时候想把这里替换掉”才出现,这种情况就要谨慎得多。

我现在判断这类设计时,经常会问四个问题:

  1. 如果没有测试,这个 Interface / injection point 还会存在吗?
  2. 是否有真实生产调用方需要替换这个 dependency?
  3. 它是否对应真实的 ownership、failure、process 或 I/O boundary?
  4. 删除这个 seam 后,是否仍然可以通过公开生产行为验证 correctness?

如果答案是 No / No / No / Yes,它通常是一个很强的简化候选。不过这仍然只是候选,不能因为“只有测试在调用”就直接删除;真正动手之前,还要继续确认调用方、动态注册、兼容性、持久化格式和行为是否真的不受影响。

最麻烦的不是 Dead Code

Dead Code 其实没有那么可怕,IDE、编译器和静态分析工具很容易告诉你哪里有 unused functionunused importunreachable code。真正难清理的是那些有调用者、有测试、有类型、有文档,但没有多少真实业务价值的代码

比如这样一套结构:

HttpClient
HttpClientAdapter
NetworkService
ApiService
UserRemoteDataSource
UserRepository
UserRepositoryImpl

每一层单独看都说得通,甚至每一层都有自己的职责说明和测试。但如果其中四层做的事情基本只是:

return nextLayer.call(...);

那么系统并没有真正获得六个有价值的 abstraction,只是多了六个以后必须长期保持一致的概念。改一个参数,六层跟着改;改一个 error type,六层重新 mapping;加一个字段,六层 DTO 跟着传。最后所有人都很忙,业务本身却没有得到与复杂度相称的收益。

这类代码不能简单叫 Dead Code,更像是一种 Maintenance Obligation:只要它存在,你以后就必须继续理解它、维护它、测试它、迁移它、兼容它。

DeepSeek Harness 确实在系统性做这件事

这也是为什么 DeepSeek Harness 的 simplification 实践很有意思。它没有停留在“代码要简单”这种原则层面,而是在仓库里维护了一个 dsh-find-simplifications (opens in a new window) Skill,专门寻找当前设计成本已经超过收益的代码面。

它列出的强候选非常具体,包括:

更关键的是,它明确要求先区分 production consumernon-production consumerambiguous consumer,不能因为静态搜索没找到调用就直接删。

仓库里还有一个很典型的真实案例:Remove the agent/steering mirror emit (opens in a new window)。这个 transient event 和前一行已经写入 durable log 的 steering/message 表达的是同一个事实,调查后确认它没有任何生产 listener,唯一 subscriber 是一个 regression test。

最后他们没有继续“优化”这个 event,而是把 declaration、emit、README、架构文档、catalog 以及对应测试依赖一起删除,测试改为验证真正保留下来的 durable event。换句话说,这段代码并非完全没人使用,测试确实依赖它,但它没有独立的生产价值,因此继续保留只会增加维护面。这和普通的 unused code cleanup 不是一回事。

AI 特别容易制造哪些 Maintenance Obligation

长期使用 Coding Agent 后,我现在会特别留意几类东西。

第一类是过度 Dependency Injection。一个函数十几个参数:clockloggerparserclientretryPolicymetricsschedulervalidatorcallback。看起来高度解耦,但生产环境只有一种组合,这种设计很多时候不是业务真的需要“解耦”,而是测试想“控制一切”。

第二类是 Relay Layer。一层调用下一层,再调用下一层,每层都没有真正隐藏复杂度,也没有形成独立 ownership,只是把调用链拉得更长。

第三类是为测试暴露内部状态,例如 retryCountpendingCountdebugStateonStartedonCompletedonRetryisInitialized。如果生产调用方完全不需要知道这些状态,而它们唯一的消费者是测试,那么 production Interface 很可能已经被测试污染了。

第四类是 Defensive Programming Theater。AI 很喜欢写 validatecopyfallbacktry/catchretryrollbacknull checkduplicate guard,因为每一条单独看都显得更“安全”。但在同一个 trusted boundary 内部,如果函数 A 调函数 B,B 又把 A 已经验证过的所有东西重新验证一遍,通常没有增加多少安全性,只是增加了更多分支。

第五类是 Ownerless Flexibility。StrategyStrategyFactoryStrategyRegistryDefaultStrategyFallbackStrategy 都有了,最后生产代码永远只有 DefaultStrategy。问为什么要保留 Factory,答案往往是“以后可能需要扩展”。未来当然可能需要,也可能永远不需要,但代码库从今天开始就已经在为这个假设付维护成本。

AI 为什么特别容易把这些东西写出来?

AI Coding 的工作方式和人不太一样。一个有经验的工程师在设计过程中,会在脑子里尝试很多方案:要不要抽 Interface,好像没必要;要不要做 Factory,目前只有一种实现,先算了;这里加个 callback 测试更方便,但不值得污染生产接口。这些被否定的方案通常不会进入 Git。

AI Agent 更容易把探索过程物化成代码:Interface、Adapter、Helper、Fixture、Fallback、Compatibility Layer、Callback、Temporary Abstraction。尤其当我们同时要求它“实现功能、补齐测试、覆盖 edge case、遵循最佳实践、保持可扩展”时,很容易得到一个极其“政治正确”的工程实现。任何一项单独拿出来都能解释,但组合起来以后,往往没人再问一句:我们真的需要长期维护这么多东西吗?

于是 AI Coding 会产生一种很有意思的沉积效应:探索成本被留在了 production codebase 里。 人类会把很多失败方案丢在脑子里,AI 则更容易把其中一部分以接口、抽象、兼容层或测试支撑代码的形式留在仓库里。

所以我开始把 Simplification 看成一个独立工程阶段

以前我的开发流程大概是:

Requirement
Design
Implement
Test
Code Review

现在我越来越倾向于加入一个阶段:

Requirement
Design
Implement
Test
Simplify
Code Review

这里的 Simplify 不只是“把这次 PR 写得简单一点”。对于长期运行 Coding Agent 的项目,我认为还应该周期性做一次代码库级别的 simplification survey:寻找没有真实生产 ownership 的维护义务,证明哪些东西可以删,再在删除后重新验证行为。

Codebase
Simplification Survey
寻找没有真实生产 ownership 的维护义务
证明可以删除
删除
重新验证行为

我更喜欢把这个过程叫 Entropy Reclamation,也就是“熵回收”。系统运行久了,本来就会留下历史实验、迁移残留、测试脚手架、过时兼容层、推测性 abstraction、没人使用的 extension point、重复状态和重复 wiring,AI Coding 只是把这个过程加速了。

Simplify 不是追求更少的代码

这一点很重要。如果目标变成“谁删的代码多谁厉害”,很容易走向另一个极端。比如删掉一个 Repository,然后让所有调用方自己写数据库逻辑,LOC 确实下降了,系统反而更差。

真正应该减少的是需要维护的事实、需要同步的状态、需要兼容的 Contract、需要理解的 abstraction,以及需要长期支持的 variability。所以我现在很认同一句话:

Delete obligations, not lines.

删除的是维护义务,不是代码行。有些 500 行代码只承担一个清晰、稳定的复杂行为,完全应该留下;有些 30 行的 Interface + Factory + Adapter,却让整个项目从此多维护三个概念,那反而更值得质疑。

Architecture Review、Codebase Design 和 Simplify 不是一回事

我最近整理自己的 Coding Agent Skills 时,也专门把这三个能力拆开了。

review-architecture 回答的是“当前架构合理吗?”,它会去发现状态 ownership 错误、依赖方向倒置、同一个事实被多处维护之类的问题。

codebase-design 回答的是“如果要解决,正确的 boundary 应该怎么设计?”,它讨论的是 Module、Interface、Seam、Adapter、Dependency Direction。

simplify 问的是另一个更直接的问题:这个东西真的需要存在吗? 它不急着改善这个 abstraction,而是先质疑我们为什么要拥有它。如果没有 production consumer,没有真实变化来源,也没有真实 ownership,只剩下一堆测试和历史原因在支撑它,那么最好的设计可能不是继续重构,而是删除。

测试也应该接受这个问题

过去我们经常说,不要为了代码方便而牺牲测试,这当然没问题。但反过来也成立:不要为了测试方便,长期污染生产设计。

好的测试应该尽可能验证真实 Interface。如果 production consumer 调的是 submitOrder(),测试也尽量从 submitOrder() 验证最终结果,而不是为了测试方便,把内部的 validate()calculate()retry()persist()notify() 全部暴露出来,再分别 assert 每一步。否则最后得到的可能是一套非常容易测试的 Implementation,以及一套越来越难修改的系统。

长期 AI Coding 需要“生成”和“回收”两个方向

现在大家讨论 AI Coding,关注的基本都是 Agent 能不能写更多代码、能不能跑更久、能不能自己修 bug、能不能自己开 PR,这些都属于 Generation

但如果 Agent 真的开始长期参与一个项目,另一个能力会越来越重要:Reclamation。它得知道什么时候不要再加一层,什么时候某个 Interface 已经没人需要,什么时候 Factory 可以删掉,什么时候 test-only seam 不应该继续留在生产设计里,什么时候 migration 已经结束,什么时候 compatibility path 已经过期,以及什么时候两个 state 其实描述的是同一件事。

一个长期运行的 Coding Agent,如果只会不断添加东西,却不会主动证明哪些东西可以安全删除,最终很可能只是一个更高效的技术债生成器。这也是我最近研究 simplify-codebase 最大的感受。

下一次让 AI 给你写出一个 Interface、Factory、Adapter、Strategy,或者往函数里塞进十几个 dependency injection 参数时,不妨多问一句:这是业务真的需要,还是 AI 为了证明自己写得对? 这个问题,可能比“代码够不够规范”重要得多。