自架系統早期,我的故障偵測機制是家人:「欸,那個網頁打不開了。」
這句話比任何監控都準——因為 md-server(當初只是個 Markdown 瀏覽頁,後來被我養成全家在用的儀表板:投資、家居、學業、生活都在上面)一掛,第一個受害者就是使用者。但被家人當面回報服務掛掉,工程師的尊嚴會受損。更實際的問題是:Agent 系統的故障常常是「安靜」的——容器還活著、行程還在跑,只是 WebSocket 斷了、排程卡住了、記憶體慢性洩漏。等到有人「發現」,往往已經斷服務好幾個小時。
現在,多數故障在我知道之前就被系統自己修好了,我只會收到一則事後通知:「偵測到 API 異常,已重啟容器,服務恢復。」今天講這套自癒與監控是怎麼搭起來的。
我的監控不是一支聰明的大程式,是兩層頻率和職責不同的機制疊起來的——而其中那層每小時的守衛,一支腳本同時扮演兩個性格相反的角色:

**第一層是每小時的健康守衛。**NAS 端一支 Python 腳本,每小時醒來一次,同時做兩件性格相反的事:
restart: unless-stopped 兜著;守衛真正親手重啟的模範是 Syncthing——它的 API 死掉時(容器還顯示 healthy、卻已經不同步了)連續三次讀不到就重啟、帶冷卻,重啟幾乎必然把它救回來。(守衛也會在行情快取過期時重啟 CLI,但那條其實不太靈,後面〈我踩過的坑〉會拿它當反例。)同一支腳本裡一半有動手權、一半只出一張嘴,界線就是下一節那張表。
**第二層是每月的體檢報告。**每月一號生成整月的品質統計:任務成功率、告警次數、費用曲線,彙整成一份健康評分(Day 25 的主角)。它不處理任何即時問題,它回答的是趨勢問題:「系統這個月比上個月更穩還是更爛?」
設計原則一句話:動手權要綁在最保守的判斷上。每小時的守衛只對「確定性故障」出手,其餘一律降級成告警;真正複雜的評價,留給沒有即時壓力、一個月才跑一次的體檢報告。把「聰明的判斷」塞進「會自動動手」的迴路裡,是自動化系統自傷的常見起手式。
不是所有故障都該自動修。我的分界線是一張明確的清單:
| 故障 | 處置 | 理由 |
|---|---|---|
| 容器整個 crash | ✅ Docker restart 政策自動拉起 |
重啟必然有效、無副作用 |
| Syncthing API 死(healthy 但不同步) | ✅ 守衛自動重啟(三振+冷卻) | 已知病因+已驗證藥方,重啟幾乎必然救回 |
| 行情快取過期(critical) | ⚠️ 守衛會自動重啟 CLI——但常修不好 | 唯一自動重啟 CLI 的觸發,卻是個沒對準病根的藥方(見下方反例) |
| Gateway 重啟後 CLI 的 WebSocket 斷線 | ❌ 手動 / MCP | 守衛沒在看連線層、偵測不到,只能我或 MCP 手動重啟 CLI |
| HA VM 沒開機(斷電後) | ❌ 只告警 | 容器內碰不到 VM 管理層 |
| API 帳單異常 | ❌ 只告警 | 這種事必須人看(Day 22) |
這張表有兩列值得對照著看。正面那列是 Syncthing:它 healthy 卻死掉 35 小時那次(下面〈我踩過的坑〉細講)之後,我才把「連續讀不到 API 就重啟」寫成守衛的自動規則——這就是「故障筆記長出一條自癒」的模範,重啟確實幾乎必然救回它。反面那列是行情快取:守衛唯一會自動重啟 CLI 的觸發就是它,但我後來發現這藥方沒對準病根——快取過期的根因通常在資料源那端,重啟 CLI 多半救不回來,守衛自己的註解都寫著「重啟修不好」。它留在清單裡,成了提醒我「把動手權綁在沒驗證過的判斷上有多容易」的反例。至於最惱人的 WebSocket 斷線(Gateway 重啟後 CLI 連線不會自動重連),守衛根本沒在看連線層、偵測不到,只能靠我或 MCP 手動重啟——它到現在都還在「告警+人工」那一欄。自癒清單不是設計出來的,是故障紀錄長出來的——只有「已知病因+已驗證藥方」的配對才配得上自動化;配不準的,寧可退回告警。
這也是我想推銷的一個文化:**個人系統也值得寫 postmortem。**我的每次故障都會在記憶檔案裡留一段「現象 → 根因 → 解法」,半年下來這份清單就是系統最值錢的文件——它讓「同一個坑跌兩次」的成本從一晚上變成一分鐘。
守衛的反射神經,核心邏輯出乎意料地簡單——關鍵不在「偵測」,在「什麼時候才准動手」。以 Syncthing 那條自癒為例(Syncthing 是我本機和 NAS 之間的檔案同步骨幹,掛了整套部署就斷):
RESTART_STREAK = 3 # 連續讀不到 3 次才考慮重啟
COOLDOWN_H = 6 # 重啟後 6 小時內不再重啟
def selfheal_decision(streak, last_restart_ts, now_ts):
if streak < RESTART_STREAK:
return "wait" # 單次讀不到常是暫態(正在重啟、網路抖動),先觀察
if last_restart_ts and (now_ts - last_restart_ts) < COOLDOWN_H * 3600:
return "cooldown" # 剛重啟過還沒好 → 別再踹,交給人
return "restart" # 連續失敗且過了冷卻 → 此時「不動作」才是比較糟的選擇
兩個保險絲,都是想過或踩過才裝上的。
三振門檻擋的是「暫態」:容器正在重啟、網路抖一下,單次讀不到很常見,一讀不到就重啟是過度反應。連續三次才算數。
冷卻時間擋的是最可怕的東西——無限重啟迴圈。如果故障原因根本不是重啟能解的(例如磁碟滿了、設定檔壞了),一個沒有冷卻的自癒會變成每小時重啟一次、每小時發一則告警,直到有人受不了。自癒機制自己也要有保險絲:修不好,就閉嘴等人來,別把一個壞掉的東西踹成一串噪音。
還有一個魔鬼藏在細節裡:自癒的最後一步不是「重啟」,是「重啟後再確認一次服務真的活了」。docker restart 回傳 0 只代表容器重啟了,不代表裡面的服務起來了——這一點,下面那個坑會用 35 小時的代價告訴你。
開頭我說 Agent 系統的故障常常是安靜的。這兩層監控就是為此而生——把安靜的故障翻譯成一則通知。
但這套機制上線一段時間之後,我撞到更難堪的第二種安靜:監控自己壞掉的時候,也是安靜的。
而且它比第一種更危險,因為儀表板上,「一切正常」和「我已經不看了」長得一模一樣。
三個真實案例,都發生在同一段時間:
① 心跳停了兩天,沒有任何東西叫。
第一層的巡邏警衛裡,有一項是 Agent 的自我巡檢(heartbeat),該在固定時段內定時醒來報一次平安。
結果有一段時間,它悄悄地漏跑了。
原因是排程「限定時段」的一個坑:當計時到點、但當下不在允許時段內時,排程器的處理是直接跳過,不是延後到時段開啟補跑——於是只要計時的相位和那個允許時段錯開,它就每次都剛好被跳過,一次都輪不到。
停擺兩天,排程狀態全綠、log 沒有一行錯誤——因為「事情沒發生」不會產生錯誤訊號。發現它的方式很偶然:我在對帳每日呼叫次數時,看到某一欄連續兩天是 0。
② 一個不可能失敗的檢查。
月度品質報告有一項「模型合規」,長期顯示零違規。查下去才發現它讀錯了欄位——排程任務的模型設定在 payload.model,掃描讀的是頂層 model,五十二個任務命中零個。
也就是說,就算有人把最貴的模型塞進排程,它也掃不到。這個檢查從上線那天起就不可能失敗。
一個不可能失敗的檢查,不是零風險,是零資訊——而它偏偏長得跟「一切正常」一樣。
③ 反過來的那一種:不該叫卻一直叫。
新做的設備離線偵測告訴我某台機器掛了,我把它寫進待辦、還特地去查電源。真相是那台設備好好的——那些「離線」的實體,是十八天前設備重新配對後留在註冊表裡的孤兒。
偵測邏輯沒有錯,它們確實處於「不可用」狀態。錯在少了時間維度:十八天前就不可用的東西,不是新故障,是歷史殘留。
前兩個是「該叫的時候沒叫」,第三個是「不該叫的時候一直叫」。看起來相反,但殺死的是同一樣東西——告警系統的信用。Day 25 會講誤報如何讓我把整個頻道靜音;這裡想補的是另一半:沉默同樣會讓你停止相信,只是它讓你相信得太多。
修完那四個之後,我加了一條紀律:每個監控都要能「空跑演練」——把假故障塞進去、確認它真的會叫,而且寫成測試、每次 CI 都替我跑一遍。
不是檢查它有沒有在跑——這件事我一直有做,而且四個缺陷全都通過了「有在跑」的檢查。要做的是塞一個假故障進去,確認它真的會叫(下面是幾個寫成測試的例子,數字為示意):
離線偵測:真實現況 0 離線 ✓ 靜默
注入「2 小時前離線的設備」 ✓ 會叫
注入「30 天前離線的設備」 ✓ 正確歸類為歷史殘留、不叫
健康監測:真實現況 0 則 ✓ 靜默
注入「電池 5%」 ✓ 會叫
呼叫量: 真實 4 次 / 上限 40 ✓ 靜默
注入「120 次/日」 ✓ 會叫
兩件事都要驗,而且**「不該叫時保持安靜」和「該叫時會叫」一樣重要**——一個整天亂叫的護欄,兩週後就會被靜音,效果等於沒有。
第一次做這個演練,就抓到了上面那個合規掃描。它很長一段時間都回報零違規,而我從來沒問過它一句:「如果真的有違規,你抓得到嗎?」
這句話後來變成我加任何監控時的最後一道手續。寫完偵測邏輯,先造一個假故障餵給它——看得到,這個監控才算完成;只是跑得動,不算。
以為做完空跑演練就補完了?就在整理這篇文章的那個週末,家裡兩台空氣清淨機無聲無息停止回報了 15 個小時,十幾個感測實體集體噤聲——而我的兩套偵測,一套都沒叫。
事後解剖,這是前面三個案例都沒覆蓋到的型態。我的離線偵測認的是實體變成「不可用」狀態;環境感測告警認的是「讀到空值」。但這次設備停止回報時,平台端的實體既沒有變不可用、也沒有變空值——它保留了最後一次的已知讀數。溫度欄位永遠顯示 26.5 度,看起來歲月靜好,其實那是 15 小時前的世界。
兩套偵測都在正常運作、也都通過過空跑演練,但它們監看的訊號在這種故障裡根本不會出現。演練驗證的是「偵測邏輯對它認得的故障會不會叫」,驗證不了「還有沒有它不認得的故障型態」——這是演練的極限,也是為什麼故障分類學只能靠真實事故一格一格補。
解法是換一個訊號源:不看「值變成什麼」,看「這個值多久沒更新了」。平台端有一個「最後回報時間」的欄位,即使設備回報相同的值也會刷新它——對它做年齡檢查(超過 N 小時沒刷新就告警),才抓得到這種「保留舊值的無聲死亡」。第一種安靜是服務死了沒人叫,第二種是監控死了沒人叫,第三種是訊號本身在說謊——資料還在流動的假象,比沒有資料更會騙人。
healthy 不等於活著最反直覺的一個坑,是我學到「容器說自己健康」這句話能有多不可信。
某天我發現本機的行情、dreaming 日誌、各種 NAS 產出物全是舊的,卡在同一個時間點。查下去——Syncthing(我本機和 NAS 之間的同步骨幹)的容器狀態是 Up 47 hours (healthy),docker ps 一切正常,埠也在聽。但它的 REST API 完全不回應,而且已經連續 35 小時沒有投遞任何一個檔案。埠在聽、healthcheck 說健康、容器狀態全綠——它就是死了。
這件事狠狠教育了我:任何只看容器狀態的監控,都抓不到這一類故障。docker ps 顯示的 healthy 是一個可能凍結的舊值,容器活著不代表它提供的服務活著。唯一抓得到的,是實際去打功能端點的那一層——去問 Syncthing 的 API「你還在同步嗎」,而不是問 Docker「這個容器還在嗎」。
所以守衛的 Syncthing 自癒,判斷依據不是容器狀態,是連續幾次打不通它的 API;而且如前面「動手做」那節所說,重啟之後還要回頭再問一次 API,才算真的救活。這裡還有一個連帶的判讀陷阱:Syncthing 斷線期間,本機所有 NAS 產出物都是舊的——這時用本機檔案的時間去判斷「系統有沒有在跑」必然誤判,要判斷生產是否存活,一律直接問 NAS。
一句話:**監控要盯的是「功能還在不在」,不是「行程還在不在」。**這兩者的差距,就是那 35 小時。
這套監控與自癒的幾個心法:只有確定性故障才自動修(其餘降級告警)、自癒要有三振門檻加冷卻保險絲、盯功能端點而不是盯行程狀態(healthy 不等於活著)、以及那三個安靜故障教我的——護欄本身也要被演練。做到之後,系統的日常故障就從「事件」降級成「通知」。
明天是第三週小結:把這六天的快取、防呆、巡檢、自癒收斂成幾個可複用的模式,然後誠實盤點一件事——這一週講了這麼多自動化,到底有哪些事我沒有自動化,而且不打算自動化。
🔑 這篇的關鍵字
兩層監控:每小時的守衛(一支腳本兼「對確定性故障動手」與「其餘只告警」)→ 每月的品質體檢 · 有限自癒:只修「已知病因 + 已驗證藥方」,帶三振門檻+冷卻保險絲,其餘降級告警 · 盯功能端點而非行程狀態(healthy≠ 活著,重啟後要回頭確認服務真的起來)· 自癒清單靠故障筆記累積,不靠設計 · 對護欄本身做空跑演練(注入假故障,確認它真的會叫)· 第三種安靜:保留舊值的無聲死亡——要看「值多久沒更新」,不能只看「值是什麼」
我是一名金融業資訊工程師,這是我半年來在家自架 AI Agent 系統的實錄。