簡介:拒絕說「完成」的關卡
照 Shopify 的用法,關卡(gate)是 AI 寫出的變更在往下走之前必須通過的一道檢查。在 Claude Code 裡重建之後,只有第一道真的有回報:驗收測試。Shopify 公開了四道關卡,卻沒有公開任何數字,所以這個實驗在一個真實的 repository 上把它們都建出來,對每一道關卡輪流關閉,各跑五十個請求,然後計算每一道攔下了什麼、花了多少成本。
Helix 在 2026 年 9 月 21 日對外公開。它讓每個 checkpoint 依序通過四道關卡:測試、設計審查、兩位審查者,以及一位工程師。這篇文章沒有給通過率。審查者是大多數人最愛照抄的部分,而在這五十次執行裡,只有測試加入迴圈時通過率才會動。審查者什麼都沒增加,成本卻大約是三倍。
同一個實驗還得出另外兩個發現。一個據說無法被覆寫的 hook,在它自己的文件裡就有三個出口。而當測試和審查者意見相左時,agent 做出了沒有人預料到的事。計算從完全沒有關卡的情況開始。
四道關卡,零個數字,一個實驗環境
實驗用的 repository 是 tinydb,一個真實的文件資料庫,在所用的 commit 上有 226 個測試全部通過。
Helix 是 Shopify 內部用來把行動 app 遷回原生程式碼的工具。它的文章把規則說得很直白:每個 checkpoint 都要依序通過四道關卡。
| 關卡 | Shopify 文章中它做的事 |
|---|---|
| 行為 | 執行測試,所有相關案例都必須通過 |
| 設計 | 請第二個模型扮演吹毛求疵的設計審查者 |
| 審查 | 兩個彼此獨立、上下文隔離的審查 agent;迴圈不斷重複,直到兩者都核准 |
| 工程師 | 由人來結束迴圈 |
這套設計最有說服力的一句話是「An attempt is allowed to be wrong. It is not allowed to ship until it isn't.」。對函式庫來說,設計關卡會被略過,文章也說只改邏輯的變更會直接跳過它。
這篇文章始終沒有給出數字。Shop app 在 12 週內重建完成,Shopify app 有超過 300 個畫面,整份帳本就只有這些。沒有通過率,沒有成本,也沒有漏網缺陷的數量。
實驗環境是五個用開發者平常的打字方式寫出的請求:push 與 pull 操作、一個 between 查詢、資料表改名、排序搜尋,以及寫入快取大小。每個請求背後都藏著五項 agent 看不到的需求,也就是一位細心的資深工程師會預期的事情。總共 25 項需求。
執行者是以 headless 模式執行的 Sonnet 5.5。五種設定乘以五個任務,再乘以兩次重複,共 50 次執行。有一點要先說明:這些請求的明確版本一次就能通過,所以提示被縮短成人們實際會打的樣子。
在完全沒有關卡的情況下,50 項需求中有 35 項通過。也就是 70%,十次執行中有三次完全乾淨。
排序任務展示了漏掉的需求長什麼樣子。agent 為每份文件以排序欄位建立索引,只要有一份文件缺少該欄位,整個搜尋就會崩潰。五項需求,零項通過,原因只是一個缺少的 key。
拒絕說「完成」的 hook,以及它的三個出口
Stop hook 是 Claude Code 在 agent 每次想結束回合時執行的腳本。Anthropic 自己的指南點出它要解決的問題:Claude 會在工作看起來完成時就停下來。沒有可以執行的檢查,「看起來完成」就是唯一的訊號,而你成了驗證迴圈。
這個 hook 放在專案的設定檔中,位置是 hooks、Stop 底下,是一個帶有 timeout 的指令。這裡用的是呼叫關卡腳本的單行包裝。約定就是結束碼:0 讓回合結束,2 則阻擋它,而腳本輸出到標準錯誤的內容會作為理由回傳給 agent。
實際運作的情形是這樣。agent 總結自己的變更然後停下。hook 執行驗收測試,有一個失敗,回合因此沒有結束。agent 把失敗當作 hook 的回饋來讀,這次是一個預期出現型別錯誤卻沒有出現的測試,它修好程式碼再停下一次。這一次關卡安靜了。
提示裡的規則只是建議,hook 則不是。然而同一頁文件卻列出三個出口。
| 出口 | 文件的說法 |
|---|---|
| 上限 | 在 stop hook 讓回合連續繼續 8 次之後,Claude Code 會覆寫下一次阻擋並結束回合。每當 agent 呼叫一次工具,計數就會重置,也可以用環境變數調高上限。 |
| 逾時 | command hook 預設為 600 秒,逾時的 hook 不會產生任何決定。跑得太慢的測試套件會自己把關卡打開。 |
| 旗標 | hook 的輸入帶有 stop_hook_active,當工作階段已經因為 stop hook 而繼續時為 true。文件要你檢查它,這樣就不會在無法解決的條件上一直阻擋。 |
這裡的關卡會記錄該旗標,但不理會它。它在六次阻擋之後自己放棄,所以迴圈的次數仍然可以計算。
還有一個沒有人記載的出口。在被阻擋一次之後,agent 開始去找測試:它對 hook 腳本執行了 cat,並在整個磁碟上搜尋測試檔。只有允許清單擋住了它。因此驗收測試放在 repository 目錄樹之外,設定中拒絕讀取 hook 與隱藏測試,agent 看得到失敗訊息,卻看不到檔案。
關卡 1:驗收測試
驗收測試關卡是一組獨立於實作者之外撰寫的測試,用來定義「完成」是什麼意思。Shopify 的第一道關卡用的不是 agent 自己的測試。一個 subagent 針對每個 checkpoint 產生整合風格的測試案例,並且逼自己去想 happy path 之外的邊界情況,這些測試定義了什麼叫證明過。
因此這裡的撰寫者是另一個獨立的 headless Sonnet。它能看到請求和變更前的 repository,唯讀,無法執行程式碼,並被要求針對一位細心的資深工程師會預期的每個行為各寫一個測試:缺少欄位、錯誤型別、邊界值、持久化、快取,以及既有行為保持不變。它寫出 23 到 53 個測試,平均 39 個,大約花 40 秒。檔案放在 repository 之外,所以測試失敗時,agent 讀到的是斷言訊息,而不是測試本身。
光靠測試,通過率就從 70% 升到 84%:50 項需求中通過 42 項,完全乾淨的執行有六次,原本是三次。
| 任務 | 沒有測試 | 有測試 |
|---|---|---|
| 排序搜尋 | 10 次中 5 次 | 10 次中 10 次 |
| 寫入快取大小 | 10 次中 8 次 | 10 次中 10 次 |
| Push 與 pull | 10 次中 4 次 | 10 次中 4 次 |
排序任務中缺少欄位導致的崩潰,正是撰寫者會問到的那類案例。在快取任務中,撰寫者要求快取大小小於一時要丟出 value error。
push 與 pull 任務沒有進步。它漏掉三項需求,其中兩項與缺少欄位的文件有關,而請求對此隻字未提。撰寫者恰好在提示沉默的地方打了模糊球:它的測試接受丟出例外或是靜默不做事,所以關卡無法救回一項沒有人說出口的需求。
在 30 次有測試的關卡執行中,關卡讓 agent 退回重做 28 次是因為真的有需求失敗,另外 5 次則是所有需求都已通過。這 5 次之一出在改名任務:撰寫者要求拒絕非字串的名稱,沒有人要求過這件事,agent 因此白白浪費了一輪。
這道關卡的第一版重跑的是實作者自己的測試。它在第一次停下時就放行了每一次執行,而當時仍有隱藏需求失敗,因為 agent 自己的測試測的只是它自己做出來的東西。
這道關卡的代價是每次執行 $0.20,而不是 $0.085,耗時 60 秒,而不是 33 秒。
關卡 3:兩位審查者,照 Shopify 的寫法
審查關卡是一對審查 agent,各自在全新的上下文中工作。Shopify 的措辭是:兩個彼此獨立、上下文隔離的審查 agent 檢查新程式碼,每一項發現都必須修正,迴圈不斷重複,直到兩位審查者都核准。Anthropic 的指南解釋了隔離為何重要:在全新上下文中的審查者只會看到 diff 和你給它的標準,看不到產生這項變更的推理過程。
這裡的每位審查者都是 headless Sonnet,只有唯讀工具,最多 15 個回合。它以結構化 JSON 回傳裁決:一個 approve 旗標,加上發現清單,每項發現的嚴重程度是 blocking 或 minor。兩位審查者針對請求和完整 diff 平行執行,把發現合併,只要任何一位找到 blocking 的問題,關卡就會阻擋。每次停下時,一對審查者的成本在 3 到 6 美分之間。
| 設定 | 通過的需求 | 通過率 | 阻擋 |
|---|---|---|---|
| 沒有關卡 | 50 項中 35 項 | 70% | 無 |
| 只有審查者 | 50 項中 34 項 | 68% | 10 次執行中 1 次 |
| 只有測試 | 50 項中 42 項 | 84% | 10 次執行中 6 次(3 次正確,3 次誤擋) |
| 測試之後的審查者 | 50 項中 41 項 | 82% | 10 次執行中 2 次,皆正確 |
只有審查者時,結果比完全沒有關卡低兩個百分點;測試之後再加審查者,則比只有測試低兩個百分點。
排序任務解釋了這個數字。有一次執行中,兩位審查者都阻擋了:排序會替文件建立索引,只要有一份文件缺少該欄位就會產生 key error,整個搜尋就失敗。agent 修好之後五項全過。
在另外兩次執行中,兩位審查者看到完全相同的崩潰,卻都評為 minor。缺少排序欄位的文件會引發一個單純的 key error,而 docstring 有記載這一點。兩位審查者都核准了,兩次執行最後都是五項中零項通過。同樣的模型,同樣的 diff,同樣的 bug,兩種不同的嚴重程度。審查者阻擋時從來沒有錯過。問題出在它們放行的東西。
八月的一篇論文在公開基準上測到了同樣的形態。zero-shot 得分 77%,一位審查者 77%,兩位獨立審查者 75%,全都落在與完全不審查無異的雜訊範圍內。
| LiveCodeBench 上的設定 | 分數 |
|---|---|
| Zero-shot | 77% |
| 一位審查者 | 77% |
| 兩位獨立審查者 | 75% |
| 審查者加批評者(對抗式審查) | 87% |
審查者加批評者,以及永不結束的迴圈
對抗式審查是把一位審查者配上一位批評者,批評者在主 agent 動手修改之前,透過結構化的反對意見來審核這份審查。同一篇論文發現,這才是真正有效的形態。它得到 87%,而兩位審查者只有 75%,也勝過最強的五 agent 基準線 82%。
| 基準 | Zero-shot | 加上對抗式審查 | 成本 |
|---|---|---|---|
| SWE-bench | 71.6 | 75.2 | 約 4.5 倍的 token |
所以批評者被放進了關卡。審查者 A 負責審查。批評者 B 拿到發現清單和 diff,替每一項發現蓋上成立或駁回的章。只有成立的 blocking 發現才會關上關卡。
在這個 repository 上,結果是 50 項中 39 項,也就是 78%。這組批評者搭檔在十次執行中阻擋了零次。在驗證期間,它確實駁回了一項錯誤的發現,而兩位審查者的形態原本會因此阻擋。
批評者解決不了的是迴圈。有三次執行在同一個驗收測試上碰到六次阻擋,關卡放棄了,而真正的需求在每一次停下時仍然失敗。其中兩次,造成阻擋的測試是撰寫者自己的錯,agent 也這麼說:它不會為了一個讀不到的測試去修改程式碼。
第三次是最值得記住的。驗收測試要求失敗的更新必須讓每份文件都保持不動,於是 agent 重寫了資料表的 update 方法。審查者阻擋了這一點,因為沒有人要求修改 update 方法,而任務只是兩個新函式。agent 把它還原。它最後的訊息說,驗收測試和程式碼審查要求的是相反的事,所以它什麼都沒改就停下了。六次阻擋之後,計數器放它出去。
這裡的放棄點設在六次。Claude Code 自己的上限是連續八次阻擋,且每次工具呼叫都會重置,所以在五十次執行中它一次都沒有觸發。
關卡 4 是你:交到人手上的東西、帳單、工具包
第四道關卡是工程師,而 Shopify 的文章承諾,沒有人盯著的時候,關卡不會因此變得鬆散。交到工程師手上的,就是其他關卡放行的東西。下面的帳本回答了哪一道關卡值回票價。
| 設定 | 50 項中通過 | 通過率 | 每次執行成本 | 留給人的 |
|---|---|---|---|---|
| 沒有關卡 | 35 | 70% | $0.085 | 15 |
| 驗收測試 | 42 | 84% | $0.20 | 8 |
| 只有審查者 | 34 | 68% | $0.15 | 16 |
| 測試加審查者 | 41 | 82% | $0.28 | 9 |
| 測試加審查者與批評者 | 39 | 78% | $0.28 | 11 |
每個有關卡的設定,每次執行耗時 54 到 86 秒,沒有關卡則是 33 秒。
五十次執行中的阻擋分布如下。測試讓 agent 退回重做 28 次是正確的,5 次是錯誤的。審查者阻擋了三次,沒有一次是錯的,另有三個迴圈放棄。整個矩陣花費 $9.95 的 API 牌價支出(依 CLI 回報),以及大約 3,136 秒,也就是 52 分鐘的工作者累計時間。
對於像這樣的 repository,結論是:保留測試關卡,搭配一個不是實作者的撰寫者,以及 agent 無法修改的測試。預期大約每六次阻擋會有一次誤擋,並且替迴圈加上計數器。Shopify 寫的那種兩位審查者的關卡,在這裡無論哪種形態都沒有改變通過率。如果你要保留它,就把它當作回饋裡的建議,而不是門檻。
路上還有一個陷阱。文件描述了一個單純的旗標,可以在腳本呼叫時略過 hook、skill、plugin 和記憶。在訂閱制登入下,它只讀取 API key 並回傳錯誤,所以改用空的設定來源並停用 hook 來隔離每位審查者。
限制是真實存在的:一個 repository,一個執行者模型,五個任務,每個格子兩次重複。兩列之間差兩個百分點屬於雜訊。沒有關卡和測試關卡之間差的十四個百分點則不是。設計關卡沒有建,工程師也沒有計時。
在 Shopify 的四道關卡裡,就這個 repository 而言,只有測試有回報。完整的工具包都在免費資源包裡:含有 hook 和拒絕清單的設定檔、stop 腳本、關卡、撰寫者與審查者的指令及其 schema,以及帳本範本。
AIDive