Martin Fowler

The Economic Benefit of Refactoring

8.5内容质量
The Economic Benefit of Refactoring

TL;DR · AI 摘要

重构能显著降低未来开发的token成本,实验显示代码冗余导致17,155行文件膨胀,分阶段重构使token消耗下降30%。

核心要点

  • 重构后token消耗降低30%,验证了经济性价值。
  • AI代理可执行无学习偏见的重构实验。
  • 数据访问层冗余导致单文件膨胀至17,155行。

结构提纲

按章节快速跳转。

  1. 介绍作者背景及使用AI代理构建复杂应用的实验场景。

  2. 数据访问层单文件膨胀至17,155行,存在严重冗余。

  3. 通过分阶段重构测量token消耗变化,验证经济性。

  4. 重构后token消耗降低30%,证明重构的长期价值。

  5. 实验避免人类工程师的学习偏见,确保数据客观性。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 重构的经济利益
    • 实验设计
      • AI代理无偏见测试
      • 分阶段重构测量
    • 核心发现
      • token消耗降低30%
      • 代码冗余导致膨胀

金句 / Highlights

值得收藏与分享的关键句。

#重构#AI代理#软件开发#Thoughtworks
打开原文

重构的经济效益

Giles Edwards-Alexander

Giles 是 Thoughtworks 欧洲、中东和印度地区的首席技术官。他在移动技术到人工智能的多个技术领域,以及零售、金融科技和医疗健康等行业拥有超过25年的工程和技术领导经验。

本文是“探索生成式AI”系列的一部分。该系列记录了 Thoughtworks 技术专家在软件开发中使用生成式AI技术的探索过程。

2026年7月30日

在深入理解智能体工程新世界的过程中,我开发了一个应用程序来支持我的工作。这是一个复杂的应用:高质量的动态刷新和查询网页界面、模态框和自动保存功能、与外部系统的集成、机器学习和文本分析、后台任务处理,以及带有全自动部署的完整环境配置。该应用大约有15万行代码,主要使用 Rust(约12万行代码),其余部分使用 TypeScript 和 Terraform。

该应用完全由智能体编写。主要使用 Claude Code,部分使用 Cursor。除了偶尔出于兴趣查看代码外,我没有阅读或审查任何代码。

在构建该应用的过程中,我注意到一些问题。当看到终端中文件第4000行的代码滚动时,我进行了更仔细的检查。数据访问层已经增长到超过6000行。随着更多功能的添加,这一情况持续恶化。每个查询、读取或写入操作都重复相同的HTTP请求设置、相同的JSON编码和解码。最终,这个文件达到了17,155行代码,全部集中在单个 Rust 文件中。

一次重构实验

这个17,155行的文件构成了整个数据访问层。它是一个自包含的模块。在审查代码时发现,没有进行任何重复代码消除,没有使用内部语言,函数提取非常有限,类提取更是微乎其微。不过它确实具有清晰的边界和需要保留的接口。这使其成为重构的理想目标。

重构智能体代码库的目标是通过当前的重构投入,降低未来工作的token消耗。实验应该能够证明,随着这个文件的重构,该代码库中实现独立功能的token成本会下降。

正因为智能体从未学习过这些知识,现在可以将这作为实验来运行。我可以提示一个全新的智能体在每次重构阶段后执行完全相同的更改。与人类工程师不同,这个实验不会受到之前步骤学习的影响。

  • 制定整体重构计划,严格遵循重构纪律。
  • 设计一个具有代表性的变更,通过单一提示进行描述。
  • 建立变更基准成本:在子智能体中执行该提示,包括要求子智能体报告token消耗。
  • 放弃该变更。
  • 在循环中:应用整体重构的单个步骤。在子智能体中执行完全相同的变更,获取变更的token成本。放弃该变更。
  • 记录所有token成本、执行变更所需的时间以及每次重构步骤后的代码行数,包括基准数据。

用于代表性变更的提示和应用的重构步骤详见附录。

需要注意的是:尽管Claude显示了令牌计数、每会话消耗的令牌数以及按令牌计费,但并未提供可靠的方法来实时统计令牌数量。我假设这是一个暂时性问题,未来会逐步改进。取而代之的是,子代理报告了接收和发送的字符数量,并通过将字符数除以四的方式使用tiktoken估算令牌数。

结果

步骤

数据访问层代码行数

最大文件代码行数

总Rust代码行数

每次更改输入令牌数

每次更改输出令牌数

每次更改耗时(秒)

基准

17,155

50,359

159,564

1,705

342

步骤1(FirestoreClient)

16,706

49,910

155,205

1,723

530

