安安~我是ChiYu~
昨天,我把同一項需求交給三種函式結構,觀察全新 Agent 會如何理解資料、又會把修改放在哪裡。我最後採用 CQS,但那只回答了資料目前該放哪裡,還沒回答修改走偏時要怎麼退回。
今天接著比較同一段清理工作:讓 Agent 一次做完,或每完成一個可驗證的理由就 Commit。
結果要拆成兩層看。加入相同錯誤後,分段 Commit 可以只撤回故障;可是只看這一次產出的最終 Code,一次重寫候選的批次彙整反而比較直接。
所以我的選擇不是二選一:工作過程採用可驗證、可還原的分段節奏,最後結構則保留當下最容易閱讀的即時計數方式。前者是可以重現的流程差異;後者只是兩份單次輸出的結構觀察,不能直接歸因於 Commit 節奏。
後文用「紅燈」表示測試失敗,「綠燈」表示測試通過。完整的 TDD Red/Green 節奏留到後面的測試文章;這裡只用來標記清理過程的驗證狀態。
《無瑕的程式碼 第二版》的〈整潔的方法〉處理清理週期;〈要有禮貌〉處理完成後的閱讀順序。今天把兩者分開驗收。
這裡的「方法」,既包含 C# Method,也包含清理程式碼的工作方法。
程式先能運作,並不代表整理已經完成。常見的節奏是:
先讓功能正確
→ 執行測試
→ 重新命名、抽取或重新編排
→ 再次執行測試
→ 確認下一步是否仍有明確價值
也就是從 Make It Work 走向 Make It Right:先讓它能動,再讓它容易理解、測試與修改。
清理過程要反覆確認三件事:這一步解決了什麼問題、行為是否仍正確,以及判斷錯誤時能不能只放棄這一步。
書中的報紙比喻是:讀者先看到標題與高階摘要,再逐層進入細節。程式碼入口也該先交代高階意圖,後面的段落再回答讀者接著會問什麼。
例如,看到「處理逾期工作項目」之後,讀者接著可能想知道:
若答案散落在不相鄰的位置,或為了拆小方法製造大量跳轉,即使每個函式都很短,閱讀仍然不算順。
今天分開驗收兩件事:
| 驗收面向 | 主要問題 |
|---|---|
| 清理流程 | 修改能不能定位、驗證與單獨還原? |
| 最終結構 | 高階意圖、抽象層次與閱讀路徑是否清楚? |
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。這段 Code 的既有行為已被測試鎖定,混合責任仍在。實驗會讓 Agent 在不新增功能的前提下,降低下一次閱讀與修改的成本。
清理目標只有一個:
保留由上而下的閱讀順序,分清楚「整批結果彙整」與「單筆工作項目處理」。
我不替方法設定行數或 Helper 數量;每新增一個方法或結果型別,Agent 都要說明它移除了哪一種混合責任。
今天主要使用兩項 CLEAN 原則。
L — Localized Change 局部變更:用修改理由切 Commit,檔案數只當線索兩份候選都只修改同一個 Controller。若只數檔案,今天完全看不出差異。
L 先問:這個 Commit 能不能用一個理由完整說明?「分離單筆處理與批次彙整」和「改變逾期查詢規則」是兩個理由,即使都在同一個檔案,也應分開。
Commit 邊界不是按照行數平均切割,而是替一項已驗證決策留下可以單獨保留或放棄的位置。
A — Auditable by Evidence 實據可審:證據要能還原決策順序一行「測試通過」只記錄終點,無法重建 Agent 中間是否曾經破壞行為。A 要保存下面這條決策時間線:
固定基準
→ 完成一項清理
→ 執行聚焦測試
→ 建立 Commit
→ 加入下一項修改
→ 測試紅燈
→ Revert 造成紅燈的修改
→ 再次通過完整驗證
「聚焦測試」是只針對這次修改最相關行為執行的測試集合。它能加快回饋,但不能取代最後的完整驗證。
本文把這套「單一理由修改、聚焦驗證、檢查 Diff、Commit 或 Revert」的短週期稱為 Cleaning Cycle(清理週期)。
兩個隔離 Worktree 都從同一個 Commit 起跑,使用 Codex GPT-5.6-SOL-HIGH。需求、驗收條件與可修改範圍相同,只有 User 規定的清理節奏不同。
固定條件包括:
WorkItemsController.cs。Overdue 的到期項目,重跑時仍要再次嘗試通知。false 時,仍要記錄失敗並儲存狀態。SaveChangesAsync 之前。每種節奏只保留一份正式輸出,所以最終結構差異只能當作觀察。後續由主流程加入相同受控故障,才能直接比較不同 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 得重新判斷哪些該留、哪些該撤回。
第二種情境允許 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,資料產生後立刻被使用,不需要保存整批結果。
分段候選同樣抽出單筆 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。
我先不看 Git 歷史,只把兩份 Controller 當成下一位讀者第一次打開的 Code。
兩份候選的高階入口與方法跳轉深度相同:
ProcessOverdue
├── 找出已到期且尚未完成的項目
├── 逐筆處理並建立摘要
├── 儲存狀態
└── 回傳結果
差異主要發生在批次結果怎麼被理解:
| 閱讀判準 | 一次重寫候選 | 分段候選 |
|---|---|---|
| 批次統計如何形成 | 在處理當下直接累加 | 先保存所有單筆結果,再統一查詢 |
| 失敗 ID 從哪裡取得 | 使用當下的 workItem.Id |
從結果型別再次投影 |
| 新增的中介狀態 | 計數器與失敗 ID 集合 | 完整單筆結果集合 |
| 目前情境的價值 | 唯一 Response,可直接彙整 | 尚未出現第二個消費者 |
| 什麼情況可能反轉選擇 | 後續仍只有單一摘要 | 稽核、事件或其他流程需要完整結果 |
以〈要有禮貌〉談的閱讀順序來看,目前我會選一次重寫候選的即時計數方式。處理完一筆後,下一段就回答「如何累加這筆結果」,不必先保存整批資料,再到方法尾端重新解讀。
目前沒有第二個消費者,所以我不保留完整結果集合。稽核、事件或其他流程真的需要逐筆結果時,分段候選才會成為較合理的選擇。
這項結構選擇和 Agent 是否分段 Commit 是兩回事。分段節奏可以產生較容易回退的歷史,也仍可能留下目前用不到的中介狀態。
如果讓兩個 Agent 自然犯錯,錯誤位置與影響範圍都不同,Commit 邊界就無法公平比較。
兩份 Agent 輸出完成後,主流程各加入同一個 Controlled Fault(受控故障)。這是實驗者刻意植入的固定錯誤,只用來比較相同失敗落在不同 Commit 邊界時的結果;它不是 Agent 原始輸出,也不能拿來排名模型能力。
我加入的錯誤,是讓查詢排除已經是 Overdue 的項目:
.Where(workItem =>
workItem.Status != "Completed" &&
workItem.Status != "Overdue" &&
workItem.DueAtUtc <= currentUtc)
這項修改第一眼很合理:「既然已經逾期,為什麼還要再找一次?」
但 Repository 的真實行為是:
Open 改成 Overdue 並嘗試通知。排除 Overdue 之後,第一次執行看起來正常;第二次查詢卻找不到原本應該再次通知的項目。
兩條路徑的錯誤已固定,接下來只比較 Revert 會帶走哪些修改。
兩條路徑都用 Git Revert 撤銷故障 Commit,差別在故障是否和清理綁在一起。
主流程另外替乾淨的 Agent 輸出建立 Tag,方便公開比對;模擬一次交付時,清理與受控故障仍被放在同一個 Commit:
原始基準
→ 清理+受控故障
→ 測試紅燈
→ Revert 整包 Commit
→ 錯誤消失,清理也一起消失
整包 Revert 之後,User 若想保留清理,仍要手動拆 Diff 或重新套用乾淨 Tag。只要在加入故障前先保存清理 Commit,流程就已經變成分段邊界。
分段路徑先保存已通過驗證的清理,再把受控故障放進下一個獨立 Commit:
原始基準
→ 完成清理並通過測試
→ 建立清理 Commit
→ 加入受控故障
→ 測試紅燈
→ 只 Revert 受控故障
→ 錯誤消失,已驗證的清理仍然保留
相同錯誤放進不同 Commit 邊界後,一次重寫路徑連乾淨清理一起撤回;分段路徑只撤回故障。這項流程差異可以直接重現。
兩份最終結構每種只有一份正式輸出,因此無法把結構差異全歸因於工作節奏。

