安安~我是ChiYu~
昨天,我們替 Work Item 逾期流程保留兩則有測試依據的警告註解,也確認 Formatter 只能整理已設定的格式規則。
註解沒有逐行翻譯 Code,排版也整理好了。可是打開 ProcessOverdue,查詢、迴圈、三種統計、狀態修改、通知、儲存與 HTTP Response,仍然擠在同一條流程裡。
這時,很容易直接丟給 AI 一句:
幫我把這個函式拆小一點,每個函式只做一件事。
這句 Prompt 對準了「小函式」與「只做一件事」兩個常見印象,Agent 也確實能很快縮短入口方法。
結果是,兩份拆分候選的入口同樣只有 15 行,Cyclomatic Complexity 與 Cognitive Complexity 也都是 0;其中一份卻把逾期流程拆成 12 個相關方法,另一份只保留 4 個。靜態數字完全一樣,閱讀路徑卻差了一大截。
大前天比較的是「這次重構應該整理多大的範圍」;今天把範圍固定在同一段逾期流程,繼續追問「這個範圍到底應該拆多細」。我要檢查的不是方法數量,而是 AI 把複雜度移到其他方法後,每次跳轉是否進入一項完整責任,還是只把原本的流程切成更多碎片。
〈整潔的函式〉與〈只做一件事〉都沒有提供固定行數門檻。只看長度時,Extract Method 很快就能讓入口縮短,卻不一定產生新的責任。Extract Method(擷取方法重構) 是把一段 Code 搬進另一個有名稱的方法,再由原本位置呼叫它。
例如:
private DateTimeOffset GetCurrentUtc() => timeProvider.GetUtcNow();
private static int Increment(int value) => value + 1;
private async Task SaveAsync(CancellationToken cancellationToken) =>
await database.SaveChangesAsync(cancellationToken);
這三個方法都很短,也都有合理名稱;多一個名稱,不等於多一個值得獨立理解的概念。
GetCurrentUtc 只把原本一眼看得懂的呼叫搬到別處,Increment 則藏起最基本的加一運算。SaveAsync 的呼叫位置仍能看出儲存順序,但讀者必須跳進方法,才能確認裡面只有 SaveChangesAsync,還是另外做了其他事情。
如果一個 Helper 沒有形成獨立責任,這次跳轉就只增加查證成本。
我不會用固定行數判斷函式是否只做一件事。實際審查時,我會檢查下面四件事:
「找出到期且未完成的工作項目」是一項完整查詢責任;呼叫端不需要立刻知道 LINQ 條件的每個細節。「把通知嘗試次數加一」則只是批次處理裡的一個簡單步驟,單獨抽出去沒有形成新的業務概念。
函式長度仍是檢查線索,但不能替每一次抽取作決定。
Stepdown Rule(由上而下展開規則) 要求入口先交代主要流程,再把每個步驟的實作細節放到下一層。讀者可以先讀完整體,需要時才往下追其中一項責任。
用這次的逾期流程來說,入口應該先讓人讀出四件事:
下一層才分別回答:「哪些資料符合逾期資格?」「每一筆如何修改狀態、嘗試通知並累計結果?」
所以,「只做一件事」要連同呼叫關係一起看。入口負責說完高階意圖,被呼叫的方法承接完整細節;若每一層只包住一行語法,Stepdown 就只剩下外觀。
團隊沒有寫下來的拆分習慣,Agent 無法直接取得。它能依據的,是 Prompt、Repository 規範、附近 Code 與測試。
如果 User 只交代「函式要小」「每個函式只做一件事」,驗收時最容易看到的是三項數字:入口從 40 多行降到 15 行、Complexity 變成 0,以及每個步驟都有名稱。這些結果都很好回報,卻沒有回答每次抽取是否值得。
今天會搭配兩項靜態指標觀察方法結構。Cyclomatic Complexity(循環複雜度) 依條件與控制流程估算獨立執行路徑;Cognitive Complexity 則關心巢狀判斷與流程切換增加多少閱讀負擔。
這兩個數字只能當成檢查訊號。入口得到 0,通常只表示條件與迴圈已經移到其他方法;整條流程的複雜度仍然存在,還可能被分散到更多符號裡。
本次過度拆分候選優先完成了可量化目標,並不是 Agent 無故犯錯。User 要求方法變短,卻沒有定義什麼樣的抽取值得保留,它自然先完成最容易驗收的答案。
Uncle Bob 在近期訪談裡提到,他正在重新校準 Agent 適用的函式門檻。他使用的 CRAP,全名是 Change Risk Anti-Patterns,會把 Cyclomatic Complexity 與 Code Coverage 放在一起,觀察一個方法的改動風險。他原本替人類採用較嚴格的上限,面對 Agent 時則從 4 放寬到 6,並考慮測試 8。
這個說法很有意思,卻不能簡化成「AI 記得比較多,所以長函式已經沒關係」。他調整的是結合複雜度與測試覆蓋的風險門檻,沒有取消單一意圖、抽象層次、內聚與可驗證性,也沒有把行數當成判斷標準。這些數字還是他當時專案裡的校準值,不是所有 Repository 都該照抄的通則。
換回今天的三種情境,兩份拆分候選的入口 Complexity 都是 0,正好顯示單看入口分數會漏掉呼叫鏈與資料搬運。Agent 或許能追蹤比人類更多符號,下一次修改仍要找到正確責任、確認副作用順序,並判斷哪些細節可以安全忽略。
因此我會放寬「形式」,繼續守住「意圖與責任」。門檻若要調整,也要連同 Coverage、測試辨錯能力與後續修改結果一起驗證。
E — Explicit Intent and Boundaries 意圖明確:先定義抽取條件與停止線E — Explicit Intent and Boundaries 意圖明確 在這篇負責定義抽取條件與停止線。只說「把函式拆小」時,什麼內容值得抽、哪些簡單語法應留在原地,以及何時停止,全都留給 Agent 猜。
這次我不逐一指定 Helper 名稱,而是提供四項可驗收規則:
這些規則不會替 User 決定固定的方法數量,而是讓每次跳轉都必須說明自己換來了什麼。
三種拆分情境刻意把成本拉開:單一 Action 暴露 Context 過寬;每個步驟都拆小,暴露跳轉與薄 Helper;Stepdown 則檢查能否在兩者之間保留完整責任。
| 實驗情境 | 交給 Agent 的主要方向 | 想觀察的問題 |
|---|---|---|
| 所有流程留在單一 Action | 不建立逾期流程內部 Helper | 沒有 Controller 內部方法跳轉時,不同抽象層次會不會擠在一起? |
| 每個可命名步驟都拆小 | 盡量讓入口只保留高階呼叫 | 行數與 Complexity 降低後,是否出現大量薄包裝? |
| 依 Stepdown 與共同變更理由拆分 | 只有形成完整責任才抽取,否則停止 | 每次跳轉能不能換到一項值得獨立理解的責任? |
三次執行固定使用 Codex GPT-5.6-SOL-HIGH、相同起點、行為邊界與修改範圍。唯一用來比較的差異,是 User 提供的拆分方向。
除了比較眼前結構,我還加入一條共同的未來變更壓力:
未來可能需要在通知失敗時,保存失敗的
WorkItemId供後續處理。
我把這條未來需求當成「可維護性探針」,只檢查合理的第一個修改位置,以及讀者必須沿哪條呼叫路徑,才能同時找到 workItem.Id 與通知失敗結果。本次不實作,也不預先決定 ID 最後要保存在哪裡。
三次正式執行都遵守以下固定條件:
1583af33b5e517530871ff3ee724cdcad4e4c5c0/Tag day-06-comments-formatting。src/WorkItems.Api/WorkItemsController.cs。每種 Prompt 各執行一次,因此後面的結論只適用這三份候選,不代表模型在其他 Repository 也會產生相同結構。
第一種情境故意取消逾期流程的內部 Helper,讓查詢、狀態、通知、統計、儲存與回應全部留在 ProcessOverdue。
你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。
任務:把 WorkItemsController.ProcessOverdue 整理成「單一 Action 直接閱讀」候選。逾期資格查詢、狀態轉換、通知、統計、儲存與回應建立都留在 ProcessOverdue 內,移除並行內化目前的 ApplyOverdueStatusAndAttemptNotificationAsync。不要為逾期流程新增其他私有 Helper、區域函式、結果型別、類別或檔案。這份候選用來觀察零內部跳轉的閱讀成本,不代表預設接受方案。
共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。
只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。
完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。
Agent 移除原本的 ApplyOverdueStatusAndAttemptNotificationAsync,把內容放回 foreach:
foreach (var workItem in dueIncompleteWorkItems)
{
var statusChangedToOverdue = workItem.Status != "Overdue";
if (statusChangedToOverdue)
{
workItem.Status = "Overdue";
}
var notificationSucceeded = await notificationGateway.SendOverdueAsync(
workItem,
cancellationToken);
if (statusChangedToOverdue)
{
overdueStatusChangeCount++;
}
notificationAttemptCount++;
if (!notificationSucceeded)
{
notificationFailureCount++;
}
}
它的優點很直接:讀者不必離開 ProcessOverdue,就能看到狀態怎麼改、通知何時送出、失敗如何累計。
這裡的 49 行包含方法宣告與大括號,不等於 49 行可執行 Code,數字本身也不是淘汰理由。真正的成本是:時間來源、EF Core 查詢、狀態轉換、外部通知、三項統計、資料儲存與 HTTP Response 都在同一個 Context。
未來若要收集通知失敗 ID,修改位置雖然就在眼前,讀者仍得先看完整條混合流程。如果流程很短、規則穩定,而且只有一個改變理由,單一函式仍可能是最簡單的答案。這份候選在目前 Demo 不採用,不代表所有單一 Action 都不乾淨。

