iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

開場故事

真正麻煩的工作,通常不是一開始就失敗。
而是它看起來快要成功了,卻在最後一刻卡住。

像這樣:

  • 任務已經派出去了,結果回不來
  • 回來了,但送不到對的人
  • 送到了,但內容不完整
  • 內容完整,但 session 已經 reset
  • 不是壞掉,只是剛好被 abort
  • 不是 abort,只是需要 retry

這種狀況最煩。因為表面上看起來「事情有在動」,實際上結果卻沒有真的落地。

第 15 天我想講的就是這個很現實的問題:

失敗不是結束,Agent 怎麼處理錯誤?

如果把 OpenClaw 想成一個工作流系統,那錯誤處理不是額外功能,而是它能不能活下去的核心。

今天要解的問題

  • OpenClaw 怎麼分辨「正常結束」、「暫時失敗」、「真的失敗」?
  • 為什麼 abortSignal 不是單純的中斷,而是流程控制的一部分?
  • session reset 時,為什麼舊事件不能亂寫回新 session?
  • 為什麼有些失敗要 retry,有些失敗要直接 fallback?
  • failed-retryable 這種狀態到底代表什麼?
  • 為什麼錯誤處理不是把 exception 丟出去就好?

架構總覽

OpenClaw 處理錯誤時,不是只有「成功」和「失敗」兩種結果。

它更像是在看一條狀態鏈:

  1. 任務先被接住
  2. 執行中如果被 abort,就立刻停止往下走
  3. 如果是可恢復的失敗,就丟進 retry 流程
  4. 如果是不可恢復的失敗,就做 fallback 或結束
  5. 如果 session 已經換代,舊事件就不能污染新狀態

這代表 OpenClaw 的錯誤處理不是單一判斷式,而是一組層層收斂的機制。

你可以把它理解成:

  • abort 是「先停」
  • retry 是「再試一次」
  • fallback 是「先給一個能用的保底」
  • stale event protection 是「不要把舊帳寫進新帳本」

這一篇就是把這幾個層次串起來看。

原始碼節錄

先看 session lifecycle 最重要的一條防線:舊事件不能改新 session。

📄 原始碼:src/gateway/session-lifecycle-state.ts(本機版本行號已變動,僅標到檔案)

function isStaleLifecycleEventForSession(params) {
	return Boolean(params.owningSessionId && params.currentSessionId && params.owningSessionId !== params.currentSessionId);
}

這個判斷很短,但意思非常重。

如果一個 run 的 sessionId 跟現在 session row 裡的 sessionId 不一樣,那它就是舊帳。
舊帳不能回頭改新帳。

再看 lifecycle event 怎麼真的寫進 session。

📄 原始碼:src/gateway/session-lifecycle-state.ts:138-167

function deriveGatewaySessionLifecycleSnapshot(params) {
	const phase = resolveLifecyclePhase(params.event);
	if (!phase) return {};
	const existing = params.session ?? void 0;
	if (phase === "start") {
		const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event);
		return {
			updatedAt: startedAt ?? existing?.updatedAt,
			status: "running",
			startedAt,
			endedAt: void 0,
			runtimeMs: void 0,
			abortedLastRun: false
		};
	}
	const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event);
	const endedAt = resolveLifecycleEndedAt(params.event);
	return {
		updatedAt: endedAt ?? existing?.updatedAt,
		status: resolveTerminalStatus(params.event),
		startedAt,
		endedAt,
		runtimeMs: resolveRuntimeMs({
			startedAt,
			endedAt,
			existingRuntimeMs: existing?.runtimeMs
		}),
		abortedLastRun: resolveTerminalStatus(params.event) === "killed"
	};
}

這裡可以看到 OpenClaw 不是只記「完成或失敗」,它還記:

  • running
  • done
  • timeout
  • killed
  • failed

也就是說,它看錯誤不是二元,而是狀態機。

再看執行層怎麼處理 abort。

📄 原始碼:extensions/discord/src/monitor/message-handler.process.ts:74-74

function isProcessAborted(abortSignal) {
	return Boolean(abortSignal?.aborted);
}