圖:分段 Commit 能縮小失敗時的回退範圍,但完成後的程式碼仍要另外驗收可讀性與結構。
這正是今天最容易混在一起的兩個答案:我接受分段驗證與還原的工作節奏,不代表分段候選的每一行 Code 都自動通過驗收。
如果這是正式產品重構,我會採用四個步驟:
以今天的案例來說,工作過程採用分段 Commit;最後的批次彙整,則收斂到一次重寫候選較直接的即時計數方式。
Commit 邊界決定故障能否局部撤回,完成後的閱讀順序仍要獨立 Review。這兩項品質不能互相代替。
我不會要求所有 AI Coding 都拆成大量 Commit。是否分段,應由風險與中間成果的保留價值決定。
| 修改情境 | 建議節奏 | 原因 |
|---|---|---|
| Formatter 能機械完成的排版 | 一次完成 | 規則明確,失敗時整包放棄即可 |
| 編譯器或 Analyzer 能完整約束的 Rename | 一次完成 | 正確性可由工具快速確認 |
| 小範圍、單一理由、聚焦測試很快 | 一次完成或單一 Commit | 沒有值得保留的中間成果 |
| 修改穿過資料流、副作用或例外 | 分段週期 | 失敗原因與影響範圍較難定位 |
| 涉及交易、外部契約或持久化行為 | 分段週期 | 錯誤可能跨越多個系統邊界 |
| 已經包含兩個以上修改理由 | 先拆分 | 每個理由應能獨立驗證與還原 |
切分依據是修改理由與風險邊界。檔案數只能當線索,不能直接決定 Commit 數量。
分段情境允許 Agent 規劃最多三步,最後它只完成一步就停止。
單筆處理與批次彙整已經分開。若繼續抽出累加器或 Response Mapper,只會增加型別與方法跳轉,沒有再移除新的混合責任。
一個合理的清理步驟至少要符合:
停止不是少做一步,而是避免為了湊出更多 Commit,把同一項尚未完成的責任切成幾個不能獨立成立的片段。
前面幾篇已經陸續把重構、命名、註解與函式結構判準整理成 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 座標如下:
5521cf76740cfbb267785d35b4b5b236f15629bb
e8bd45a
c05e37b
84e1094087e3b29a1ef92d2540685e4b0ec8edee
5d73d8b
995449b5617fb0606cf9554eaf46707ba53ef401
day-09-cleaning-cycle
完整 Prompt、兩份 Agent 最終回覆、GitHub Diff、測試輸出與 Revert 歷史,都保存在固定版本的 Day 9 清理週期公開 Evidence。
回到標題的兩個問題,答案也要分開。
相同故障出現時,分段 Commit 比一次重寫更容易局部還原;至於完成後哪一份 Code 更好讀,仍要看當下的資料路徑、閱讀順序與中介狀態,不能從 Commit 數量推導。
〈整潔的方法〉要求短週期驗證,〈要有禮貌〉要求 Code 由高階意圖走向細節。L — Localized Change 局部變更 用單一修改理由切 Commit;A — Auditable by Evidence 實據可審 保存 Prompt、Diff、測試、紅燈、Commit 與人工決策。
我的選擇是讓 Agent 用可驗證、可還原的小週期工作,完成後再用閱讀順序與結構成本驗收成品。工作節奏採分段,最終 Code 則採用目前情境最直接的結構。
今天把函式內部整理成可以驗證、可以還原的步驟後,明天會把問題往外推到物件邊界:WorkItem 究竟只是搬運資料的容器,還是應該保護自己的狀態與行為?