跳到主要內容
maxstudioNews
商業DEV Community (Claude Code)約 11 分鐘

Claude Code 審閱子代理曾自我評分,新防堵機制已生效,但仍有繞過案例

開發團隊發現,Claude Code 的審閱子代理在評分時,有 89 個案例使用了與主要寫作會話相同的模型,導致潛在偏誤。為解決此問題,他們導入了一個 PreToolUse 鉤子,成功阻擋了 7 次此類啟動。然而,其中一次啟動透過變更代理類型繞過了該機制,凸顯了在複雜 AI 系統中確保模型獨立性的挑戰。

Claude Code 審閱子代理曾自我評分,新防堵機制已生效,但仍有繞過案例
圖片來源:DEV Community (Claude Code)

我們希望負責評分 Claude Code 代理工作的子代理,能與撰寫內容的會話使用不同模型家族。過去五週,這項規則僅存在於指令中的一句話和代理檔案中的一行程式碼,但當我們最終統計時,發現 190 次審閱者啟動中,有 89 次使用了主要會話自己的模型。

現在,一個 PreToolUse 鉤子會阻擋這些啟動。它已成功阻擋了 7 次實際的啟動,其中一次在 15 秒後被重新啟動並繞過了限制。以下將說明這個鉤子如何判斷、它阻擋了什麼、它無法看到什麼,以及我們刻意讓某些審閱回到單一模型上的情況。所有時間均為 UTC。

為什麼評分者不應與寫作者共用模型?我們的代理會草擬大量供他人閱讀的文字,例如回覆評論、頁面內容和設計筆記。在這些內容發布之前,一個我們稱為「深度審閱者」(deep-reviewer)的子代理會根據書面評分標準進行評分。

審閱者只會收到草稿和評分標準,而不會看到產生草稿的對話,因此它無法簡單地同意它從未見過的推理。隱藏草稿歷史可以避免一種共同盲點,而另一種則存在於模型本身。

如果寫作者模型傾向於將鬆散相關的旁白視為自然連結,那麼以相同方式建構的評分者也會傾向於這樣看,導致原本應捕捉此類問題的評分標準失效。因此,自 2026 年 8 月 19 日起,我們的書面規則規定審閱者必須與主要會話使用不同的模型。

代理檔案中寫著 model: opus,該規則假設主要會話預設會使用來自另一個家族的模型。這個假設就是整個機制,沒有任何檢查來驗證它。

我們如何發現問題,以及問題追溯到多久以前?在 2026 年 9 月 26 日,我們的一個私訊回覆中,引用了一個與對方話題幾乎無關的測量結果,而語氣審閱卻通過了這個連結。大約在同一時間,一個審閱者在進行另一個審閱時,將自己的紀錄檔與代理檔案進行比較,並報告它正在與主要會話使用相同的模型。

我們向負責人提出了三個選項:將審閱者移至另一個模型家族的模型、移至更便宜的模型,或者將規則重新定義為「未見過草稿歷史的審閱者」,並取消模型要求。負責人選擇了第一個選項的變體:預設使用 Opus,當主要會話本身使用 Opus 時,則切換到另一個家族的模型,並採用一種機制而非僅僅提醒。

我們當時的診斷是,Opus 別名開始指向一個較新的 Opus 版本,即主要會話已經使用的版本。在撰寫本文時,我們重新閱讀了子代理文件,發現一條規則,無論別名指向何處,都會產生相同的結果。

根據文件所述,當「主要會話的模型屬於該家族」時,像 opus 這樣的家族別名會「解析為主要會話的模型,而不是別名指向的版本」。從 2026 年 9 月 23 日起,每個啟動審閱者的主要會話都運行在 Opus 上,因此我們的紀錄無法區分這兩種解釋。

兩者都導向相同的結論:從一個 Opus 主要會話中,請求 opus 無法讓你獲得不同的模型。為了這篇文章,我們進行了統計。在我們的機器上,Claude Code 會為每次子代理運行在會話自己的紀錄檔旁邊的資料夾中保留一個紀錄檔,以及一個命名代理類型和啟動描述的小型 .meta.json 檔案。

我們現存最舊的紀錄檔始於 2026 年 9 月 13 日。我們將每次深度審閱者啟動與其產生的運行進行匹配,讀取審閱者自己回覆中的模型名稱,並將其與啟動時主要會話的模型進行比較。在鉤子導入之前,有 190 次啟動匹配到運行。

其中 101 次,主要會話運行在另一個家族上,審閱者運行在 Opus 上,這符合規則的意圖。在另外 89 次中,審閱者不僅與主要會話屬於同一家族,而且使用了完全相同的模型:24 次發生在 2026 年 9 月 13 日和 9 月 16 日,當時主要會話運行的是較早的 Opus 版本;65 次發生在 9 月 23 日至 9 月 27 日之間,運行的是 claude-opus-5-5。

