iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
AI Engineering

從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作系列 第 27 篇

Day 27:【例外處理與 API 限流】當免費額度撞牆時,該犧牲誰?

  • 分享至 

  • xImage
  •  

前言

在前四天完成「記憶治理(Memory Governance)」與「語料庫加強(Corpus Quality)」之後,我們的 Agent 已經聰明得有點過分了,她會自動記事、會主動關心、會發現你改了名字、還知道什麼時候該閉嘴不亂講設定。

然而,當這些功能一層層疊上去之後,隨之而來的是系統韌性(Resilience)的災難與死角:

  • 呼叫次數失控(API Call Amplification):
    每一項「聰明」都是拿 API 呼叫換來的。分流一次、評分一次、主對話一次、記憶提取一次、矛盾判斷再一次,使用者說一句話,系統打 4 到 5 次 API。免費層的 RPM(每分鐘請求數)上限實質被除以 5,延遲也累積成 4~5 秒。
  • 暫時性錯誤等於整輪報廢(No Retry):
    目前 app.py 只有一個大 try/except,任何例外都是「這句話不算數」。資料一致性上是對的,但一次暫時性的 503 過載,就讓使用者白打一句話,而 503 正是我這整季撞最多的錯誤,通常等一秒再送就過了。
  • 沒有優先級可言(No Priority):
    在現在的架構裡,好感度評分、記憶提取跟主對話是平等的,任何一個掛掉整輪就結束。但它們的重要性天差地遠:沒有好感度評分,角色只是這輪態度沒變;沒有主對話,角色就是死的。

會壞的系統不可怕,不知道該先放掉什麼的系統才可怕。

今天 Day 27 的核心任務,就是要為整套 Agent 建立一條失敗時的優先順序。我們將補上退避重試(Exponential Backoff)、額度用盡時的降級模式(Graceful Degradation),並且把能合併的呼叫合併掉。

更重要的是,我們要立下一條貫穿全篇的工程防線:輔助功能不該有能力弄死主對話。

現況盤點:一輪對話到底打幾次 API?

先算一下現況,一輪對話到底打幾次 API:

router.classify_intent      意圖分流         1 次
evaluator.evaluate_emotion  好感度評分       1 次
chat.send_message           主對話           1 次
memory_extractor            記憶提取         1 次
judge_relations             矛盾判斷         0~1 次(有灰色地帶候選才呼叫)
                                            ────────
                                            4~5 次

而現在的失敗處理,是一個包住全部的 try/except:

except Exception as e:
    st.error(f"角色沒有回應,API 出錯了:{e}")
    st.info("這句話沒有被記錄,重新整理後會消失,可以直接再說一次。")

四次呼叫、一個 except,於是任何一環出錯,整輪都算不存在。

今天的重量級目標:

  • 可重試錯誤判斷(Retryable Classification):分辨哪些錯誤重試有意義、哪些重試只是把失敗時間拉長 N 倍。
  • 退避重試(Exponential Backoff + Jitter):503 這類暫時性過載自動重送,並加上抖動避免同時重試。
  • 兩種失敗語意(Essential vs Optional):主對話「盡力重試、失敗要吵」,輔助模組「失敗就閉嘴」。
  • 降級模式(Graceful Degradation):額度用盡時完全不呼叫輔助功能,把剩下的額度全留給對話本身。
  • 合併呼叫(Call Merging):把 router + evaluator 併成單次 API,每輪 4 次降到 3 次。
  • A/B 量測驗證:合併省下的一次呼叫,不能拿準確率去換。
  • 撰寫 test_day27.py,用注入的假錯誤驗證重試與降級,不燒真額度。

第一關:不是所有錯誤都值得重試

寫重試最容易犯的錯,是「所有例外都重試三次」。

RETRYABLE_CODES = {429, 500, 502, 503, 504}

https://ithelp.ithome.com.tw/upload/images/20261011/20183877qymTpBC7lw.png

對 400/403/404 重試,唯一的效果是**把失敗時間拉長 N 倍,**使用者要多等 3 秒,才看到同一個必然的錯誤。

而且判斷要建立在型別上,不是字串比對:

def is_retryable(exc: Exception) -> bool:
    # 只認 SDK 的 APIError。其他例外(JSON 壞掉、程式寫錯、KeyError)
    # 重試是白費工,同樣的輸入會得到同樣的錯
    if not isinstance(exc, errors.APIError):
        return False
    return _status_code(exc) in RETRYABLE_CODES