圖:單一 Action 沒有方法跳轉,但查詢、狀態、通知、統計與回應共享同一個閱讀 Context。
第二種情境把「函式要小、每個函式只做一件事」照字面交給 Agent:只要一個步驟可以命名,就允許抽成方法。
你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。
任務:依「函式要小、每個函式只做一件事」整理 WorkItemsController.ProcessOverdue。把每個可以獨立命名的步驟抽成私有 Helper,讓 ProcessOverdue 盡量只保留高階呼叫。時間取得、候選查詢、單筆狀態處理、通知、計數、儲存與回應建立都可以拆開;新增內容仍必須留在 WorkItemsController.cs,不得新增其他類別或檔案。這份候選用來觀察持續抽取小函式後的閱讀路徑,不代表預設接受方案。
共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。
只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。
完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。
Agent 把 ProcessOverdue 縮成五個高階呼叫:
public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
CancellationToken cancellationToken)
{
var currentUtc = GetCurrentUtc();
var dueIncompleteWorkItems = await FindDueIncompleteWorkItemsAsync(currentUtc, cancellationToken);
var overdueProcessingSummary = await ProcessDueIncompleteWorkItemsAsync(
dueIncompleteWorkItems,
cancellationToken);
await SaveOverdueStatusesAsync(cancellationToken);
return CreateProcessOverdueResponse(overdueProcessingSummary);
}
入口只有 15 行,Cyclomatic Complexity 與 Cognitive Complexity 都是 0。若驗收只看長度與入口指標,這份候選已經完成任務。
往下追之後,逾期流程擴張成 12 個相關方法,其中幾個薄 Helper 如下:
private DateTimeOffset GetCurrentUtc() => timeProvider.GetUtcNow();
private async Task SaveOverdueStatusesAsync(CancellationToken cancellationToken)
{
// 通知必須先於狀態儲存;即使通知回報失敗,仍須儲存 Overdue 狀態。
await database.SaveChangesAsync(cancellationToken);
}
private static int CountOverdueStatusChange(int currentCount, bool statusChangedToOverdue) =>
statusChangedToOverdue ? currentCount + 1 : currentCount;
private static int CountNotificationAttempt(int currentCount) => currentCount + 1;
private static int CountNotificationFailure(int currentCount, bool notificationSucceeded) =>
notificationSucceeded ? currentCount : currentCount + 1;
GetCurrentUtc 只把 timeProvider.GetUtcNow() 搬到別處;三個 Count Helper 把原本跟著條件式就能看懂的加一運算切開。SaveOverdueStatusesAsync 的呼叫位置仍能看出儲存發生在批次處理之後,但實際的 SaveChangesAsync 與警告註解被移進另一個方法。
這些 Helper 都有名稱,卻沒有替讀者排除真正複雜的細節。
讀者若要確認通知失敗結果從哪裡來,路徑會變成:
ProcessOverdue
└─ ProcessDueIncompleteWorkItemsAsync
└─ ProcessDueIncompleteWorkItemAsync
└─ AttemptOverdueNotificationAsync
└─ notificationGateway.SendOverdueAsync
本文把讀者從一個方法追到下一個方法的動作稱為一次 method hop(方法跳轉)。這份候選從入口到 Notification Gateway 最深要走 4 次跳轉;找到通知結果後,還得回到批次方法,重新拼出三個 Count Helper 如何累計統計。
這種高階流程、單行語法與批次處理來回切換的閱讀路徑,稱為 abstraction roller coaster(抽象層次雲霄飛車)。Agent 確實完成了 User 提供的目標:方法變短,每個步驟都有名稱。問題出在驗收條件沒有區分完整責任與薄包裝。

