iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

! 本篇文章將會介紹 錯誤處理:Agent 失敗了誰來擦屁股,期望大家都能學會幫自動化系統蓋一套防禦工事:暫時性失敗自動重試、靜默失敗靠驗收判準抓包、最後防線有備稿與告警接手 :D

昨天填完 PATH 地雷,照預告今天來談錯誤處理。這題對本系列不是理論題——scripts/generate.sh 的檔頭註解原文就寫著:「失敗重試最多 5 次(backoff 30s)→ 備稿 → 告警」,而這套工事在開賽前十天真的挨過實彈:d02 生成三連敗靠備稿補位、d09 被機敏掃描連續隔離五次。你正在讀的這篇文章,就是那支腳本今晚叫起 opencode 寫的,所以今天等於用系統講系統,跳針迴圈第 12 天繼續轉 :D

自動化系統的鐵律:失敗不可怕,可怕的是失敗了沒人知道,或是卡在一個不上不下的狀態。口號既然是「我耍廢、Agent 完賽」,擦屁股的邏輯就必須寫進程式碼,而不是靠我每晚睡前祈禱。

本篇目標

讀完這篇你會學到:

  • 失敗的三種類型:暫時性、永久性、靜默——各自的對策完全不同
  • 重試的設計細節:上限怎麼定、為什麼要 backoff、驗收判準為什麼比 Agent 的自我報告可靠
  • 逾時、冪等與最後防線:讓失敗的任務能安全重跑,備稿與告警負責收尾

環境準備

  • macOS(內建 bash 3.2 與 launchd),昨天裝好的三支排程:generatepublishrepublish
  • 一支會被排程執行的腳本,開頭慣例先掛好 set -uo pipefail
# 確認三支排程都在線上
launchctl list | grep ironman

# 順便看一眼 generate 任務的保險絲:ExitTimeOut 1800 秒,後面會用到
plutil -p ~/Library/LaunchAgents/com.benben.ironman.generate.plist | grep ExitTimeOut
# ---- 預期輸出 ----
# "ExitTimeOut" => 1800

主要內容

步驟一:先分類失敗,再談對策

擦屁股第一步是搞清楚這是哪種屎。實戰上失敗分三類:

  • 暫時性失敗:網路抖一下、模型服務逾時。特徵是同樣的事再試一次往往就過。對策:有限重試 + backoff。
  • 永久性失敗:outline 缺條目、prompt 寫錯、機敏掃描命中(Agent 每一輪都會犯同一個錯)。特徵是重試只是浪費子彈。對策:快點失敗,直接告警找人。
  • 靜默失敗:程序正常結束、Agent 說 DONE,但產出根本不符期待。最危險,因為沒有 exception 可以接。對策:獨立的驗收判準,只信驗證、不信報告。

第三類我們真的遇過。看 logs/generate-20260912.log 的案發現場(節選):

# d02 事故:Agent 每輪都宣稱寫檔成功,判準就是不通過
[19:03:36] 第 1 次失敗(不存在/過短/標題不符)
[19:04:06] opencode run 第 2 次嘗試
[19:12:53] 第 2 次失敗(不存在/過短/標題不符)
[19:16:17] 第 3 次失敗(不存在/過短/標題不符)
[19:16:17] 已啟用備稿 ff1-when-not-to-automate.md → articles/2026-09-12-d02.md

Agent 每一輪都輸出了「Wrote file successfully. DONE: ...」,但腳本親自讀檔驗收就是不過。更糟的是當時的失敗訊息把三種可能打包在一起,事後完全無從修起。這就是 generate.sh 裡那行註解的由來:「逐項診斷:哪個判準沒過要寫進 log,不能只說『失敗』」——錯誤訊息本身也是要迭代的產品。

步驟二:重試三原則——上限、backoff、可診斷

generate.sh 的重試迴圈(節選):

