iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
佛心分享-SideProject30

30 天走進 OpenClaw:一個 AI Agent 的誕生、掙扎與進化系列 第 16

第 16 天:重試,回退,補救,系統怎麼撐住現場

  • 分享至 

  • xImage
  •  

開場故事

現場工作最怕的,不是第一次失敗。
最怕的是,第一次失敗後沒人知道下一步該怎麼辦。

你可能看過這種狀況:

  • 任務卡住了,大家只會重新送一次
  • 送第二次還是失敗,然後就開始亂猜
  • 有人以為是權限問題,有人以為是網路問題
  • 其實真正的問題是上一輪的狀態根本沒收乾淨
  • 任務還沒死透,下一輪又被當成新任務接走

這時候系統要做的就不是「裝沒事」,而是要有一套撐住現場的方法:

  • 能 retry 的就 retry
  • 不能 retry 的就 fallback
  • 已經亂掉的就 cleanup
  • 卡在中途的就 recover

第 16 天我想講的就是這件事:

重試、回退、補救,系統怎麼撐住現場?

如果第 15 天是在分辨錯誤類型,這一天就是在看 OpenClaw 怎麼真的把那個錯誤現場穩住。

今天要解的問題

  • retry 不是只有重新執行嗎?為什麼還要分很多層?
  • failed-retryable 到底怎麼往下接?
  • 什麼情況會 backoff,什麼情況會直接停?
  • 為什麼 cleanup 比 retry 還重要?
  • claim recovery / stale recovery 為什麼要存在?
  • fallback 為什麼不是失敗的附屬品,而是系統保命機制?

架構總覽

OpenClaw 的現場處理,不是單一 retry loop。

它其實是四層一起工作:

  1. Retry:這次沒成功,但還有機會再來一次
  2. Backoff:不要立刻衝第二次,先讓系統冷靜
  3. Cleanup:如果中途失敗,先把暫存狀態收掉
  4. Fallback / Recovery:如果主路徑不通,改走保底或回收路徑

這四層組起來,才像真的在撐現場。

因為現場錯誤通常不是乾淨的:

  • 有時是投遞失敗
  • 有時是 session claim 被別人搶走
  • 有時是中途 abort
  • 有時是舊事件晚到
  • 有時是結果還沒 deliver 就先崩了

OpenClaw 不是假設這些都不會發生,而是直接把它們當常態來設計。

原始碼節錄

先看 failed-retryable 這個狀態在系統裡怎麼被用。

📄 原始碼:extensions/telegram/src/telegram-ingress-spool.ts(本機版本行號已變動,僅標到檔案)

const deliveryFailureWithoutFinalResponse = !finalAnswerDelivered && (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0);
const retryableDispatchFailure = dispatchError ?? (deliveryFailureWithoutFinalResponse ? new Error(`Telegram reply delivery failed without a final response (failed=${deliverySummary.failedNonSilent}, skipped=${deliverySummary.skippedNonSilent})`) : null);
if (retryableDispatchFailure && retryDispatchErrors && (dispatchError != null && !hasFinalResponse || dispatchError == null && deliveryFailureWithoutFinalResponse && !hasVisibleResponse)) return {
	kind: "failed-retryable",
	error: retryableDispatchFailure
};

這段很重要,因為它告訴我們:

  • 不是所有失敗都直接算死
  • 只要還沒有最終回覆,系統就可能把它標記成可重試
  • retry 的前提不是「看起來很像」,而是有沒有保留恢復空間

再看 retry 進到哪裡。

📄 原始碼:extensions/telegram/src/bot-processing-outcome.ts:59-59

if (result.kind === "failed-retryable") {
	...
}

這種結果會被上層當成「不是完全死掉」。

也就是說,系統收到的不是單純 error,而是一種可接續處理的結果。
這點很關鍵,因為它會影響後面的 abort、queue、以及後續重送策略。

再看 backoff。

📄 原始碼:extensions/telegram/src/bot-handlers.callback-interactions.runtime.ts:236-236