圖:入口很短,確認通知結果卻要連續跨越四層方法;薄 Helper 讓閱讀路徑拉長。
第三種情境同樣要求小函式與 Stepdown Rule,並補上停止條件:抽取若只搬移語法、增加跳轉,沒有形成新概念,就停下來。
你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。
任務:依同一抽象層次與 Stepdown Rule 整理 WorkItemsController.ProcessOverdue,讓入口能由上而下讀出逾期流程。只有一組程式碼形成可獨立命名、具有共同變更理由的責任時才抽成私有 Helper;抽取若只搬移語法、增加跳轉,沒有形成新概念,就停止。每個 Helper 也要維持單一抽象層次。所有修改留在 WorkItemsController.cs,不得新增其他類別或檔案。
共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。
只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。
完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。
第三次 Run 沿用相同模型、起點與行為邊界,拆分指令則加入「共同變更理由」與「只搬移語法就停止」。這次產生的入口同樣是 15 行,只新增兩個 Helper:
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);
}
入口可以由上而下讀成四步:找候選、處理候選、儲存、回傳。
FindDueIncompleteWorkItemsAsync 把時間來源與 EF Core 條件留在「找出符合資格的工作項目」這項查詢責任裡:
private async Task<List<WorkItem>> FindDueIncompleteWorkItemsAsync(
CancellationToken cancellationToken)
{
var currentUtc = timeProvider.GetUtcNow();
// 不可排除已是 Overdue 的項目;重複執行仍須再次嘗試通知。
return await database.WorkItems
.Where(workItem => workItem.Status != "Completed" && workItem.DueAtUtc <= currentUtc)
.ToListAsync(cancellationToken);
}
ProcessDueIncompleteWorkItemsAsync 則保留迴圈、單筆處理結果與三項統計。未來真的要收集通知失敗 ID,讀者進到這裡就能同時取得 workItem.Id 與 NotificationSucceeded:
private async Task<ProcessOverdueResponse> ProcessDueIncompleteWorkItemsAsync(
IReadOnlyCollection<WorkItem> dueIncompleteWorkItems,
CancellationToken cancellationToken)
{
var overdueStatusChangeCount = 0;
var notificationAttemptCount = 0;
var notificationFailureCount = 0;
foreach (var workItem in dueIncompleteWorkItems)
{
var overdueProcessingResult = await ApplyOverdueStatusAndAttemptNotificationAsync(
workItem,
cancellationToken);
if (overdueProcessingResult.StatusChangedToOverdue)
{
overdueStatusChangeCount++;
}
notificationAttemptCount++;
if (!overdueProcessingResult.NotificationSucceeded)
{
notificationFailureCount++;
}
}
return new ProcessOverdueResponse(
overdueStatusChangeCount,
notificationAttemptCount,
notificationFailureCount);
}
Agent 停在這裡,是因為 GetCurrentUtc、Count Helper、Save Helper 與 Response Helper 都只會搬移一行語法,沒有形成值得獨立維護的責任。

