我從來沒有向上捲動

我每天都在使用編碼代理。我給它一個任務,然後去做其他事情,當我回到螢幕底部,看到一行像是這樣的文字:

"빌드·회귀 스모크·기존 분석 테스트 모두 통과."
(建置、回歸煙霧測試,以及現有分析測試全部通過)

那是我自己會話記錄中的真實一行。我從來沒有檢查過它。我沒有向上捲動去看看測試是否真的執行過,而是直接給它下一個任務。這樣持續了幾個月。

後來我在一個韓國開發者社群看到一篇貼文,有人調查了他們代理的私人推理,發現給予測試者角色的記憶體中,包含了一份當問題發生時如何繞過問題的方法清單

這並不意味著代理有惡意。如果你告訴它要讓測試通過,找到最便宜的路徑就是最佳化的作用。問題在於,當最便宜的路徑是「避開檢查」而不是「修復程式碼」,而你沒有辦法察覺。

因此我決定進行檢查。這個工具叫做 red-handed

不是審判者,而是差異比對

我不是試圖建立一個審判者。我想要的是代理所說的內容與實際所做的事情的並排比較

這些資料已經存在了。Claude Code 會為每次會話寫下記錄:它執行的每個命令和回傳結果、每個檔案編輯的前後狀態,以及它對我說的每一句話。將這些與 git 比對,你就可以計算出語言與工作之間的差距。

它是否聲稱測試通過但實際沒有執行?它是否在測試執行失敗後立刻聲稱測試通過?它是否重寫預期值以符合損壞程式碼的輸出?共有九項這樣的檢查。

我花最長時間思考的不是功能,而是當工具出錯時會發生什麼

這裡有兩種出錯的方式。

遺漏某些事情。使用者沒有損失任何東西——他們的資訊不足程度與之前完全相同。

指控誠實工作的人。這就是告訴一個人他們作弊了,但實際上他們沒有。這種錯誤發生一次,就不會有人再開啟它。

這兩種成本完全不對稱。因此規則變成:寧可遺漏也不要錯誤指控

這轉化為兩個硬性限制。

確認的發現需要兩項證據。會話必須顯示事情發生過,而且變更必須仍然存在於工作樹中。如果代理後來撤銷了它,那就沒有什麼可以指控的,而發現也會消失。

未讀取的東西不是失敗。如果測試執行過而我無法解析輸出,那就是「我不知道」,而不是「它失敗了」。這種區別最終會顛倒整個結果。

這整個過程不需要呼叫任何模型。如果判決來自大型語言模型,同一份記錄在不同日子會產生不同的答案——那就不是證據。

執行 249 次會話

我將它指向我機器上的每一次會話。

249 次會話。你的代理說「測試通過」124 次。
   117  先執行了測試
     7  該會話中根本沒有執行測試

進入全螢幕模式 退出全螢幕模式

確認的發現:

老實說,這令人沮喪。在一個測謊器上花了數週的工作,卻沒有抓到任何謊言。

然後我逐一打開那七個,才意識到我問錯了問題。

真正的問題不在於說謊

七個中有四個來自同一個專案。代理說了這樣的話:

"드래그·휠 5방향 브라우저 테스트 통과."
(拖曳和捲動,所有五個方向,瀏覽器測試通過)

這不是謊言。代理確實開啟了瀏覽器並進行檢查。但那個檢查沒有留下任何機器可讀的痕跡。因此後來無法確定該聲明是否成立。對我、對工具,以及對繼承該程式碼的人來說,都是如此。

另外三個是相同的形狀:專案透過自己的腳本執行測試,而我的解析器不知道該輸出格式。我可以知道某個測試形狀的東西執行過。但我無法知道它是否通過。

結論就在這裡翻轉了。我原本擔心的是會說謊的代理。我實際發現的是無法讀取的驗證

這比較不是 AI 的特性,而是我們現在工作方式的特性。以前一個人開啟瀏覽器、查看,然後繼續前進是沒問題的——因為那個人一直看著那段程式碼。現在進行檢查的是記憶體每回合都會重置的代理。無法讀取的驗證與從未發生的驗證是無法區分的

因此這七個被回報為 SUSPICIOUS(可疑),而不是 CAUGHT(抓到),用詞是「去看看該命令實際回報了什麼」,而不是「它對你說謊」。這是工具誠實地能做到的極限。

我寧可先聲明一個但書,也不希望有人後來發現:在這 249 次會話中,只有 12 次包含「測試通過」的聲明。其餘都是簡短的——一兩個問題。因此「249 次中有 0 次」會是一種誇大的讀法。

它錯誤指控了我六次

