PRINCIPLE 3 / 4

精準修改

Surgical Changes
只碰必須碰的,只清理自己造成的混亂。
核心句:只動你必須動的地方。只清理你自己造成的混亂。

只動你必須動的地方。只清理你自己造成的混亂。

編輯既有程式碼時:

當你的修改產生了孤兒程式碼時:

檢驗標準: 每一行修改都應該能直接追溯到使用者的需求。

檢驗標準:每一行修改都應該能直接追溯到使用者的需求。
教學解說:為什麼「順帶重構」是壞習慣?

LLM 有一個自然的傾向:看到「不夠優雅」的程式碼就想改進。當你要求修復一個 bug,模型會順便改引號風格、加型別提示、加入 docstring、重構變數命名⋯⋯這些「改進」在技術上可能是對的,但會:

  • 增加 code review 的負擔: reviewer 需要區分哪些是 bug 修復、哪些是「改進」
  • 引入意外的回歸: 格式改動可能改變程式碼行為(例如字串比較)
  • 違背團隊共識: 如果團隊用單引號,你不該改成雙引號

關鍵心態:把「配合現有風格」當作專業的表現。資深工程師會說:「這段程式碼不夠優雅,但既然它運作正常,我不會動它。」

Worked Example:修復驗證錯誤
對應範例 3-1 — AI 順帶重構 vs. 精準修改
AI 編碼前(❌ 順帶重構)
   def validate_user(user_data):
+      """Validate user data."""          # 加了 docstring
+      email = user_data.get('email', '').strip()  # 改了取值方式
+      # Validate email                  # 改了註解
+      if '@' not in email or '.' not in email.split('@')[1]:
           raise ValueError("Invalid email")
+      username = user_data.get('username', '').strip()
+      if not username:                   # 加了驗證
+          raise ValueError("Username required")
+      if len(username) < 3:             # 加了新驗證
+          raise ValueError("Username too short")
AI 編碼後(✅ 精準修改)
   def validate_user(user_data):
       # Check email format
-      if not user_data.get('email'):
+      email = user_data.get('email', '')
+      if not email or not email.strip():
           raise ValueError("Email required")

       # Basic email validation
       # Check username
       if not user_data.get('username'):
           raise ValueError("Username required")
       return True
關鍵差異
  • 之前:改了 10+ 行,加了 docstring、型別提示、新驗證邏輯
  • 之後:只改了修正空郵件處理的 2 行
  • 核心改變:從「順便改進」變成「只修 bug」
常見錯誤:AI 的範圍蔓延模式
  • 風格改動:改引號(單→雙)、改空白、改括號位置
  • 文檔衝動:看到沒有 docstring 的函式就想加
  • 型別提示:看到沒有型別標註的參數就想加
  • 重構衝動:看到「不夠優雅」的邏輯就想重寫
  • 新功能:順便加入你認為「應該有」的驗證邏輯
  • 死程式碼:順便清理「看起來沒用」的舊程式碼
練習 / 檢核清單
  • diff 檢查:每一行修改都能追溯到使用者需求嗎?
  • 範圍控制:我只改了 bug 相關的行嗎?
  • 風格一致性:我配合了現有的引號、空白、命名風格嗎?
  • docstring:我加了沒人要求的 docstring 嗎?
  • 型別提示:我加了沒人要求的型別標註嗎?
  • 死程式碼:我有注意到沒人要求清理的舊程式碼嗎?我有忽略它嗎?

對應範例

到互動範例頁看這個原則的 ❌/✅ 對照:

← 上一原則原則 2 · 簡潔優先
進階單元 · Round 1 Deepening
進階 Worked Example:在 800 行 legacy 認證模組修安全漏洞
需求:「token 驗證繞過漏洞,`alg: none` 會被接受。修掉它。」檔案老舊:tab 縮排、無型別提示、註解是十年前的英文。
❌ 常見的 AI 直覺(順手「現代化」)
   def verify_token(token):
