工商時間

這個系列講的是「AI 功能上線之後」會遇到的事。而在那之前,你得先有一套真的跑得起來的系統。

《AI 賦能全端開發:從零打造企業級智慧應用》:用同一套 AI CRM 專案,從 Spring Boot 後端、React 前端到資料庫與權限,一路加上 Spring AI、RAG 與 AI Agent,完成真正能上線的企業級應用。

👉 前往 Hahow 課程頁

事件回得了前端:進度、失敗與死信

前四期把事件的路鋪完了:解耦(E0)、非同步(E1)、送得出去(E2)、收得下來(E3)。

系統現在很穩:存檔立刻回應、交易失敗不會亂送、當機不會遺失、重送不會重跑。

然後你把畫面打開,發現一件事——使用者完全不知道發生了什麼。

按下儲存,畫面立刻回來了。摘要區塊空空的。過三十秒,還是空的(因為前端不會自己重新查)。使用者重新整理一次,摘要出現了。

他不知道要等,也不知道等多久,更不知道到底成功了沒有。

我們拿掉了等待,也拿掉了感知

同步有一個被低估的好處:它的進度回報是免費的。

畫面在轉圈圈,就代表還在跑;轉圈停了、內容出現,就是成功;跳出錯誤訊息,就是失敗。使用者不需要學任何東西就懂。

改成非同步之後,這三件事全部消失了:

使用者想知道 同步時 非同步後
現在在跑嗎 看轉圈圈 不知道
成功了嗎 內容出現 不知道
失敗了嗎 跳錯誤訊息 不知道

而且第三項最糟:失敗的時候,畫面跟「還沒跑完」長得一模一樣。使用者會一直等一個永遠不會來的東西。

這期就是把這三件事補回來。而且好消息是——上期我們已經把地基打好了。

狀態、進度、放棄

一、狀態機:先讓系統自己知道

上期為了做冪等,我們建了一張表記錄哪些事件處理過。當時多存了幾個欄位,現在派上用場:

create table event_tasks (
    event_id     varchar(64) primary key,
    status       varchar(20) not null,     -- PENDING / RUNNING / DONE / FAILED
    attempts     int         not null default 0,
    last_error   varchar(500),
    updated_at   timestamp   not null
);

四個狀態,一條路徑:

  • PENDING:已登記,還沒開始
  • RUNNING:正在呼叫 LLM
  • DONE:成功了
  • FAILED:重試到上限,放棄了

關鍵原則是只進不退。已經 DONE 的任務不可以再被改回 RUNNING——這條規則會擋掉「重送的舊事件把新結果蓋掉」這類極難查的問題。實作上就是在更新時加條件:

update event_tasks set status = 'RUNNING'
 where event_id = ? and status = 'PENDING'

更新到 0 筆,就代表這個事件不該由你處理,直接跳過。

任務狀態機:PENDING(已登記還沒開始)→ RUNNING(正在呼叫 LLM)→ 分為 DONE(成功)與 FAILED(重試到上限放棄),FAILED 再進入死信表(查得到、可人工重跑);DONE 不可退回 RUNNING,靠 update … where status = 'PENDING' 更新到 0 筆就跳過來保證只進不退

二、把狀態送到畫面上

有了狀態,前端就有東西可問了。最簡單的做法是輪詢:畫面每兩秒問一次「好了沒」。

但這個系列的讀者應該記得 009 期——這正是 SSE 的主場:一條不掛斷的 HTTP,伺服器有進度就推一次。

