Экономическая выгода рефакторинга в эпоху AI-агентов

от автора

Осваивая разработку с помощью AI-агентов, я написал веб-приложение для собственной ежедневной работы. Проект получился довольно сложным: с динамическим обновлением интерфейса и поиском, модальными окнами, автосохранением, интеграциями с внешними системами, модулями машинного обучения, текстовым анализом, фоновыми задачами и автоматическим деплоем. Объём кода составил около 150 000 строк, из которых примерно 120 000 написаны на Rust, а остальные — на TypeScript и Terraform.

Весь этот код сгенерировали агенты — в основном Claude Code и частично Cursor. За редкими исключениями я почти не открывал и не читал исходные файлы.

В процессе разработки я начал замечать странности. Когда в терминале мелькнула правка 4000-й строки в одном файле, я решил посмотреть на код ближе. Выяснилось, что слой доступа к данным разросся до 6000 строк. С каждой новой функцией он продолжал расти. В коде каждого запроса, чтения или записи повторялись настройка HTTP-запроса, кодирование и декодирование JSON. В итоге весь слой доступа к данным оказался в одном файле на 17 155 строк Rust.

Эксперимент по рефакторингу

Файл на 17 155 строк содержал весь слой доступа к данным. Это был изолированный модуль с чётким интерфейсом, который требовалось сохранить.

Внутри файла было много повторяющегося кода. Общие операции редко выносили в функции или отдельные структуры. Не было и собственного мини-языка (domain-specific language, DSL), который упростил бы построение запросов.

При этом модуль имел чёткий внешний интерфейс, который требовалось сохранить. Можно было менять внутреннее устройство модуля, не затрагивая код, который к нему обращался. Поэтому он хорошо подходил для эксперимента с рефакторингом.

В кодовой базе, которую поддерживают AI-агенты, цель рефакторинга проста: потратить токены сейчас, чтобы снизить их расход на все последующие изменения. Эксперимент должен был проверить, уменьшится ли стоимость работы с кодом по мере структурирования монолитного файла.

Каждый новый запуск агента начинался без памяти о предыдущих попытках. Поэтому после каждого шага рефакторинга можно было открыть отдельную сессию и повторить тот же промпт. В отличие от разработчика, агент не накапливал знания о задаче, которые могли бы повлиять на следующий результат.

Схема эксперимента выглядела так:

  1. Подготовить общий план рефакторинга по строгим правилам дисциплины рефакторинга.

  2. Сформулировать типовую задачу по добавлению фичи в виде одного промпта.

  3. Измерить базовую стоимость изменений: запустить отдельного субагента с этим промптом и запросить отчёт о потраченных токенах.

  4. Отменить внесённые изменения.

  5. Повторять в цикле:

    • Выполнить один шаг рефакторинга.

    • Запустить свежего субагента с точно таким же промптом и записать расход токенов.

    • Отменить изменения фичи.

  6. Зафиксировать расход токенов, время выполнения и количество строк кода после каждого шага.

Промпт для типовой задачи и применённые шаги рефакторинга приведены в приложении в конце статьи.

Важное уточнение: Claude не предоставляет надёжного способа считать токены в реальном времени, хотя показывает их количество, сообщает расход за сессию и использует его при расчёте стоимости. Я исхожу из того, что это временное ограничение. В эксперименте субагент сообщал количество полученных и отправленных символов. Затем я оценивал расход с помощью библиотеки tiktoken, исходя примерно из 4 символов на токен.

Результаты измерения

Шаг

Строк в слое данных

Макс. строк в файле

Всего строк Rust

Входные токены

Выходные токены

Время (с)

Baseline

17 155

17 155

50 359

159 564

1 705

342

Step 1 (FirestoreClient)

16 706

16 706

49 910

155 205

1 723

530

Step 2 (extract_doc_id, new_link)

16 562

16 562

49 766

159 227

2 105

574

Step 3 (link-query helpers)