-      parts = token.split('.')
+      header = base64.urlsafe_b64decode(parts[0])   # 新增
+      alg = json.loads(header).get('alg')
+      if alg is None or alg.lower() == 'none':       # 修漏洞
+          raise InvalidToken('alg none rejected')
-      ...   # 既有 60 行驗證邏輯
+
+      # 順手整理:把 tab 改成 4 空格、加型別提示、
+      # 加 docstring、把變數更名為更「清楚」的名字
+      # (共 40 行與漏洞無關的改動)

       # 30 分鐘後:
       # - 漏洞修好了 ✅
       # - 但整個 module 的 diff 有 120 行
       # - reviewer 必須在 120 行裡找出真正的那 5 行
✅ 運用四原則的決策過程
1. 編碼前思考(原則 1):
   → 漏洞面有多大?`alg: none` 之外,是否也接受 `HS256` 但密鑰為空的組合?
   → 提問確認,但先以最小修復範圍回覆。

2. 簡潔優先(原則 2):
   → 最小修復是「拒絕不安全演算法」,一個條件判斷就夠。

3. 精準修改(原則 3):
   → 只在驗證邏輯入口加 4 行,其餘 60 行一個字都不動:

   def verify_token(token):
       parts = token.split('.')
       header = json.loads(base64.urlsafe_b64decode(parts[0]))
       alg = header.get('alg', '').lower()
       if alg == 'none' or alg not in ALLOWED_ALGS:   # +4 行
           raise InvalidToken('unsafe algorithm')
       ...

4. 目標驅動(原則 4):
   → 先寫重現漏洞的測試(`alg: none` 的 token 應被拒絕)
   → 套用修補 → 測試通過 → 既有測試全綠

   「順便整理」的部分,我寫在 PR 描述裡建議,
   但不在這個 PR 動手——分開處理才有乾淨的責任邊界。
為什麼選這條路徑
  • 因為安全修復必須可被審計:資安團隊需要能在 30 秒內確認「這次只改了漏洞相關的行」。混入 120 行重構,等於讓審計變猜謎。
  • 因為 legacy 程式碼「能動」比「漂亮」值錢:tab 縮排與無型別提示是這個 module 的既有風格——配合它(原則 3),就是尊重它的歷史與測試覆蓋。
  • 因為「提出但不動」是正確的出口:想整理是專業直覺,但把它寫進建議、另開議題,既盡了告知義務,又不污染這個 PR。
深入原理:LLM 為什麼「忍不著順手改一下」?

模型的訓練資料裡,「重構 PR」與「code review 讚美清理」的樣本非常多——它們獎勵「改進」。於是模型學到一條潛規則:把程式碼改得更好 = 更好的輸出。問題是模型的目標函式裡沒有「diff 最小化」這一項:它優化「改進的總量」,而不是「改進的相關性」。

更深一層是關聯性判斷的失敗:模型難以區分「相鄰程式碼」(adjacent,同一區塊)與「相關程式碼」(associated,與本需求有因果關係)。空郵件 bug 與 username 驗證邏輯相鄰、卻不相關——模型在 token 層面把它們當作同一段落順手重寫,這個重寫不是「決定」,而是生成的慣性。

成本不對稱:對模型來說,「重寫整段」比「精準改 2 行」更容易生成(生成的 token 少、上下文一致)。但對人類 reviewer 來說,這兩個選項的成本剛好相反:2 行的 diff 審計 30 秒,120 行的 diff 審計 40 分鐘。原則 3 就是要把模型的「低成本路徑」扳回人類的「低成本路徑」。

案例:大家以為遵守原則,其實違反了

使用者說「只改這一行」。Agent 回應「好的」,也真的只改了那一行——但它把那一行裡的三個變數名全部換成「更好的名字」。行數沒有蔓延,語義卻蔓延了:下游呼叫方依賴的變數名被悄悄改掉,編譯器幫忙攔下一個,另兩個在 runtime 才爆炸。真正的違反:精準是「最小語義變更」,不是「最小行數變更」。變數名、簽名、回傳值——這些「一行裡的小事」同樣在原則 3 的管轄範圍內。

