iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 9

Day 9|AI Agent 一次重寫或分段 Commit:哪個容易還原,哪個完成後更好讀?

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天,我把同一項需求交給三種函式結構,觀察全新 Agent 會如何理解資料、又會把修改放在哪裡。我最後採用 CQS,但那只回答了資料目前該放哪裡,還沒回答修改走偏時要怎麼退回。

今天接著比較同一段清理工作:讓 Agent 一次做完,或每完成一個可驗證的理由就 Commit。

結果要拆成兩層看。加入相同錯誤後,分段 Commit 可以只撤回故障;可是只看這一次產出的最終 Code,一次重寫候選的批次彙整反而比較直接。

所以我的選擇不是二選一:工作過程採用可驗證、可還原的分段節奏,最後結構則保留當下最容易閱讀的即時計數方式。前者是可以重現的流程差異;後者只是兩份單次輸出的結構觀察,不能直接歸因於 Commit 節奏。

後文用「紅燈」表示測試失敗,「綠燈」表示測試通過。完整的 TDD Red/Green 節奏留到後面的測試文章;這裡只用來標記清理過程的驗證狀態。

Clean Code 同時關心清理過程與完成後的閱讀品質

《無瑕的程式碼 第二版》的〈整潔的方法〉處理清理週期;〈要有禮貌〉處理完成後的閱讀順序。今天把兩者分開驗收。

〈整潔的方法〉談的是怎麼把能動的程式碼逐步整理好

這裡的「方法」,既包含 C# Method,也包含清理程式碼的工作方法。

程式先能運作,並不代表整理已經完成。常見的節奏是:

先讓功能正確
→ 執行測試
→ 重新命名、抽取或重新編排
→ 再次執行測試
→ 確認下一步是否仍有明確價值

也就是從 Make It Work 走向 Make It Right:先讓它能動,再讓它容易理解、測試與修改。

清理過程要反覆確認三件事:這一步解決了什麼問題、行為是否仍正確,以及判斷錯誤時能不能只放棄這一步。

〈要有禮貌〉談的是整理完成後,下一位讀者怎麼讀

書中的報紙比喻是:讀者先看到標題與高階摘要,再逐層進入細節。程式碼入口也該先交代高階意圖,後面的段落再回答讀者接著會問什麼。

例如,看到「處理逾期工作項目」之後,讀者接著可能想知道:

  1. 系統先找出哪些項目?
  2. 每一筆如何改變狀態與發送通知?
  3. 整批結果怎麼彙整?
  4. 最後何時儲存並回傳?

若答案散落在不相鄰的位置,或為了拆小方法製造大量跳轉,即使每個函式都很短,閱讀仍然不算順。

今天分開驗收兩件事:

驗收面向 主要問題
清理流程 修改能不能定位、驗證與單獨還原?
最終結構 高階意圖、抽象層次與閱讀路徑是否清楚?

Commit 數量只能描述工作節奏,不能直接替完成後的 Code 宣布「有禮貌」。

我選擇一段行為已正確、責任仍混在一起的逾期流程

實驗起點延續昨天接受的版本。ProcessOverdue 已經能由上而下讀出四個主要步驟:

public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
    CancellationToken cancellationToken)
{
    var dueIncompleteWorkItems = await FindDueIncompleteWorkItemsAsync(cancellationToken);
    var processingSummary = await ProcessDueIncompleteWorkItemsAsync(
        dueIncompleteWorkItems,
        cancellationToken);

    // 通知必須先於狀態儲存;即使通知回報失敗,仍須儲存 Overdue 狀態。
    await database.SaveChangesAsync(cancellationToken);

    return Ok(processingSummary);
}

下一層的批次方法仍把四種責任塞在同一個迴圈裡:

  • 判斷單筆狀態是否需要改成 Overdue
  • 呼叫外部通知。
  • 累加處理數量、通知次數與失敗次數。
  • 保存通知失敗的工作項目 ID。

這段 Code 的既有行為已被測試鎖定,混合責任仍在。實驗會讓 Agent 在不新增功能的前提下,降低下一次閱讀與修改的成本。

清理目標只有一個:

保留由上而下的閱讀順序,分清楚「整批結果彙整」與「單筆工作項目處理」。

我不替方法設定行數或 Helper 數量;每新增一個方法或結果型別,Agent 都要說明它移除了哪一種混合責任。

