iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0

前言

歡迎來到第十五天!就...就快了,突發的事情就快處理完讓我能花時間寫文章了,回頭看了一下從Day 12開始的文章我差點吐了,錯誤相當的多,驗證也做得不足,請 AI 產出的部分也基本上不能用,我有利用手邊零碎的時間一天天的修正了,但我還是得在完賽前把第二週後半的東西重寫一次,真的很抱歉~! OK,抱怨跟道歉結束!昨天是我們專案第二週的收尾,我們將目前手邊的工具整合起來並串接到前端的頁面,雖然目前前端的頁面還是塞了大量的假資料以及有一些顯示邏輯的問題我們還沒整理完,但勉勉強強算是把東西包在一起可用了。

我們現在的功能在回覆上已經能做到相當不錯的水準,但目前的體驗並不是很好,最大的問題還是在於等待的時間過久,我們甚至不知道 AI 現在回覆的進度到哪,不管使用者提交程式碼或概念問答後,都必須花相當長的時間盯著空白的畫面空等,直到我們的三大工具RAG、Judge0、Gemini 全部完成工作,整個 JSON 結構生成完畢後,才能看到結果。這體驗就像是在等一個超大檔案下載完成,進度條一動也不動,讓人焦慮,模型回覆較慢的部分當然跟我們是免費仔有點關係,但至少體驗的部分我們可以做點優化吧!

今天,我們要解決這個體驗問題。我們要引入 Streaming (串流) 技術,把 AI 的回覆從「一次性厚重報告」變成「即時打字對話」。就像看 YouTube 不用等整部影片下載完一樣,使用者可以即時看到 AI 正在逐字逐句地生成分析,大幅提升互動感與體感速度。

今日目標

  • 將 Day 14 的單次請求 API,升級為支援 Streaming 的版本。
  • 學習並使用 @google/genai SDK 中專為串流設計的 generateContentStream 方法。
  • 實作一個能回傳 ReadableStream 的 Next.js API Route。
  • 在前端(概念上)學會如何消費 (consume) 串流,並即時渲染 AI 的回覆。

Step 1: 從後端開始 - 將 generateContent 換成 generateContentStream

要實現串流,我們的第一刀要砍向後端的 Gemini 呼叫。@google/genai SDK 提供了兩種生成內容的方法:

generateContent:

這是一個標準的 async/await 函式。它會發出請求,然後等待 Gemini 生成全部內容後,一次性地將完整結果回傳。這就是我們昨天用的方法,簡單直接,但會造成等待。

generateContentStream:

這個方法同樣會發出請求,但它會立刻回傳一個非同步迭代器 (Async Iterator)。我們可以透過 for await...of 迴圈,在 Gemini 生成每一小塊 (chunk) 內容時就即時取得它,而不用等全部生成完。
我們的策略是建立一個新的 API 端點,專門處理串流請求。這讓我們的架構更清晰,也能保留舊的 API 做為備用或測試。

讓我們修改昨天整合好的 api 端點,要做的修改不算多,請你拉到最後回應的部分即可,完整程式碼請看最後的commit分支連結。


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

// 上方 RAG 之類的邏輯完全省略
const result = await genAI.models.generateContentStream({
      model: 'gemini-2.5-flash',
      contents: contents,
      config: {
        responseMimeType: 'application/json',
      },
    });

    const stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        for await (const chunk of result) {
          // 確保我們只傳遞有內容的文字部分
          const text = chunk.text;
          if (text) {
            controller.enqueue(encoder.encode(text));
          }
        }
        controller.close();
      },
    });
    return new Response(stream, {
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
    });

程式碼摘要說明:

  • 呼叫 generateContentStream:我們將核心呼叫從 generateContent 換成了 generateContentStream。這一步是啟動串流魔法的關鍵。
  • 建立 ReadableStream:Next.js API Route 可以直接回傳一個 Response 物件。我們利用 Web 標準的 ReadableStream API 來建立一個可以被前端 fetch 接收的串流。
  • start(controller):這是 ReadableStream 的建構函式。當前端開始讀取這個流時,這裡的程式碼就會執行。
  • for await (const chunk of result):我們遍歷從 Gemini 來的串流。chunk 是 Gemini 回傳的每一小塊資料。
  • controller.enqueue(encoder.encode(text)):TextEncoder 將文字字串轉換成瀏覽器串流需要的 Uint8Array 格式。enqueue 就像是把這一小塊資料「塞進」通往前端的水管裡。
  • controller.close():當 Gemini 的串流結束後,我們也關閉通往前端的水管,告訴它:「沒資料了!」
  • 回傳 Response:我們直接回傳這個 stream。