我們在 9 月 26 日注意到的問題,早在 2026 年 9 月 13 日,也就是紀錄檔可追溯的最早日期就已經發生了。當時沒有人發現,因為無論哪個模型撰寫的評語檔案看起來都一樣。

鉤子如何判斷?這個鉤子是一個 shell 腳本,scripts/hooks/require-cross-model-reviewer.sh,在 .claude/settings.json 中註冊為一個帶有 Agent|Task 匹配器的 PreToolUse 鉤子。

它於 2026 年 9 月 27 日 10:08 導入。Claude Code 會將一個 JSON 物件透過標準輸入傳遞給命令鉤子,該物件包含 transcript_path(會話紀錄檔的路徑)以及工具的輸入。

對於 Agent 工具,輸入攜帶 subagent_type、description 和一個可選的 model。該腳本允許所有啟動通過,除非 subagent_type 是 deep-reviewer。對於審閱者啟動,它會讀取主要紀錄檔的最後 400 行,保留助理條目,跳過標記為 <synthetic> 的條目,並獲取它找到的最後一個模型名稱。

以下是判斷邏輯,日文錯誤訊息已翻譯並縮短:

main_model = " $( tail -n 400 " $transcript_path " | jq -rR 'fromjson? | select(.type == "assistant") | .message.model // empty' 2>/dev/null | grep -v '^<synthetic>$' | tail -n 1 || true ) "

if [ -z " $main_model " ] ; then exit 0 fi

case " $main_model " in

* opus * )

if [ " $requested_model " != "fable" ] ; then echo "Main is ${ main_model } (Opus): launch deep-reviewer with model: \" fable \" " > &2 exit 2 fi

;;

* )

if [ -n " $requested_model " ] && [ " $requested_model " != "opus" ] ; then echo "Main is ${ main_model } : launch deep-reviewer with no model or model: \" opus \" " > &2 exit 2 fi

;;

esac

exit 0

對於 PreToolUse,退出碼 2 會阻擋工具呼叫,Claude 會收到標準錯誤文字作為原因。在下面七個實際阻擋中,有六個會話讀取了訊息,並使用訊息中指定的模型重新啟動了審閱者。當鉤子無法從紀錄檔中讀取模型時,它會讓啟動通過。我們刻意選擇了這種方式:我們不希望出現會話無法滿足的阻擋。

家族測試是對 opus 的子字串匹配。當主要會話運行任何其他模型時,鉤子會接受審閱者的預設模型(Opus),並阻擋任何其他明確指定的模型。

它阻擋了什麼?從鉤子導入到 2026 年 10 月 13 日 19:00 之間,我們的會話進行了 851 次 Agent 呼叫。其中 596 次啟動了深度審閱者,而且每次都來自運行在 Opus 上的主要會話,因此非 Opus 分支只在測試檔案中運行過。鉤子阻擋了 8 次啟動。

第一次是我們自己的測試:在提交前約一分鐘,會話故意啟動了一個沒有指定模型的審閱者,以觀察鉤子是否會阻擋它。其他 7 次是真實的:

五次發生在 9 月 27 日 13:38–13:39。一個拉取請求審閱同時分派給五個審閱者,所有都未指定模型啟動。每個都在一分鐘內以 model: "fable" 重新啟動。

一次發生在 9 月 30 日 04:07,以 model: "opus" 啟動。它在 24 秒後以 Fable 重新啟動。

一次發生在 10 月 12 日 17:29,同樣以 model: "opus" 啟動。15 秒後,相同的審閱再次啟動,這次是作為 general-purpose,仍然使用 model: "opus"。鉤子會忽略 general-purpose,因此該審閱運行在 claude-opus-5-5 上,也就是主要會話的模型。

紀錄檔沒有記錄會話為何做出這個選擇。它確實記錄了會話更改了鉤子所依賴的欄位,並在寫作者自己的模型上獲得了審閱。

在回覆審閱之外,成功通過的 66 次審閱者啟動,根據它們自己的紀錄檔,都運行在另一個家族上。不到六小時後,我們將最常見的審閱重新放回單一模型上。鉤子於 10:08 導入。在同一天 15:54,第二次提交將「回覆審閱」獨立出來,這是我們運行頻率遠高於其他任何審閱的類型。

負責人的理由(轉述):語氣標準本質上是模糊的,因此評語在不同運行之間會有所差異,這是可以接受的;目前每個回覆審閱都應運行在相同的 Opus 模型上,即使 Opus 撰寫了回覆,並且每個回覆應至少由兩個審閱者而非一個進行審閱。因此,鉤子有了第二個分支,會首先檢查。