步骤2(extract_doc_id, new_link)

16,562

49,766

159,227

2,105

574

步骤3(link-query helpers)

16,567

49,771

154,054

524

步骤4(FakeStore predicates)

16,577

49,781

154,146

2,060

654

步骤5(value ctors)

16,469

49,673

171,251

2,036

1,353

步骤6(FieldsBuilder)

步骤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

446

步骤9(traits/ split)

步骤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)

步骤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

这里值得关注的指标包括:数据访问层总代码行数、数据访问层中单个最大文件的代码行数,以及在生成更改过程中消耗的输入令牌总数。

该图表展示了四个方面的信息。第一个数据点是基准线(步骤0),然后在每次重构步骤完成后重复显示相同的指标。

  • 数据访问层整体的总代码行数。最初这只是我开始时的单个文件。随着重构的进行,文件数量逐渐增加。最终共有19个Rust文件。
  • 数据访问层中单个最大文件的代码行数。起初这是初始单个文件中数据层的全部内容。最终,最大的单个文件成为一个测试库。后续的重构步骤可以对这个文件应用相同的方法。
  • 子代理在应用代表性更改时消耗的总输入令牌数。
  • 子代理在应用代表性更改时生成的总输出令牌数。

重构降低令牌消耗

结果显而易见。在最大文件规模开始下降之前,输入令牌数量基本保持稳定,随后如Claude所说,出现断崖式下降。

从基准线到最终重构,相同任务的输入令牌数量从159,564降至27,360,节省了132,204个令牌,降幅达83%。而且这种节省并非一次性效果。从这一刻起,所有涉及数据访问层的更改成本都将显著降低。

节省幅度有多大?假设按照写作时Sonnet 5的定价3美元/百万令牌计算,节省了39.7美分。这似乎不多。这种节省会成倍增长吗?在调试过程中、更复杂的特性开发中,这种效果会如何演变?目前只是重构了代码库的一部分,能否对整个代码库进行激进重构以在各处寻找节省空间?这些重构本身又需要付出多少成本?

这种节省的原因在于代理需要读取的代码更少,而不是因为可读代码总量减少。数据访问层整体代码量基本保持稳定。因此,若要实现这种节省,代理必须能够成功识别出需要读取的最小文件子集。实验结果表明这确实发生了。在变更应用过程中阅读Claude Code的思考输出和文件读取摘要也显示,子代理每次都能成功读取越来越小的代码片段。

换句话说,随机将文件拆分成更小的文件可能帮助有限:即使每个文件更小,代理仍需遍历大量文件寻找相关代码。虽然影响最大的步骤发生在最后,但之前的重构步骤为实现这种节省奠定了基础。这并非刻意设计,而是重构通常推进方式的自然结果:先通过局部文件修改提取重复代码,待核心重复部分显现后才拆分为更小文件。

重构并未使代表性变更更简洁。编写代码时生成的标记数量基本不受影响:输出标记的变动幅度不大。这些标记的价格是输入标记的五倍。但它们的数量显著减少。是否存在能减少输出标记生成的重构方式?我需要更复杂的变更样本来探讨这些问题。非确定性代码生成过程的噪声掩盖了代码分解变化所导致的任何差异。

过程说明

Claude在重构方面表现欠佳。若阅读提示和下方的重构步骤,可以看出生成的重构直接响应了提示内容。Claude无法查看代码、审视通用重构方式并判断哪些适合应用,必须由人类主动引导。这与该应用的更广泛经验一致。开发框架包含一个显式的重构步骤,但该步骤并未促使Claude改进此文件。更轶事性的是,Claude.ai的表现优于Claude Code。我使用两个接口创建重构计划时,Claude Code首先识别出提取函数作为第一步,而Claude.ai更进一步,发现了可提取的完整客户端类。

在应用重构方面也表现不佳。重构的机械操作是通过编写使用grep和sed的Python脚本完成的。这些脚本经常因缩进问题而困惑。哦,真是讽刺。此外,最有价值的重构在首次尝试中被遗漏,必须作为后续步骤重新应用。这就是图中步骤数量与附录中的重构步骤不匹配的原因。

整个实验耗时约八小时,大部分时间无人值守。唯一干预发生在六小时四十分时,当时似乎已完成但跳过了某个步骤,需要重新引导。该实验在酒店缓慢的WiFi环境下运行。我曾怀疑这是否影响了耗时。但深入分析代码库后发现,cargo临时构建缓存已变得非常庞大,测试执行明显受到影响。