const { cfg, discordConfig, accountId, runtime, botUserId, mediaMaxBytes, discordRestFetch, abortSignal, guildHistories, historyLimit, replyToMode, message, author, sender, canonicalMessageId, data, client, channelInfo, channelName, messageChannelId, isGuildMessage, isDirectMessage, isGroupDm, messageText, shouldRequireMention, canDetectMention, effectiveWasMentioned, shouldBypassMention, channelConfig, threadBindings, route, abortSignal: abortSignal2 } = ctx;
if (isProcessAborted(abortSignal)) return;

這很像一個現場工作守則:

  • 如果外部已經說停止
  • 那就不要繼續往下做

OpenClaw 不把 abort 當成例外,而是當成正常控制訊號。

接著看比較完整的失敗處理,像 Telegram 傳遞結果時怎麼收口。

📄 原始碼:extensions/telegram/src/bot-message-dispatch.ts:2959-2961

if (!isRoomEvent && !suppressFailureFallback && !finalAnswerDelivered && (dispatchError || deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)) {
	const fallbackText = dispatchError ? "Something went wrong while processing your request. Please try again." : EMPTY_RESPONSE_FALLBACK;
	sentFallback = (await (telegramDeps.deliverReplies ?? deliverReplies)({
		replies: [{ text: fallbackText }],
		...deliveryBaseOptions,
		silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
		mediaLoader: telegramDeps.loadWebMedia
	})).delivered;
}

這段很實際。

如果主流程失敗了,但又不能什麼都不回,OpenClaw 就會送一個保底訊息。

這不是裝懂,而是避免使用者看到空白。

再看它怎麼把失敗區分成可重試和不可重試。

📄 原始碼: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
};

這一段很值得看。

它不是只問「有沒有失敗」,而是問:

  • 有沒有最終回覆
  • 有沒有可見回覆
  • 是否只是投遞過程失敗
  • 這個失敗能不能重試

這就是 failed-retryable 的意義。

不是所有失敗都一樣。有些失敗只是這一輪送出沒成功,系統還可以再撐一次。

再看 abort 跟 retry 的配合。

📄 原始碼:extensions/telegram/src/bot-message.ts:291-479

if (turnAbortSignal.aborted && !participant.abortSignal.aborted) {
	const abortResult = turnAbortSignal.reason === "skipped" ? { kind: "skipped" } : {
		kind: "failed-retryable",
		error: turnAbortSignal.reason
	};
	participant.settle(abortResult);
}

這裡很清楚地表達一件事:

  • 有些 abort 只是跳過,不算真正失敗
  • 有些 abort 是可以再試的失敗

OpenClaw 並不把所有中斷都視為悲劇,它會看中斷的原因,再決定要怎麼結案。

最後看 announce 路徑如果活不過去,系統怎麼收尾。

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

if (params.expectsCompletionMessage && isCronRunSessionKey(canonicalRequesterSessionKey) && !resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive && !agentMediatedCompletion) {
	const generatedMediaDelivery = await tryGeneratedMediaDirectDelivery();
	if (generatedMediaDelivery) return generatedMediaDelivery;
	if (!agentMediatedCompletion) return {
		delivered: true,
		path: "none"
	};
}

這表示如果 requester 不在了,OpenClaw 不會硬等。

它會嘗試 direct delivery,實在不行也要讓流程有一個保底結束點。
不然背景任務就會卡在「理論上做完了,但沒地方送」的狀態。

白話拆解

1. OpenClaw 不是把錯誤當成例外,而是當成正常流程的一段

很多系統一碰到錯誤,就直接丟 exception。

OpenClaw 比較像在說:

錯誤本來就是流程的一部分,只是我們要分辨它是哪一種錯誤。

所以它會先看:

  • 是不是 abort
  • 是不是 timeout
  • 是不是 delivery failure
  • 是不是 stale event
  • 是不是可以 retry
  • 是不是需要 fallback

這種做法比較麻煩,但比較能活。

2. abortSignal 不是取消按鈕,而是流程控制訊號

很多人看到 abort 會直覺想成「失敗了」。