# scripts/generate.sh(節選):上限 5 次、backoff 30 秒、逐項診斷
MAX_ATTEMPTS=5
for (( attempt=1; attempt<=MAX_ATTEMPTS; attempt++ )); do
  (cd "$ROOT" && opencode run --auto --title "ironman-d${DD}-gen" "$PROMPT") >>"$LOG" 2>&1

  # 品質判準:檔案存在 >1500 bytes、CJK >=1500 字、標題與 outline 一致
  R=""
  if [[ ! -s "$ARTICLE" ]]; then
    R="檔案不存在"
  else
    B=$(wc -c <"$ARTICLE" | tr -d ' '); C=$(cjk_count "$ARTICLE"); T=$(head_title "$ARTICLE")
    (( B > 1500 ))  || R="${R}bytes(${B})過短、"
    (( C >= 1500 )) || R="${R}CJK(${C}字)不足、"
    [[ "$T" == "${DD} ${TITLE}" ]] || R="${R}標題不符[${T}]、"
  fi
  [[ -z "$R" ]] && exit 0          # 全數過關
  log "第 ${attempt} 次未過判準:${R%、}"
  (( attempt < MAX_ATTEMPTS )) && sleep 30   # backoff:暫時性失敗別硬碰硬
done

三個設計決策:

  • 上限 5 次是算出來的:單次生成約 4-5 分鐘,加 backoff 最壞約 25 分鐘,趕得上 20:00 的發文排程。重試上限不是拍腦袋,是從下游排程表倒推的。
  • backoff 30 秒:暫時性失敗(網路、服務逾時)立刻重打是硬碰硬,睡一下讓暫時性狀態過去,命中率明顯變好。
  • 失敗要說人話bytes(943)過短、標題不符[02 ...] 這種訊息,隔天早上瞄一眼就知道要修哪;「失敗」兩個字只會讓你從頭考古。

發文端也是同一套哲學,只是驗收對象從檔案換成 URL:

# scripts/publish.sh(節選):點擊發佈 x3,每輪點完輪詢 URL 驗收
for attempt in 1 2 3; do
  "${AB[@]}" eval 'document.querySelector(".save-group__dropdown-btn--publish").click()'
  # 成功特徵:URL 變成 /articles/10xxxx 且沒有 /draft
  for i in $(seq 1 6); do
    sleep 5
    AFTER=$("${AB[@]}" get url 2>/dev/null)
    if [[ "$AFTER" =~ /articles/10[0-9]+$ ]]; then PUBLISHED=1; break; fi
  done
  [[ -n "$PUBLISHED" ]] && break
  log "第 ${attempt} 次點擊後 URL 仍在 ${AFTER:-未知},20 秒後重試"
  sleep 20
done

這段是 D1 的血淚結晶。當晚 iThelp 尖峰時段按「發表文章」會靜默無反應,腳本按完就收工,最後回報 FATAL、我手動重發收場。隔天就把「點擊後輪詢 URL」補上,腳本註解原文留著紀念:「2026-09-11 D1 教訓:iThelp 尖峰時段按發佈會『靜默無反應』,必須輪詢 URL 自動重點」。

小小小測驗:你知道網站尖峰時段按了送出按鈕,可能不跳錯誤、不轉址、什麼都不會發生嗎?所以本系統的成功判定從來不是「按了按鈕」,而是「輪詢 URL 看到文章編號」——驗收結果,不驗收動作。

步驟三:逾時、冪等與最後防線

重試的另一面是逾時:沒有逾時的重試等於無限卡死。本系統分三層設防——launchd 層 ExitTimeOut 1800(跑超過 30 分鐘直接終結,防止殭屍任務佔住排程)、工具層 agent-browser wait --timeout 15000(等元素最多 15 秒)、邏輯層像 Turnstile token 最多等 12 輪、每輪 3 秒,等不到就截圖存證、帶著警告繼續往下走,而不是原地永動。

重試的前提則是冪等:重跑不會出事,重試才敢放手跑。