診斷式疑難排解:原則 3 常見故障
症狀可能原因解決方案
diff 出現沒人要求的 docstring文檔衝動——「看到就補」的習慣只接受能直接追溯到需求的變更,其餘退回
引號/空白/縮排被改格式化慣性,可能還混入 formatter 自動執行要求配合現有風格;提醒「格式化不在本次需求內」
修一個 bug 卻動了 3 個函式順帶重構——把「相關」誤當「相鄰」要求最小 diff;每個變更都要能回答「這一行是為了修哪個需求?」
變數/函式被改名命名衝動——即使名稱不在需求範圍內明確聲明「名稱不是本次需求」,改名單獨開 PR
「看起來沒用」的舊程式碼被刪死程式碼誤判——刪除權責被越界提醒規則:「提出來,但不要刪」;先問使用者再處理
進階挑戰題
  1. diff 判讀:下面這段 diff 有 8 行。標出哪些是「必要」(可直接追溯到需求)、哪些是「範圍蔓延」,並說明理由:
    提示:`- 改引號`、`+ 加 docstring`、`+ 加驗證`、`~ 重排 if`、`~ 改名` 各自屬於哪一類?
  2. 發現相鄰的 bug:修 bug 時你發現相鄰 5 行藏著一個嚴重的邏輯錯誤,但它與本次需求無關。怎麼做才符合原則 3?寫出你的處理順序。
    提示:提出來 → 記錄 → 分開處理。想想「提出」的形式該是什麼。
  3. 可追溯性證明:你要如何在 PR 描述裡「證明」每一行都可追溯到使用者需求?寫出你的檢查流程(含一個可複製的模板)。
    提示:需求編號 → 變更清單 → 對照表 → 排除清單(列出你「刻意沒動」的東西)。
進階單元 · Round 2 Deepening
專案級端到端 Worked Example:在生產環境修復支付回調漏洞
需求:「支付成功回調有時漏單——使用者付了錢但系統沒收到通知。修復它。」——在 1500 行的 legacy 程式碼中精準手術。
階段 1 → 原則 1(編碼前思考):釐清問題範圍
先釐清:
1. 「漏單」的定義?是回調完全沒收到,還是收到了但處理失敗?
2. 影響範圍:所有支付方式?只有特定閘道?
3. 時間線:什麼時候開始的?有沒有 trigger event?
→ 確認:回調有收到,但處理時 Redis 逾時導致訂單狀態沒更新。
→ 這是「處理失敗」不是「沒收到」,解法完全不同。
階段 2 → 原則 3(精準修改):只碰回調處理邏輯
1500 行 legacy 認證 + 支付模組,我只改 3 行:

   def handle_payment_callback(payload):
       order_id = payload['order_id']
-      status = redis.set(f"order:{order_id}", payload['status'], ex=30)
+      status = redis.set(f"order:{order_id}", payload['status'],
+                         ex=3600)  # 從 30 秒改為 1 小時
       if not status:
           raise PaymentError("Redis write failed")
       return {"received": True}

刻意沒動的:
- 1497 行的既有認證邏輯 — 與本需求無關
- callback 路由的格式(混合 tab/空白) — 配合既有風格
- 相鄰 5 行的另一個 bug(已知但未被要求修) — 提出但不動
階段 3 → 原則 2(簡潔優先):最小修復
不用 Redis 陣列、不用重試佇列、不用 dead letter queue。
一行數字從 30 改成 3600——這就是「剛好夠」。

如果日後 Redis 逾時問題再次出現(第二個真實案例),
再提重構方案。今天不猜測。
階段 4 → 原則 4(目標驅動執行):驗證修復
計畫與驗證:
1. 寫重現測試:付款回調 → Redis 寫入 → 等 35 秒 → 驗證值仍在
   驗證:修復前測試失敗(30 秒逾時),修復後通過(3600 秒)
2. 套用修復 → 驗證:測試通過
3. 既有支付測試 → 驗證:全綠無回歸
4. 手動驗證:在測試環境做一筆真實支付 → 等 1 分鐘 → 確認訂單狀態正確