CLEAN 原則先規定怎麼切變更、怎麼留下決策紀錄

今天主要使用兩項 CLEAN 原則。

L — Localized Change 局部變更:用修改理由切 Commit,檔案數只當線索

兩份候選都只修改同一個 Controller。若只數檔案,今天完全看不出差異。

L 先問:這個 Commit 能不能用一個理由完整說明?「分離單筆處理與批次彙整」和「改變逾期查詢規則」是兩個理由,即使都在同一個檔案,也應分開。

Commit 邊界不是按照行數平均切割,而是替一項已驗證決策留下可以單獨保留或放棄的位置。

A — Auditable by Evidence 實據可審:證據要能還原決策順序

一行「測試通過」只記錄終點,無法重建 Agent 中間是否曾經破壞行為。A 要保存下面這條決策時間線:

固定基準
→ 完成一項清理
→ 執行聚焦測試
→ 建立 Commit
→ 加入下一項修改
→ 測試紅燈
→ Revert 造成紅燈的修改
→ 再次通過完整驗證

「聚焦測試」是只針對這次修改最相關行為執行的測試集合。它能加快回饋,但不能取代最後的完整驗證。

本文把這套「單一理由修改、聚焦驗證、檢查 Diff、Commit 或 Revert」的短週期稱為 Cleaning Cycle(清理週期)

實驗只改變 Agent 的工作節奏

兩個隔離 Worktree 都從同一個 Commit 起跑,使用 Codex GPT-5.6-SOL-HIGH。需求、驗收條件與可修改範圍相同,只有 User 規定的清理節奏不同。

固定條件包括:

  • 只能修改 WorkItemsController.cs
  • Route、HTTP Status Code 與 Response JSON 不變。
  • 已經是 Overdue 的到期項目,重跑時仍要再次嘗試通知。
  • 通知回傳 false 時,仍要記錄失敗並儲存狀態。
  • 未知 Exception 與 Cancellation 不得儲存新狀態。
  • 通知必須發生在 SaveChangesAsync 之前。
  • 失敗 ID 必須維持實際通知嘗試順序。

每種節奏只保留一份正式輸出,所以最終結構差異只能當作觀察。後續由主流程加入相同受控故障,才能直接比較不同 Commit 邊界造成的 Revert 範圍。

實驗情境一:全部修改完成後,才做第一次驗證

第一種情境模擬一句範圍很模糊的指令:

幫我把這段 Code 整理乾淨。

Agent 可以先閱讀 Production Code、測試與 Repository 規則,也可以先形成完整設計;但在第一次執行測試前,必須完成全部修改,中間不能 Commit。

## 一次重寫規則

1. 先閱讀 Production Code、行為測試與 Repository Instruction。
2. 在第一次執行測試前完成全部程式碼修改。
3. 中間不建立 Commit。
4. 全部修改完成後,先執行 ProcessOverdue 聚焦測試,
   再執行完整 Build、Test 與 Format Gate。
5. 任一 Gate 失敗時停止,保留當下 Diff 與錯誤輸出,
   不得盲目修改到測試變綠。

Agent 可以一次看完整段 Code 並統一設計。代價是紅燈若在最後才出現,所有結構決策已混在同一份 Diff,User 得重新判斷哪些該留、哪些該撤回。

實驗情境二:每完成一個清理理由就驗證與 Commit

第二種情境允許 Agent 規劃最多三步,但一次只能完成一個能獨立說明的清理理由。

## 分段清理規則

1. 先列出最多三個可獨立驗證的清理步驟。
2. 一次只執行一個步驟。
3. 每一步先執行 ProcessOverdue 聚焦測試,
   通過後檢查 Diff,再建立 Commit。
4. 前一步失敗,或無法用單一理由說明時,停止。
5. 下一步只會增加跳轉、型別或映射,
   卻沒有移除新的混合責任時,停止。
6. 最後執行完整 Build、Test 與 Format Gate。
7. 任一 Gate 失敗時,回到最後一個綠燈 Commit。

每個 Commit 都是一個可還原的版本點,也應完成一項可以命名的清理理由,讓修改能被理解、驗證與單獨放棄。平均切分行數沒有這項價值。

兩份候選都守住既有行為,但批次結果的組裝方式不同

兩份候選都抽出了單筆處理 Helper,差異出現在整批結果如何彙整。