16 567

16 567

49 771

154 054

2 105

524

Step 4 (FakeStore predicates)

16 577

16 577

49 781

154 146

2 060

654

Step 5 (value ctors)

16 469

16 469

49 673

171 251

2 036

1 353

Step 6 (FieldsBuilder)

16 469

16 469

49 673

171 251

2 036

1 353

Step 7 (queries.rs)

16 474

15 670

49 678

151 850

1 800

587

Step 8 (traits.rs)

16 508

13 845

49 712

132 558

1 723

446

Step 9 (traits/ split)

16 508

13 845

49 712

132 558

1 723

446

Step 10 (codec.rs)

16 521

12 846

49 725

131 871

1 750

540

Step 11 (fake_store.rs)

16 535

11 122

49 739

133 016

2 460

600

Step 12 (store/ split)

16 550

9 269

49 754

104 080

2 050

490

Step 13 (co-locate tests)

16 550

9 269

49 754

104 080

2 050

490

Step 14 (complete fake_store.rs)

16 553

7 225

49 757

107 205

2 453

523

Step 15 (store/ split)

16 608

3 695

49 812

27 360

2 113

454

Ключевые метрики здесь — общий объём слоя данных в строках, размер самого большого файла в этом слое и количество входных токенов, затраченных на типовое изменение.

На графике отображены четыре показателя. Точка 0 — это базовое состояние (Baseline), а последующие точки показывают метрики после каждого этапа рефакторинга:

  1. Data Access Layer (LOC) — количество строк кода во всём слое доступа к данным. Сначала весь слой находился в одном исходном файле, а к концу эксперимента его код был распределён по 19 файлам на Rust.

  2. Largest File (LOC) — количество строк в крупнейшем файле слоя. Вначале этим файлом был весь исходный монолит. В конце крупнейшим файлом оказалась вспомогательная библиотека тестов.

  3. Input Tokens — входные токены, потраченные субагентом на типовое изменение.

  4. Output Tokens — выходные токены, сгенерированные субагентом при написании кода.

Как рефакторинг снижает расход токенов

В этом прогоне объём входных токенов оставался высоким, пока размер крупнейшего файла не начал сокращаться. После разделения файла расход токенов резко упал.

Между исходным кодом и финальным этапом рефакторинга количество входных токенов на выполнение одной и той же задачи снизилось со 159 564 до 27 360. Экономия составила 132 204 токена, или 83%. И это не одноразовый эффект: каждое следующее изменение в этом модуле теперь обходится значительно дешевле.

Сколько это в деньгах? По действовавшей на момент публикации цене Sonnet 5 — $3 за 1 млн входных токенов — экономия составляет около 39,7 цента на одну задачу. На первый взгляд немного. Но будет ли эффект накапливаться при отладке, разработке более сложных функций и частых итерациях? Можно ли получить похожую экономию после рефакторинга других частей системы? И сколько токенов потребует сам рефакторинг? Этот эксперимент не отвечает на такие вопросы.

Вероятная причина экономии — агенту приходилось читать меньше кода. Общий размер слоя данных практически не изменился и остался на уровне 16,5 тыс. строк. Чтобы сэкономить токены, агент должен был находить минимальный набор файлов, необходимый для изменения. Результаты эксперимента указывают, что это происходило. Сообщения Claude Code о ходе работы и отчёты о прочитанных файлах также показывали, что с каждым шагом агент обращался ко всё меньшим и более точным фрагментам репозитория.

Простая нарезка монолита на случайные файлы вряд ли дала бы такой результат. Если файлы разделены без понятной логики, агенту всё равно придётся прочитать многие из них в поисках нужной функции. Самый заметный эффект возник на последнем шаге, но предыдущие рефакторинги подготовили это разделение. Я не планировал такой результат заранее. Процесс развивался обычным для рефакторинга способом: сначала агент устранял локальные повторы, а затем разделил код на модули, когда стала видна общая повторяющаяся логика.

