服務還活著,但它什麼都沒做:自動化工具的靜默失敗與偵測
目錄
監控面板上那顆燈是綠的,狀態寫著「運作中」。實際上,這個服務前一天晚上就停了,整夜一則留言都沒回、一篇貼文都沒發。隔天早上,是人工看數據才發現的。同一週,我們在同一套系統裡連抓到四種這類故障。這篇把四個案例全部攤開,每一個都用同一個格式寫:錯的判準、為什麼錯、修後的判準。結尾附一份你可以直接照抄的自檢清單。
先交代場景。我們是 Ultra Lab 工程團隊,MindThread 是我們的 Threads 自動化 SaaS:平台上 110+ 個帳號(其中 75 個啟用中)、累計自動發布 7,400+ 篇、近 60 天發布 1,174 篇;平台帳號合計收到 3,385 則留言、其中 3,285 則已回覆(以上為 2026-08-09 平台實查,皆為含客戶帳號的平台合計口徑)。關鍵的架構事實:自動互動引擎(我們內部叫它「海巡」)跑在客戶自己瀏覽器的擴充裡,留言、按讚、發文都在客戶端執行,伺服器只管額度、內容生成和監控。執行端在我們摸不到的地方,這種架構天生就是靜默失敗的溫床:斷網、關分頁、額度用完、瀏覽器把分頁凍結,全都長得像「沒事」。
「服務掛了」很好偵測:行程不見、端點不通、錯誤噴滿 log。難的是「服務還活著,但它什麼都沒做」:行程在、心跳在、監控全綠,唯一消失的是產出。以下四個案例,全部屬於後者。
案例一:恆為假的布林旗標,把正在工作的服務報成已停止
錯的判準:擴充面板每 45 秒向伺服器輪詢一次用量,順帶回報一顆 extRunning 布林旗標;監控看這顆旗標判斷「在跑」或「已停止」。
為什麼錯:這顆旗標有兩個獨立的死法。第一是回報時序:程式裡唯一會把旗標帶成 true 的那次回報,被排在啟動流程把 running 設為 true 之前執行,所以它回報的永遠是 false。這種 bug 不會炸、不會噴錯,只會讓旗標安靜地恆為假。第二是多寫者互蓋:同一把金鑰可以在多個瀏覽器視窗登入,任何一個開著但閒置的分頁,它的 45 秒輪詢都會把「正在跑」蓋回 false。兩個死法疊起來的結果:正在發文的設定檔,被監控誤報成已停止。這是「以為停了、其實在跑」方向的誤報,看似無害,代價會在案例三被討回來。
修後的判準:把布林換成時間戳 lastRunningAt,並立下一條紀律:只在「正在跑」時寫入,永遠不寫「沒在跑」。閒置視窗什麼都不寫,自然蓋不掉別的視窗的紀錄;判斷改成「5 分鐘內有更新就算在跑」。時間戳只增不減,把「最後寫入者獲勝」變成「有在工作的那個寫入者獲勝」。
案例二:心跳說謊,量的是存在不是動作
錯的判準:擴充最後一次跟伺服器講話的時間(lastSeenAt)在 15 分鐘內,就算活著。
為什麼錯:面板分頁只要開著就會打用量查詢,跟自動化迴圈有沒有在跑完全無關。開頭那次事故正是這樣:某天晚上額度用完、海巡停了一整夜,但客戶的分頁還開著,照樣每 45 秒查一次用量,lastSeenAt 一路新鮮,監控整夜顯示運作中。這個心跳量到的是「行程存在」,不是「工作發生」。存在型心跳最殘酷的地方在於,它在你最需要它的時候(服務半死不活時)最會說謊。
修後的判準:心跳要量動作。只有留言、按讚、發文三個分支真的執行時,才蓋 lastActionAt;而這三個分支只有迴圈真的在跑才會走到,所以它是造不了假的實訊號。lastSeenAt 沒有被丟掉,它降級成「分頁開著」的訊號,跟動作訊號組合出一個新的偵測器:「分頁開著、但沒在跑」,正是最容易漏掉的半死狀態。閾值也不是拍腦袋:迴圈空檔等待最長 30 分鐘,閾值就設 60 分鐘,留兩倍餘裕,避免把正常空檔誤報成停擺。
再補一個過渡期陷阱:lastActionAt 是新欄位,舊資料沒有。新判準直接上線的話,部署完那段時間所有設定檔都會因為「沒有這個欄位」被判成已停止,假訊號雪崩。所以判準要寫回退:沒有新欄位,就看既有的發文嘗試時間。每次加新判準,都要先問一句:舊資料會被判成什麼。
案例三:告警去重把真警報消音
錯的判準:同一個設定檔的「已停止」告警,12 小時內只推一次,避免同一件事吵一整天。
為什麼錯:去重窗是一種狀態,而誤報也會寫入這個狀態。實際時序是:凌晨,案例一那顆恆為假的旗標觸發了一次「已停止」誤報,去重鑰匙寫進 12 小時窗;當天下午,服務真的停了,告警邏輯正確地偵測到,然後撞上還沒過期的去重窗,被整個消音。我們過去把誤報當成「就是吵一點」的雜訊問題,這次學到它更陰的一面:誤報會佔用去重窗,等於把接下來 12 小時內第一次真警報的名額,預先燒掉。
修後的判準:去重窗要綁「這一次停擺」,不能綁牆上時鐘。改法很小:偵測到恢復(又在跑了)就把去重鑰匙清掉。這樣「跑起來又停」能立刻再告警,12 小時窗只防「同一次停擺重複吵」,不會誤傷「下一次停擺」。
案例四:自動停止不配自動恢復,等於把故障排程到明天
錯的判準:客戶當日額度用完時,擴充自動呼叫 stop() 停掉迴圈。聽起來不但合理,還很負責任。
為什麼錯:stop() 是給「使用者按停止」用的,它會把「使用者要它跑」的意願旗標一起關掉。機制狀態(現在能不能跑)和意願狀態(使用者要不要它跑)被塞在同一個開關裡。於是隔天額度重置時,系統裡已經沒有任何訊號記得「他其實要它一直跑」,服務不會自己回來,要客戶手動按開始。實際發生的版本:某天晚上額度用滿、自動停掉,整夜到隔天都沒有服務,而分頁開著、用量照查,配上案例二那顆說謊的心跳,看起來一切正常。自動停止不配自動恢復,不是保護,是把故障延後到隔天,而且讓延後的這段時間看起來像沒事。
修後的判準:意願和機制拆成兩個訊號。shouldRun(意願)只有使用者按開始或停止會改動;額度用完只停機制、不碰意願。意願還在,日切額度重置後就自動重啟。這個拆分還讓監控第一次能把三種「沒在跑」分開處置:使用者自己按了停止(不告警)、額度停了等日切(提醒即可)、意願還在但迴圈死了(立刻告警,最常見原因是分頁被瀏覽器凍結,請客戶重整頁面)。同一顆「已停止」,三種原因、三種處置。在意願訊號存在之前,我們只能瞎猜。
四個案例的共同結構
把四個判準排在一起,共同點就浮出來了:判準實際量到的東西,和你以為它量的東西不一樣。
- extRunning 量的是「最後一個回報的視窗怎麼說」,不是「有沒有任何視窗在工作」。
- lastSeenAt 量的是「分頁開著」,不是「工作有發生」。
- 去重窗量的是「12 小時內推播過沒有」,不是「這一次停擺推播過沒有」。
- stop() 關掉的是「意願加機制」,你以為它只關了機制。
還有一個更上層的教訓:這四個修正是同一天上線的,而起點不是任何監控告警,是一次人工看數據。如果你的監控從來沒抓到過事故,先假設監控瞎了,再假設系統很穩。
自檢清單(五條,照抄就能跑)
- 列出系統裡所有代表「正在運作」的布林旗標,逐一問:誰會寫它?有沒有寫入者可能在不知道實況的情況下把它蓋掉?有,就換成「只在真的時候寫」的時間戳。
- 對每個心跳問:服務停掉之後,這個心跳還會繼續跳嗎?會,它量的就是存在不是動作,換成只有工作真的發生才會動的訊號。
- 檢查告警去重:誤報之後緊接著真事故,第二次會被消音嗎?去重鑰匙有沒有在偵測到恢復時清掉?
- 列出所有會自動停止的路徑(額度、限流、錯誤上限),逐一問:停止的條件消失之後,誰負責把它拉起來?答不出來的每一條,都是排程到明天的故障。
- 意願和機制分開存:使用者要不要它跑,和它現在能不能跑,是兩個欄位;自動化只准動後者。
這一篇的四句話
- 布林旗標會被最後一個寫入者蓋掉。時間戳加上「只在真的時候寫」,天生免疫互蓋。
- 心跳要量動作,不量存在。服務停了還在跳的心跳不是心跳,是裝飾。
- 誤報不只是雜訊,它會佔用去重窗,把下一次真警報消音。
- 自動停止不配自動恢復,等於把故障排程到明天,而且明天之前一切看起來都正常。
原始碼位置:MindThread api/haixun.ts(usage 回報改時間戳、lastActionAt 只在留言/按讚/發文分支蓋章)、api/cron-insights.ts 的雲端巡邏(每 30 分一輪:lastRunningAt 優先、動作時間回退、去重鑰匙在偵測到恢復時清除)。四個修法都在 2026-08-07 上線。
這是《Agentic Design Patterns》× 龍蝦艦隊系列。我們照書把一人公司的 AI agent 艦隊系統化,然後對自己做對抗式稽核。宣稱做到的每一章,都被重新驗證過一次,也把查法和改法整理成你能照著跑的步驟。這個系列的可信度,來自我們願意公開自己打臉自己。