google.genai.errors.APIError 帶了 .code 屬性,ClientError(4xx)與 ServerError(5xx)都繼承它。這比去 parse 錯誤訊息裡的字串可靠得多。

退避還要加抖動(Jitter):

def _sleep_seconds(attempt: int, base_delay: float) -> float:
    """指數退避 + 隨機抖動。

    抖動不是裝飾:沒有它的話,同時撞牆的多個請求會在同一毫秒一起重試,
    等於把尖峰完整複製一次到下一秒(thundering herd)。
    """
    return base_delay * (2 ** (attempt - 1)) + random.uniform(0, base_delay * 0.5)

第二關:主對話與輔助模組,該有完全不同的失敗語意

這是今天整支 resilience.py 的核心,也是整篇文章最想講的一句話:

輔助功能不該有能力弄死主對話。

所以我們不寫一個萬用的 retry(),而是刻意把它拆成兩個語意完全不同的函式:

def call_essential(fn, label, max_attempts=3, base_delay=1.0):
    """主對話用:盡力重試,重試完還是失敗就往外拋。

    往外拋是刻意的,主對話失敗使用者必須知道,
    而 app.py 外層已經有 try/except 會顯示紅字並且整輪不落地。
    """

def call_optional(fn, label, fallback, max_attempts=2, base_delay=1.0):
    """輔助模組用(分流/評分/記憶提取/矛盾判斷):失敗就回 fallback,永不拋出。"""

差異不只是「拋不拋」,還有三處:

https://ithelp.ithome.com.tw/upload/images/20261011/20183877ZTkYuMfHDF.png

而每個輔助模組的 fallback,都是它原本就有的保守值:

router    → CHAT    (誤判成 TASK 會讓角色突然變客服,體驗差得多)
evaluator → 0       (好感度會落地,寧可不動也不要亂給)
extractor → []      (寧可不記,也不要記垃圾)
judge     → 全部 DIFFERENT(寧可多存一筆重複,也不要誤封存真的事實)

這些值不是今天才想的,它們在 Day 20、Day 23、Day 25 就各自定好了。今天做的只是讓「API 掛掉」也走同一條收斂路徑。 好的預設值會在你意想不到的地方回報你。

第三關:降級模式,「不呼叫」跟「失敗才 fallback」差很多

這關我一開始想錯了。

我原本以為,只要每個輔助模組失敗時都回 fallback,額度用盡時系統自然就「優雅降級」了。錯。

假設當日額度用盡,每次呼叫都必定 429。那麼每一輪對話會發生什麼事?

triage    → 打 API → 429 → 重試 → 429 → 回 fallback   (2 次白打 + 等待)
主對話    → 打 API → 429 → 重試 → 429 → 重試 → 失敗   (3 次白打)
extractor → 打 API → 429 → 重試 → 429 → 回 fallback   (2 次白打)

功能上是「優雅」的,體驗上是災難:使用者每說一句話都要等 7 次必敗的請求跑完,才看到一句紅字。

所以降級必須是一個有狀態的模式,而不只是失敗處理:

DEGRADE_SECONDS = 300

def call_optional(fn, label, fallback, ...):
    if is_degraded():
        print(f"[resilience] 降級中,跳過 {label}({degraded_seconds_left()} 秒後恢復)")
        return fallback     # ← 連 API 都不打

而 429 一出現,就立刻進降級模式,不逐一重試:

except Exception as e:
    if is_quota_error(e):
        # 額度問題不必逐一重試:分流、評分、提取會連續撞同一面牆。
        # 直接進降級模式,把剩下的額度留給主對話
        mark_degraded(f"{label} 撞到額度上限 (429)")
        return fallback

這裡有個判斷不了的事實要承認:429 可能是「這一分鐘打太快」(等一下就好),也可能是「當日額度用盡」(等也沒用),從錯誤碼分不出來。 我們選擇一律當成後者處理,理由是:

  • 若其實只是 RPM 瞬間爆掉,代價是「接下來 5 分鐘沒有好感度變化」,可惜,但主對話還活著。
  • 若真的是額度用盡而系統還一直重試,代價是「每輪對話都要卡 7 秒才顯示失敗」,這種體驗使用者不會再回來。

代價不對稱時,往代價小的那邊倒。 這跟 Day 25 選去重門檻、Day 26 選檢索門檻是同一套思路。

降級狀態當然要讓使用者看得到,所以側邊欄也加了:

if resilience.is_degraded():
    st.warning(f"⚠️ 降級模式({resilience.degraded_seconds_left()} 秒後恢復)")
    st.caption(f"原因:{resilience.degraded_reason()}")
    st.caption("目前暫停:好感度評分、長期記憶提取、矛盾偵測")
    if st.button("立即恢復"):
        resilience.clear_degraded()
        st.rerun()

不講的話,使用者只會發現「今天講什麼好感度都不動」,以為程式壞了,實際上是刻意關掉評分在省額度。沉默的降級跟壞掉沒有區別。

第四關:把 4 次 API 變成 3 次

降級是被動防守,主動的做法是少打幾次。

看 router 跟 evaluator 這兩支:

  • 讀的是同一句話
  • 用的是同一個 lite 模型
  • 彼此的答案互不影響

分兩次呼叫等於同一段輸入被 tokenize 兩遍、往返兩趟。合成一次就少一次,這就是 triage.py:

_SCHEMA = {
    "type": "object",
    "properties": {
        "intent": {"type": "string", "enum": ["CHAT", "TASK"]},
        "affection_delta": {"type": "integer"},
    },
    "required": ["intent", "affection_delta"],
}

Prompt 刻意寫成「兩個彼此獨立的任務」並各自編號,而不是混成一段敘述:

【任務一:意圖分類 intent】
...
【任務二:好感度變化 affection_delta】
...
【重要】
兩個任務互不影響:TASK 不代表好感度必為 0,CHAT 也不代表一定是正分。
請分別依各自的判準作答。

最後那段「重要」是必要的。不寫的話模型很容易產生**任務間的污染,**判成 TASK 之後就順手把 delta 給 0,因為「算數學嘛,哪來的感情」。這推論在多數情況下剛好成立,但它是模型自己編出來的規則,不是我們的判準。

但這個合併有風險。 Day 20 量過 router 的分流準確率是 10/10,塞進第二個任務可能讓模型分心而分錯類。所以不能直接上,要 A/B。

第五關:A/B 量測,結果量測腳本自己把系統打爆了

measure_triage.py 對傳統 10 個案例,分別跑「分開呼叫」與「合併呼叫」,比對兩件事:intent 準確率(有標準答案)、delta 方向(分數沒有唯一正解,所以只比正負與量級)。

第一次跑,結果是這樣的:

唸書唸得好累                 CHAT/0  CHAT 0  OO   CHAT 2  OO
[resilience] 進入降級模式 300 秒:triage 撞到額度上限 (429)
你會做什麼甜點?             CHAT/0  CHAT 0  OO   CHAT 0  OO
[resilience] 降級中,跳過 router(299 秒後恢復)
[resilience] 降級中,跳過 evaluator(299 秒後恢復)
[resilience] 降級中,跳過 triage(299 秒後恢復)
你做的薄餅真的超好吃…          CHAT/+  CHAT 0  OX   CHAT 0  OX
...
----------------------------------------------------------------
intent 準確率              10/10        10/10
delta 方向一致              7/10         7/10

我今天剛寫好的降級機制,在量測我今天剛寫好的合併功能時,真的觸發了。

一個案例要連打 3 次 lite 模型(router + evaluator + triage),10 個案例 30 次,沒有節流,跑到第 5 案就撞 429。

而更該警惕的是那個 intent 準確率 10/10,它是假的。 降級之後兩邊都只回 fallback,而 fallback 恰好是 CHAT,剩下 5 個案例的正解也恰好都是 CHAT。於是它們「全對」,報告出一個漂亮但毫無意義的滿分。

如果我只看最後那張表就寫結論,我會得到一個完全正確的數字和一個完全錯誤的判斷。

所以量測腳本本身也得修,兩件事:

# 節流。第一次跑這支時沒有節流,量測腳本自己把系統壓垮,就量不到東西了
PACE_SECONDS = 8.0

# 降級一啟動,兩邊都只會回 fallback(CHAT / 0),
# 那不是「判斷結果」而是「沒判斷」。繼續跑只會產出漂亮的假數據,
# 所以直接中止,量測腳本最該避免的就是報告一個自己造成的結論
if resilience.is_degraded() or m["degraded"]:
    print(f"!! 第 {i + 1} 案時進入降級模式:{resilience.degraded_reason()}")
    print("!! 降級後兩邊都只回 fallback,數據已失真,本次量測作廢。")
    sys.exit(1)

這也是為什麼 triage() 的回傳值裡有一個 degraded 旗標,「我判斷不出來」跟「我判斷是 CHAT」必須能被分開。

加上節流重跑,這次是真的數據:

輸入                               正解    分開            合併
----------------------------------------------------------------------------
幫我算 17 乘 23                    TASK/0  TASK 0   OO    TASK 0   OO
幫我把這句話翻成英文:天氣很好     TASK/0  TASK 0   OO    TASK 0   OO
最近巴哈動畫瘋有什麼新番?         TASK/0  TASK 0   OO    TASK 0   OO
唸書唸得好累                       CHAT/0  CHAT 2   OO    CHAT 2   OO
你會做什麼甜點?                   CHAT/0  CHAT 0   OO    CHAT 4   OO
你做的薄餅真的超好吃,我從沒吃過   CHAT/+  CHAT 10  OO    CHAT 10  OO
你姊妹感情真好,看得出來你很重視   CHAT/+  CHAT 10  OO    CHAT 8   OO
你根本什麼都不會,煮的東西很難吃   CHAT/-  CHAT -11 OO    CHAT -9  OO
哈囉                               CHAT/0  CHAT 0   OO    CHAT 0   OO
我等一下要去補習                   CHAT/0  CHAT 0   OO    CHAT 0   OO
----------------------------------------------------------------------------
                                          分開            合併
intent 準確率                             10/10         10/10
delta 方向一致                             10/10         10/10
API 呼叫次數                               20 次          10 次
總耗時                                    36.6 秒        27.5 秒

每輪省下:1 次 API、0.91 秒
→ intent 準確率沒有退步,合併可用

值得注意的是:單次合併呼叫其實比單次分開呼叫「慢」(2.75 秒 vs 1.83 秒,因為 prompt 更長還要出 JSON),但總量還是省了。省的不是單次速度,是往返次數。

router.py 與 evaluator.py 我們沒有刪掉,A/B 需要對照組,而且它們就是這次「合併可用」這個結論的證據本身。

第六關:test_day27.py

測 API 失敗有個現實問題:要測「撞到 429 會怎樣」,不能靠真的把額度打爆,那還得等一小時才能再測一次。所以全部用注入的假錯誤:

def boom(code: int):
    """製造一個指定狀態碼的 API 錯誤"""
    cls = errors.ServerError if code >= 500 else errors.ClientError
    return cls(code, {"error": {"message": f"fake {code}"}})

class Flaky:
    """前 fail_times 次失敗,之後成功。用來數「到底重試了幾次」"""
    def __init__(self, code, fail_times):
        self.code, self.fail_times, self.calls = code, fail_times, 0

    def __call__(self):
        self.calls += 1
        if self.calls <= self.fail_times:
            raise boom(self.code)
        return f"成功(第 {self.calls} 次)"

base_delay 也調成 0.01 秒,不然光等退避就要跑好幾分鐘。八個案例:

【1】503 過載 → 退避重試後成功
  結果:成功(第 3 次)|總呼叫次數:3

【2】503 一直失敗 → 試滿 3 次才放棄並拋出
  拋出 ServerError 503|總呼叫次數:3(= MAX_ATTEMPTS)

【3】400 參數錯 → 一次就放棄,不重試
  拋出 ClientError 400|總呼叫次數:1

【4】輔助模組(call_optional)失敗 → 回 fallback,不拋出
  回傳:0(fallback)|總呼叫次數:2(輔助只試 2 次)

【5】429 額度上限 → 立刻進降級模式,不浪費次數重試
  回傳:None|總呼叫次數:1(沒有重試)
  降級中:True|原因:triage 撞到額度上限 (429)

【6】降級中 → 後續輔助呼叫完全跳過
  回傳:[]|總呼叫次數:0 ← 必須是 0

【7】降級中 → 主對話不受影響
  目前降級中:True
  主對話結果:成功(第 1 次)|呼叫次數:1

【8】降級有期限,不必手動解除
  剛標記:is_degraded = True(剩 1 秒)
  1.1 秒後:is_degraded = False

案例 3 和案例 6 是我最在意的兩個。

  • 案例 3 驗證「不該重試的不重試」,這種行為在功能上看不出來(反正都是失敗),只有數呼叫次數才驗得到。
  • 案例 6 的 總呼叫次數:0 則是整個降級設計的價值所在。如果這裡是 2,代表我只做出了「失敗才 fallback」,沒做出「降級」。
  • 案例 7 把降級的定義釘死:降級是「犧牲輔助、保住主線」,不是整個系統停擺。

今日更動

https://ithelp.ithome.com.tw/upload/images/20261011/20183877wj1eez6naC.png


上一篇
Day 26:【語料庫與檢索品質】不是給她更多知識,是教她「什麼時候不要開口」
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言