但在 OpenClaw 裡,abort 比較像是:

  • 使用者不想等了
  • 上層流程切換了
  • 當前 turn 應該停止

也就是說,abort 不一定代表壞掉,它可能只是「這條路先別走了」。

這種設計很重要,因為工作流裡最怕的是:

  • 上層已經換方向
  • 下層還在死做

那就會開始產生重複、衝突、過期回覆。

3. failed-retryable 是系統留給自己的一次呼吸

不是所有失敗都要立刻宣告結束。

有些失敗只是:

  • 網路慢了一點
  • delivery path 暫時沒通
  • reply 途中被切斷
  • 還沒拿到可見回覆

這時候 failed-retryable 就像系統說:

我先不判死刑,給自己再試一次的機會。

這很像實際工作。

郵件寄送失敗,不代表內容錯了。
它可能只是郵件系統剛剛剛好掛了。

4. fallback 的價值,是避免使用者看到空白

如果主路徑壞了,最怕的不是錯誤訊息,而是什麼都沒有。

因為什麼都沒有,使用者只會懷疑:

  • 是不是沒收到
  • 是不是根本沒跑
  • 是不是卡住了

所以 OpenClaw 會在必要時補一個 fallback text。

這不是最完美的答案,但至少是可理解的答案。

5. stale event protection 很像防止舊快遞寫進新住址

reset 之後,舊 lifecycle event 還是可能晚到。

如果系統不檢查 sessionId,它就可能把舊 run 的狀態寫到新 session 上。

那就會出現很奇怪的現象:

  • 新 session 明明剛開始,卻被寫成已完成
  • 舊任務的失敗訊息跑到新任務裡
  • 使用者看見一個根本不是這輪的狀態

所以 isStaleLifecycleEventForSession 的存在很像防止快遞送錯地址。

6. 好的錯誤處理,不是讓一切都成功,而是讓系統不亂

OpenClaw 的錯誤處理很少是「神奇修好」。

它比較像:

  • 先停下來
  • 再判斷要不要重試
  • 不行就 fallback
  • 最後一定要把狀態收乾淨

這才是工程上真的有用的錯誤處理。

因為工作流系統最怕的不是失敗,而是失敗之後還留下半套狀態,下一輪接著亂。

設計取捨

  • 好處是錯誤分類清楚,abort、retry、fallback 不會全混在一起
  • 好處是 session reset 不會讓舊事件污染新流程
  • 好處是系統能在 delivery fail 時補保底回覆,避免空白
  • 好處是 failed-retryable 讓某些暫時性問題有機會修復
  • 代價是錯誤路徑變多,閱讀和除錯都比單純 exception 麻煩
  • 代價是要理解 session、run、delivery、abort 之間的關係
  • 代價是如果沒有看整條流程,很容易把保底訊息誤認成主答案

如果換成最簡單的錯誤處理,就是失敗直接 throw。
那樣寫起來很省,但你會得到一個很脆的系統:

  • 一點波動就中斷
  • 一次失敗就整條線斷掉
  • 沒有保底
  • 沒有區分可重試與不可重試

OpenClaw 顯然不是要那種系統。
它要的是在錯誤裡仍然能保持秩序。

今天的結論

  • OpenClaw 把錯誤看成流程的一部分,而不是流程外的意外
  • abortSignal 是控制訊號,不只是單純取消
  • failed-retryable 表示系統保留重試空間,不急著宣判失敗
  • fallback 的目的是避免使用者看到空白或無結論的狀態
  • isStaleLifecycleEventForSession 防止舊事件污染新 session
  • 好的錯誤處理不是讓一切成功,而是讓系統在失敗後仍然可控

下一步

第 16 天我想接著看更進一步的事情:

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

因為第 15 天只是把錯誤分類清楚,下一天才是重點:
當真的要 retry、要回退、要補救時,OpenClaw 是怎麼把整個現場撐住的。


上一篇
第 14 天:流程不是直線,OpenClaw 的工作流思維
下一篇
第 16 天:重試,回退,補救,系統怎麼撐住現場
系列文
30 天走進 OpenClaw:一個 AI Agent 的誕生、掙扎與進化17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言