圖:Stepdown 只抽出具有完整責任的查詢與批次處理,保留高階順序,也避免單行包裝。
三份候選都通過相同的 Build、Test、格式、套件弱點檢查與 HTTP Smoke。受保護的 HTTP 契約、統計語意、重複通知與副作用順序都沒有漂移。
這個結果只證明三份候選都守住目前已知行為,不能替我們選出最適合的結構。
| 判斷問題 | 單一 Action | 每個步驟都拆小 | Stepdown |
|---|---|---|---|
| 入口讀到什麼 | 查詢、狀態、通知、統計、儲存與回應混在一起 | 五個高階呼叫,細節分散到多個薄 Helper | 找候選、處理候選、儲存、回傳 |
| 逾期流程相關方法數,包含入口 | 1 | 12 | 4 |
| 入口到通知 Gateway 的最深方法跳轉 | 1 | 4 | 3 |
| 未來失敗 ID 規則的第一個合理落點 | 49 行 Action 裡的失敗分支 | 批次方法,但還要追查單筆通知與多個結果搬運方法 | 同時握有 ID 與通知結果的批次責任 |
| 主要結構成本 | Context 太寬 | 跳轉與碎片太多 | 仍有跳轉,但每次都進入完整責任 |
| 較適合的情境 | 短小、穩定、只有一個改變理由的局部流程 | 這次沒有找到足以支持薄 Helper 的情境 | 目前這種具有查詢、批次處理與副作用順序的應用流程 |
兩份拆分候選的入口都是 15 行,兩項入口 Complexity 也都是 0。完整呼叫路徑攤開後,一份有 12 個相關方法,另一份只有 4 個。
這次真正拉開差異的,不是短了幾行,而是每次跳轉後看見什麼。Stepdown 仍然需要跳轉,但讀者每次進去,都能取得一項完整責任;過度拆分版本則常常只看到另一個轉呼叫或一次加一。
如果後續需求大多交給 AI Agent,函式結構仍然會影響它怎麼找程式碼、重建資料流,以及判斷修改何時可以停止。三份候選都能執行,卻會把下一個 Agent 帶進三條不同的閱讀路徑:
| 拆分方式 | Agent 接到下一次修改時要處理什麼 | AI Coding 下的採用條件 |
|---|---|---|
| 單一 Action | 在同一段 Code 裡同時辨認查詢、狀態、通知、統計、儲存與回應 | 流程短、規則穩定,而且只有一個變更理由時保留 |
| 每個步驟都拆小 | 反覆搜尋 Helper、追參數與回傳值,再把被切碎的資料流重新拼回來 | 只有每個 Helper 都形成完整責任時才成立;本次這種單行包裝不採用 |
| Stepdown | 先從入口讀懂流程,再跳進查詢或批次處理等完整責任 | 多步驟應用流程的目前預設;每次跳轉都必須帶來新的意圖或邊界 |
因此,AI Coding 下的預設不該是「函式越短越好」,也不能只追求最少跳轉。我的選擇是先讓 Agent 看見高階流程,再用少量、能獨立命名的責任收起細節。這能提供明確的搜尋入口與停止點,又不必為了外觀整齊,把一段流程拆成十幾個薄 Helper。
如果 Repository 已有穩定的 Controller、Use Case、Domain、Gateway 與 Infrastructure 邊界,更多跳轉反而可能幫 Agent 縮小單次任務範圍;前提是每一層真的隔離不同依賴、變更理由或測試方式。沒有這些證據時,新增層次只會增加要讀的檔案。
現在回到共同的未來變更壓力:通知失敗時,保存失敗的 WorkItemId 供後續處理。
Stepdown 適合目前 Demo,因為第一個合理修改位置和現有責任一致:批次處理方法同時擁有工作項目與通知結果。
ID 最後要進資料庫、背景佇列、可靠訊息暫存機制或 Response,仍需要新的需求證據;Agent 找到判斷位置後就應停止。
單一 Action 的修改位置也很好找,只是周圍 Context 較大;過度拆分候選則要追過更多方法與資料搬運路徑。
這個比較也再次提醒我:方法長度描述的是今天的外觀,下一項需求落在哪裡,才更接近未來真正要支付的修改成本。
這次我用五項條件接受 Stepdown 候選:
這五項條件都來自目前的 Work Item API Demo;換成其他流程,結論可能不同。
流程只有幾個簡單步驟、規則長期穩定時,留在單一函式通常更直接。正式專案若已有 Controller、Use Case、Domain、Gateway 與 Infrastructure 等穩定邊界,可以接受更多有意義的跳轉;前提是每一層都隔離不同的變更理由、依賴或測試方式。
這次找不到證據支持的,是為時間取得、計數加一、單行儲存與 Response 包裝各建立一個薄 Helper。這些方法有名字,卻沒有替讀者排除需要暫時忽略的細節。
所以這次 Demo 選擇 Stepdown。專案規模、領域規則或既有邊界改變後,就要重新跑一次相同判斷。
Function Structure Policy 是跟著 Repository 保存、讓 Agent 判斷函式要不要拆,以及何時應該停止的共同規則。
我把這次校準結果整理進 Function Structure Policy,讓後續 Agent 能依流程情境判斷每個 Helper 是否值得抽取,不必等待人類逐一指定。現在先在 AGENTS.md 保留 Repository 特有的語言與邊界;等規則穩定後,再把通用的檢查流程整理成 Skill。
## Function Structure Policy
- 不使用固定行數作為函式是否需要拆分的唯一標準;行數與 Complexity 只能作為檢查訊號。
- 流程短、只有一個變更理由,而且狀態、副作用與回傳能在同一段讀完時,可以保留單一函式;不得為了符合 Stepdown 的外觀強迫抽取。
- 一般應用流程優先讓入口維持同一抽象層次,並依 Stepdown Rule 由上而下展開細節。
- 只有一組 Code 形成可獨立命名、具有共同變更理由的責任時,才抽成 Helper。
- Helper 的名稱必須說明呼叫端需要知道的意圖;跳入 Helper 後應能一次讀完該項責任。
- 不為單純的時間取得、欄位讀寫、計數加一、單行儲存或 Response 包裝建立 Helper,除非 Repository 已存在相同抽象或真實變更壓力。
- 關鍵副作用與順序若留在入口更容易閱讀,不得只為縮短方法而隱藏。
- Repository 已有 Controller、Use Case、Domain、Gateway 或 Infrastructure 等穩定架構邊界時,可以接受較多跳轉;每一次跳轉都要隔離不同變更理由、依賴或測試方式。
- 跳入 Helper 只看見欄位讀寫、計數加一、單行轉呼叫或另一個沒有新增語意的薄包裝方法時,視為過度拆分,應合併回最近的完整責任。
- 重構前先固定外部契約、狀態、通知、儲存、錯誤與重跑行為;不得修改測試期待來配合拆分。
- 完成後回報入口直接 Helper 數、最深呼叫路徑、每個新增 Helper 的責任,以及已知下一次變更的預期落點。
- 抽取若只搬移語法、增加參數傳遞或閱讀跳轉,沒有形成新概念,停止抽取並允許零 Diff。
- 若拆分必須新增架構層、公開契約、Schema 或新的領域概念,停止並交回 User 決定。
Function Structure Policy 同時保存三條判斷路線:簡單流程可以不拆,一般應用流程優先使用 Stepdown,既有架構邊界則能接受更多有意義的跳轉。Agent 先辨認目前情境,再選擇對應策略;遇到 Policy 沒有涵蓋的新領域責任或架構邊界,才交回 User。
如果已經 Clone 公開的 AI Clean Code API Demo,可以先切到三次執行共用的起點:
git fetch origin --tags
git switch --detach day-06-comments-formatting
git rev-parse HEAD
最後一行應顯示:
1583af33b5e517530871ff3ee724cdcad4e4c5c0
三份候選分別固定在:
| 實驗情境 | Commit | Annotated Tag |
|---|---|---|
| 單一 Action | 80396f749017678b475f791ad46d5b83a53fd756 |
day-07-sol-high-single-action-run-01 |
| 每個步驟都拆小 | e0295c5ef2cba454518f65ee9b87d339a7d78f31 |
day-07-sol-high-over-split-run-01 |
| Stepdown | 8fb0953b9e2034dfea57915d0644ef498ed7d1e0 |
day-07-sol-high-stepdown-run-01 |
本系列後續沿用 Stepdown 版本,導覽 Tag 為 day-07-function-levels。
讀者可以直接比較單一 Action、每個步驟都拆小與 Stepdown 的完整 Diff。
三份 Prompt、結構量測、驗證結果與人工決策則保存在 Day 7 公開 Evidence。
這些證據只能比較本 Repository 的三種候選,不能推論某種函式風格永遠比較省 Token,也不能把單次輸出當成 Codex 的固定行為。
回到開頭,兩份候選的入口同樣是 15 行,Complexity 也同樣是 0,真正的差異卻是 12 個相關方法與 4 個相關方法背後的閱讀路徑。
Clean Code 用單一意圖、抽象層次與閱讀順序判斷函式是否夠小。每次跳轉,都應該進入一項值得獨立理解的責任。
E — Explicit Intent and Boundaries 意圖明確 把這些觀念轉成 Agent 能執行的條件:什麼責任可以抽取、哪些行為禁止改動,以及什麼情況必須停止。
目前 Demo 接受 Stepdown,因為它保留完整責任,也替下一項變更留下明確落點。簡單、穩定的流程仍可保留單一 Action;既有架構已形成真實邊界時,也能接受更多有意義的跳轉。把每一個步驟都包成薄 Helper,則不會成為預設選項。
Stepdown 整理了閱讀層次,函式內部的責任衝突仍然存在:ProcessDueIncompleteWorkItemsAsync 會修改 Entity、呼叫通知並建立摘要,ApplyOverdueStatusAndAttemptNotificationAsync 也同時包含狀態變更與外部副作用。
明天要把參數、CQS、DRY、例外與副作用放回同一段 Code,看看 Agent 該如何在互相衝突的函式原則之間做取捨。