最近讀到 Triton Ho 在 Facebook 上的一篇貼文,講的是怎麼判斷一個人(或一個 AI)是不是真的懂一件事。他用 bloomfilter 舉例:
我寫 bloomfilter 時,就直接在 config 內寫
m = 1000003。對工程師來說,我不需要解釋為何不是 1M 而是 1M + 3。但你覺得 AI 會知道這裡代表需要一個質數嗎?
底下有人留言說:拿 1M+3 去問 AI,它知道這是質數啊。他的回應是把「知道」拆成三級。
讀完我第一個想到的,是自己半年前那次 43 個模組假完成 的事故。當時我把它歸因成「完成的定義沒寫清楚」,這個結論沒錯,但用這三個等級重看一次,我才看清楚它到底錯在哪一層。
「知道」的三個等級
| 等級 | 表現 | bloomfilter 的例子 |
|---|---|---|
| lv0 | 不知道 | 不曉得 m 跟質數有什麼關係 |
| lv1 | 被問到時答得出來 | 問「1000003 是什麼」,答「是質數」 |
| lv2 | 沒人提醒,做事時也會主動用上 | 被要求「把 m 改成兩倍」時,改成 2000003,而不是 2000006 |
他的重點在最後一列:問問題只能測到 lv1。 要測 lv2,得先準備好一個真的會用到這個知識的情境,下一個看起來跟它無關的指令,再看對方有沒有自己把它用上。
他原本的語境是帶新人:問新人懂不懂,對方都會說懂;交代一件事下去,才知道是不是真的懂。這個測法拿來看 AI 也完全適用。
重看那次事故:每一步都是 lv1
簡單交代背景。那是一個 Legacy PHP 系統往 Laravel 的嵌入式移植,78 個模組各有一個 migration flag:
flag = true:使用者被導向 Laravel 的新頁面flag = false:使用者繼續走 Legacy
有一輪作業把 78 個 flag 全部翻成 true,事後盤點發現其中 43 個模組的 Blade view 裡只有一行佔位文字:
已遷移至 Laravel。
請透過正確的 URL 參數存取。
flag 一翻,使用者打開這些模組看到的就是這兩行字,功能整個不見。
現在用三個等級來看這件事。
AI 有沒有 lv1?有
如果當時拿那頁佔位文字去問 Claude:「這個頁面算完成移植了嗎?」我幾乎可以確定它會回答「不算,這只是佔位頁」。它也一定知道 flag = true 會把使用者導到新頁面,那是它自己寫的路由邏輯。
兩個知識它都有,而且都答得出來。
但是做事的時候沒有用上
實際下的指令是「把這批模組移植到 Laravel,完成後啟用 flag」。在這個任務框架裡,它做的事情是:
- 為每個 Legacy 檔建立對應的 Blade view
- 建 Controller 和路由
- 把 flag 翻成
true - 寫 commit:「N files → M Blade views + controller」
每一步都合理,而且整整齊齊做了 78 次。缺的是一個沒人要求的動作:翻 flag 之前,回頭看一下使用者到時候會看到什麼。
這就是 2000006。知識都在,只是在「被叫去做別的事」的時候,沒有被主動拿出來用。
測試也只測到 lv1
更糟的是當時的測試長這樣:
public function test_billing_migration_flag_enabled(): void
{
config(['migration.modules.billing' => true]);
// ...
$this->assertTrue(config('migration.modules.billing'));
}
這個測試檢查的是「flag 的值是不是 true」,等於在問「你有把 flag 設成 true 嗎?」。答案當然是有,CI 一片綠燈。
它從頭到尾沒有問「flag 是 true 的時候,使用者看到的頁面有沒有內容?」。換句話說,我們用一個 lv1 的問題去驗收一件需要 lv2 的工作,然後把綠燈當成完成。
我自己也是 lv1
公平起見,這件事不能全算在 AI 頭上。flag 會把使用者導走這件事,我比誰都清楚;佔位頁不算完成,我也不需要別人提醒。
但那一輪我看的是 commit 訊息和測試結果,沒有實際打開任何一個模組。我的知識也停在「被問到答得出來」,沒有在驗收時主動用上。
所以 43 個假完成的真正成因是:AI 和我都有 lv1,而整個流程裡沒有任何一個環節要求 lv2。
為什麼 lv2 特別難靠 AI 自己做到
lv1 和 lv2 的差別,在於知識要不要「自己跳出來」。
被直接問的時候,問題本身就把相關知識叫出來了。被叫去做另一件事的時候,注意力在任務的成功條件上,而這次任務的成功條件是「view 建好、flag 翻 true、測試綠燈」,三個都達成了。「使用者看到什麼」不在成功條件裡,它就不會被想起來。
Triton Ho 在另一篇貼文裡提過,AI 和人一樣是 non-deterministic 的:之前做 100 次都沒錯,不代表第 101 次不會錯。lv2 正是最容易受這種不穩定影響的部分。lv1 有提問幫忙觸發,lv2 只能靠它自己剛好想起來。
所以我現在的結論是:lv2 不能拿來賭。 該主動想到的事情,要嘛寫進流程變成必經的一步,要嘛寫成檢查讓它不可能被跳過。
把 lv2 變成可以檢查的東西
事故之後我們做了幾件事,事後看,每一件都是在把「需要主動想到的」改成「一定會被問到的」。
1. 用 guardrail 測試把「完成」寫下來
在測試裡加了一條:flag 是 true 的模組,所有 Blade view 都不能含佔位字串。
private const SCAFFOLD_PHRASES = [
'實際表單邏輯待後續',
'實際內容尚未遷移',
'尚未遷移',
'已遷移至 Laravel。請透過正確的',
'已由 Laravel 接管',
'scaffold —',
];
public function test_flag_true_modules_have_no_scaffold_placeholder_in_blades(): void
{
foreach (config('migration.modules', []) as $mod => $enabled) {
if (!$enabled) continue;
// 掃這個模組的所有 blade,含佔位字串就報錯
}
}
這條測試做的事,就是替「翻 flag 之前先看使用者會看到什麼」這個 lv2 動作,每次都自動問一遍。不管是 AI 還是我忘了想,CI 都會擋下來。(詳細設計寫在 Guardrail 測試)
2. 測行為,不測設定
assertTrue(config(...)) 這種測試,測的是「你有沒有照我說的設」,是 lv1 的問題。真正要測的是設定生效之後系統的行為:導向哪裡、頁面上有沒有東西。
判斷方法很簡單:如果一個測試在功能完全沒做的情況下也會過,它測的就不是功能。
3. 驗收時問情境題,不問「完成了嗎」
這是直接從 Triton Ho 的測法搬過來的。以前我會問 AI「這批模組都移植完了嗎?」,它會說完成了,而且從它的任務框架看,它說的是實話。
現在我改成問情境:
- 「使用者現在打開 billing 模組,畫面上會看到什麼?請列出實際會渲染的欄位。」
- 「如果明天把這個 flag 關掉再打開,有哪些資料會受影響?」
這類問題逼它重新從使用者的角度走一遍,lv2 該想到的事情就會被問題本身叫出來。等於是用提問把 lv2 降回 lv1,而 lv1 是它很可靠的部分。
4. 翻 flag 改成獨立的一步
後來的做法是:一個模組裡每張 view 真的移植完成,各自一個 milestone;全部完成之後,最後再用一個獨立的 milestone 只做翻 flag 這件事,同時把測試從 flag_disabled 改回 flag_enabled。
把「宣告完成」從「做事」裡拆出來,宣告就不會被當成做事的附帶動作順手完成。
給新手 RD:動手做的步驟
上面四點講的是原則。如果你是剛開始帶 AI 寫 code 的工程師,下面是可以直接照著做的版本。專有名詞的解釋放在文末。
寫第一條 guardrail 測試
- 先用一句話寫下「完成」是什麼意思。 例如「flag 開啟的模組,使用者看到的每一頁都有真正的內容」。寫不出這一句,就先別寫測試。
- 把這句話轉成程式能檢查的條件。 常見的有三種:某些字串不能出現(佔位文字、
TODO)、某些檔案必須存在、某個網址必須正常回應。 - 掃全部對象,不要只檢查一個。 這類錯誤的特徵是同時發生在很多地方,只檢查一個抓不到。
- 失敗訊息寫清楚「哪個檔案、違反哪一條」。 讓看到紅燈的人不用再去翻程式碼找原因。
// 簡化示意:掃描所有 flag 開啟的模組,任何 Blade 含佔位字串就失敗
public function test_flag_true_modules_have_no_scaffold_placeholder_in_blades(): void
{
$errors = [];
foreach (config('migration.modules', []) as $mod => $enabled) {
if (!$enabled) continue;
foreach (glob(resource_path("views/module_a/{$mod}/*.blade.php")) ?: [] as $file) {
$body = file_get_contents($file);
foreach (self::SCAFFOLD_PHRASES as $phrase) {
if (str_contains($body, $phrase)) {
$errors[] = "{$mod}/" . basename($file) . " 含佔位字串「{$phrase}」";
}
}
}
}
$this->assertSame([], $errors, implode("\n", $errors));
}
把設定測試改成行為測試
// 只測設定:功能完全沒做也會過
$this->assertTrue(config('migration.modules.billing'));
// 測行為:實際打開頁面,確認使用者看得到內容(示意)
$response = $this->get('/api/module_a/billing/index');
$response->assertOk();
$response->assertDontSee('已遷移至 Laravel');
$response->assertSee('帳單列表');
自我檢查的小技巧:暫時把功能程式碼註解掉,再跑一次測試。如果測試還是過,就代表它沒測到功能。
驗收時的提問範本
把「完成了嗎?」換成下面這些問題:
- 「請列出使用者打開 X 頁面時,畫面上實際會出現的欄位和按鈕。」
- 「這次改動之後,哪些頁面的行為會跟改之前不一樣?」
- 「有沒有哪個檔案目前還是佔位內容或
TODO?請全部列出來。」 - 「如果把這個 flag 關掉,使用者會回到哪個畫面?」
拿到答案後,自己實際打開一兩個頁面對照。AI 的描述和畫面對不上,就是警訊。
翻 flag 前的檢查清單
- 模組內每個頁面都實際打開看過
- guardrail 測試通過
- 行為測試(不是設定測試)通過
- 測試名稱已從
flag_disabled改成flag_enabled - 知道萬一出事時怎麼關掉(kill-switch)
lv2 的另一面:教得會才算懂
Triton Ho 那篇最後還有一句話,我覺得跟這件事一樣重要:
真正的懂得一件事,是能手把手讓對方本來不懂,你能輕鬆教懂他。
回頭看,寫那條 guardrail 測試,其實就是把我腦中「完成是什麼意思」教給 CI。寫之前,這個定義只存在我的直覺裡,連我自己都沒在驗收時用上;寫成 SCAFFOLD_PHRASES 之後,它變成任何人、任何 AI、任何一次 PR 都會被檢查的規則。
能把它寫成一條會失敗的測試,才代表我真的把「完成」想清楚了。
小結
- 問 AI「你知道嗎」,只能測到 lv1。 它幾乎什麼都答得出來,這不代表做事時會用上。
- 43 個假完成不是 AI 不知道,而是流程裡沒有任何環節要求 lv2。 測試問的是 lv1 的問題,我驗收時也停在 lv1。
- lv2 不要拿來賭。 該主動想到的事,寫成流程裡必經的一步,或寫成會失敗的檢查。
- 驗收時問情境題。 「使用者會看到什麼」比「完成了嗎」有用得多。
- 能寫成測試,才算真的懂。 寫不出檢查條件的「完成」,通常是自己也還沒想清楚。
名詞解釋
- Bloom filter:一種很省記憶體的資料結構,用來快速判斷「某個東西是不是可能在集合裡」。它可能誤判「在」,但不會誤判「不在」。
- Hash 與質數:hash 是把資料轉成數字、決定它放在哪一格的方法。格子總數用質數時,搭配取餘數的 hash,資料比較不會擠在同幾格。
- Legacy PHP / Laravel:Legacy PHP 指年代較久、沒有使用現代框架的 PHP 程式;Laravel 是目前主流的 PHP 框架。
- 嵌入式移植:不一次重寫整套系統,而是把新框架放進舊系統裡,一個模組一個模組地把功能搬過去,新舊同時運作。
- Migration flag / Feature flag:一個開關設定,決定某個功能走新程式還是舊程式。好處是出問題時改設定就能退回,不用重新部署。
- Kill-switch:緊急總開關。一關掉,所有新功能立刻退回舊版。
- Blade view:Laravel 的頁面範本檔,副檔名是
.blade.php,負責產生使用者看到的 HTML。 - Controller / 路由(route):路由決定「哪個網址交給哪段程式處理」;Controller 是實際處理請求、準備資料、選擇要顯示哪個頁面的程式。
- Scaffold / 佔位頁:先建好檔案和結構、但還沒有真正內容的骨架。像房子先搭好鋼筋,還沒砌牆。
- Commit / Milestone:commit 是一次提交到版本控制的改動;milestone 是專案裡標記一個階段完成的里程碑。
- CI(Continuous Integration)/ 綠燈:每次推送程式碼時自動跑測試的機制。全部通過顯示綠色,有失敗就是紅色。
- Assertion(斷言):測試裡「我預期結果應該是這樣」的檢查,例如
assertTrue(...)、assertSee(...)。不符合預期,測試就失敗。 - Guardrail 測試:像公路護欄,不測某個特定功能,而是檢查整個專案有沒有違反團隊規則,防止同一類錯誤再發生。
- 設定測試 vs 行為測試:設定測試檢查「值有沒有設對」;行為測試檢查「系統實際做出的結果對不對」,例如打開頁面看內容。
- 非確定性(non-deterministic):同樣的輸入不保證每次都得到同樣的輸出。AI 和人都是這樣,所以不能因為前幾次對了,就假設下一次也會對。
- 驗收:確認工作真的完成、符合需求的步驟。重點是用使用者的角度實際檢查,而不是只看報告或測試結果。
本文的 lv0/lv1/lv2 分級與 bloomfilter 例子出自 Triton Ho 的公開 Facebook 貼文,引用部分為摘錄改寫。事故細節見 Migration Flag 止血策略,關於「解題變便宜後錯誤會換長相」的更多討論見 AI 時代的解題能力與思考能力。