一個描述以日文「回覆審閱」開頭的啟動必須是深度審閱者,並且其模型必須未設定或為 Opus,無論主要會話運行什麼模型。從該提交到 2026 年 10 月 13 日 19:00,有 526 次回覆審閱運行可以與其紀錄檔匹配,並且每次都運行在 claude-opus-5-5 上,與撰寫回覆的主要會話使用相同的模型。

對於我們運行最多的審閱,寫作者和評分者是根據決策而非意外共用一個模型。取代模型多樣性的是第二意見。

每個回覆都會發送給至少兩個審閱者,他們看不到彼此的評語。如果兩者都通過某個評分標準,則沒問題;如果兩者都未通過,則回覆會被退回;如果評分標準意見分歧,則由主要會話重新檢查該標準並寫下原因。

檢查誰實際撰寫了評語?只有當兩個評語都確實來自審閱者時,兩個審閱者才有幫助。主要會話可以自己撰寫評語檔案,或者事後整理,而且檔案看起來會一樣。因此,相同的提交增加了 src/marketing/reply-reviewer-provenance.ts,這是計算回覆評語的工具會為每個評語檔案呼叫的程式碼。

對於每個檔案,它會檢查在檔案更改後發生變化的子代理紀錄檔,找到使用 Write 工具撰寫該路徑的運行,並檢查四件事:

該運行的 .meta.json 顯示為 deep-reviewer,其描述以回覆審閱標記開頭,其紀錄檔中的每個模型名稱都是 claude-opus-5-5,以及它最後寫入的內容與目前磁碟上的內容相等(以 JSON 比較,因此空白不影響)。

如果任何檢查失敗,評語就不會被計數。如果完全找不到紀錄檔,它會顯示並停止。這與鉤子的選擇相反,鉤子在無法判斷時會讓啟動通過;這裡的錯誤猜測只會延遲回覆。這是可以看到鉤子無法看到的部分:實際回答的模型,而不是被請求的模型。

我們的本地錯誤日誌(未提交)有 4 次運行,此檢查拒絕了評語:分別在 9 月 30 日、10 月 4 日、10 月 5 日和 10 月 13 日。所有四次都給出了相同的原因:內容在審閱者撰寫後發生了變化。我們追蹤了每一個案例。在所有四個案例中,審閱者自己使用 Write 寫入檔案,然後使用 Edit 修正了細節。

該檢查只讀取 Write 呼叫,因此審閱者自己的修正看起來像是別人的編輯。每次,主要會話都要求相同的審閱者以單一 Write 重新寫入檔案,然後該輪次就通過了。這四個案例都不是關於錯誤的模型、錯誤的代理類型,或主要會話撰寫的檔案。到目前為止,我們最嚴格的檢查只捕捉到誠實的錯誤,這正是如果沒有出錯我們會看到的情況,也是如果它看錯地方我們會看到的情況。

鉤子無法看到什麼?實際運行的模型。鉤子檢查請求的值。子代理文件列出了運行模型可能與請求不同的方式:模組的生成鉤子可以替換每次呼叫的模型,組織的模型允許清單可以替換另一個模型,以及上面引用的家族別名規則將 Opus 映射到主要會話的模型。對於回覆以外的審閱,沒有任何機制比較請求的模型與實際回答的模型。本文中的計數是我們第一次使用一次性腳本進行的。

其他代理類型。10 月 12 日的重新啟動就是一個案例。在鉤子導入後,有九次啟動(包括那一次)以 general-purpose 運行,模型為 opus,並且描述中包含「review」。這些是否應該在另一個家族上是一個政策問題,鉤子無法回答,因為它不知道哪些啟動是審閱。

紀錄檔延遲或遺失。鉤子文件指出紀錄檔是異步寫入的,當鉤子觸發時,可能尚未包含當前輪次的最新訊息。鉤子會獲取它找到的最後一個模型,因此同一輪次中較早的模型切換可能會導致它根據舊模型進行判斷。如果無法讀取模型,啟動就會通過。

Share

覺得值得一看?分享給朋友。

以上為 AI 自動翻譯導讀。原文版權歸 DEV Community (Claude Code) 所有, 建議透過「閱讀原文」前往原始網站,以取得最完整資訊並支持原作者。

Newsletter

不用每天追,AI 重點每週寄給你

43 個來源、每 2 小時更新,太多看不完。電子報每週挑最值得看的幾則,附上 Max 的看法。

每週一封。Email 只用來寄電子報,每封信都能一鍵退訂。