歡迎來到第十五天!就...就快了,突發的事情就快處理完讓我能花時間寫文章了,回頭看了一下從Day 12開始的文章我差點吐了,錯誤相當的多,驗證也做得不足,請 AI 產出的部分也基本上不能用,我有利用手邊零碎的時間一天天的修正了,但我還是得在完賽前把第二週後半的東西重寫一次,真的很抱歉~! OK,抱怨跟道歉結束!昨天是我們專案第二週的收尾,我們將目前手邊的工具整合起來並串接到前端的頁面,雖然目前前端的頁面還是塞了大量的假資料以及有一些顯示邏輯的問題我們還沒整理完,但勉勉強強算是把東西包在一起可用了。
我們現在的功能在回覆上已經能做到相當不錯的水準,但目前的體驗並不是很好,最大的問題還是在於等待的時間過久,我們甚至不知道 AI 現在回覆的進度到哪,不管使用者提交程式碼或概念問答後,都必須花相當長的時間盯著空白的畫面空等,直到我們的三大工具RAG、Judge0、Gemini 全部完成工作,整個 JSON 結構生成完畢後,才能看到結果。這體驗就像是在等一個超大檔案下載完成,進度條一動也不動,讓人焦慮,模型回覆較慢的部分當然跟我們是免費仔有點關係,但至少體驗的部分我們可以做點優化吧!
今天,我們要解決這個體驗問題。我們要引入 Streaming (串流) 技術,把 AI 的回覆從「一次性厚重報告」變成「即時打字對話」。就像看 YouTube 不用等整部影片下載完一樣,使用者可以即時看到 AI 正在逐字逐句地生成分析,大幅提升互動感與體感速度。
要實現串流,我們的第一刀要砍向後端的 Gemini 呼叫。@google/genai SDK 提供了兩種生成內容的方法:
這是一個標準的 async/await 函式。它會發出請求,然後等待 Gemini 生成全部內容後,一次性地將完整結果回傳。這就是我們昨天用的方法,簡單直接,但會造成等待。
這個方法同樣會發出請求,但它會立刻回傳一個非同步迭代器 (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' },
});
後端的水管接好了,前端也要換上對應的水龍頭。原本使用 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 對話框。這提供了即時的視覺回饋,讓使用者知道系統已經收到了他們的請求並正在處理,大幅降低了等待的焦慮感。
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;
});
}
// 串流結束,最後一次更新,並解析完整的 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) {
// ... 錯誤處理 ...
}
透過以上三步,我們成功地將前端從一個被動的資料接收者,轉變為一個能夠與後端串流即時互動的動態介面,顯著提升了「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