! 本篇文章將會介紹 異常自癒:self-healing 的第一步,期望大家都能讓系統學會自己擦屁股,而不是每次都把人類搖醒 :D
昨天(D25)翻完黑盒子,還原了前天 d24 的 14 分鐘事故:20:09 的發文 job 和 20:15 的補發 job 依序醒來、依序失敗、依序告警,最後是人類被 Discord 叫醒、補好文章、手動重跑才收工。系統的劇本演得很忠實,但「擦屁股」這件事,兩次機會都沒擦動,球一路滾到人類手上。
這幾天的主線一直在鋪同一件事:D12 的錯誤處理、D23 的備援、D25 的稽核,止步於「防禦」和「告訴人類」。今天往前推一步——self-healing:讓系統不只會擋和叫,還會自己動手修。好消息是,盤點之後你會發現這套系統早就偷偷在自癒了,今天只是把這個本能變成有意識的設計。
讀完這篇你會學到:
不用裝任何新東西,今天全程盤點既有程式碼:
$ cd /Users/benben/ai/automations/ironman
$ ls state/quarantine-*.md
state/quarantine-d04-190404.md # d04 被隔離的文章,共 1 篇
state/quarantine-d09-190431.md # d09 以下五篇,時間戳從 19:04 排到 19:25
state/quarantine-d09-190934.md
state/quarantine-d09-191525.md
state/quarantine-d09-191922.md
state/quarantine-d09-192526.md
這六個檔案是今天最重要的證物,先記著。本文引用的程式碼除了特別標注「概念示意」的那段,都是從 scripts/ 裡原封不動搬出來的。
先講清楚定義。這套系統到目前為止有兩種「失敗後的動作」:
這兩個都很好,但有個共同盲點:不知道自己修好了沒,也不問「壞掉的東西去哪了」。self-healing 多出來的,是一個完整閉環:
偵測(detect)→ 隔離(isolate)→ 修復(repair)→ 驗證(verify)
偵測:先有機器能判斷的「健康定義」(品質判準、機敏掃描、發佈 flag);隔離:把壞產物移出正路,而不是讓它混進下一關;修復:重做或換料;驗證:修完要再檢查一次,過了才叫修好。四步缺一不可——少了隔離,壞文章會一路流到 iThelp 上;少了驗證,你只是把失敗往下游推遲一步。
產物層:generate.sh 的機敏掃描守衛,其實就是一個完整的自癒閉環:
# generate.sh:掃描命中 → 隔離 → 重生成(四步全在這幾行裡)
if secret_scan "$ARTICLE"; then
QUAR="$ROOT/state/quarantine-d${DD}-$(date +%H%M%S).md"
mv "$ARTICLE" "$QUAR" # 隔離:壞產物移出正路,但留著當證據
log "機敏掃描命中!已隔離至 ${QUAR},重試生成"
continue # 修復:整個生成重來(不是修補,是換新的)
fi
開頭那六個隔離檔就是這段程式碼的戰績。最精采的是 d09:五篇文章接連被自動隔離、自動重寫,第五版過關,當天照常發佈。從頭到尾沒有人類參與,讀者完全不知道背後炸了五次。這就是 self-healing 該有的樣子:修復過程對外界透明,證據對自己透明。
小小小測驗:你知道 d09 那天,從第一篇被隔離到第五版過關,系統自己撐了多久嗎?(答案:21 分鐘。隔離檔的時間戳就是自癒的呼吸紀錄——19:04 到 19:25,五輪循環,全程無人值守)
環節層:publish.sh 的降級與驗證。填內文走 CodeMirror API,失敗就降級成 keyboard type——同一個目標換一招修復動作:
# publish.sh:主要手段失敗 → 降級模式(修復動作換一招)
if ! "${AB[@]}" eval "(function(){ /* cm.CodeMirror.setValue(...) */ })()" >>"$LOG" 2>&1; then
log "CodeMirror API 失敗,fallback: keyboard type"
...
fi
發佈點擊也有驗證:按下去不是就算數,而是輪詢 URL 直到長出 /articles/10xxxx 才宣告成功——這就是閉環的最後一步。
排程層:D23 裝的 20:15 republish job。重看它會發現,這其實就是排程層的自癒:
# publish.sh 開頭的冪等守衛,同時就是 republish job 的健康檢查
PUB_FLAG="$ROOT/state/published-$(date +%Y%m%d).flag"
if [[ -e "$PUB_FLAG" ]]; then
log "今日已發佈過(flag 存在),跳過" # 健康 → 什麼都不做,睡回去
exit 0
fi
# 走到這裡 = 偵測到異常(20:00 沒發成)→ 後面整條發文流程就是修復動作
設計漂亮的地方在於:健康檢查不用另外寫,冪等守衛本身就是。flag 在 = 健康,跳過;flag 不在 = 異常,重跑整支 publish.sh,它內建的重試、降級、告警全是修復手段。republish 幾乎每天都在睡,但睡著本身就是「系統健康」的證明。
那前天 d24 為什麼沒自癒成?回頭看很清楚:病灶在生成層(根本沒有文章檔),但兩次修復嘗試都發生在發文層。20:09 的 publish.sh 和 20:15 的 republish 只能修「發文環節」的病——瀏覽器炸了、按鈕沒反應、tag 沒掛上——對「沒料」這種病完全無效。它們忠實地失敗了兩次,然後正確地上拋告警,已經是能力範圍內最好的表現。
這帶出自癒漸進做法的核心紀律:動手修之前,先問這個修復動作治的是哪一層的病;治不了的,就設計乾淨的上拋。與其幻想一個萬能自癒(不存在),不如每層各自的修復能力,加上明確的升級路徑:本層能修的修,修不了的 die() 加 Discord error,把球乾淨地丟給上一層——目前是人類。
照這個模式,生成層的缺口就很好補。把 republish 的「偵測 + 重跑」複製一份過來(概念示意,這是我接下來要加的 job):
# 概念示意:生成層的自癒 job
# 19:30 檢查——generate.sh 最壞 27 分鐘收尾(5 次重試 + backoff),時間剛好錯開
ARTICLE="$ROOT/articles/$(date +%Y-%m-%d)-d$(printf '%02d' $DAY).md"
if [[ ! -s "$ARTICLE" ]]; then
# 偵測到異常:19:00 的生成沒出貨 → 重跑 generate.sh
# 它是冪等的(文章已存在就跳過),內建重試 + 備稿 + 告警,重跑安全
"$ROOT/scripts/generate.sh"
fi
如果 d24 那天有這個 job,19:30 就會發現文章難產、自動重跑生成(或啟用備稿),20:00 的發文 job 醒來時有料可發,根本輪不到後來的 14 分鐘驚魂。一層小手術補掉病根——這就是 self-healing 第一步的具體長相:不急著造神經系統,先讓每一層把自己那層的常見病學會自己看診。(細節如「別跟還沒跑完的實例打架」的鎖,留到明天談邊界設計時一併處理。)
Q:既然要自癒,重跑腳本不會把文章發兩次嗎?
A:會——如果沒有冪等守衛。這就是自癒的第一前提:generate.sh 開頭檢查文章已存在就 exit 0,publish.sh 開頭檢查 flag 已存在就跳過。冪等讓「重跑」永遠安全,自癒才有資格談「自動重跑」。順序不能顛倒:先冪等,後自癒,不然 self-healing 會變成 self-harming。
Q:自癒會不會陷入無限重試,把系統越修越爛?
A:靠三道煞車:重試有上限(MAX_ATTEMPTS=5)、每次之間有 backoff(sleep 30)、上限到了絕不硬拗,該告警就告警。還有一個容易忽略的設計:generate.sh 在「文章存在但品質不足」時保留現狀、不讓備稿覆蓋、只發 warn 叫人來看(D23 的教訓)。自癒動作太自信,有時比不自癒更危險;修不了的病,老實承認也是自癒的一部分。
Q:系統默默自癒成功,我怎麼知道它做過什麼?
A:靠 D25 的黑盒子——自癒必須留痕。隔離區檔名的時間戳讓 d09 五連擋一目了然;published.log 一行一事實;流水帳裡每個判準未過的理由都逐項記下(「不能只說失敗」是 d02 的教訓)。默默修好其實是最大的雷:你看不到它修過什麼,就預測不了它下次會修壞什麼。
下一篇我們要介紹 安全煞車:全自動系統的邊界設計,敬請期待!自癒讓系統開始「自己動手」,但會自己動手的系統更需要煞車——哪些 domain 可以碰、哪些動作需要核可、人類的搶救通道長什麼樣,明天把界線畫清楚。
參考資料:
有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D