Step 2: 迎接前端的改變 - 如何「喝」串流

後端的水管接好了,前端也要換上對應的水龍頭。原本使用 await response.json() 的方式行不通了,因為它會試圖等待整個回應結束再一次性解析,這就失去了串流的意義。
我們需要使用 response.body.getReader() 來逐塊讀取。

請你修改我們在 interview/[sessionId]/page.tsx檔案中的handleSubmit函數,完整貼上以下的程式碼即可,其餘的部分都不用修改:

  const handleSubmit = async () => {
    if (!answer || !currentQuestion) return;

    // 1. 立刻更新 UI,包含使用者的回答和一個 AI 的空回應框
    const userMessage: ChatMessage = { role: 'user', content: answer };
    const aiPlaceholderMessage: ChatMessage = { role: 'ai', content: '' };

    // 更新 chatHistory,讓 UI 即時反應
    setChatHistory((prevHistory) => [
      ...prevHistory,
      userMessage,
      aiPlaceholderMessage,
    ]);

    setAnswer(''); // 清空輸入框
    setIsLoading(true);

    try {
      const response = await fetch('/api/interview/evaluate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          questionId: currentQuestion.id,
          answer: answer,
          userId: 'anonymous-user',
        }),
      });

      if (!response.ok) {
        throw new Error(`API request failed with status ${response.status}`);
      }
      if (!response.body) {
        throw new Error('Response body is null');
      }

      // --- 串流處理邏輯 ---
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let accumulatedResponse = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          // 串流結束,最後一次更新,並解析完整的 JSON
          try {
            const finalJson = JSON.parse(accumulatedResponse);
            setChatHistory((prevHistory) => {
              const newHistory = [...prevHistory];
              newHistory[newHistory.length - 1] = {
                role: 'ai',
                content: finalJson.summary || accumulatedResponse, // 若有問題則使用原始文字
                evaluation: finalJson,
              };
              return newHistory;
            });
          } catch (e: unknown) {
            console.error('無法解析最終的 JSON 字串:', accumulatedResponse);
            if (e instanceof Error) {
              console.error('錯誤訊息:', e.message);
            }
            // 如果解析失敗,至少保留原始文字流
            setChatHistory((prevHistory) => {
              const newHistory = [...prevHistory];
              newHistory[newHistory.length - 1].content =
                accumulatedResponse + '\n\n[AI 回應格式錯誤]';
              return newHistory;
            });
          }
          break;
        }

        // 持續解碼並更新最後一條 AI 訊息的 content
        accumulatedResponse += decoder.decode(value, { stream: true });
        setChatHistory((prevHistory) => {
          const newHistory = [...prevHistory];
          newHistory[newHistory.length - 1].content = accumulatedResponse;
          return newHistory;
        });
      }
    } catch (error) {
      console.error('錯誤:', error);
      // 更新最後一條 AI 訊息為錯誤提示
      setChatHistory((prevHistory) => {
        const newHistory = [...prevHistory];
        newHistory[newHistory.length - 1].content =
          '抱歉,我現在無法提供回饋,請稍後再試。';
        return newHistory;
      });
    } finally {
      setIsLoading(false);
    }
  };

程式碼摘要說明:

這次的改動比較多,稍微有些複雜,前端部分的改動我會一段段說明新的函數究竟發生什麼事情了。

1.樂觀更新 (Optimistic UI)
在發送 API 請求之前,我們就先更新了 chatHistory 狀態:

// 1. 立刻更新 UI,包含使用者的回答和一個 AI 的空回應框
const userMessage: ChatMessage = { role: 'user', content: answer };
const aiPlaceholderMessage: ChatMessage = { role: 'ai', content: '' };

// 更新 chatHistory,讓 UI 即時反應
setChatHistory((prevHistory) => [
  ...prevHistory,
  userMessage,
  aiPlaceholderMessage,
]);

