Day14 到 Day21,我們完成了系列的第二個階段。和 Day13 一樣,在進入下一個階段之前,先回頭看看這個階段想解決什麼、做到了哪裡,又把哪些問題留給了後面。
系統只要有一定的規模,就大到沒有人能一次理解全部;coding agent 的 context 再大,也是如此。所以 Day14 一開始問的是:為了改這一小段程式,我們到底需要知道多少其他東西?
階段二的答案是 Contain:用 boundary 把一次 change 圈住。要圈住的範圍有兩個,Day17 與 Day21 都提過:
兩個範圍都夠小,我們才能只看局部、也只改局部,這就是 local reasoning。
回頭看這八天,其實是沿著同一條線,一步一步把 boundary 守起來:
| 主題 | 守住了什麼 | |
|---|---|---|
| 立起一扇門 | Boundary(Day14) | 分出裡與外,client 不必知道裡面怎麼做 |
| Contract(Day15) | 把兩側的約定寫下來:spec 給 client,test 驗 implementer | |
| Abstraction(Day16) | 把 contract 從實作中抽出來,寫得進型別的交給工具 | |
| 堵住側門 | Dependency(Day17) | 只依賴完成工作所需要的資訊 |
| State & Side Effect(Day18) | 讓隱藏的輸入與輸出看得見,並集中在少數地方 | |
| Representation Invariant(Day19) | 門後的資料,由 implementer 全權負責 | |
| 連成一張圖 | Module(Day20) | 一個 module,對外只留一扇門 |
| Dependency Graph(Day21) | 依賴的箭頭有方向,但不回頭 |
前三天,是把一扇門立起來。Boundary 把程式分成裡與外,client 只需要知道怎麼用,implementer 才需要知道怎麼做;contract 把兩側的約定寫下來,改動只要沒碰到 contract,就留在 implementer 這一側;abstraction 再把 contract 從實作中抽出來,讓 type checker 替兩側把關。
中間三天,處理的是「門立好了,change 卻不走門」的情況。Dependency 是 change 傳播的路徑,依賴了不需要的資訊,路就變多了;state 與 side effect 是 signature 上看不見的輸入與輸出,讓 dependency 繞過了 contract;而門後的資料若沒有 invariant 守著,或是直接交到 client 手上,裡面也就不再由 implementer 說了算。
最後兩天,把同一件事放大到整個 system。Module 從一整群 class 與 function 裡,挑出能跨出去的名稱,對外只留一扇門;dependency graph 則把這些門連起來,箭頭有方向、不回頭,兩個範圍才有盡頭。
這八天有一個反覆出現的模式:先由人決定線在哪裡,把它寫下來,再交給工具守住。寫得進型別的交給 type checker,invariant 寫成檢查,module 的界線與依賴的方向交給 compiler 或 linter。這和階段一是同一件事:把原本只是提醒的東西,變成驗證。
但工具只能守線,不能替我們劃線。因此,階段二中人的角色是:建立 boundary,並且讓它守得住。
具備這個能力之後,與 coding agent 合作的方式也會跟著改變:
Storage 存取帳目」、「這個 module 只公開這幾個名稱」、「pi-ai 不能 import pi-agent-core」。前兩件事方向相反,一個是向 AI 指定結構,一個是讀懂 AI 產出的結構,用的卻是同一組詞彙。而 boundary 守得住,agent 自己也受益:使用別人的 code 時能少讀一點,寫自己的 code 時也知道該守住什麼。
階段二回答的是「boundary 如何守得住」。但回頭看這八天的例子:Storage、Square、storage module、pi 的四個 module,boundary 都是一開始就給定的。我們只問它守不守得住,從來沒有問:為什麼線是劃在這裡?
Day13 留下過兩個例子:同一條折扣規則散落在三個 module 裡;一個 class 同時負責計價、庫存與通知。用階段二的標準檢查,它們可以全部過關:每個 module 都只留一扇門,contract 寫得清楚,dependency graph 也沒有環。但折扣規則一改,還是得改三個地方;想改計價,還是得先理解庫存與通知。
Boundary 守住了,卻劃錯了位置。 該放在一起的被拆開,不該放在一起的被關在同一扇門後。理解範圍與影響範圍依然很大,卻沒有任何工具會提醒我們。
其他幾天也留下了類似的問題:
這些問題的共同點是:答案取決於未來會怎麼變。 而這正是階段二沒有處理的事。
過去,boundary 有一道天然的煞車:建立它很花力氣。抽一個 abstract class、拆一個 module、搬檔案、改 import,每一步都要人動手;不值得的 boundary,往往在動手之前就被放棄了。
coding agent 把這道煞車拿掉了。它能在很短的時間內交出一層又一層的抽象,而且 type check 通過、test 通過,每一層都有清楚的 contract。
假設系統只串接了一家金流,agent 卻可能寫出:
IPaymentGateway → PaymentGatewayFactory → StripePaymentGatewayAdapter → StripeClientWrapper → Stripe
用階段一的標準看,它全部通過;用階段二的標準看,boundary 清楚、依賴有方向、沒有環。但它仍然可能是不好的設計:為了一個還不存在的 change,現在就先付了 complexity。
因為變便宜的,只有「把 boundary 寫出來」這件事。Boundary 存在之後的成本,一點都沒有少:
所以,下一個階段要回答的問題是:boundary 該放在哪裡?又值得為未來的 change,先付多少成本?
這個問題難在,未來的需求沒有人知道。劃得太少,change 來的時候到處都要改;劃得太多,每天都在為沒有發生的事付出代價。階段三的名字是 Choose:每一次 change 都有成本,而設計,就是決定把成本付在哪裡。
SOLID 這類設計原則會在這個階段登場,但不是當成必須遵守的戒律。它們回答的其實是同一個問題:哪一種結構,讓哪一類 change 變得比較便宜? 而每一個答案,都得把 abstraction 本身的成本一起算進去。
把三個階段放在一起看:
| 階段一:Control | 階段二:Contain | 階段三:Choose | |
|---|---|---|---|
| 消不掉的限制 | 程式可能做錯 | 系統大到無法一次理解 | 未來的需求無法預測 |
| 問題 | 如何控制 AI 做出的一次 change? | 如何限制一次 change 需要理解與影響的範圍? | boundary 該放在哪?值得先付多少成本? |
| 人的角色 | 定義 constraints | 建立並守住 boundaries | 決定 boundary 的位置與 trade-offs |
階段一與階段二,最後大多能交給工具:constraint 可以執行,boundary 可以檢查。階段三不行。沒有任何 linter 能告訴我們,這條 boundary 值不值得存在;這需要的是對需求與未來的判斷,而這個判斷,終究要由人來負責。
階段二教會我們怎麼守住一條線。接下來要學的,是怎麼選擇把線劃在哪裡。