const retryDelayMs = TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS[attempt];
if (!isReplySessionInitConflictResult(result) || retryDelayMs === void 0) throw new TelegramRetryableCallbackError(result.error);
logVerbose(`telegram plugin callback submitText hit active reply session; retrying in ${retryDelayMs}ms`);
await sleepWithAbort(retryDelayMs, spooledReplayParticipant?.abortSignal);

這段不是 OpenClaw 核心 agent loop 本身,但它很能代表整個系統的 retry 精神。

當衝突是暫時性的,它不會立刻狂打第二槍,而是先等一下:

  • 第一次失敗先確認是不是「活動中的 session 衝突」
  • 如果是,就等一段時間再試
  • 重試期間還要尊重 abortSignal

這就是現場撐住的關鍵。

不是硬上,而是有節奏地重來。

再看另一個典型的 retry / fallback 結合:cron 投遞失敗。

📄 原始碼:src/agents/subagent-announce-delivery.ts:602-613

async function retryTransientDirectCronDelivery(params) {
	const retryDelaysMs = resolveDirectCronRetryDelaysMs();
	for (const [retryIndex, delayMs] of retryDelaysMs.entries()) {
		const nextAttempt = retryIndex + 2;
		const maxAttempts = retryDelaysMs.length + 1;
		await logCronDeliveryWarn(`[cron:${params.jobId}] transient direct announce delivery failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1e3)}s: ${summarizeDirectCronDeliveryError(err)}`);
	}
}

這裡很像實際現場救火:

  • 第一次不通,不代表永遠不通
  • 先記錄,再等一下
  • 再試一次
  • 再失敗,再往下一層收斂

再看更底層的 retry 保護,像 Telegram replay 裡面對重試的處理。

📄 原始碼:extensions/telegram/src/bot-message.ts:438-457

while (!turnAbortSignal.aborted) {
	retryAttempt += 1;
	...
	const delayMs = resolveSpooledUpdatePersistenceRetryDelayMs(retryAttempt);
	runtime.error?.(danger(`telegram spooled turn durable replay protection retry ${retryAttempt} failed after active steer commit; retrying in ${delayMs}ms: ${String(retryError)}`));
}

這段的味道很明顯:

  • 不是一次就放棄
  • 但也不是無限重試
  • 每次都要有 delay
  • 每次都要看 abort

這就是有節制的 retry。

再看 cleanup。

📄 原始碼:src/agents/subagent-spawn.ts:1324-1324

async function cleanupFailedSpawnBeforeAgentStart(params) {
	await cleanupProvisionalSession(params.childSessionKey, {
		emitLifecycleHooks: false,
		deleteTranscript: true
	});
}

這段很短,但它代表一個很實際的原則:

失敗不是只要回報,還要把現場收掉。

如果子代理還沒正式開始就失敗了,那就不要留下半截 session。
該刪 transcript、該清 provisional 狀態,就要先做。

再看另一個 cleanup 路徑。

📄 原始碼:src/agents/openclaw-tools.ts(本機版本行號已變動,僅標到檔案)

async function cleanupProvisionalSession(childSessionKey, options) {
	...
}

OpenClaw 對 provisional state 很小心。
因為一旦 provisional 沒清乾淨,下一輪就會接到莫名其妙的殘影。

最後看 recovery。

📄 原始碼:extensions/telegram/src/telegram-ingress-spool.ts:453-453

async function recoverStaleTelegramSpooledUpdateClaims(params) {
	return await createTelegramIngressQueue(params.spoolDir).recoverStaleClaims({
		...
	});
}

這說明系統會主動去找那些失聯的 claim,試著恢復。

這不是補鍋,是把掉在地上的工作撿回來。

白話拆解

1. retry 不是再做一次而已,而是「重新進場」

很多人把 retry 想得太簡單。

以為就是:

失敗了,再執行一次。

但 OpenClaw 的 retry 比這更像「重新進場」。

因為它還要確認:

  • 上一輪是不是還活著
  • 這一輪能不能繼承狀態
  • 中間有沒有 abort
  • 有沒有 claim 被別人拿走
  • 有沒有必要先等一下

所以 retry 其實是有前置條件的。

如果前置條件不對,硬 retry 只會讓錯誤複製一次。

2. backoff 是讓系統不要自撞

當一個問題剛發生時,立刻重試常常沒用。