使用者點擊送出後,他們的答案會立即顯示在聊天視窗中,同時出現一個空的 AI 對話框。這提供了即時的視覺回饋,讓使用者知道系統已經收到了他們的請求並正在處理,大幅降低了等待的焦慮感。

  1. 串流消費 (Stream Consumption)
    我們用一個 while 迴圈和 ReadableStreamDefaultReader 來取代了原本簡單的 await response.json():
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let accumulatedResponse = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) {
    // ... 串流結束後的處理 ...
    break;
  }

  // 持續解碼並更新最後一條 AI 訊息的 content
  accumulatedResponse += decoder.decode(value, { stream: true });
  setChatHistory((prevHistory) => {
    const newHistory = [...prevHistory];
    newHistory[newHistory.length - 1].content = accumulatedResponse;
    return newHistory;
  });
}
  • 核心邏輯:這個迴圈會持續從後端拉取資料 chunk (value)。每收到一塊,就用 TextDecoder 將其解碼成文字,並附加到 accumulatedResponse 字串上。
  • 即時渲染:最關鍵的是,在迴圈的每一次迭代中,我們都呼叫 setChatHistory 來更新畫面。我們找到 chatHistory 中的最後一則訊息(也就是 AI 的佔位符),並將其 content 更新為目前累積收到的所有文字。這就實現了 AI 回覆文字「一段段」即時出現的效果。
  1. 最終解析與資料整合
    當串流結束時 (done 為 true),我們需要處理收到的完整資料:
    TypeScript
// 串流結束,最後一次更新,並解析完整的 JSON
try {
  const finalJson = JSON.parse(accumulatedResponse);
  setChatHistory((prevHistory) => {
    const newHistory = [...prevHistory];
    newHistory[newHistory.length - 1] = {
      role: 'ai',
      content: finalJson.summary || accumulatedResponse,
      evaluation: finalJson,
    };
    return newHistory;
  });
} catch (e) {
  // ... 錯誤處理 ...
}
  • 從文字到物件:此時的 accumulatedResponse 是一個完整的 JSON 字串。我們使用 JSON.parse() 將其轉換為一個結構化的 JavaScript 物件。
  • 資料注入:我們最後一次更新 AI 的訊息,這次不僅用 JSON 中的 summary 欄位來作為最終的顯示內容,更重要的是,將完整的 finalJson 物件存儲在 evaluation 欄位中。這為我們後續可能需要的詳細報告、分數展示等功能鋪平了道路。

透過以上三步,我們成功地將前端從一個被動的資料接收者,轉變為一個能夠與後端串流即時互動的動態介面,顯著提升了「AI 前端面試官」的專業感與使用者體驗,修改完成後,再次輸入

npm run dev

並移駕到http://localhost:3000/interview/js-core頁面隨便回答個問題,你會發現雖然API請求時間還是非常的長,但至少它終於回應時我們可以不用等到完整的訊息都跑完才看到,畫面上會有一段一段出現的訊息,彷彿他真的在輸入文字一樣!(當然,目前根本不算是輸入文字,超級卡)

今日回顧

今天我們稍稍優化了昨天新增的整合 API,從一次性的給予所有回覆改為 stream 的作法,大致上我們做了以下內容:
✅ 我們學會了 @google/genai 中 generateContentStream 的用法,從源頭開啟了串流。
✅ 我們成功實作了一個能回傳 ReadableStream 的 Next.js API Route,搭建了從後端到前端的即時數據管道。
✅ 我們掌握了在前端使用 fetch、getReader() 和 TextDecoder 來消費串流資料的標準模式。
✅ 我們讓 AI 的回覆從一個緩慢的「報告」,變成了一場即時的「對話」,稍稍改善了使用者體驗。

明日預告

我們的串流現在能跑了,但還是有蠻多問題的,先不論本身回應的速度,這與模型的選用和免費仔有關,但目前的體驗仍稱不上好,更遑論他還有些「天真」。它像一輛沒有煞車和儀表板的賽車——跑得很快,但不太可控。如果使用者想中途取消生成怎麼辦?如果串流到一半發生錯誤怎麼辦?

明天 (Day 16),我們將為這個最小可行的串流進行「專業級強化」。我們將引入 AbortController 讓使用者可以隨時取消請求,並學習更優雅的錯誤處理機制,讓我們的串流體驗更加穩健、可靠,行有餘力的話則會花點工夫在前端的顯示上下一點小小的優化。

我們明天見!

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-15


上一篇
AI 的文理雙全:整合 RAG 與 Judge0,強制輸出結構化 JSON
下一篇
Streaming 優化:AbortController、錯誤處理與打字機效果
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言