Appearance
AI 出錯了怎麼辦?
先理解:AI Agent 的錯誤比一般程式更複雜
一般的 Web 應用遇到錯誤,通常就是重試幾次。但 AI Agent 的錯誤有語義層面的——不只是「請求失敗」,還有「回應被截斷」「上下文太長」「模型容量不足」等。每種錯誤需要不同的恢復策略。
Claude Code 的處理方式就像一個經驗豐富的醫生:先診斷是什麼類型的問題,再選擇對應的治療方案,而不是「吃退燒藥試試看」。
錯誤分類的全景圖
先看整體的錯誤處理流程:
錯誤分類:不同的錯誤,不同的解法
| 錯誤類型 | 白話翻譯 | 正確的處理 |
|---|---|---|
| 429 (Rate Limit) | 「你太快了,慢一點」 | 等一下再試(指數退避) |
| 529 (Overloaded) | 「伺服器忙不過來」 | 最多試 3 次,再不行就換模型 |
| prompt_too_long | 「你的對話太長了」 | 壓縮對話(見上一章),然後重試 |
| max_output_tokens | 「AI 的回答被截斷了」 | 告訴 AI「繼續」,最多 3 次 |
| 網路斷線 | 「連不到伺服器」 | 重新連線,重試 |
| 認證過期 | 「你的登入已失效」 | 重新認證 |
第一個設計:「不是所有請求都值得重試」
看看 withRetry.ts 中的這段設計:
typescript
// 只有使用者正在等待結果的請求才值得重試 529
const FOREGROUND_529_RETRY_SOURCES = new Set([
'repl_main_thread', // 使用者的主對話
'sdk', // SDK 呼叫
'agent:custom', // 自訂代理
'compact', // 壓縮操作
'auto_mode', // 安全分類器
])什麼意思? Claude Code 在背景會做很多事:生成對話標題、提供提示建議、分析對話品質。這些背景任務如果因為伺服器過載而失敗,使用者根本不會注意到。
如果這些背景任務也重試 529,會發生什麼?程式碼中的註解告訴你:
// 在容量風暴中,每次重試都是 3-10 倍的閘道放大。
// 背景任務的使用者看不到它們失敗。「閘道放大」是什麼意思? 想像一個十字路口已經塞車了。如果每輛車都不停按喇叭(重試),塞車只會更嚴重。正確的做法是:只有救護車(使用者正在等的請求)才按喇叭,其他車(背景任務)安靜等待或繞道。
實戰思考
盤點你的 AI 應用發出的所有 API 請求。哪些是使用者正在等待的(必須重試)?哪些是背景操作(可以放棄)?分別設定不同的重試策略。
第二個設計:「不要提前宣布失敗」
當 AI 的回答被截斷(max_output_tokens)時,Claude Code 不會立刻告訴 SDK 呼叫者「出錯了」。它會先嘗試恢復:
typescript
// query.ts 中的 Withheld(「扣住」)模式
function isWithheldMaxOutputTokens(msg): boolean {
// 如果這是一個 max_output_tokens 錯誤,先不要 yield 給呼叫者
return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
}為什麼要「扣住」錯誤,不立刻報告?
想像一個情境:你請翻譯員翻譯一份文件,翻譯到一半筆沒水了(max_output_tokens 截斷)。翻譯員可以換一支筆繼續翻。
但如果你的秘書看到「筆沒水了」就大喊「翻譯失敗!取消整個案子!」——這就是問題所在。
Claude Code 的一些 SDK 呼叫者(比如桌面應用、企業整合工具)就是這種「敏感型秘書」——他們的程式碼寫的是「收到任何 error 就終止對話」。如果 Claude Code 在嘗試恢復之前就把 error 往外送,這些呼叫者會立刻斷開連線,而 Claude Code 的恢復迴圈還在背後努力——但已經沒人在聽了。
所以 Claude Code 的做法是:先把 error 「扣住」不送出去,自己偷偷嘗試恢復(告訴 AI「你的回答被截斷了,接著上次的繼續寫」)。如果恢復成功,使用者和 SDK 呼叫者完全不知道出過問題——他們看到的是一個完整的回應。如果連續嘗試 3 次都失敗了,才真的把 error 送出去。
這就像醫院的急診分級:護士不會每個流鼻血的病人都按緊急鈴叫所有醫生放下手邊的事來會診。她會先自己評估:能用紗布止血的就處理掉,真的是嚴重出血才往上報。這節省了整個系統的資源,也避免了不必要的恐慌。
第三個設計:「從真實事故中學習」
斷路器的故事
原始碼中有這段令人印象深刻的註解:
typescript
// 連續失敗 3 次後停止自動壓縮
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
// BQ 2026-03-10(BigQuery 分析,2026 年 3 月 10 日):
// 1,279 個會話有 50 次以上的連續失敗
//(最多 3,272 次),每天浪費約 250,000 次 API 呼叫發生了什麼? 某些對話卡在一個死循環裡:
對話太長 → 觸發自動壓縮 → 壓縮失敗(因為太長)
→ 下次 API 請求 → 還是太長 → 又觸發壓縮 → 又失敗
→ ... 重複 3,272 次 ...一個會話重複了 3,272 次無效的壓縮嘗試!在全域規模下,這每天浪費了 25 萬次 API 呼叫。
「斷路器」(Circuit Breaker) 這個名字來自電力工程——當電流太大時,保險絲會自動斷開,防止電線燒毀。同樣的道理:當壓縮連續失敗 3 次,就停止嘗試,不再白白浪費資源。
遞迴保護
另一個防護措施——壓縮操作本身不能觸發新的壓縮:
typescript
if (querySource === 'compact') {
return false // 壓縮操作不會觸發自動壓縮
}為什麼? 壓縮是用一個子 AI(forked agent)執行的。如果這個子 AI 的上下文也太長,它會嘗試壓縮自己的上下文,然後那個壓縮又觸發壓縮,形成無限遞迴。
這就像用一台印表機來修理另一台印表機——如果修理過程中印表機也壞了,你就陷入了無限循環。
實戰思考
在你的系統中搜尋「重試」邏輯。每一處都問自己:
- 最多重試幾次?有上限嗎?
- 如果 A 操作失敗了會觸發 B 操作,B 操作失敗了會不會觸發 A?(遞迴風險)
- 有沒有真實數據告訴你「這個上限應該設多少」?
第四個設計:「prompt_too_long 的反應式壓縮」
有時候主動壓縮(autocompact)的閾值計算不準確。比如使用者貼了一張大圖片——圖片的 token 數量很難精確估算。結果 autocompact 覺得「還沒到閾值」,但 API 返回了「prompt_too_long」。
這時候就需要反應式壓縮——「事前沒預防到,事後趕緊補救」:
typescript
// query.ts 中的處理
if (isPromptTooLongMessage(errorMessage)) {
if (!hasAttemptedReactiveCompact) {
// 壓縮對話後重試
state = { ...state, hasAttemptedReactiveCompact: true }
// 回到迴圈頂部重新嘗試
}
}hasAttemptedReactiveCompact 這個旗標確保反應式壓縮只嘗試一次。如果壓縮後還是太長,就不再嘗試了(交給斷路器處理)。
第五個設計:Query Loop 的狀態追蹤
Claude Code 的查詢迴圈不是簡單的 while(true)。它有一個明確的狀態物件,記錄了「為什麼繼續」:
typescript
type State = {
messages: Message[]
maxOutputTokensRecoveryCount: number // 截斷恢復了幾次
hasAttemptedReactiveCompact: boolean // 已經嘗試過反應式壓縮嗎
turnCount: number // 第幾輪了
transition: Continue | undefined // 為什麼從上一輪繼續過來
}transition 欄位的設計特別巧妙:它記錄了「上一次迴圈是因為什麼原因繼續的」——是因為工具執行完了、還是因為壓縮完了、還是因為截斷恢復了。
這讓測試變得容易:你可以斷言「在這個場景下,迴圈應該因為 reactive_compact 原因繼續」,而不需要去解析複雜的訊息內容。
整體回顧
| 設計 | 解決什麼問題 | 核心思想 |
|---|---|---|
| 來源感知的重試 | 背景任務重試會放大容量風暴 | 只有使用者在等的請求才重試 |
| Withheld 模式 | 過早報告錯誤會終止可恢復的會話 | 先嘗試恢復,恢復不了再報錯 |
| 斷路器 | 無限重試浪費資源 | 連續 N 次失敗就停止 |
| 遞迴保護 | 恢復操作可能觸發自身 | 壓縮不能觸發壓縮 |
| 反應式壓縮 | 主動預防可能不夠 | 事前預防 + 事後補救 |
| 狀態追蹤 | 難以測試和除錯 | 明確記錄「為什麼繼續」 |
實戰思考(最重要的一個)
你的 AI 應用目前有「錯誤就重試 3 次,不行就報錯」的邏輯嗎?試試把它升級成「先分類錯誤,再根據分類選擇策略」。不同的錯誤真的需要不同的處理方式——就像感冒、骨折、過敏需要看不同的科一樣。