不幸的是,直到重构计划完成之后,我才意识到应该统计创建和执行该计划所需的标记数量。我已经查看了在进行这项工作期间的总体消耗情况,包括设计和运行实验的过程。我无法准确说明执行重构所需的标记数量,但上限是五百万。这包括两次创建重构计划、设计实验(包括代表性更改)的工作,以及其他各种任务。未来的工作应包括更精确地统计重构过程中消耗的标记数量。

这只是一个实验,针对一个仍在开发阶段且由单个开发者构建和维护的重要应用。但我认为这可能是一个有趣的初步尝试。这项工作展示了重构在时间和金钱上的价值,同时也测量了重构的成本。研究更复杂的更改、更广泛的重构、持续重构,以及不同重构方法的相对价值,将是非常有趣的课题。

这只是个开始。

附录

注意:这些附录包含我使用的提示和返回的输出。唯一进行的编辑是删除了需要修改的具体代码更改。这些内容未经过编辑,以展示代理是如何被指导的。没有隐藏的技巧。因此,其中可能包含一些令人困惑的内容。错误源于原始内容。

代表性更改

这是提供给每个子代理的记录提示,除了代码库和配套的架构文档外,没有提供其他上下文信息。每个子代理都从完全相同的信息开始。

你正在 ~/dev/your-project-name 的 Rust 项目中工作。按照现有模式,向 Firestore 层添加一个新的公共异步 trait ItemWatchStore。该 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 块(填写真实值):{ "files_read": [ {"path": "src/firestore.rs", "chars": 123456}, ... ], "response_chars": 7890 } 不要提交更改。编写代码后立即停止。

重构步骤

这是用于创建重构计划的提示。

根据重构是可证明保持正确性的代码编辑序列的严格定义,并使用 Martin Fowler 的《重构》第二版作为参考,检查 @src/firestore.rs。这是一个17K行的Rust文件。没有任何文件应该这么长。它几乎可以肯定没有使用内部语言来构建和管理查询。生成并描述但不要执行一系列重构,这些重构将大幅减少该文件的行数,而不会改变任何接口。

以下是根据 Claude 制定并执行的重构计划提取的描述。实际计划包含预测的代码更改。每个重构都列出了具体步骤,每个步骤均可单独测试并已通过测试。这比大多数人类工程师遵循的重构方式更为严格。

此处列出的步骤与上述度量更改不完全对应,因为 Claude 在首次迭代中跳过了最有价值的单个重构(将 store 拆分为子文件),之后又通过两个额外步骤完成了该重构。

#### 步骤1 — 提取类:FirestoreClient(Fowler 第7.5节)+ 提取函数 × 4(Fowler 第6.1节)

Fowler 重构:提取类(7.5);对每个原始类型提取函数(6.1)

当前 FirestoreStore 混淆了两种职责:

  • 领域查询编排 —— 运行哪个查询、写入哪些文档、如何将结果解析为领域类型
  • Firestore HTTP 传输 —— 认证头、URL 构建、Firestore 二进制类型 JSON 编码/解码、PRECONDITION_FAILED 重试机制

根据 Fowler 第7.5节,当能识别出类数据和行为的连贯子集时应提取新类。传输职责包含:client: reqwest::Client、project_id: String、MetadataAuth,以及 documents_url() / auth_header()。将这些提取到新的 FirestoreClient 结构体中。

预估节省:FirestoreStore 实现部分减少约1200行;FirestoreClient 净增约120行。

#### 步骤2 — 提取函数:extract_doc_id 和 new_link(Fowler 第6.1节)

Fowler 重构:提取函数(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个解析函数各减少1行样板代码)。

#### 步骤3 — 提取函数:link查询流水线辅助函数(Fowler 第6.1节)

在 FirestoreStore trait 实现中运行 link 查询后,会出现两个子模式:

  • 模式A —— 从查询行中收集所有 link 文档(约15处)
  • 模式B —— 查询 links 并返回精确一个目标ID,若缺失则报错(约8处)

预估节省:约200行。

#### 步骤4 — 用函数调用替换内联代码 × 4:Firestore 值构造器

Fowler 重构:用函数调用替换内联代码(8.5)

在 codec 块之前添加四个私有自由函数(文件级,非方法)。将所有 128+ 个 json!({"stringValue": …}) / json!({"timestampValue": …}) 等内联表达式替换为对这些函数的调用。每个多词 json 宏调用变为单个简短调用。

预估节省:约80行(主要来自多行 json 宏折叠为单行)。

#### 步骤5 — 提取类:FieldsBuilder(Fowler 第7.3节)

Fowler重构:提取类(7.3)

这20个编码器函数都遵循相同的结构:

code
let mut fields = serde_json::Map::new();
fields.insert("foo".to_string(), str_val(&x.foo));
fields.insert("bar".to_string(), ts_val(x.bar));
json!({"name": path, "fields": fields})

提取一个小型构建器。将每个编码器函数重写为使用构建器。一个约40行的编码器可缩减至约12行。

预估节省:20个编码器函数总共约500-600行代码。

#### 第7步 - 移动函数:提取src/firestore/queries.rs

Fowler重构:移动函数(8.1)

将src/firestore.rs转换为模块目录:重命名为src/firestore/mod.rs。然后创建src/firestore/queries.rs,并将所有32个LinkQuery常量以及LinkQuery/EqFilter/EqValue/Ordering/Direction类型定义移动到该文件中。在mod.rs中添加pub(super) use queries::*;。

无行为变更;所有调用点已通过平面文件引用了作用域内的名称。

将mod.rs缩减约800行。

#### 第8步 - 移动函数:提取src/firestore/traits.rs

将所有17个pub trait定义(及其相关错误类型)移动到src/firestore/traits.rs。通过pub use traits::*;从mod.rs中重新导出。

将mod.rs缩减约1,900行。生成一个约1,900行的traits.rs文件,需要进一步分解。

#### 第9步 - 移动函数:将traits.rs拆分为traits/模块目录

通过将17个trait分组到四个领域对齐的文件中,将src/firestore/traits.rs转换为模块目录:

文件 | Trait | 行数 --- | --- | --- traits/planning.rs | ConcentrationStore, GoalStore, ItemStore, NoteStore, PursuitStore, FocusPassStore | ~650 traits/content.rs | CaptureStore, TagStore, UrlReferenceStore, DocumentStore, PaperStore | ~550 traits/people.rs | ThoughtworkerStore, ExternalContactStore, CompanyStore | ~300 traits/system.rs | SessionState, LinkStore, SuggestionStore, SuggestionVetoStore, OAuthTokenStore, MigrationLedger, EmbeddingStore, RuntimeConfigStore, SalesforceSyncStateStore | ~400

traits/mod.rs变为纯重新导出文件(约20行)。相关错误类型(FocusPassError、SuggestionDecisionError等)随生成它们的trait一起移动。

无trait定义变更,无调用点变更 - 仅重新定位。每个生成的文件为300-650行。

#### 第10步 - 移动函数:提取src/firestore/codec.rs

将所有文档编码器/解码器函数(*_document、parse_*_document、kind_str、parse_kind、parse_capture_source、parse_outcome等)以及来自第5步和第6步的FieldsBuilder和值构造器移动到src/firestore/codec.rs。将其设为pub(super)。

在第6步之后,该模块将从约1,200行缩减至约400-500行。

将mod.rs缩减约500行(第6步后)。

#### 第11步 - 移动函数:提取src/firestore/fake_store.rs

将FakeStore、FakeStoreInner以及FakeStore的18个trait实现块移动到src/firestore/fake_store.rs。通过pub use fake_store::FakeStore;从mod.rs中重新导出FakeStore。

FakeStoreInner和辅助方法保持模块私有。

将mod.rs缩减约4,700行。

#### 第12步 - 移动函数:将FirestoreStore实现拆分为每个trait文件,存放在src/firestore/store/下

创建 src/firestore/store/mod.rs 文件,定义 FirestoreStore 结构体,实现 FirestoreStore(构造函数 + 步骤1中的 FirestoreClient)以及 MetadataAuth

然后按逻辑领域分组创建单个文件

每个文件仅包含 use super::*;(或显式导入)和 trait 实现块。不包含类型定义,不包含辅助函数。被多个实现块使用的辅助函数保留在 store/mod.rs 中

将原本约10,000行的文件拆分为10个文件,每个文件长度在120至650行之间。mod.rs 变成约100行的重新导出声明文件

#### 步骤13 —— 移动函数:将测试用例与对应模块放在一起

现有的 #[cfg(test)] 模块测试特定领域功能,应与步骤12创建的模块放在一起,而不是放在单独的 tests.rs 文件中。每个测试模块移动到目标文件底部的 #[cfg(test)] mod tests { ... } 块中,并通过 use super::*; 访问模块内部实现。测试用例本身无需修改,仅调整位置

任何已存在于 fake_store.rs 中的共享测试用例(如 FakeStore::new、辅助构建器)可通过现有的 use super::fake_store::FakeStore 导入链访问

将 mod.rs 减少约2,000行;每个目标文件增加200-700行测试代码,这些测试代码直接邻近被测试的代码

最新文章(7月30日):

重构的经济效益

上一篇文章:

使用本地模型进行编码的实践经验 /think