原因很簡單:

  • 下游還沒恢復
  • 資源還在忙
  • 前一輪還沒完全釋放
  • 失敗的原因還在原地

所以 OpenClaw 用 backoff 的概念,讓 retry 先退一步。

這很像你去敲門沒人回,不是立刻連敲十下,而是先等兩秒、五秒、十秒。
不是因為你懶,是因為現場本來就需要時間恢復。

3. cleanup 是補救的第一步

很多系統失敗後最容易忘記的事,就是收尾。

但如果不 cleanup:

  • provisional session 會殘留
  • transcript 會留半截
  • claim state 會卡住
  • 下一輪會誤判舊狀態

所以 OpenClaw 在失敗前後都會很重視 cleanup。

你可以把它想成:

  • 不是「這次沒成功」就算了
  • 而是「這次沒成功,現場要先復原」

4. recovery 是把掉到地上的工作撿回來

有些問題不是 retry 可以解的。

例如:

  • claim 被搶走
  • update 在中途失聯
  • gateway 重開後有遺留狀態
  • 上一輪是暫時中斷,不是結束

這時候就要 recovery。

Recovery 的精神不是執行新的流程,而是把還沒完成的舊流程接回來。

這很像收善後,不是重做整份。

5. fallback 是保底,不是投降

很多人把 fallback 誤會成「失敗了,只好隨便回一個」。

其實不是。

OpenClaw 的 fallback 比較像:

  • 主路徑不通時,先給使用者一個可理解的結果
  • 讓系統不要完全沉默
  • 讓後續還有機會追蹤

這種保底很重要,因為使用者最怕的是:

  • 沒結果
  • 沒回應
  • 沒人知道發生什麼事

fallback 至少能保住可觀察性。

6. 現場撐住,靠的是「分層補救」

OpenClaw 不把修復押在單一點上。

它會分層處理:

  • 失敗前看能不能避免
  • 失敗時看能不能 retry
  • retry 失敗時看能不能 backoff
  • backoff 後看能不能 recover
  • recover 不行時再 fallback
  • fallback 後還要 cleanup

這樣才像真的在撐系統。

如果只有 retry,系統很容易一直撞牆。
如果只有 fallback,系統很容易變成永遠不解決問題。
如果只有 cleanup,系統可能會很乾淨,但沒有把任務救回來。

所以 OpenClaw 的重點是:把每一層都放進來,然後按情況接力。

設計取捨

  • 好處是 transient failure 有機會被撐住,不會一失敗就整條斷掉
  • 好處是 retry / backoff / fallback / cleanup 各自分工,不會全部混成一坨
  • 好處是 stale claim 和 provisional state 會被主動清理
  • 好處是系統能在完成前出錯的情況下保持可觀察性
  • 代價是狀態和路徑變多,行為不再像單純 try/catch
  • 代價是 debug 時要看 retry、abort、claim、cleanup、fallback 五件事
  • 代價是多層補救意味著更多規則,也意味著更多理解成本

如果換成最簡單的做法,就是失敗就報錯,報錯就結束。
那種系統很乾脆,但一遇到現場波動就會很脆。

OpenClaw 選的是另一條路:

失敗可以發生,但系統不能亂掉。

今天的結論

  • retry 不是單純再執行一次,而是有前置條件的重新進場
  • backoff 讓系統避免在同一個故障點上連續自撞
  • cleanup 是補救的第一步,避免殘留狀態污染下一輪
  • recovery 是把掉在地上的舊流程撿回來,而不是重開一份
  • fallback 保住可觀察性,避免使用者看到空白或死寂
  • OpenClaw 撐住現場的核心,不是單一機制,而是分層接力

下一步

前面這幾天一直在看系統怎麼撐住失敗。
下一步我想換一個角度,看系統怎麼主動把工作排進來:

cron 是怎麼被 OpenClaw 掛進來的?

因為一個系統不只要會救火,也要會自己定時出手。


上一篇
第 15 天:失敗不是結束,Agent 怎麼處理錯誤
下一篇
第 17 天:cron 是怎麼被 OpenClaw 掛進來的
系列文
30 天走進 OpenClaw:一個 AI Agent 的誕生、掙扎與進化17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言