PR 描述附上:變更追溯表(3 行 → 全部追溯到「修復 Redis 逾時」)
+ 想順手整理的地方(寫在建議裡,不在此 PR 動手)。
四原則如何串成一條鏈
  • 原則 1 省下了「修錯地方」的時間:如果不釐清「漏單」的定義,可能去改 webhook URL 重試邏輯——完全修錯方向。
  • 原則 3 保護了 legacy 程式碼:1500 行的 legacy 模組,每一行修改都是風險。3 行的 diff 可以在 30 秒內審完;120 行的「順便現代化」需要 40 分鐘。
  • 原則 2 防止了過度修復:Redis 逾時一個數字就能修。重試佇列、dead letter、監控告警——這些是「正確的」但不是「現在需要的」。
  • 原則 4 提供了證明:「修復前測試失敗,修復後通過」——這比「我改了 3 行」更有說服力。
效能 × 品質 × 安全深度:精準修改在三面向的實務影響

效能:順帶重構引入的「改進」可能悄然改變效能特徵。例如:把同步改為 async(「順手最佳化」)可能改變並發模型,引入 race condition;把 for 迴圈改成 list comprehension(「順手優化」)可能在大資料集上因為中間 list 分配而更慢。更根本的是:無關改動增加了 diff 大小,增加了 code review 時間,增加了 CI 執行時間——這些都是隱性效能成本。

品質:精準修改是品質的第一道防線。每個不必要的改動都是一個潛在的回歸:改了變數名,下游可能依賴舊名;改了引號,某些語言的字串比較行為可能改變;改了 return value 的型別,序列化可能失敗。品質的定義不是「改完更漂亮」,而是「改完行為完全符合預期,零副作用」。

安全:安全修復必須可審計——這是合規的基本要求。一個包含 5 行安全修補 + 40 行格式整理的 PR,在安全審計時等於「大海撈針」。審計者需要在 30 秒內確認「這次只改了漏洞相關的行」。更嚴重的是:格式改動可能悄悄改變安全行為(例如把 if not token 改成 if token is None,在某些邊界條件下行為不同)。精準修改在安全場景是硬性要求,不是建議。

安全案例:格式改動改變了空值判斷

Agent 修復一個 SQL injection 漏洞,同時「順手」把 if not user_input 改成 if user_input is None。原來的寫法會擋住空字串 ""(因為 not "" 為 True),改成 is None 後空字串不再被攔截——攻擊者可以送 ' OR '1'='1 前面加一個空字串觸發不同路徑。修了舊漏洞,順手開了新漏洞。

面向原則 3 · 精準修改相關原則差異說明
核心動作 只碰必須碰的,diff 每行可追溯到需求 原則 2(簡潔優先) 兩者都反對「多做」:原則 3 反對的是「改了不該改的檔案/行」(範圍蔓延),原則 2 反對的是「寫了不必要的程式碼」(過度工程)。一個管「動什麼」,一個管「寫什麼」。
時機 正在修改既有程式碼時 原則 1(編碼前思考) 原則 1 是「開始前的閘門」;原則 3 是「過程中的護欄」。原則 1 防止方向錯,原則 3 防止範圍溢出。
對抗的偏誤 格式化慣性 + 死程式碼誤判 + 命名衝動 原則 4(目標驅動) 原則 3 管「改了多少」,原則 4 管「改了對不對」。兩者搭配:改得精準(3),且每步可驗證(4)。
最大價值 讓 PR 可被審計,讓回歸風險趨近於零 原則 2(簡潔優先) 原則 2 用「最少程式碼」降低複雜度;原則 3 用「最小 diff」降低審計成本。兩者共同目標:用最少的變更達到需求。
Round 2 互動式檢核清單
  • diff 裡的每一行都能直接回答「這一行是為了修哪個需求?」
  • 我刻意保留了既有的引號風格、縮排方式、變數命名
  • 我發現了無關的 bug / 死程式碼,提出但沒有在這個 PR 動手
  • 在安全修復場景中,我的 diff 只包含漏洞相關的行,無任何格式改動
  • 我沒有把「一行裡的變數名換成更好的名字」(語義蔓延 ≠ 行數蔓延)
  • 我的 PR 描述附上了「刻意沒動的東西」清單,讓審計者知道邊界在哪