驗收的手藝:怎麼 review AI 的 code
· tech
#ai#code-review#leadership
📑 目錄
前言
第一篇說,頸寬等於你能負責任地驗收的頻寬;上一篇說,規範決定的是「什麼時候算做完」。這篇往下走一步:東西做完了,交到你手上,你怎麼驗?
「review AI 的 code」聽起來跟 review 人的 code 是同一件事,只是量變大了。這篇不從論述開始,從一場實驗開始——我當受試者。
實驗:一份埋了三個 bug 的綠燈 PR
素材是上一篇實驗留下來的:AI 在 GitCrisp 做的「Create branch from stash…」,一份真的由 AI 寫出來、一百六十多行的 PR。我請 AI 在裡面埋三個不同層次的 bug,條件只有兩個:現有測試全部要過——1294 個測試,全綠,就像你每天收到的那種 PR;而且埋了什麼、埋在哪,一個字都不能透露給我,連提示都不行。以下是 review 完才揭曉的答案:
- B1 邏輯邊界:stash 的 index 被「翻譯」了一次,上面附一句信心十足的註解:「
get_stashes()是最舊的排前面,git 從最新的開始數,所以要換算。」這句話是錯的。只有一個 stash 時,換算前後剛好一樣,所以測試會過;有兩個以上,使用者點的是最新的,被拿去開 branch、然後被丟掉的是最舊的。 - B2 靜默回歸:改到一行既有的 wiring——「Apply」被接到了「Pop」。從此按 Apply 會順便把 stash 刪掉。新功能完全沒問題,壞的是旁邊本來好好的東西,而現有測試只驗了 signal 有沒有發出去,沒驗它接到哪。
- B3 架構:新流程在 presentation 層直接
import pygit2驗 branch 名稱,繞過了 port。功能上完全正確,是一個「該不該這樣寫」的問題。
然後兩邊同時開跑。人的那組是作者本人盲測——不知道埋了幾個、埋在哪,先只讀 PR 說明寫下預測,再計時讀 diff。AI 的那組是乾淨的 Claude Code,同一句「review 這個 PR,不要改檔案」,分三種條件各跑三次:什麼都沒有的裸 review、帶 GitCrisp 的 CLAUDE.md、再加一份 review skill(改編自我另一個專案的 review 迴圈,核心一條是「每個發現都要寫出『輸入/狀態 → 錯誤結果』,寫不出來就不算」)。
先講誠實的部分:每個條件只跑三次,而受試的人只有一個(我)。這不是統計,是一次對照觀察。另外,負責架實驗的 AI 第一次設定裸 review 那組時有漏洞——CLAUDE.md 雖然刪了,卻還留在 git 歷史裡,有兩份 review 讀到了它。圖上是把 CLAUDE.md 從歷史裡也拿掉、重跑之後的數字。
我抓到的,和我怎麼抓到的
二十分鐘,三個都碰到了,但碰到的方式不一樣。
B2 是讀出來的。diff 裡大部分是新增,只有那一行是「改」——connect(self._on_stash_apply) 變成 connect(self._on_stash_pop)。一個加功能的 PR 沒有理由改到 Apply 的 wiring,這種「理論上不應該有改動」的地方,眼睛會停下來。
B3 是預測到的。讀 diff 之前我寫下的預測是:「實作完成和架構是否一致是兩回事」——AI 很擅長把功能做完,但做完的那條路未必走在 Clean Architecture 的格線上。所以讀到 presentation 層出現 import pygit2,我知道要找的就是這個。
B1 最有意思,因為我沒有抓到它錯。我預測的第二件事是「我不確定 pygit2 這類 lib 的行為,會需要一些證明」。讀到那句「最舊的排前面」的註解時,我查不到證據證明它對,也查不到證據證明它錯——而老實說,身為 reviewer,我也不想為了它去細查。所以我的 review 意見是:「雖然 comment 表示 oldest 在前面,但沒有 test case 釘住行為。」
其實還有一條我 review 時一定會提、這次卻忘了寫進回報的標準:不要用 comment 解釋行為,行為要用 function 和 variable 的命名表達;comment 只留給「為什麼要這樣 workaround」,不解釋怎麼做。照這條,B1 那句註解不管內容對不對都過不了——它解釋的是「怎麼換算」,該做的是把換算抽成一個名字說得清楚的 function,再用 test 釘住它的行為。對照組正好在同一個檔案裡:原 PR 那句「libgit2 沒有 git stash branch 的等價物,所以 shell out 給 git」講的是為什麼要繞路,這種就該留。
AI 抓到的,和它怎麼抓到的
B1 和 B2,九份報告全中。而 B1 的抓法跟我完全不同:九份裡有八份,當場寫了一支一次性腳本——開一個暫存 repo、stash 兩次、印出 pygit2 回傳的順序——然後拿著輸出說「這句註解是錯的,我實測過」。我不想做的那一步,它花一兩分鐘、十幾美分就做完了。
B3 就不一樣了。裸 review 三次只有一次提到,帶 CLAUDE.md 是三次兩次,加上那份明寫「presentation 不得繞過 port 碰 infrastructure」的 review skill 之後才三次全中,而且其中兩次把它標成 blocking。判斷題,AI 要靠規範才穩定看得到。
然後是那一格我輸掉的:原本 AI 寫的 PR 本來就有一個我沒埋的 bug。stash 帶著 untracked 檔案、而工作目錄剛好有一個同名檔時,git stash branch 已經建好新 branch 並切過去,卻在還原 stash 時失敗——UI 只顯示一行 ERROR,使用者不知道自己已經站在另一個 branch 上。事後 AI 寫腳本重現過,是真的。我沒看到;帶 review skill 的三次全都看到了。
另一面也要講:每份 AI 報告除了這些,還附了三到七條 non-blocking 意見——有的有用(名稱以 - 開頭會被 git 當成參數),有的是推測(「index.read() 可能沒刷新 HEAD」),有一條的機制還說錯了(它說工作目錄髒的時候會留下半套狀態,事後實測是乾淨地失敗)。這些都要有人一條一條判斷。
驗收的分工:證據、判斷、護欄
把那張矩陣攤開來看,我對「review AI 的 code」的理解換了一個形狀。它不是「讀得更仔細」,而是把每一個疑點分到對的處理方式:
證據便宜的,交給機器。pygit2 的 stash 順序是一個有標準答案的事實,答案藏在一個十行腳本的輸出裡。這種疑點以前靠 reviewer 的經驗或意願——有經驗的人記得,有意願的人去查,兩者都沒有就放過去。現在它的價格是一兩分鐘、十幾美分。我沒做那一步,不是因為我不會,是因為人的意願是稀缺資源;稀缺資源不該花在十幾美分就能買到的東西上。
需要判斷的,留給人。B3 沒有標準答案可以實測——import pygit2 會動、測試會過,問題在「這個專案該不該這樣寫」。這正是漏斗的頸該花力氣的地方。而實驗的另一個結論是:判斷也可以部分外包,但前提是先把判斷寫成規範。裸 review 三次中一次,寫進 review skill 之後三次全中——這就是上一篇說的「規範即程式」,只是這次跑它的是 reviewer。
拿不到證據的,要求護欄。回頭看我對 B1 的那句意見:「沒有 test case 釘住行為。」它沒有指出 bug,但它指向了正確的修法——補一個兩個 stash 的 test,而那個 test 一跑就會紅。這是 reviewer 一個被低估的動作:證明的責任在作者,不在 reviewer。我不必自己去證明它錯,我只需要拒絕在沒有證據的地方簽名。
反思
綠燈是一個說法,不是一個證據
這份 PR 最狡猾的地方,是 1294 個測試全綠——而且新增的測試寫得不差:infrastructure 有真 repo 的測試、presentation 有 flow 測試、還有一條 e2e journey。只是它們全部只用一個 stash,剛好是錯的換算和對的換算給出同一個答案的那個點。
這是第一篇說的「護欄壞了是靜默的」的實物。所以我 review AI 的 PR 時,看測試的力道比看 feature 更重,而且問的不是「有沒有測試」,是「如果這段 code 是錯的,這個測試會不會紅?」一個在錯誤和正確之間無法分辨的測試,不是護欄,是裝飾。九份 AI 報告裡,帶 review skill 的三份都把「只用一個 stash」單獨列成一條 test gap,其中兩份標成 blocking——因為那份 skill 裡有一行:「測試真的會在 code 錯的時候失敗嗎?檢查每個測試實際 assert 了什麼。」
老實說「這塊我不能簽」,是頸寬的一部分
第一篇寫過:頸寬 = 驗收速度 × 校準,真正的頸寬包含知道自己哪裡看不懂,並對看不懂的部分降速、加護欄,或老實說「這塊我還不能簽」。寫的時候是論述,這次是實物——我對 B1 做的正是這件事。我不確定 pygit2 的行為,所以我沒有假裝確定,而是要求一個 test。
反過來想才可怕:如果我讀到那句「最舊的排前面」,覺得「嗯,聽起來合理」,然後放行——那就是事故篇說的權威幻覺:AI 的註解長得最像答案。這句註解信心十足、語氣專業、還附上理由,而它是錯的。AI 寫的註解不是文件,是待驗證的主張——而最好的處理,常常是根本不讓解釋「怎麼做」的註解存在:命名說得清楚的東西不需要註解,命名說不清楚的,註解只會讓它看起來可信。
AI reviewer 不是第二雙眼睛,是一台證據機
常聽到的說法是,AI reviewer 的價值是「多一雙眼睛」。這次的結果看起來,這個比喻不太對。它跟我抓到的東西大半重疊,所以它不是一雙不同的眼睛;它真正不同的地方,是它願意為每一個小疑點付出驗證的成本——開暫存 repo、跑腳本、重現失敗。那個沒埋的真 bug 也是這樣被找到的。
但它也是一台會產生雜訊的機器:每份報告三到七條 non-blocking,有推測、有說錯機制的。把它的輸出直接當結論,等於把頸換成另一種形式的蓋章——我只是從「蓋 AI 寫的 code」變成「蓋 AI 寫的 review」。所以我的用法會是:讓它先跑,把它的發現當成待驗證的線索,我只花時間在兩種東西上——它標為 blocking 的,和它根本不會判斷的。
這篇想留下的一句話是:review AI 的 code,難的不是讀懂每一行,是知道每一個疑點該由誰、用什麼代價來確認。證據交給機器,判斷留給自己,沒有證據的地方,不簽名。