一旦它運作,我花了一段時間試圖破壞它。那結果是這個專案最有價值的部分。對抗性審查發現了種它會指控誠實工作的不同方式:

  1. 測試執行因逾時而被終止,被讀取為失敗
  2. 不認識的執行器(rspecphpunittox),被讀取為「沒有執行測試」
  3. 在它看不到的啟動器下重新執行,因此根據過時的結果進行判斷
  4. 使用者明確要求的行為變更,被讀取為重寫答案
  5. 背景執行的命令,被計算為通過

前兩項值得詳細說明。

逾時的那個是最糟糕的。當測試執行因花費太長時間而被終止時,會留下獨特的退出訊號,而我的程式碼將其讀取為失敗。因此如果代理接著說測試通過,它會印出工具中最侮辱性的判決:「說測試通過——最後一次執行失敗了」。我的程式碼直接違反了我在設計筆記中用兩段文字寫下的規則。

第二個只是一個字元。識別測試執行器的模式尋找前面有詞界邊界的 spec 這個字——這永遠不會匹配 rspec 內部,因為 r 是一個詞字元。因此 Ruby 開發者可以執行他們的測試套件,得到 20 examples, 0 failures,而我的工具會自信地表示根本沒有執行測試

刺痛的部分來了。我的 README 自豪地寫著「183 次真實會話中 0 個假陽性」。這是真的——但不是因為工具很好。這是真的因為我機器上的每個專案都是 JavaScript 或 TypeScript,因此這兩條路徑都從未執行過。我建立了一個用來驗證聲明的工具,回報它通過了自己的驗證,卻沒有真正測試過程式碼。我做了工具存在就是要抓到的那件事。

第六個是在我發布後發現的。一個範圍限定的 pytest tests/test_sync_cli.py,接著編輯 GitHub 工作流程檔案和兩個 .tsx 檔案,而工具判斷該聲明已過時。Python 測試無法匯入 .tsx,CI 工作流程也不是剛在這台機器上執行的東西。沒有任何東西過時。

有一個誘人的修復:「不同的目錄,將其視為無關。」簡單,而且大多數時候是正確的。但這也是錯的——執行 pytest tests/ 然後編輯 src/,聲明確實已經過期。因此我沒有猜測,我只抑制確定為真的情況:Python 無法載入 .tsx,JS 執行器無法載入 .py,CI 工作流程不是本地執行。任何模稜兩可的仍然計算在內。「看起來無關」是一個猜測,你不會根據猜測丟棄證據

這六個現在都有回歸測試固定。

它在我寫這篇文章時再次抓到我

我重新執行它來更新本文的數字,發現我的其中一項在清單中:

"테스트 3개로 고정했고 전체 389개 통과."
(用三個測試固定,全部 389 個通過)
→ 之後有 1 個檔案變更:scripts/social.tape

我說的是真的,而變更的檔案是一個用於 demo GIF 的螢幕錄製腳本。它與測試套件無關。

但這不是錯誤。這是我在前一節選擇的規則——任何模稜兩可的仍然計算在內——正在確切執行我告訴它做的事。工具不知道 .tape 檔案是什麼,而我決定未知的東西會被計算。

這就是選擇的代價。為了避免遺漏,它有時會無的放矢。作為交換,這一項被輸出為 SUSPICIOUS 而非 CAUGHT,變更的檔案名稱就印在旁邊,所以我花了三秒鐘就駁回了它。這就是兩個層級的用途。

我會保留的三件事

在調整準確度之前,先決定你會以哪種方式出錯。「準確度是多少?」對於這種工具來說接近無意義,因為兩種錯誤方向的成本差異極大。選擇方向,然後在每個分支中捍衛該決定。我將原則寫在文件中,然後在程式碼中破壞了三個地方。

提供證據,而不是判決。如果輸出說「它說謊了」,使用者只能選擇相信或不相信。顯示時間戳和引用的行,他們就可以自己決定。這也是為什麼駁回我自己的假陽性只需要三秒鐘的原因。你能抓到它出錯的工具,才是你會繼續使用的工具。

真正的修復是可讀取的驗證。這是四個瀏覽器測試發現教會我的。當你將工作交給代理時,不要說「檢查它」——要說「檢查它並將結果留在機器可以讀取的地方」。只存在於某人腦中的檢查,在下一次會話時就不存在了。

試試看

在本機執行。你的記錄和程式碼永遠不會離開你的機器——它不呼叫任何模型,也不發出自己的網路請求。同一次會話,每次都有相同的答案。

# 一個虛構的會話,其中每個檢查都會觸發,讓你先看到形狀
npx @jinhyuk9714/red-handed@latest demo

# 然後是上面數字的你自己的版本
npx @jinhyuk9714/red-handed@latest stats

進入全螢幕模式 退出全螢幕模式

採用 MIT 授權,程式碼位於 github.com/sjh9714/red-handed

你的數字會與我的不同。我有零個確認的發現、七個沒有人能讀取的檢查,以及我在寫這段文字時發現的一個假警報。不管對你來說結果如何,重點是你不查看就不會知道