一次重寫候選:處理每一筆時直接累加結果

一次重寫候選讓單筆 Helper 只回傳兩項必要資訊:

private async Task<OverdueWorkItemProcessingResult> ProcessDueIncompleteWorkItemAsync(
    WorkItem workItem,
    CancellationToken cancellationToken)
{
    var statusChanged = RequiresOverdueStatusChange(workItem);

    if (statusChanged)
    {
        ChangeStatusToOverdue(workItem);
    }

    var notificationSucceeded = await notificationGateway.SendOverdueAsync(
        workItem,
        cancellationToken);

    return new OverdueWorkItemProcessingResult(statusChanged, notificationSucceeded);
}

批次方法取得結果後,直接在同一個迴圈累加統計與失敗 ID:

foreach (var workItem in dueIncompleteWorkItems)
{
    var processingResult = await ProcessDueIncompleteWorkItemAsync(
        workItem,
        cancellationToken);

    if (processingResult.StatusChanged)
    {
        overdueStatusChangeCount++;
    }

    notificationAttemptCount++;

    if (!processingResult.NotificationSucceeded)
    {
        notificationFailureCount++;
        failedNotificationWorkItemIds.Add(workItem.Id);
    }
}

這份候選把單筆處理與批次彙整分開,並在同一個迴圈裡完成統計與失敗 ID 收集。現在只有一種 Response,資料產生後立刻被使用,不需要保存整批結果。

分段候選:先保存完整結果,再統一建立 Response

分段候選同樣抽出單筆 Helper,但結果型別還會帶回 WorkItemId。批次方法先保存每一筆結果:

var processingResults = new List<DueIncompleteWorkItemProcessingResult>(
    dueIncompleteWorkItems.Count);

foreach (var workItem in dueIncompleteWorkItems)
{
    var processingResult = await ProcessDueIncompleteWorkItemAsync(
        workItem,
        cancellationToken);
    processingResults.Add(processingResult);
}

最後再使用 LINQ 建立 Response:

return new ProcessOverdueResponse(
    processingResults.Count(result => result.OverdueStatusChanged),
    processingResults.Count,
    processingResults.Count(result => !result.NotificationSucceeded),
    processingResults
        .Where(result => !result.NotificationSucceeded)
        .Select(result => result.WorkItemId)
        .ToArray());

若後續的稽核、事件發布或第二個消費者都需要完整處理結果,保留集合才有重用價值。目前它只用來建立一次 Response,卻會隨工作項目數量成長,讀者也要到方法尾端才看見彙整規則。

兩份候選都通過既有行為關卡,因此可以進入結構比較。測試全綠守住 API 行為;閱讀品質仍要另外 Review。

Commit 容易還原,與完成後的程式碼是否好讀,是兩種不同品質

我先不看 Git 歷史,只把兩份 Controller 當成下一位讀者第一次打開的 Code。

兩份候選的高階入口與方法跳轉深度相同:

ProcessOverdue
├── 找出已到期且尚未完成的項目
├── 逐筆處理並建立摘要
├── 儲存狀態
└── 回傳結果

差異主要發生在批次結果怎麼被理解:

閱讀判準 一次重寫候選 分段候選
批次統計如何形成 在處理當下直接累加 先保存所有單筆結果,再統一查詢
失敗 ID 從哪裡取得 使用當下的 workItem.Id 從結果型別再次投影
新增的中介狀態 計數器與失敗 ID 集合 完整單筆結果集合
目前情境的價值 唯一 Response,可直接彙整 尚未出現第二個消費者
什麼情況可能反轉選擇 後續仍只有單一摘要 稽核、事件或其他流程需要完整結果

以〈要有禮貌〉談的閱讀順序來看,目前我會選一次重寫候選的即時計數方式。處理完一筆後,下一段就回答「如何累加這筆結果」,不必先保存整批資料,再到方法尾端重新解讀。

目前沒有第二個消費者,所以我不保留完整結果集合。稽核、事件或其他流程真的需要逐筆結果時,分段候選才會成為較合理的選擇。

這項結構選擇和 Agent 是否分段 Commit 是兩回事。分段節奏可以產生較容易回退的歷史,也仍可能留下目前用不到的中介狀態。

為了公平比較 Revert 範圍,我替兩條路徑加入同一個重跑錯誤

如果讓兩個 Agent 自然犯錯,錯誤位置與影響範圍都不同,Commit 邊界就無法公平比較。