@GetMapping(value = "/api/customers/{id}/summary-status",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter status(@PathVariable Long id) {
    SseEmitter emitter = new SseEmitter(60_000L);   // 一分鐘沒結果就讓前端重連
    statusBroadcaster.register(id, emitter);
    return emitter;
}

然後在狀態每次變更的地方推一則事件出去。前端收到 DONE 就把摘要抓回來、收到 FAILED 就顯示錯誤與重試按鈕。

這裡有個很容易忽略的細節:連線建立的當下要先推一次目前狀態。否則使用者重新整理頁面時,如果任務早就跑完了,SSE 之後不會再有任何事件,畫面就會永遠停在「處理中」。

三、失敗要退,但不能退到負數

呼叫 LLM 會失敗——限流、逾時、回傳格式不對。失敗就重試,但不能立刻重試(對方正在喘氣,你馬上再打一次只會更糟)。

標準做法是指數退避:每次失敗就把等待時間加倍,並且設上限。

private static final long BASE_MS = 1000;
private static final long MAX_MS  = 30_000;

/** 指數退避:先把指數夾在安全範圍,再位移 */
long backoffMillis(int attempt) {
    int safe = Math.min(attempt, 30);
    long delay = BASE_MS << (safe - 1);
    return Math.min(delay, MAX_MS);
}

實測前八次:

B1 退避序列 1s→2s→4s→8s→16s→30s(碰上限後維持)
   實際=[1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]

那個 Math.min(attempt, 30) 看起來多餘,其實是整段程式最重要的一行。

如果沒有它,直接寫 BASE_MS * (1L << (attempt - 1)),當 attempts 一路累加上去,位移就會溢位:

B2 反例——沒夾住指數時退避會歸零或變負(等於完全不等)
   backoffUnsafe(55)=-432345564227567616 / backoffUnsafe(64)=0 毫秒

指數退避與溢位反例:正確版的退避長條依 1s、2s、4s、8s、16s 遞增,第 6 次之後碰到 30 秒上限維持不變;反例則是沒夾住指數時,attempt=55 退避變成 −4.3×10¹⁷ 毫秒、attempt=64 退避變成 0 毫秒,兩者都等於完全不等

負的退避、零的退避,效果一樣:完全不等,立刻重試。你以為自己寫了一個愈退愈慢的保護,實際上它會在某個時間點突然變成全速重試風暴——而且是對著一個已經在出問題的下游。

夾住之後就穩定了:

B3 正確版同樣的 attempt 仍回傳上限,不會歸零或變負
   backoffMillis(55)=30000 / backoffMillis(64)=30000 毫秒

(順帶一提:如果你有設重試上限——下一段就要講——attempts 根本不會長到 55。這兩件事是互相保護的:上限擋住了溢位,夾指數則擋住「有人不小心把上限拿掉」的那一天。)

四、死信:承認放棄,而不是無限重試

重試不能永遠試下去。試到某個次數還是失敗,就該停手,把它搬到一個專門的地方:

if (attempts >= MAX_ATTEMPTS) {
    jdbc.update("insert into dead_letters(event_id, attempts, last_error) values (?, ?, ?)",
                eventId, attempts, lastError);
    jdbc.update("delete from event_tasks where event_id = ?", eventId);
}

實測(上限設 3 次):

B4 重試達上限後移入死信,且不再被撈出
   待處理=0 / 死信=1 / 死信中記錄的嘗試次數=3

死信表的價值不在於「存放垃圾」,而在於它把一件事變成看得見的:

  • 有幾筆任務徹底失敗了?select count(*) from dead_letters
  • 為什麼失敗?last_error 裡有
  • 誰受影響?event_id 查得回去

沒有死信表的系統,失敗的任務要嘛無限重試(消耗資源、洗版 log),要嘛安靜消失。兩種都不好,因為你不會知道它發生過。

這也呼應了 E1 講 @Async 例外被吞掉時說的那句話:例外沒有消失,只是換了去處。死信表就是替它準備一個你會去看的去處。

最後一步是人:死信不該只是躺在那裡。後台給一個列表、一顆「重新處理」按鈕,讓人看過原因之後決定要不要再跑一次。自動化處理不了的事,就明確地交給人——這比假裝系統全自動要誠實得多。

🤖 檢查自己專案的非同步任務有沒有這三層,可以把這句丟給 AI:

找出這個專案裡所有非同步執行的任務(@Async、訊息佇列消費者、排程)。
逐一回答:
1. 這個任務有沒有狀態紀錄?使用者或後台查得到「現在跑到哪」嗎?
2. 失敗時有沒有重試?重試間隔是固定的還是指數退避?有沒有上限?
3. 重試到上限之後,失敗的任務去了哪裡?有沒有地方可以查、可以人工重跑?
把三項都缺的標為高風險。先不要改程式碼。

下期預告

系列走到這裡,事件的路已經完整了:送得出去、收得下來、回得了前端。

最後一期要換個角度看同一件事。

前幾期我們一直在講「怎麼要求 AI 交出可以被審查的產物」。但反過來——Agent 自己跑的每一步,誰來審查?

它讀了哪些資料、用了哪個提示、呼叫了哪些工具、花了多少 token、為什麼最後給出這個答案?如果客戶問「這份摘要是怎麼來的」,你答得出來嗎?

下期,也是這個系列的最後一期:把事件流變成 Agent 的稽核軌跡。

—— 凱文大叔


延伸閱讀: