Reed's News
← 返回精选

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

AI 82 javaeeeee 2026/7/30 2693 字 原文 ↗

本文是《探索生成式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成本会逐步下降。

恰恰因为智能体不具备学习能力,这项实验才得以开展。我可以在每一轮重构完成后,让一个全新的智能体执行完全相同的修改操作。与人类工程师不同,智能体不会从之前的步骤中学习,因此实验不会受到经验干扰。

实验流程如下:

  1. 制定完整的重构计划,严格遵循重构规范。
  2. 设计一个具有代表性的功能修改需求,用单条提示词描述。
  3. 建立修改成本基线:让子智能体执行该提示词,并要求其报告token消耗情况。
  4. 丢弃本次修改。
  5. 循环执行以下步骤:
    • 执行重构计划中的单一步骤。
    • 让子智能体执行完全相同的修改操作,记录token消耗。
    • 丢弃本次修改。
  6. 记录所有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::Clientproject_id: StringMetadataAuth,以及documents_url()auth_header()方法。将这些内容提取为新的FirestoreClient结构体。

预计代码量变化:FirestoreStore实现减少约1200行;新增FirestoreClient约120行,净减少约1080行。

步骤2:提取函数extract_doc_idnew_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 ConcentrationStoreGoalStoreItemStoreNoteStorePursuitStoreFocusPassStore ~650
traits/content.rs CaptureStoreTagStoreUrlReferenceStoreDocumentStorePaperStore ~550
traits/people.rs ThoughtworkerStoreExternalContactStoreCompanyStore ~300
traits/system.rs SessionStateLinkStoreSuggestionStoreSuggestionVetoStoreOAuthTokenStoreMigrationLedgerEmbeddingStoreRuntimeConfigStoreSalesforceSyncStateStore ~400

traits/mod.rs变为纯导出文件(约20行),关联的错误类型(如FocusPassErrorSuggestionDecisionError等)随所属trait一起移动。

**变化说明:**不修改任何trait定义和调用点,仅调整文件位置。最终每个文件行数在300-650之间。

步骤10:移动函数:拆分出src/firestore/codec.rs(Fowler §8.1)

Fowler参考:《移动函数》(8.1)

将所有文档编解码函数(*_documentparse_*_documentkind_strparse_kindparse_capture_sourceparse_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)

FakeStoreFakeStoreInner以及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行测试代码,与被测代码直接相邻。