iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

前言

歡迎來到18天,以我個人來說是第二喜歡的啤酒!如果能邊喝邊寫就好了,但感覺會直接喝到忘記發文,想想還是算了。昨天我們做了相當簡單的改動讓我們的 AI 面試官有著短期的記憶,同時也重構了我們系統提示詞的部分,讓提示詞更兼用,未來在維護時比較不容易想殺了當時的自己!

今天我們要做的內容同樣相當簡單,我們要替我們的 AI 面試官做錯誤處理的機制,你也許會想「欸?不是早就有了嗎? 幾乎所有的請求我們都有做 try catch 和正確的錯誤提示, UI 上也做了調整讓正確的錯誤訊息能顯示不是嗎?」這點說得沒錯!現在即便 API 呼叫失敗我們也做了基本的處理,使用者不至於直接看到一個壞掉的頁面,以一般的情況來說這已經是一個可接受的處理方式了。然而,一個成熟的應用程式不僅要聰明可用,更要可靠。目前我們的系統高度依賴數個外部 API:Supabase (RAG)、Judge0 (程式碼執行) 和 Google Gemini (AI 模型)。這就像我們的面試官需要同時跟三個不同的部門(知識庫、實驗室、大腦)溝通才能完成工作。只要其中任何一個環節的網路連線不穩定,或是對方伺服器暫時打個盹,我們的 API 就會失敗,前端使用者看到的就是一個無情的錯誤訊息,只能重刷頁面或是稍後再來,雖然不是什麼大問題,但以軟體服務這種東西來說,一點點的不便都可能讓人走人,我們勢必要讓系統本身更可靠一些!

今日目標

  • 分析失敗情境:盤點在 /api/interview/evaluate 中,有哪些外部呼叫可能會失敗。
  • 建立重試輔助函式:撰寫一個通用的 retryAsyncFunction 函式,封裝基本的重試邏輯。
  • 整合重試機制:將新的重試函式應用到對 Judge0 和 Supabase RPC 的呼叫上。
  • 強化前端錯誤顯示:確保當後端窮盡所有努力後依然失敗時,前端能給使用者一個清晰且友善的錯誤提示。

Step 1: 分析失敗的情境

在動手寫程式碼前,我們先來當個偵探,找出 /api/interview/evaluate/route.ts 中所有可能因為網路問題而出錯的地方。仔細一看,主要有三個「嫌疑犯」:

  • RAG 查詢 (supabase.rpc):當我們為概念題尋找相關知識點時,需要呼叫 Supabase 的資料庫函式。這是一個網路請求。
  • 程式碼執行 (fetch 到 Judge0):當我們評估程式題時,需要呼叫自己的 /api/judge0/execute 代理,這個代理內部又會去 fetch Judge0 的 API。這也是一個網路請求。
  • AI 生成 (genAI.models.generateContentStream):這是最核心的呼叫,我們請求 Google Gemini 產生評估結果。這同樣是一個網路請求。
    其中,Gemini 的 SDK (@google/genai) 內部可能已經有自己的重試機制,所以我們今天先將重點放在我們 fetchsupabase.rpc 的呼叫上。

Step 2: 建立 retryAsyncFunction 輔助函式

每次遇到網路請求就寫一次 try...catch 和重試迴圈,會讓程式碼變得很臃腫。最好的方式是將這套邏輯封裝成一個可重用的輔助函式。
我們來建立一個 retryAsyncFunction 函式,它會接收一個 fetch 請求,並在失敗時自動重試幾次。重試的間隔時間會越來越長(例如 1s, 2s, 4s),這就是所謂的「指數退避」,可以避免在短時間內用同樣的請求轟炸已經有問題的伺服器。

app/api/interview/evaluate/route.ts 的頂部,加入這個新的輔助函式:

// app/api/interview/evaluate/route.ts

// ... (imports) ...

// --- 新增的通用重試輔助函式 ---
async function retryAsyncFunction<T>(
  asyncFn: () => Promise<T>,
  retries = 3,
  delay = 1000,
  onRetry?: (error: any, attempt: number) => void
): Promise<T> {
  for (let i = 0; i < retries; i++) {
    try {
      return await asyncFn();
    } catch (error) {
      if (onRetry) {
        onRetry(error, i + 1);
      }
      if (i === retries - 1) throw error;
      await new Promise((res) => setTimeout(res, delay * Math.pow(2, i)));
    }
  }
  // 迴圈結束後還是失敗,拋出錯誤 (理論上不會執行到這裡,但為求型別安全)
  throw new Error('Retry failed after multiple attempts.');
}

// ... (既有的 formatChatHistory 和 prompt 模板) ...

程式碼摘要說明

  • 泛型 :讓這個函式可以接受任何類型的非同步函式,並回傳對應的型別,非常靈活。
  • asyncFn: () => Promise:參數是一個函式,而不是一個值。這讓我們可以把 fetch 或 supabase.rpc 呼叫包在裡面傳進來。
  • onRetry 回呼函式:這是一個可選的參數,讓我們可以在每次重試時,印出日誌來追蹤狀況,對於 Debug 非常有幫助。

Step 3: 整合重試機制到 API 中

現在我們有了強大的 retryAsyncFunction 工具,可以來加固我們的 API 呼叫了。

  1. 加固 Judge0 的呼叫
    在 POST 函式中,找到處理程式題 (question.type === 'code') 的區塊,將 fetch 呼叫包裝起來。