# scripts/publish.sh(節選):冪等守衛,20:15 補發 job 重跑全靠它防重複發文
PUB_FLAG="$ROOT/state/published-$(date +%Y%m%d).flag"
if [[ -e "$PUB_FLAG" ]]; then
  log "今日已發佈過(flag 存在),跳過"
  exit 0
fi

generate.sh 開頭也有同款守衛:文章檔已存在且非空就跳過,手動補跑不會覆蓋你改到一半的稿。而 20:15 那支 republish plist 做的事,就是重跑同一支 publish.sh——有 flag 擋著,重跑一百次也只會發一篇。

最後是備援梯子。生成 5 次用完之後,generate.sh 的決策樹長這樣:

  1. 文章存在但品質不足 → 保留現狀不覆蓋,警告交人工。這是 d02 的第二個教訓:備稿的標題與明日預告跟當日大綱對不上,拿 60 分備稿蓋掉 80 分草稿並不划算,編輯判斷留給人類。
  2. 根本沒有文章 → 依序啟用 fallback/*.md,每支備稿靠 .used flag 只准用一次,避免連續兩天刊同一篇。
  3. 備稿也用完 → error 級告警:「生成與備稿全數失敗,斷更危險!請立即手動處理」。

9 月 19 日這套梯子真的全走過一遍:機敏掃描連續命中五次,五份稿子全被 mvstate/quarantine-d09-*.md 隔離(不是刪除,留著驗屍),最後 ff2 備稿上場,當天照常出刊、flag 照常落地——我全程沒動手。防禦工事不是裝飾,是實彈射擊過的。

常見問題 / 踩坑記錄

  • Q:Agent 每一輪都說 DONE,品質判準卻一直不過,到底是誰對?
    A:驗收對。Agent 的自我報告只是參考,產出以腳本親自讀檔驗證為準。配套是失敗訊息必須逐項診斷(d02 教訓):列出 bytes、CJK 字數、標題的實際值,一眼看出問題;只寫「失敗」二字,事後等於沒有 log。

  • Q:重試最怕重複發文或覆蓋東西,怎麼防?
    A:先蓋冪等,再談重試。publish 成功才 touch 當日 flag、開頭先查 flag;生成端檔案存在即跳過。順帶一提,兩支腳本都是 set -uo pipefail 而故意不加 -e——自動化腳本裡「失敗是預期內的」情境太多,一行就炸的 -e 反而誤事,錯誤要自己判斷著接。

  • Q:機敏掃描命中的稿子去哪了?直接刪掉嗎?
    A:mvstate/quarantine-*.md 隔離區,不刪。它是事後檢討的重要線索(哪類內容會誘發 Agent 寫出可疑字串),d09 一天隔離五份,全都還在,隨時可以驗屍。

  • Q:重試全部用完,剩下一篇「存在但品質不足」的文章,讓備稿蓋掉它?
    A:不要。備稿只在「根本沒有文章」時啟用。標題正確、內容八十分的稿,好過與當日大綱對不上的通用備稿;此時正確動作是警告 + 讓人工在 20:00 前裁決,系統不要替你做編輯判斷。

小結

  • 失敗分三種:暫時性用重試 + backoff、永久性快點失敗 + 告警、靜默失敗靠獨立驗收判準抓包
  • 重試三原則:有上限、有 backoff、失敗訊息可診斷;上限從下游排程倒推(5 次約 25 分鐘,趕得上 20:00)
  • 沒有逾時的重試是卡死:ExitTimeOutwait --timeout、輪詢次數全部有界
  • 冪等是重試的前提:published flag 讓補發 job 安心重跑;備稿梯子 + 人工告警是最後防線

明日預告

下一篇我們要介紹「Discord 告警:讓機器人主動回報戰況」,敬請期待!今天文中反覆出現的 "$NOTIFY" -s ok/warn/error 就是它的本體,明天拆解怎麼讓機器人主動回報戰況、告警分級怎麼設計。

參考資料:

有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D


上一篇
11 環境變數地雷:launchd 找不到 nvm/node 怎麼辦
系列文
自我耍廢組:全自動化の鐵人12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言