重构的经济效益:一项面向 AI 代理的实证实验

本文是《探索生成式AI》系列的一部分,该系列记录了Thoughtworks技术人员探索生成式AI(Gen AI)在软件开发中应用的实践。
2026年7月30日
为了熟悉智能体工程(agentic engineering)这一新领域,我开发了一款辅助工作的应用。这款应用功能复杂:不仅拥有支持动态刷新、数据查询、弹窗和自动保存的高质量网页界面,还集成了外部系统,具备机器学习与文本分析能力,支持后台任务,并且拥有一套配置完善、部署全自动化的运行环境。应用代码总量约15万行,主要基于Rust语言(约12万行),剩余部分采用TypeScript和Terraform编写。
而这所有代码,均由AI智能体完成编写——主要是Claude Code,也用到了Cursor。除了出于好奇偶尔查看,我没有阅读或审核任何一行代码。
开发过程中,我发现一些问题。某次看到终端滚动显示对某文件第4000行的修改后,我仔细检查了代码,发现数据访问层已经膨胀到6000多行。随着新功能不断添加,代码量持续增长:每一个查询、读取或写入操作,都重复着相同的HTTP请求配置、JSON编解码流程。最终,这个单一的Rust文件竟达到了17155行。
重构实验
这个17155行的文件就是完整的数据访问层,是一个独立的模块。查看代码后我发现,它完全没有去重,没有形成内部抽象,函数和类的提取程度极低,但对外接口边界清晰,是重构的绝佳对象。
对智能体生成的代码库进行重构,核心目标是通过当前投入的token成本,降低未来开发的token消耗。本次实验要验证的是:随着对该文件的重构,在代码库中实现新功能所需的token成本会逐步下降。
恰恰因为智能体不具备学习能力,这项实验才得以开展。我可以在每一轮重构完成后,让一个全新的智能体执行完全相同的修改操作。与人类工程师不同,智能体不会从之前的步骤中学习,因此实验不会受到经验干扰。
实验流程如下:
- 制定完整的重构计划,严格遵循重构规范。
- 设计一个具有代表性的功能修改需求,用单条提示词描述。
- 建立修改成本基线:让子智能体执行该提示词,并要求其报告token消耗情况。
- 丢弃本次修改。
- 循环执行以下步骤:
- 执行重构计划中的单一步骤。
- 让子智能体执行完全相同的修改操作,记录token消耗。
- 丢弃本次修改。
- 记录所有token成本、修改执行时间,以及每一步重构后的代码行数,包括基线数据。
代表性修改所用的提示词和具体重构步骤见下文附录。
需要说明的是:尽管Claude会显示token计数、报告会话token消耗并据此计费,但目前它无法提供可靠的实时token统计方法。我认为这是一个会逐步改善的临时问题。因此,实验中由子智能体统计接收和发送的字符数,再通过tiktoken工具,将字符数除以4来估算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(提取链接查询辅助函数) | 16,567 | 16,567 | 49,771 | 154,054 | 2,105 | 524 |
| 步骤4(提取FakeStore断言函数) | 16,577 | 16,577 | 49,781 | 154,146 | 2,060 | 654 |
| 步骤5(提取值构造函数) | 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/模块) | 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/模块) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |
| 步骤13(测试代码与模块同目录存放) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |
| 步骤14(完善fake_store.rs) | 16,553 | 7,225 | 49,757 | 107,205 | 2,453 | 523 |
| 步骤15(拆分store/模块) | 16,608 | 3,695 | 49,812 | 27,360 | 2,113 | 454 |
本次实验的核心指标包括:数据访问层的总代码行数、数据访问层中最大单个文件的代码行数,以及执行代表性修改时消耗的输入token数。

该图表展示了四项指标的变化:第一个数据点是基线(步骤0),后续数据点对应每一步重构完成后的指标。
- 数据访问层总代码行数:初始阶段仅包含一个文件,随着重构推进,逐渐拆分为多个文件,最终形成19个Rust文件。
- 数据访问层最大单个文件代码行数:初始时即为整个数据访问层的代码量,重构后最大文件变为一个测试库,后续还可对其继续应用相同的重构方法。
- 子智能体执行代表性修改时消耗的总输入token数。
- 子智能体执行代表性修改时生成的总输出token数。
重构降低token消耗
实验结果十分明确:在最大文件的代码行数开始下降前,输入token数保持相对平稳;随后token数逐步减少,最终用Claude的话说,出现了"断崖式下跌"。
从基线到最终重构完成,完成相同任务所需的输入token数从159,564降至27,360,减少了132,204个token,降幅达83%。这一节省并非一次性的——此后所有涉及数据访问层的修改,token成本都会显著降低。
具体能节省多少成本?按撰写本文时Sonnet 5的定价(每百万token3美元)计算,每次修改可节省约39.7美分。这个数字看似不大,但如果放大到整个项目呢?在调试、开发更复杂功能时,能带来多少累计节省?本次仅重构了代码库的一部分,如果对整个代码库进行全面重构,能实现多大范围的成本节约?而重构本身又需要多少token成本?
token消耗减少的原因,是智能体需要读取的代码量减少了,但并非因为代码总量变少——数据访问层的总代码行数基本保持稳定。要实现这一成本节约,关键在于智能体能够准确识别出完成任务所需读取的最小代码子集。实验结果表明,智能体确实做到了这一点。观察Claude Code执行修改时的思考输出和文件读取记录也能发现,每一轮重构后,子智能体读取的代码范围都在逐步缩小。
换句话说,单纯将大文件随机拆分成小文件,效果可能十分有限:即便单个文件变小,智能体仍可能需要遍历多个文件才能找到相关代码。尽管效果最显著的步骤出现在实验后期,但前期的重构工作为最终的成本节约奠定了基础。这并非刻意设计的结果,而是符合常规重构的自然流程:先通过局部修改消除重复代码,待核心逻辑逐渐清晰后,再将代码拆分为更小的文件。
重构并未减少代表性修改的工作量——生成代码时的输出token数基本没有变化。虽然输出token的单价是输入token的五倍,但输出token的总量远少于输入token。那么,是否存在能减少输出token消耗的重构方式?这需要更复杂的测试案例来探索,当前非确定性代码生成过程中的干扰,掩盖了代码结构变化可能带来的差异。
实验过程说明
Claude并不擅长重构。查看下文的提示词和重构步骤就能发现,它生成的重构方案完全是对提示词的直接响应。Claude无法自主分析代码、判断适合的重构方式,必须由人类主动引导。这与我在开发这款应用时的整体经验一致:开发流程中包含明确的重构环节,但该环节并未促使Claude主动优化这个文件。另外,从经验来看,Claude.ai的重构能力优于Claude Code——我曾用两个工具分别制定重构计划,Claude Code仅提出提取函数作为第一步,而Claude.ai则更进一步,提出了提取完整客户端类的方案。
Claude执行重构的能力也欠佳。实际的重构操作是通过Python脚本调用grep和sed完成的,但这些脚本经常因缩进问题出错,颇具讽刺意味。此外,第一轮重构中遗漏了最关键的一项操作,不得不作为后续步骤补充执行,这也导致图表中的步骤数与附录中的重构步骤不完全对应。
整个实验耗时约8小时,大部分时间无需人工干预。唯一的一次介入是在6小时40分后,发现实验看似已完成,但实际跳过了关键步骤,需要重新引导。实验期间我使用的是酒店的慢速WiFi,起初我以为这是耗时较长的原因,但深入分析代码库后发现,cargo的临时构建缓存过大,导致测试执行速度大幅下降,这才是主要因素。
后续工作与广泛意义
遗憾的是,我在实验完成后才意识到,应该统计制定和执行重构计划所需的token数量。我查看了实验期间的token消耗总量,包括两次制定重构计划、设计实验(含代表性修改案例)以及其他相关任务,但无法准确区分出重构本身消耗的token数量,只能确定上限约为500万。未来的研究应更精准地统计重构的token成本。
本次实验仅针对一个仍处于初始阶段、由单人开发维护的大型应用,但我认为这是极具探索价值的第一步。它不仅展示了重构在时间和成本方面的价值,也为衡量重构成本提供了参考。后续可以进一步探索更复杂的修改场景、更全面的重构策略、持续重构模式,以及不同重构方法的相对价值。
这仅仅是个开始。
附录
注:附录包含我使用的提示词和智能体的输出内容,仅删除了具体的代码修改部分。内容未做其他编辑,旨在展示如何引导智能体,不存在隐藏技巧。因此部分表述可能存在混淆,相关错误均来自原始内容。
代表性修改提示词
以下是发送给每个子智能体的提示词,除代码库和配套的架构文档外,未提供其他上下文。所有子智能体的初始信息完全一致。
你正在~/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>>
关注关系存储在Firestore的item_watches集合中,每个文档包含以下字段: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文件。这是一个17000行的Rust文件,显然过长,且几乎可以肯定没有使用内部抽象来构建和管理查询。请制定并描述一系列重构步骤,大幅减少该文件的代码行数,且完全不改变对外接口。请勿执行重构操作。
以下是从Claude制定并执行的计划中提取的重构步骤说明。原始计划包含预期的代码修改,每个重构步骤都列出了具体操作,且每一步都可单独测试——这比大多数人类工程师的重构流程更严谨。
由于Claude在第一轮重构中遗漏了最关键的步骤(将存储模块拆分为多个子文件),后续不得不补充了两个步骤,因此此处列出的步骤与前文测量数据中的步骤并非完全对应。
步骤1:提取类FirestoreClient(Fowler §7.5)+ 提取函数×4(Fowler §6.1)
Fowler参考:《提取类》(7.5);为每个基础操作执行《提取函数》(6.1)
FirestoreStore当前承担了两项职责:
- 领域查询编排——执行何种查询、写入哪些文档、如何将结果解析为领域类型
- Firestore HTTP传输——认证头、URL构造、Firestore wire类型的JSON编解码、PRECONDITION_FAILED错误重试
根据Fowler §7.5,当类的部分数据和行为可构成独立职责时,应提取为新类。传输职责涉及client: reqwest::Client、project_id: String、MetadataAuth,以及documents_url()和auth_header()方法。将这些内容提取为新的FirestoreClient结构体。
预计代码量变化:FirestoreStore实现减少约1200行;新增FirestoreClient约120行,净减少约1080行。
步骤2:提取函数extract_doc_id和new_link(Fowler §6.1)
Fowler参考:《提取函数》(6.1)
extract_doc_id:所有20个parse_*_document函数开头都重复出现doc.name.rsplit('/').next()?.to_string(),将其提取为函数。new_link:创建包含metadata: HashMap::new()、provenance: None和新UUID的Link结构体的代码出现了62次,将其提取为工厂函数。
**预计代码量变化:**减少约500行(62个约10行的结构体初始化变为约2行的函数调用;20个解析函数各减少1行样板代码)。
步骤3:提取链接查询流水线辅助函数(Fowler §6.1)
Fowler参考:《提取函数》(6.1)
FirestoreStore trait实现中,执行链接查询后存在两种重复模式:
- 模式A:从查询结果中收集所有链接文档(约15处)。
- 模式B:查询链接并返回唯一目标ID,不存在则报错(约8处)。
**预计代码量变化:**减少约200行。
步骤4:提取FakeStoreInner上的FakeStore链接断言函数(Fowler §6.1)
Fowler参考:《提取函数》(6.1)
在FakeStore实现中,约15个方法重复出现inner.links.iter()...的变体。
在FakeStoreInner上提取两个方法,使15个调用点简化为单行代码。对于需要额外过滤条件(如同时检查to_kind)的方法,可在调用辅助函数后链式调用.into_iter().filter(…)。
**预计代码量变化:**减少约120行。
步骤5:用函数调用替代内联代码×4:Firestore值构造函数(Fowler §8.5)
Fowler参考:《用函数调用替代内联代码》(8.5)
在 codec 代码块前添加4个私有自由函数(文件级,非方法),将所有128处以上的json!({"stringValue": …})/json!({"timestampValue": …})等内联表达式替换为函数调用。多词的json宏调用将简化为简短的函数调用。
**预计代码量变化:**减少约80行(主要来自多行json宏简化为单行)。
步骤6:提取类FieldsBuilder(Fowler §7.3)
Fowler参考:《提取类》(7.3)
约20个编码器函数均遵循以下结构:
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)
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(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
将所有17个pub trait定义(及其关联的错误类型)移至src/firestore/traits.rs,在mod.rs中通过pub use traits::*;重新导出。
代码量变化:mod.rs减少约1900行,生成约1900行的traits.rs,需进一步拆分。
步骤9:移动函数:将traits.rs拆分为traits/模块目录(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
将src/firestore/traits.rs转换为模块目录,将17个trait按领域分为4个文件:
| 文件 | 包含的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(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
将所有文档编解码函数(*_document、parse_*_document、kind_str、parse_kind、parse_capture_source、parse_outcome等),以及步骤5和6中提取的FieldsBuilder和值构造函数,移入src/firestore/codec.rs,并设置为pub(super)可见性。
经过步骤6的重构后,该模块约400-500行,而非原来的约1200行。
代码量变化:mod.rs减少约500行(步骤6后)。
步骤11:移动函数:拆分出src/firestore/fake_store.rs(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
将FakeStore、FakeStoreInner以及FakeStore的所有18个trait实现块移至src/firestore/fake_store.rs,在mod.rs中通过pub use fake_store::FakeStore;重新导出FakeStore。
FakeStoreInner和辅助方法保持模块私有。
代码量变化:mod.rs减少约4700行。
步骤12:移动函数:将FirestoreStore实现按trait拆分到src/firestore/store/目录下的文件中(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
创建src/firestore/store/mod.rs,包含FirestoreStore结构体定义、impl FirestoreStore(构造函数+步骤1中的FirestoreClient)以及MetadataAuth。
然后按领域逻辑分组创建文件,每个文件仅包含use super::*;(或显式导入)和对应的trait实现块,不包含类型定义或辅助函数。被多个实现块共用的辅助函数保留在store/mod.rs中。
**代码量变化:**将原本约10000行的文件拆分为10个120-650行的文件,mod.rs变为约100行的导出清单。
步骤13:移动函数:测试代码与模块同目录存放(Fowler §8.1)
Fowler参考:《移动函数》(8.1)
现有的#[cfg(test)]模块针对特定领域进行测试,应与步骤12创建的模块放在一起,而非单独的tests.rs文件。将每个测试模块移至目标文件底部的#[cfg(test)] mod tests { … }块中,通过use super::*;访问模块内部内容。不修改任何测试代码,仅调整位置。
已在fake_store.rs中的共享测试夹具(如FakeStore::new、辅助构建器)可通过现有导入链use super::fake_store::FakeStore访问。
代码量变化:mod.rs减少约2000行;每个目标文件增加200-700行测试代码,与被测代码直接相邻。