Рефакторинг почти не повлиял на количество выходных токенов: объём генерируемого агентом кода оставался примерно одинаковым. Выходные токены стоили в 5 раз дороже входных, но их требовалось гораздо меньше. Мне нужен более сложный пример изменения, чтобы проверить, можно ли сократить и этот расход. Кроме того, недетерминированная генерация создаёт шум, который может скрывать небольшое влияние структуры кода на количество выходных токенов.

Особенности и ограничения процесса

В ходе эксперимента проявились характерные черты работы с агентами:

  1. Без подробного плана Claude не справился с рефакторингом. Агент не смог самостоятельно изучить большой файл, выбрать подходящие приёмы и выстроить последовательность изменений. В процессе разработки он получал отдельную команду провести рефакторинг, но так и не улучшил этот файл. Человеку пришлось задать направление и затем вести агента по шагам. При подготовке плана Claude.ai сразу предложил выделить FirestoreClient, а Claude Code начал с более локального приёма Extract Function — выделения функции.

  2. С выполнением плана тоже возникли проблемы. Claude применял изменения с помощью скриптов на Python, которые вызывали grep и sed. Скрипты регулярно ошибались при обработке отступов. В первом проходе агент также пропустил самое полезное изменение — разделение реализаций хранилища на отдельные модули. Этот рефакторинг пришлось завершить позже в два дополнительных этапа. Поэтому в плане 13 шагов, а в таблице результатов — 15.

  3. Инфраструктура влияла на длительность. Весь эксперимент занял около 8 часов и почти не требовал моего участия. Я вмешался только через 6 часов 40 минут, когда агент пропустил важный шаг. Сначала причиной задержки казался медленный Wi-Fi в отеле. Однако дальнейшая проверка показала, что временный кэш сборки cargo сильно разросся и замедлил тесты.

Дальнейшие шаги

Я не посчитал точный расход токенов на сам процесс рефакторинга. По общему расходу за время работы можно назвать только верхнюю границу — около 5 млн токенов. В эту оценку входят две попытки составить план, проектирование эксперимента и типового изменения, а также другие задачи. В будущих исследованиях стоит отдельно измерять стоимость рефакторинга.

Это один эксперимент на новом проекте без накопленного legacy-кода. Я разрабатываю и поддерживаю его один. Результат выглядит интересным первым шагом, но не общим доказательством экономической выгоды рефакторинга. Дальше стоит проверить более сложные изменения, рефакторинг других частей системы, непрерывный рефакторинг и относительную пользу разных подходов.

В этом эксперименте рефакторинг помог агенту читать меньше кода и снизил стоимость типового изменения. Но сколько стоил сам рефакторинг и повторится ли эффект в других условиях, пока неизвестно.

Приложения

Приложение 1. Промпт типового изменения

Этот промпт передавался каждому субагенту в абсолютно одинаковых условиях вместе с документацией по архитектуре:

You are working in the Rust project at ~/dev/your-project-name.

Add a new ItemWatchStore public async trait to the Firestore layer, following existing patterns exactly. The trait must have three methods:

  • 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>>

Watches are stored in a item_watches Firestore collection. Each document has fields: itemId (string), userId (string), createdAt (timestamp). There is no Rust struct for a watch record — the methods return Vec<String> (item ids).

Implement the trait for both FakeStore (using an in-memory Vec<(String, String)> field added to FakeStoreInner) and FirestoreStore (using the same HTTP patterns used for other store impls in this file).

At the very end of your response, output exactly this JSON block (fill in real values):

{ "files_read": [ {"path": "src/firestore.rs", "chars": 123456}, ... ], "response_chars": 7890 }

Do NOT commit the change. Stop after writing the code.

Приложение 2. Сокращённый перевод плана рефакторинга

Промпт для генерации плана рефакторинга:

Following the strict definition that a refactoring is a provably correctness preserving series of code edits, and using Martin Fowler’s 2nd edition of Refactoring as the source, examine @src/firestore.rs. This is a 17K LoC Rust file. No file should be that long. It is almost certainly not using an internal language to build and manage queries. Produce and describe, but don’t execute, a sequence of refactorings that would massively reduce the line count of that file, without changing the interface at all.

Claude составил подробный план с предполагаемыми изменениями. Каждый шаг можно было проверить отдельно, и после каждого шага запускались тесты. Приведённые ниже 13 пунктов не совпадают с 15 этапами измерения в таблице: агент сначала пропустил разделение реализаций хранилища на файлы, а затем выполнил его в два дополнительных этапа.

Шаг 1 — Extract Class: FirestoreClient (Fowler §7.5) + Extract Function × 4 (Fowler §6.1)

Выделение HTTP-транспорта (reqwest::Client, project_id, MetadataAuth) из FirestoreStore в отдельную структуру FirestoreClient.

Оценка: реализации FirestoreStore сократятся примерно на 1200 строк, а новый FirestoreClient добавит около 120 строк.

Шаг 2 — Extract Function: extract_doc_id и new_link (Fowler §6.1)

Выделение повторяющегося разбора ID документа и фабричной функции для создания Link.

Оценка экономии: около 500 строк.

Шаг 3 — Extract Function: вспомогательные функции конвейера запросов связей (Fowler §6.1)

Выделение функций для двух повторяющихся операций: собрать документы связей из результатов запроса и найти единственный целевой ID.

Оценка экономии: около 200 строк.

Шаг 4 — Extract Function: предикаты связей в FakeStoreInner (Fowler §6.1)

Выделение двух методов для повторяющихся проходов по связям в тестовом хранилище.

Оценка экономии: около 120 строк.

Шаг 5 — Replace Inline Code with Function Call: конструкторы значений Firestore (Fowler §8.5)

Замена более 128 встроенных выражений json!({"stringValue": ...}) и похожих конструкций вызовами четырёх приватных функций.

Оценка экономии: около 80 строк.

Шаг 6 — Extract Class: FieldsBuilder (Fowler §7.3)

Создание билдера для словарей полей документов.

Оценка экономии: около 500-600 строк.

Шаг 7 — Move Function: src/firestore/queries.rs (Fowler §8.1)

Вынос 32 констант LinkQuery и связанных типов в отдельный файл queries.rs.

Основной файл сократится примерно на 800 строк.

Шаг 8 — Move Function: src/firestore/traits.rs (Fowler §8.1)

Вынос 17 описаний публичных трейтов и связанных типов ошибок в traits.rs.

Основной файл сократится примерно на 1900 строк.

Шаг 9 — Move Function: разделение traits.rs на каталог traits/ (Fowler §8.1)

Разделение traits.rs на четыре доменных файла: planning.rs, content.rs, people.rs и system.rs.

Шаг 10 — Move Function: src/firestore/codec.rs (Fowler §8.1)

Вынос функций кодирования и декодирования документов, FieldsBuilder и конструкторов значений в codec.rs.

Основной файл сократится примерно на 500 строк.

Шаг 11 — Move Function: src/firestore/fake_store.rs (Fowler §8.1)

Вынос FakeStore, FakeStoreInner и реализаций всех 18 трейтов в fake_store.rs.

Основной файл сократится примерно на 4700 строк.

Шаг 12 — Move Function: разделение реализаций FirestoreStore по файлам (Fowler §8.1)

Разделение реализаций FirestoreStore на доменные файлы в каталоге src/firestore/store/. Вместо одного файла примерно на 10 000 строк должны получиться 10 файлов размером от 120 до 650 строк.

Шаг 13 — Move Function: перенос тестов к соответствующим модулям (Fowler §8.1)

Перенос тестов из общего блока непосредственно к тестируемым модулям. Сами тесты при этом не меняются.

ссылка на оригинал статьи https://habr.com/ru/articles/1065178/