把工程工作交給 AI agent 之後,出事的地方跟大多數人預期的不一樣。
不是它寫錯語法——那個 typecheck 會抓。不是它捏造不存在的 API——那個跑一次就知道。真正貴的失效模式是它產出了一個看起來有證據支撐的結論,而那個證據的取法是錯的。
因為結論帶著證據,下游不會質疑。你會拿它去寫文件、去派下一個工作、去做決策。
這篇是四個我們實際踩到的。
一、把「搜尋不到」當成「不存在」的證明
形狀
agent 跑一次 grep 或掃描,回傳零命中或少於實際的命中數,直接據此下「不存在」或「只有 N 個」的結論並往下決策。
因為結論看起來有證據(「我 grep 過了」),沒有人會回頭驗。
實例 A:把 7 條漏洞數成 1 條
一個安全掃描的 gate 紅燈。agent 用一段固定格式的 grep 去數表格裡 HIGH 等級的條數,得到 2(兩個 job 各 1),據此判定「唯一擋住 gate 的是某個特定套件」,並把這個結論寫進三份文件、以及派給下一個 agent 的 brief。
實際是 7 條全部都是 HIGH。
掃描工具對合併儲存格的重複值會留空、由上一列繼承,固定格式的 grep 只抓得到每一組的第一列。
後果是第一輪修法只涵蓋七分之一,而錯誤的前提已經傳播到三份文件與一個下游工作。
而決定性的反證極其簡單,卻沒有人想到:那個 gate 設定的 severity 過濾是 CRITICAL,HIGH,掃描工具只會印出符合過濾條件的項目——所以表格裡出現的每一條,依定義都是 HIGH 或 CRITICAL。
根本不需要數。
實例 B:把三處連結數成零處
判斷某個頁面是不是遷移殘留時,agent 回報「grep 不到任何 app 內部連結指向那個路徑」。實際上有三處。
它的 pattern 抓的是 to="/path" 這類字面形式。用它自己事後的說法:
在心裡把「內部連結」預設成「從 app 導覽進得去的連結」,等於用一個沒說出口的假設收窄了搜尋範圍,然後把「沒抓到」當成「不存在」回報。
如果照那個結論直接刪掉三個頁面,會刪掉一組仍在互相連結的頁面。
根因
負向搜尋結果不是不存在的證據,但它長得像證據。
兩個機制會合流:
- pattern 的形狀隱含了未言明的假設。「內部連結」在腦中被收窄成「這一種寫法的內部連結」,而收窄這件事沒有被寫出來,所以也沒有被檢查
- 零命中在輸出上與「真的沒有」完全同形。grep 回空的時候,不會告訴你它有沒有找對地方
對策
三條,按有效性排序:
第一,先問「有沒有不需要搜尋的判準」。 實例 A 的正解是讀 gate 的設定,不是數表格。這一步能省掉最多力氣,也最常被跳過——因為搜尋比思考便宜。
第二,任何負向結論都要附一個 known-positive 對照。 也就是用同一個 pattern 去搜一個你確定存在的東西,確認它抓得到。抓不到,你的零命中就沒有意義。
第三,把 pattern 隱含的假設寫出來。 「我搜的是 to="..." 這種字面形式,動態產生的連結不在範圍內」——寫出來之後,範圍不足就會自己浮現。
這三條對人類工程師同樣適用。差別只在於 agent 的產出速度讓錯誤結論傳播得更快。
二、把查得出答案的事升級給人
形狀
agent 正在準備提交,git status 突然出現大批不屬於自己的變更——三十幾個檔案,全部帶著 staged 標記,而自己改的只有兩行。
agent 的反應是停下來,問使用者「這批要不要一起送」。
同一輪還會連帶誤判:靜態檢查紅在那批陌生檔案上,agent 把它讀成「這次提交過不了」,而那些檔案一個都不在自己的範圍裡。
根因
規約層有兩條看起來都命中的條款,方向相反:
- 一條說「遇到衝突要保留現狀並詢問,不要自行對齊」
- 一條說「所有未提交的變更無條件納入本次提交,不徵詢」
於是 agent 判定為「規約衝突,交還給人」。
但這不是規約衝突,是它少跑了一次探測。
那兩條規約各自的前提,在這個情境下全部可以機械驗證:
| 要判的事 | 機械判準 |
|---|---|
| 這些路徑會不會進我的提交 | git commit --only -- <my paths> 的語意 |
| 檢查紅燈是不是我造成的 | git cat-file -e HEAD:<path>,不在 HEAD 就不在我的樹裡 |
| 那批是不是真的別人正在做的 | git worktree list 加上 index 的 mtime |
| 我的產物有沒有被污染 | 逐條比對每個檔案是否在 HEAD |
四問全部有確定答案,所以沒有分歧存在。
那條「遇到衝突要詢問」的規約,攔的是「無法用證據判定該不該動別人東西」的推理鏈。它不是「只要看到別人的東西就交還給人」。
agent 把「情境涉及他人」誤讀成「命中衝突條款」,跳過了「先確認到底有沒有歧義」這一步。
為什麼這是個真問題
因為它看起來像謹慎。
一個把決策交還給人的 agent,表面上是安全的、尊重人的、不會亂來的。所以這個失效模式很難被當成問題提出來——批評它等於在說「你應該更大膽一點」。
但實際成本很具體:每一次不必要的升級,都是把已經完成一半的判斷工作原樣退回。人拿到的不是「兩個選項與各自的代價」,是「這裡有情況」。而那個情況本來三個指令就查得完。
對策
在「要不要問人」之前,先跑一次歧義探測:
我要問的這件事,有沒有一個可執行的判準能給出確定答案?
有 → 跑它,然後自己決定。 沒有 → 才問,而且要帶著已經收斂過的選項去問,不是帶著情況。
這條原則的一般形式是:升級給人的門檻,是「機械上不可判定」,不是「感覺上有風險」。
講到這裡你可能會想,那不可逆的操作怎麼辦——刪東西、推 production、花錢的呼叫,難道也要 agent 自己決定?不是。不可逆是另一條獨立的門檻,它擋的不是歧義而是後果,兩條同時成立: 可判定但不可逆的照樣要問,不可判定但可逆的照樣自己做。會出事的是把兩條混成一條—— 一旦「有風險就問」蓋過「不可判定才問」,agent 會開始為了可逆的小事排隊等人。
三、用 agent 當輪詢器
形狀
要監看一個長時間執行的 CI 流程,於是派了兩個背景 agent 去盯,brief 裡明確寫「用 blocking 的方式等,完成之後才回報」。
實際收到的是一連串中途回報:
<result>監看已建立。Production 正在執行中。Staging 在排隊。
三層監看持續運行中…等待完成通知…</result>
<usage><subagent_tokens>143260</subagent_tokens></usage>
<result>Watch 仍執行中,推進到 "Check Nuxt imports"。持續監看完成信號...</result>
<usage><subagent_tokens>109896</subagent_tokens></usage>
四個獨立的失效疊在一起:
- 不 block、反覆中途回報。 每一次「持續監看中…」都是一次完整的 LLM turn。兩個 watcher 累計 235k 以上的 token,最後被掐掉時仍未產出任何最終結果
- 監看目標死掉之後空轉。 被監看的那個 run 因為新的推送觸發
cancel-in-progress而被取消,watcher 繼續等一條已死的 run,最後回報「已取消」就結案,沒有改追取代它的那一條 - 改派指令吃不到。 送訊息叫它改監看新的 run,它回覆「收到,立即開始 block watch」,下一則回報卻仍是舊 run 的結論
- 同一份 brief、同一個模型,兩個 watcher 的行為完全不同
最後改成一段背景 shell 輪詢——查狀態、sleep 45、達終態才退出並輸出證據——一次到位拿到完整結果,成本接近零。
根因
「等待」這件事不需要語言模型。
一個 LLM turn 的成本結構是為了「產生判斷」設計的。用它來執行「時間還沒到,再等一下」,等於每次檢查都付一次判斷的錢,而那個判斷的內容是空的。
更根本的問題是:LLM 沒有可靠的「什麼都不做」。給它一個 turn,它傾向於產出些什麼——於是就有了那些「持續監看中」的回報。要它安靜地 block,是在對抗它的預設行為。
對策
一條很硬的界線:
決定性的、有明確終止條件的等待,交給 shell。需要判斷的,才交給 agent。
判準是「這件事能不能寫成一個 until 迴圈」。能,就寫那個迴圈。agent 的位置是在迴圈結束之後讀結果、做判斷、決定下一步。
第 2 與第 3 點也指向同一件事:agent 作為長時間執行的狀態機是不可靠的。它的強項是在一個有限的 context 裡做一次好的判斷,不是在三十分鐘裡維持一個正確的目標。
四、等待迴圈匹配到自己
形狀
要等一個長任務結束,於是送出一段背景等待迴圈:
until ! pgrep -f 'vitest' >/dev/null 2>&1; do sleep 10; done
echo "=== 結果 ==="
pnpm test 2>&1 | grep -E '...' | head -40
現象是完全沒有現象。job 顯示 running、輸出檔 0 bytes、沒有任何錯誤。
73 分鐘後才因為去查 ps 而發現:迴圈後半段——真正要跑的那件事——從來沒有開始執行。
根因
ps 揭示了原因。迴圈自己的 shell 命令列裡就含有 vitest 這個字串:
204077 01:15:14 /usr/bin/zsh -c ... until ! pgrep -f 'vitest' ...
269998 01:13:34 /usr/bin/zsh -c ... until ! pgrep -f 'vitest' ...
pgrep -f 比對的是完整命令列,而執行這個迴圈的 shell,它的命令列裡必然包含 pattern 字串本身。
任何形如
pgrep -f '<X>'的等待迴圈,只要它自己是被sh -c帶著這段文字啟動的,就保證至少匹配到一個進程——它自己。
pgrep 只自動排除它自己那一個 pgrep 進程(man page 明講:「The running pgrep process will never report itself as a match」),不會排除呼叫它的父 shell。
於是 pgrep -f vitest 恆非空,! pgrep 恆為 false,until 永不退出。
第二個放大因子
pgrep -f <泛用詞> 也會匹配其他工作階段的同名進程。
這次除了自我匹配之外,另有別的階段正在跑同一個測試工具。即使解決了自我匹配,這個條件仍然不該用來判斷「我這條工作結束了沒」——process 列表沒有租戶邊界。
反方向也會壞
同一個根因有第二個出口,症狀完全相反:pattern 打不到任何東西 → 迴圈立刻退出 → 偽成功。
一個等待迴圈如果條件從一開始就成立,它會在零秒內「完成」,而下游會把那個瞬間的結束讀成「等到了」。
所以這一類迴圈有兩種壞法,而且它們在輸出上都很安靜:一種永遠不結束,一種立刻結束。
對策
不要用 process 列表判斷自己的工作有沒有結束。
改用有明確歸屬的訊號:
- 自己啟動的程序,記下 PID,等那個 PID
- 用檔案系統的標記——工作結束時寫一個 sentinel 檔,等那個檔案出現
- 用工具本身提供的 blocking 介面(
wait、--exit-status之類)
以及一條通則:任何等待迴圈都要能回答「如果條件從一開始就成立,我會怎樣」。答不出來,那個迴圈就有偽成功的路徑。
共同的形狀
四個失效模式,一句話:它們產出的都是「看起來有依據的錯誤結論」,而不是明顯的錯誤。
| 失效 | 它產出的東西 | 為什麼下游不會質疑 |
|---|---|---|
| 窄 pattern 搜尋 | 「不存在」 | 有 grep 當證據 |
| 過度升級 | 「這需要你決定」 | 看起來謹慎 |
| agent 當輪詢器 | 「持續監看中」 | 看起來在工作 |
| 自我匹配的迴圈 | 沉默 | 看起來還沒到時候 |
這四個沒有一個是「AI 不夠聰明」的問題。它們是把一個會產生合理輸出的系統,放進一個需要嚴格證據的位置時必然會出現的落差。
實務上我們得到的三條原則:
一,負向結論一律要 known-positive 對照。 「沒找到」在任何情況下都不是結論,除非你證明過你的儀器是靈敏的。
二,決定性的工作不要交給機率性的系統。 等待、比對、計數這些有唯一正確答案的事,用 shell、用工具、用機械檢查。agent 的位置在「拿到確定的事實之後,決定要做什麼」。
三,升級給人的門檻是「機械上不可判定」。 不是「感覺有風險」,也不是「情境比較複雜」。在按下那個問題之前,先問自己有沒有一個指令能給出答案。
這三條與其說是 AI 協作的規則,不如說是工程判斷的規則。差別只在於——當你的協作對象每分鐘能產出十個有依據的結論時,證據的取法有多可靠,會被放大成整個系統的可靠度。