兩份 Agent 輸出完成後,主流程各加入同一個 Controlled Fault(受控故障)。這是實驗者刻意植入的固定錯誤,只用來比較相同失敗落在不同 Commit 邊界時的結果;它不是 Agent 原始輸出,也不能拿來排名模型能力。

我加入的錯誤,是讓查詢排除已經是 Overdue 的項目:

.Where(workItem =>
    workItem.Status != "Completed" &&
    workItem.Status != "Overdue" &&
    workItem.DueAtUtc <= currentUtc)

這項修改第一眼很合理:「既然已經逾期,為什麼還要再找一次?」

但 Repository 的真實行為是:

  1. 第一次執行時,把 Open 改成 Overdue 並嘗試通知。
  2. 第二次執行時,不再重複計算狀態變更,但仍要再次嘗試通知。

排除 Overdue 之後,第一次執行看起來正常;第二次查詢卻找不到原本應該再次通知的項目。

兩條路徑的錯誤已固定,接下來只比較 Revert 會帶走哪些修改。

相同的重跑錯誤,因為 Commit 邊界不同而留下兩種結果

兩條路徑都用 Git Revert 撤銷故障 Commit,差別在故障是否和清理綁在一起。

一次重寫:清理與錯誤被綁在同一次交付

主流程另外替乾淨的 Agent 輸出建立 Tag,方便公開比對;模擬一次交付時,清理與受控故障仍被放在同一個 Commit:

原始基準
→ 清理+受控故障
→ 測試紅燈
→ Revert 整包 Commit
→ 錯誤消失,清理也一起消失

整包 Revert 之後,User 若想保留清理,仍要手動拆 Diff 或重新套用乾淨 Tag。只要在加入故障前先保存清理 Commit,流程就已經變成分段邊界。

分段 Commit:只撤回造成紅燈的查詢修改

分段路徑先保存已通過驗證的清理,再把受控故障放進下一個獨立 Commit:

原始基準
→ 完成清理並通過測試
→ 建立清理 Commit
→ 加入受控故障
→ 測試紅燈
→ 只 Revert 受控故障
→ 錯誤消失,已驗證的清理仍然保留

相同錯誤放進不同 Commit 邊界後,一次重寫路徑連乾淨清理一起撤回;分段路徑只撤回故障。這項流程差異可以直接重現。

兩份最終結構每種只有一份正式輸出,因此無法把結構差異全歸因於工作節奏。

一次重寫與分段 Commit 在失敗時的回退範圍比較

圖:分段 Commit 能縮小失敗時的回退範圍,但完成後的程式碼仍要另外驗收可讀性與結構。

我接受分段工作節奏,但不整份採用分段候選的結構

這正是今天最容易混在一起的兩個答案:我接受分段驗證與還原的工作節奏,不代表分段候選的每一行 Code 都自動通過驗收。

如果這是正式產品重構,我會採用四個步驟:

  1. 讓 Agent 每次只完成一個清理理由。
  2. 每一步通過聚焦測試後再建立 Commit。
  3. 發生紅燈時,只撤回造成失敗的那一步。
  4. 全部完成後,重新檢查閱讀順序與結構成本。

以今天的案例來說,工作過程採用分段 Commit;最後的批次彙整,則收斂到一次重寫候選較直接的即時計數方式。

Commit 邊界決定故障能否局部撤回,完成後的閱讀順序仍要獨立 Review。這兩項品質不能互相代替。

哪些修改可以一次完成,哪些應該採用分段週期?

我不會要求所有 AI Coding 都拆成大量 Commit。是否分段,應由風險與中間成果的保留價值決定。

修改情境 建議節奏 原因
Formatter 能機械完成的排版 一次完成 規則明確,失敗時整包放棄即可
編譯器或 Analyzer 能完整約束的 Rename 一次完成 正確性可由工具快速確認
小範圍、單一理由、聚焦測試很快 一次完成或單一 Commit 沒有值得保留的中間成果
修改穿過資料流、副作用或例外 分段週期 失敗原因與影響範圍較難定位
涉及交易、外部契約或持久化行為 分段週期 錯誤可能跨越多個系統邊界
已經包含兩個以上修改理由 先拆分 每個理由應能獨立驗證與還原

切分依據是修改理由與風險邊界。檔案數只能當線索,不能直接決定 Commit 數量。

