缓存时间:
2026/07/30 16:50
# 重构的经济效益
来源:https://martinfowler.com/articles/exploring-gen-ai/refactoring-economic-benefit.html
在探索代理工程这个新世界的过程中,我构建了一个支持我工作的应用程序。这是一个复杂的应用:高品质的Web用户界面,具备动态刷新和查找、模态框和自动保存、外部系统集成、机器学习和文本分析、后台任务,以及带有全自动部署的完善环境设置。它约有15万行代码,主要是Rust(约12万行),其余部分是TypeScript和Terraform。这完全由代理编写。主要是Claude Code,也有一些使用了Cursor。我没有阅读或审查过任何代码,除了偶尔出于兴趣。
在构建应用程序的过程中,我可以看到一些事情正在偏离正轨。在终端上看到对一个文件第4000行的编辑滚动过去后,我仔细查看了一下。数据访问层已经增长到超过6000行。随着更多功能的加入,这个数字还在持续增长。每个查询,无论是读还是写,都重复着相同的HTTP请求设置,相同的JSON编码和解码。最终,它达到了17,155行。在一个单一的Rust文件中。
## 一个重构实验
这个17,155行的文件是整个数据访问层。一个单一的、独立的模块。审查代码后,发现没有去重,没有内部语言,有限度的函数提取,很少的类提取。它确实有一个明确的边界,需要保留接口。这是一个绝佳的重构目标。
重构一个代理驱动的代码库的目标是:现在就花费token进行重构,以使未来工作的token消耗更低。一个实验应该能够证明,随着这个文件被重构,在此代码库中实现单独功能的token成本会降低。
恰恰因为代理从不学习,这现在可以作为实验来运行。我可以在每个重构阶段之后,提示一个全新的代理去做完全相同的修改。与人类工程师不同,这个实验不会因之前的步骤学习而受到影响。
1. 制定一个整体的重构计划,遵循严格的重构纪律。
2. 设计一个代表性的变更,用单一提示来描述。
3. 建立变更的基线成本:在一个子代理中,执行那个提示,包括要求子代理报告token消耗。
4. 丢弃这个变更。
5. 在一个循环中:
1. 应用整体重构的一个步骤。
2. 在一个子代理中,执行*完全相同的*变更,接收变更的token成本。
3. 丢弃这个变更。
6. 记录所有token成本、执行变更的时间,以及每个重构步骤(包括基线)后的代码行数。
用于代表性变更的提示以及所应用的重构步骤,见下方的附录。
一个注意事项:尽管Claude会显示token计数、报告每个会话消耗的token,并*根据token收费*,但它没有提供可靠的实时计数token的方法。我假设这是一个临时问题,会随着时间推移而改善。作为替代,子代理报告接收和发送的字符数,并使用tiktoken (https://github.com/openai/tiktoken) 通过将字符数除以四来近似估算token数。
## 结果
| 步骤 | 数据访问层代码行数 | 最大文件代码行数 | 总Rust代码行数 | 每次变更输入token数 | 每次变更输出token数 | 每次变更时间(秒) |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 基线 | 17,155 | 17,155 | 50,359 | 159,564 | 1,705 | 342 |
| 步骤1 (FirestoreClient) | 16,706 | 16,706 | 49,910 | 155,205 | 1,723 | 530 |
| 步骤2 (extract_doc_id, new_link) | 16,562 | 16,562 | 49,766 | 159,227 | 2,105 | 574 |
| 步骤3 (link-query helpers) | 16,567 | 16,567 | 49,771 | 154,054 | 2,105 | 524 |
| 步骤4 (FakeStore predicates) | 16,577 | 16,577 | 49,781 | 154,146 | 2,060 | 654 |
| 步骤5 (value ctors) | 16,469 | 16,469 | 49,673 | 171,251 | 2,036 | 1,353 |
| 步骤6 (FieldsBuilder) | 16,469 | 16,469 | 49,673 | 171,251 | 2,036 | 1,353 |
| 步骤7 (queries.rs) | 16,474 | 15,670 | 49,678 | 151,850 | 1,800 | 587 |
| 步骤8 (traits.rs) | 16,508 | 13,845 | 49,712 | 132,558 | 1,723 | 446 |
| 步骤9 (traits/ split) | 16,508 | 13,845 | 49,712 | 132,558 | 1,723 | 446 |
| 步骤10 (codec.rs) | 16,521 | 12,846 | 49,725 | 131,871 | 1,750 | 540 |
| 步骤11 (fake_store.rs) | 16,535 | 11,122 | 49,739 | 133,016 | 2,460 | 600 |
| 步骤12 (store/ split) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |
| 步骤13 (co-locate tests) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |
| 步骤14 (complete fake_store.rs) | 16,553 | 7,225 | 49,757 | 107,205 | 2,453 | 523 |
| 步骤15 (store/ split) | 16,608 | 3,695 | 49,812 | 27,360 | 2,113 | 454 |
这里有趣的指标是:数据访问层的总代码行数、数据访问层中*最大单一文件*的代码行数,以及生成变更时消耗的输入token数。
这张图展示了四件事。第一个点是基线,步骤0,然后同样的指标在*应用了*每个重构步骤*之后*重复显示。
1. 整个数据访问层的总代码行数。最初,这只是我开始时的单一文件。随着重构的应用,它变成了许多文件。到最后有19个Rust文件。
2. 数据访问层中最大单一文件的代码行数。这最初是整个数据层都在单一的初始文件中。到最后,最大的单一文件是一个测试库。进一步的重构传递可以将相同的方法应用于此。
3. 子代理在应用代表性变更时消耗的输入token总数。
4. 子代理在应用代表性变更时产生的输出token总数。
## 重构减少了token消耗
结果很清晰。输入token数量保持相当平稳,直到最大文件开始缩小,然后它们下降,用Claude的话说,就是断崖式下跌。从基线到最终重构,相同任务的输入token从 **159,564** 减少到 **27,360**。节省了 **132,204** 个token,即 **83%**。
而且这种节省不是一次性的。从此以后,每一个涉及数据访问层的变更成本都会显著降低。
能节省多少?假设按撰写本文时的Sonnet 5定价每百万token3美元计算,节省39.7美分。不算多。这个效应会放大吗?在调试过程中会如何体现?更复杂的功能呢?这只是重构了代码库的一部分,能否对整个代码库进行积极的重构以在处处找到节省?那些重构*会花费多少*?
这种节省是因为代理需要阅读更少的代码。但这并不是因为没有那么多代码可读。整个数据访问层的总代码量保持相当稳定。因此,要能够实现这种节省,代理必须能够成功地识别需要阅读的最小文件子集。结果似乎表明这种情况正在发生。在应用变更时阅读Claude Code的思考输出和文件阅读摘要也表明,子代理每次都在成功地阅读越来越小的代码部分。
换句话说,随意将文件切割成更小的文件不太可能有那么大的帮助:即使每个文件都更小,代理也将被迫翻阅许多文件来寻找相关代码。
虽然影响最大的步骤出现在最后,但之前的步骤正是为实现这种节省而设置的重构。这不是计划好的。这只是重构通常如何进行的结果:先进行本地文件更改以提取重复部分,然后在出现重复核心时分解成更小的文件。
重构并没有使代表性变更本身变得更小。编写代码时产生的token数量基本不受影响:输出token变化不大。这些输出token的价格是输入token的五倍。但是,它们的数量少得多。是否有可以应用的重构来减少输出token的产生?我需要一个更复杂的示例变更来探索这些问题。非确定性代码生成过程产生的噪音掩盖了由代码因式分解变化引起的任何差异。
## 过程说明
Claude不擅长重构。如果你阅读下面的提示和重构步骤,很明显,产生的重构是直接响应提示的。Claude无法查看代码、查看一般的重构并找出哪些是适合应用的:需要人类积极引导它。
这与在应用中的更广泛经验相吻合。开发工具包含一个明确的重构步骤。那个重构步骤并没有提示Claude改进这个文件。更带轶事性质的是,Claude.ai 比 Claude Code 更好。我使用了两个接口来创建重构计划。Claude Code 发现提取函数是第一步。Claude.ai 更进一步,看到了一个要提取的整个客户端类。
Claude也不擅长应用这些重构。执行重构的机械步骤是通过使用grep和sed编写Python脚本来完成的。这些脚本经常被缩进搞糊涂。真是讽刺。
此外,最有价值的一次重构在第一次扫描中被遗漏了,必须作为后续步骤重新应用。这就是为什么图中的步骤数与附录中的重构步骤数不匹配。
完成整个实验大约花了八个小时。这大部分时间都是无人值守的。唯一的干预是在6小时40分钟后,当时它似乎已完成,但跳过了那个步骤,需要被重新引导。这个实验是在缓慢的酒店WiFi上运行的。我怀疑这是否导致耗时较长。但在对代码库进行更深入分析后发现,cargo的临时构建缓存变得非常大。测试执行受到了严重影响。
## 进一步工作与更广泛的影响
不幸的是,我直到完成后才想到要统计创建和执行重构计划所需的token数量。我查看了在我做这项工作时的时间窗口内的总消耗,包括设计和运行实验。我无法说出执行重构需要多少token。然而,上限是五百万。这包括创建两次重构计划、设计实验(包括代表性变更)的工作,以及其他各种任务。未来的工作应包括更精确地统计重构消耗的token。
这只是一个实验,针对一个仍然处于绿地阶段、由单个开发者构建和维护的重要应用程序。但我相信这是一个潜在有趣的初步步骤。这项工作展示了重构在时间和金钱上的价值,同时也衡量了重构的成本。
研究更复杂的变更、更广泛的重构、持续重构,甚至不同重构方法的相对价值,将会很有趣。这仅仅是个开始。
## 附录
*注:这些附录包含了我使用的提示,以及返回的输出。所做的唯一编辑是删除了要进行的特定代码更改。这些原样包含在内,以展示代理是如何被指导的。没有隐藏的技巧。因此,这里有些语言可能令人困惑。错误存在于原文中。*
### 代表性变更
这是记录下来的提供给每个子代理的提示,除了代码库和附带的架构文档外,没有提供进一步的上下文。每个子代理都是从完全相同的信息开始的。
> 你正在位于 `~/dev/your-project-name` 的 Rust 项目中工作。按照现有模式,向 Firestore 层添加一个新的 `ItemWatchStore` 公共 async trait。该 trait 必须有三个方法:
> - `async fn watch_item(&self, item_id: &str, user_id: &str) -> Result<()>`
> - `async fn unwatch_item(&self, item_id: &str, user_id: &str) -> Result<()>`
> - `async fn watched_items_for_user(&self, user_id: &str) -> Result<Vec<String>>`
>
> 监视记录存储在 `item_watches` Firestore 集合中。每个文档包含字段:`itemId`(字符串)、`userId`(字符串)、`createdAt`(时间戳)。没有用于监视记录的 Rust 结构体——方法返回 `Vec<String>`(项目ID)。
>
> 为 `FakeStore`(使用添加到 `FakeStoreInner` 的内存中 `Vec<(String, String)>` 字段)和 `FirestoreStore`(使用此文件中其他存储实现所使用的相同 HTTP 模式)实现该 trait。
>
> **在你的响应最末尾**,精确输出这个 JSON 块(填写真实值):
> ```json
> {
> "files_read": [
> {"path": "src/firestore.rs", "chars": 123456},
> ...
> ],
> "response_chars": 7890
> }
> ```
>
> 不要提交更改。编写完代码后停止。
### 重构步骤
这是用于创建重构计划的提示。
> 遵循重构是可证明保持正确性的代码编辑序列的严格定义,并使用 Martin Fowler 的《重构》第二版作为参考资料,检查 `@src/firestore.rs`。这是一个 17K LoC 的 Rust 文件。没有文件应该这么长。它几乎可以肯定没有使用内部语言来构建和管理查询。
>
> 制定并描述,但*不执行*,一系列重构步骤,这些步骤将大幅减少该文件的代码行数,同时*完全不改变*接口。
以下是所应用的重构描述,提取自 Claude 构建并遵循的计划。实际计划包括预测的代码更改。对于每个重构,列出了要遵循的各个步骤。每个步骤都可以单独测试,并且已经过单独测试。这比大多数人类工程师遵循的重构更为严格。此处列出的步骤与上述测量的变化并不完全对应,因为 Claude 在第一次扫描中跳过了最有价值的单一重构(将存储拆分为子文件),并且不得不在之后作为两个额外的步骤完成它。
#### 步骤 1 — 提取类:`FirestoreClient`(Fowler §7.5)
**Fowler 参考:** *Extract Class* (7.5)
`FirestoreStore` 当前混淆了两个职责:
- **领域查询编排** — 运行哪个查询、写入哪些文档、如何将结果解析为领域类型
- **Firestore HTTP 传输** — 认证头、URL 构建、Firestore 线格式的 JSON 编码/解码、PRECONDITION_FAILED 时的重试
Fowler §7.5 要求在你能识别出类中数据和行为的连贯子集时提取一个新类。传输职责拥有:`client: reqwest::Client`、`project_id: String`、`MetadataAuth`,以及 `documents_url()` / `auth_header()`。将这些提取到一个新的 `FirestoreClient` 结构体中。
**预估节省:`FirestoreStore` 实现中约 1,200 行;`FirestoreClient` 净增加约 120 行。**
#### 步骤 2 — 提取函数:`extract_doc_id` 和 `new_link`(Fowler §6.1)
**Fowler 参考:** *Extract Function* (6.1)
- **`extract_doc_id`** — 表达式 `doc.name.rsplit('/').next()?.to_string()` 在所有 20 个 `parse_*_document` 函数的开头逐字出现。提取它。
- **`new_link`** — 使用 `metadata: HashMap::new()` 和 `provenance: None` 以及一个全新的 UUID 构建 `Link` 结构体出现了 62 次。提取一个工厂函数。
**预估节省:约 500 行**(62 个 × ~10 行结构体 → 62 个 × ~2 行调用;20 个 parse 函数每个减少 1 行样板代码)。
#### 步骤 3 — 提取函数:链接查询辅助方法(Fowler §6.1)
**Fowler 参考:** *Extract Function* (6.1)
在运行链接查询后,`FirestoreStore` trait 实现内部重复出现两个子模式:
- **模式 A — 从查询行收集所有链接文档(约 15 处)。**
- **模式 B — 查询链接并返回恰好一个目标 ID,如果缺少则报错(约 8 处):**
**预估节省:约 200 行。**
#### 步骤 4 — 提取函数:FakeStore 谓词(Fowler §6.1)
**Fowler 参考:** *Extract Function* (6.1)
在 FakeStore 实现内部,约 15 个方法重复了 `inner.links.iter()...` 的变体。在 `FakeStoreInner` 上提取两个方法。这 15 个调用点将变成单行方法。那些额外按第二个谓词过滤的方法(例如还检查 `to_kind`)会链式调用 `.into_...