如何解决长期 AI Coding 产生的代码屎山?
最近整理自己的 Coding Agent Skills 时,我研究了一个项目:simplify-codebase (opens in a new window)。它从 DeepSeek Harness 的 simplification 实践里提炼出了一套更通用的方法。
一开始我以为它做的就是普通的“代码重构”:找重复代码、删掉没用的 abstraction、简化复杂函数。看下去才发现,它想解决的问题和我长期使用 AI Coding 的感受很接近:AI 写出来的代码通常没错,甚至非常“规范”,却会一点一点把项目写成屎山。
这种屎山和传统印象不太一样:没有几千行代码塞在一个文件里,没有叫 a1、a2 的变量,也没有满屏复制粘贴,看起来甚至非常专业。Interface、Dependency Injection、Repository、Adapter、Factory、Strategy、Mock 一应俱全,RetryPolicy 也抽出来了,连 Clock 都可以注入。
单独 review 每一个 PR,好像都挑不出什么大问题,但半年之后再看,很容易疑惑:这玩意到底为什么需要这么复杂?
假设原来有这么一个函数:
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(); // ...}这样当然很好测试,可以注入 FakeApiClient、FakeClock、FakeRetryPolicy、FakeParser,甚至可以精确验证第一次失败、等待多久、第二次重试、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 则只是因为“测试的时候想把这里替换掉”才出现,这种情况就要谨慎得多。
我现在判断这类设计时,经常会问四个问题:
- 如果没有测试,这个 Interface / injection point 还会存在吗?
- 是否有真实生产调用方需要替换这个 dependency?
- 它是否对应真实的 ownership、failure、process 或 I/O boundary?
- 删除这个 seam 后,是否仍然可以通过公开生产行为验证 correctness?
如果答案是 No / No / No / Yes,通常就很值得考虑简化。但不能仅凭“只有测试在调用”就直接删除;动手之前,还要确认调用方、动态注册、兼容性、持久化格式和行为是否真的不受影响。
Dead Code 其实没有那么可怕,IDE、编译器和静态分析工具很容易告诉你哪里有 unused function、unused import、unreachable code。难清理的是那些有调用者、有测试、有类型、有文档,但没有多少真实业务价值的代码。
比如这样一套结构:
HttpClient ↓HttpClientAdapter ↓NetworkService ↓ApiService ↓UserRemoteDataSource ↓UserRepository ↓UserRepositoryImpl每一层单独看都说得通,甚至每一层都有自己的职责说明和测试。但如果其中四层做的事情基本只是:
return nextLayer.call(...);这六个 abstraction 没有给系统带来相应的价值,却成了以后必须长期保持一致的六个概念。改一个参数,六层跟着改;改一个 error type,六层重新 mapping;加一个字段,六层 DTO 跟着传。大家忙着同步这些改动,业务却没有得到与复杂度相称的收益。
这类代码不能简单叫 Dead Code,更像是一种 Maintenance Obligation:只要它存在,你以后就必须继续理解它、维护它、测试它、迁移它、兼容它。
DeepSeek Harness 把这类调查做成了具体流程。它在仓库里维护了一个 dsh-find-simplifications (opens in a new window) Skill,专门寻找当前设计成本已经超过收益的代码面。
它列出的强候选包括:
- public method、event、config、helper、package 没有生产消费者;
- tests 或 docs 是唯一消费者,而且这些行为并不是系统真正依赖的 contract;
- 两套状态或事件在表达同一个事实;
- 所有实现都必须支持某个 seam method,但没有任何 consumer 使用;
- 为了未来可能的需求保留 speculative product generality;
- 某些 invariant、rollback path 或 special-case test 只是为了保护一个实际上没人用的 API;
- 手写 infrastructure 已经可以被成熟依赖或平台 builtin 替代,而且替换后能真正减少实现、测试和文档维护面。
它还明确要求先区分 production consumer、non-production consumer 和 ambiguous 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 不是一回事。
长期使用 Coding Agent 后,我现在会特别留意下面几类代码。
比如过度 Dependency Injection,一个函数有十几个参数:clock、logger、parser、client、retryPolicy、metrics、scheduler、validator、callback。看起来高度解耦,但生产环境只有一种组合。很多时候,业务并不需要这么多“解耦”,这些参数只是为了让测试能“控制一切”。
Relay Layer 则是一层调用下一层,下一层再调用下一层。每层都没有真正隐藏复杂度,也没有形成独立 ownership,只是把调用链拉得更长。
还有为测试暴露内部状态的代码,例如 retryCount、pendingCount、debugState、onStarted、onCompleted、onRetry、isInitialized。如果生产调用方完全不需要知道这些状态,而它们唯一的消费者是测试,那么 production Interface 很可能已经被测试污染了。
至于 Defensive Programming Theater,AI 很喜欢写 validate、copy、fallback、try/catch、retry、rollback、null check、duplicate guard,因为每一条单独看都显得更“安全”。但在同一个 trusted boundary 内部,如果函数 A 调函数 B,B 又把 A 已经验证过的所有东西重新验证一遍,通常没有增加多少安全性,只是增加了更多分支。
Ownerless Flexibility 往往长这样:Strategy、StrategyFactory、StrategyRegistry、DefaultStrategy、FallbackStrategy 都有了,最后生产代码永远只有 DefaultStrategy。问为什么要保留 Factory,答案往往是“以后可能需要扩展”。未来当然可能需要,也可能永远不需要,但我们从今天开始就得为这个假设付出维护代码的成本。
AI Coding 的工作方式和人不太一样。一个有经验的工程师做设计时,会在脑子里尝试很多方案:要不要抽 Interface,好像没必要;要不要做 Factory,目前只有一种实现,先算了;这里加个 callback 测试更方便,但不值得污染生产接口。这些被否定的方案通常不会进入 Git。
AI Agent 更容易把探索时尝试的方案留成代码:Interface、Adapter、Helper、Fixture、Fallback、Compatibility Layer、Callback、Temporary Abstraction。尤其当我们同时要求它“实现功能、补齐测试、覆盖 edge case、遵循最佳实践、保持可扩展”时,很容易得到一份逐项满足要求的实现。单独看,每一项都能解释得通,组合起来却往往没人再问:我们真的需要长期维护这么多东西吗?
这些设计逐渐积累,探索成本也就留在了 production codebase 里。 人类会把很多失败方案丢在脑子里,AI 则更容易把其中一部分以接口、抽象、兼容层或测试支撑代码的形式留在仓库里。
以前我的开发流程大概是:
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 只是把这个过程加速了。
如果目标变成“谁删的代码多谁厉害”,很容易走向另一个极端。比如删掉一个 Repository,然后让所有调用方自己写数据库逻辑,LOC 确实下降了,系统反而更差。
我想减少的是需要维护的事实、需要同步的状态、需要兼容的 Contract、需要理解的 abstraction,以及需要长期支持的 variability。这也是我认同下面这句话的原因:
Delete obligations, not lines.
删除的是维护义务,不是代码行。有些 500 行代码只承担一个清晰、稳定的复杂行为,完全应该留下;有些 30 行的 Interface + Factory + Adapter,却让整个项目从此多维护三个概念,那反而更值得质疑。
我最近整理自己的 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,关注的基本都是 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 为了证明自己写得对? 这个问题可能比“代码够不够规范”重要得多。