清理週期還要知道何時停下來

分段情境允許 Agent 規劃最多三步,最後它只完成一步就停止。

單筆處理與批次彙整已經分開。若繼續抽出累加器或 Response Mapper,只會增加型別與方法跳轉,沒有再移除新的混合責任。

一個合理的清理步驟至少要符合:

  1. 能用一句話說明解決了哪個問題。
  2. 結束時 Repository 仍處於可驗證狀態。
  3. 單獨 Revert 不會留下無法運作的半成品。

停止不是少做一步,而是避免為了湊出更多 Commit,把同一項尚未完成的責任切成幾個不能獨立成立的片段。

把清理節奏整理成可放進 AGENTS.md 的 Cleaning Cycle Policy

前面幾篇已經陸續把重構、命名、註解與函式結構判準整理成 Repository Policy。今天再補上清理節奏:若團隊想讓 Agent 自主拆解步驟,可以在 AGENTS.md 提供下面這份示範規則。

## Cleaning Cycle Policy

- 修改前列出不得改變的外部契約、錯誤路徑、重跑行為與副作用順序。
- 機械式修改、小範圍單一理由且失敗時可整包放棄的工作,可以一次完成。
- 修改穿過資料流、副作用、例外、交易或外部契約時,改用可驗證的分段週期。
- 分段時一次只處理一個能用單一理由說明的問題;修改前後執行聚焦測試。
- 通過後檢查 Diff,再建立可獨立 Build、Test 與 Revert 的 Commit。
- Gate 失敗時回到最後一個綠燈 Commit,不得修改測試期待值掩蓋行為漂移。
- 全部修改完成後,另行檢查高階意圖、抽象層次、跳轉與中介狀態;分段 Commit 不代表最終結構自動通過。

這裡先示範 AGENTS.md 版本;規則在更多案例中穩定後,再抽成 Skill。

這份 Policy 的目的,是減少 User 回頭拆大型 Diff、重找錯誤來源,以及整包放棄已驗證清理的情況。

重現實驗與查看完整證據

如果你已經 Clone AI-CleanCode-API-Demo,可以切到實驗基準:

git fetch origin --tags
git switch --detach day-09-cleaning-cycle-baseline
git rev-parse HEAD

最後一行應顯示:

73d29ed2a36b80f1dd436ab2d3f155b5f4ff201f

本次主要 Git 座標如下:

  • 一次重寫 Agent 輸出:5521cf76740cfbb267785d35b4b5b236f15629bb
  • 一次重寫的清理與故障包:e8bd45a
  • 一次重寫整包 Revert:c05e37b
  • 分段清理:84e1094087e3b29a1ef92d2540685e4b0ec8edee
  • 分段受控故障:5d73d8b
  • 分段只 Revert 故障:995449b5617fb0606cf9554eaf46707ba53ef401
  • 系列接續 Tag:day-09-cleaning-cycle

完整 Prompt、兩份 Agent 最終回覆、GitHub Diff、測試輸出與 Revert 歷史,都保存在固定版本的 Day 9 清理週期公開 Evidence

清理節奏與完成後的 Code,要分開驗收

回到標題的兩個問題,答案也要分開。

相同故障出現時,分段 Commit 比一次重寫更容易局部還原;至於完成後哪一份 Code 更好讀,仍要看當下的資料路徑、閱讀順序與中介狀態,不能從 Commit 數量推導。

〈整潔的方法〉要求短週期驗證,〈要有禮貌〉要求 Code 由高階意圖走向細節。L — Localized Change 局部變更 用單一修改理由切 Commit;A — Auditable by Evidence 實據可審 保存 Prompt、Diff、測試、紅燈、Commit 與人工決策。

我的選擇是讓 Agent 用可驗證、可還原的小週期工作,完成後再用閱讀順序與結構成本驗收成品。工作節奏採分段,最終 Code 則採用目前情境最直接的結構。

今天把函式內部整理成可以驗證、可以還原的步驟後,明天會把問題往外推到物件邊界:WorkItem 究竟只是搬運資料的容器,還是應該保護自己的狀態與行為?


上一篇
Day 8|同一項需求交給下一個 AI Agent,三種函式結構會走出哪條修改路徑?
下一篇
Day 10|WorkItem 要公開資料還是封裝行為?用新增業務種類與新增操作比較三種模型
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言