// app/api/interview/evaluate/route.ts

// ...
} else if (question.type === 'code') {
  try {
    // 【修改點】使用 retryAsyncFunction 包裝 fetch 呼叫
    const judge0Response = await retryAsyncFunction(
      () => fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/judge0/execute`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ source_code: answer }),
      }),
      3, 1000, (error, attempt) => console.log(`Judge0 fetch attempt ${attempt} failed:`, error)
    );

    if (!judge0Response.ok) {
      // 如果重試後請求依然不成功 (例如 4xx 錯誤),也要優雅地處理
      judge0ResultText = `程式碼執行服務出錯: 狀態碼 ${judge0Response.status}`;
    } else {
      const judge0Result = await judge0Response.json();
      judge0ResultText = `Status: ${
        judge0Result.status?.description || 'N/A'
      }\nStdout: ${judge0Result.stdout || 'N/A'}\nStderr: ${
        judge0Result.stderr || 'N/A'
      }`;
    }
  } catch (error) {
    console.error('Judge0 call failed after all retries:', error);
    judge0ResultText = '程式碼執行服務暫時無法連線。';
  }
}
// ...
  1. 加固 Supabase RPC 的呼叫
    同樣地,在處理概念題的區塊,用 retryAsyncFunction 包裝 supabase.rpc 呼叫。
// app/api/interview/evaluate/route.ts

// ...
if (question.type === 'concept') {
  try {
    // ... (產生 embedding 的程式碼) ...

    // 【修改點】使用 retryAsyncFunction 包裝 RPC 呼叫
    const { data: ragData } = await retryAsyncFunction(
      async () => {
        const result = await supabase.rpc('match_documents', {
          query_embedding: answerEmbedding,
          match_threshold: 0.7,
          match_count: 5,
          p_question_id: questionId,
        });
        // 在這裡檢查 Supabase 回傳的 error 物件,如果存在就拋出
        if (result.error) throw new Error(result.error.message);
        return result;
      },
      3, 1000, (error, attempt) => console.log(`Supabase RPC attempt ${attempt} failed:`, error)
    );
    
    // 因為重試函式會處理錯誤,這裡就不需要再檢查 ragError
    ragContext =
      ragData?.length > 0
        ? ragData.map((d: { content: string }) => `- ${d.content}`).join('\n')
        : '在知識庫中找不到相關的參考資料。';

  } catch (error) {
    console.error('RAG search failed after all retries:', error);
    ragContext = '知識庫查詢服務暫時無法連線。';
  }
}
// ...

透過這些修改,我們不僅為外部呼叫加上了重試,還讓程式碼的錯誤處理邏輯更清晰、更集中。

Step 4: 確保前端的友善提示

這部分的程式碼原本就寫得還行,我們保持不變。當後端用盡所有重試機會、最終還是失敗並拋出錯誤時,我們的 POST 函式外層的 try...catch 會捕捉到這個錯誤,並回傳 500。

前端的 handleSubmit 中的 catch 區塊會捕捉到這個失敗的請求,並顯示我們設定好的友善提示。

// interview/[sessionId]/page.tsx

// ...
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    // ... (處理取消) ...
  } else {
    // 【關鍵】處理所有其他錯誤 (網路錯誤、API 5xx 等)
    console.error('Error in handleSubmit:', error);
    setChatHistory((prev) => {
      const newHistory = [...prev];
      const lastMessage = newHistory[newHistory.length - 1];
      if (lastMessage && lastMessage.role === 'ai') {
        // 提供一個通用的、友善的錯誤訊息
        lastMessage.content =
          '抱歉,我現在遇到一點技術問題,無法提供回饋。請稍後再試一次。';
      }
      return newHistory;
    });
  }
} finally {
  // ...
}

今日回顧

今天,我們從一個「樂天派」開發者,轉變成了一個考慮周全的「穩健派」工程師。

✅ 分析了風險:我們識別了 API 中所有可能因網路問題而失敗的環節。
✅ 打造了工具:我們編寫了通用的 retryAsyncFunction 輔助函式,將重試邏輯模組化。
✅ 強化了後端:我們將重試機制應用到了對 Judge0 和 Supabase 的呼叫上,大幅提升了 API 的可靠性。
✅ 改善了前端:我們確保了在後端最終失敗時,前端也能提供清晰、友善的錯誤回饋。

明日預告

第三週的技術核心(RAG, Judge0, Streaming, Context, Retry)已經全部到位!我們的 AI 面試官現在既聰明、反應快(當然,撇掉API請求還是慢到哭的部分),又相當可靠。

這幾天的內容都挺輕鬆的吧? 我承認我在安排撰寫企劃時在這周排得太過於輕鬆了,畢竟都是不熟悉的東西,當時以為會碰到更多的問題所以多給了一點緩衝,回頭來看就會覺得挺水的,我很抱歉~! 不過之後幾天的內容也不會太高強度,原本就是了為了在邊做邊學的中間留一點空擋去處理之前文章沒處理到的部分,因次後續幾天的內容會首先圍繞在目前程式碼的一些優化上,把惱人的問題先解決一些掉,接著我們再進入最後功能的開發階段!

我們明天見!


上一篇
為 AI 植入短期記憶 :實作對話上下文
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言