Serverless 上的長連線:CareerWise 用 SSE 傳送 AI 的回答
12 Aug 2026
CareerWise 是一個 AI 職涯諮詢的對話產品:使用者描述現況與想達成的目標,Agent 會自動規劃職涯目標、呼叫工具分析履歷,再用對話的方式一步步給出建議。特色是它不是一次性的 Q&A,而是會持續追蹤目標進度、把使用者的特質更新回 profile。
而這篇文章想要探討的,是那條把 AI 的回答一段段送到畫面上的長連線。
每次問答都要等很久,才發現使用者已經走了
假設一個場景:在 AI 職涯諮詢的對話頁,使用者送出一個問題,畫面卡在「思考中…」,十秒、二十秒過去,長長的答案一次跳出來。在等待時使用者不知道系統到底有沒有在跑,可能以為當機了。
等待本身不可怕,可怕的是一切照舊。所以 CareerWise 的聊天功能,有個很直接的需求:讓使用者還沒看完第一行,就先看到答案開始跑出來。而這個需求,在技術選型上把我們帶向 SSE(Server-Sent Events)。
串流這條線,從哪邊開始
把整條線畫出來,CareerWise 的對話串流大概是這樣:
使用者 Next.js Route Handler Groq (LLM)
│ POST /api/chat │ │
│─────────────────>│ verifyAuth(token) │
│ │───────────────────────────────────>│
│ │ stream: true(逐 token 吐) │
│ <── SSE data: "..." <── ReadableStream 包裝 ←───────┘
│ <── SSE data: "..." <── 一段一段事件
│ <── SSE data: [DONE]
Server 端(src/app/api/chat/route.ts)把 Groq 的 stream 接到一個 ReadableStream,再把每個事件以 data: ...\n\n 的格式放進串流,一起跟著 response 傳給瀏覽器:
export const runtime = "nodejs"
const encoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
for await (const event of getReplyStream(message, history, opts)) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
},
})
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
})
選 runtime = "nodejs",是因為串流需要比較長的 Request 生命週期;Next.js Route Handler 直接回 Response 搭配 ReadableStream,就能把事件一路流到瀏覽器。
事件不是只有文字,還有「狀態」
CareerWise 的串流不是只有文字。答案是 LLM 生成的,但生成過程裡還夾著「現在在幹嘛」的訊號。Server 端用 getReplyStream 這個 async generator,把整個流程交錯成好幾種不同型別的結構化事件,一次 yield 給外層。
先定義事件長什麼樣子——每個事件只是 { type, ...payload } 的物件:
type SSEEvent =
| { type: "text"; content: string }
| { type: "status"; message: string }
| { type: "discovery_active"; active: boolean }
| { type: "goal_decomposed"; decomposition: Goal[] }
| { type: "smart_goal"; goal: SmartGoal }
| { type: "profile_update"; traits: string[] }
// src/lib/getReplyStream.ts(簡化,只留核心骨架)
// goal / smartGoal / inferredTraits 等示意變數,實作時各自從工具結果或 LLM 回饋的物件產生
// 實際流程會是「分析→工具往返→LLM 生成→收尾」,這裡省略工具結果對目標推算的影響
type ToolResult = { summary: string; source: string }
const runTool = async (tools: ChatOptions["tools"], msg: string): Promise<ToolResult> => {
...
}
export async function* getReplyStream(
message: string,
history: ChatMessage[],
opts: ChatOptions,
): AsyncGenerator<SSEEvent> {
// 1. 先發一個「開始做事」的狀態事件
yield { type: "status", message: "正在分析你的履歷…" }
// 2. 需要呼叫工具時,工具結果也另發一個狀態事件
if (opts.tools?.length) {
yield { type: "status", message: "正在執行:履歷檢視工具…" }
const toolResult = await runTool(opts.tools, message)
...
}
// 3. 目標探索階段
if (isGoalDiscovering(history)) {
yield { type: "discovery_active", active: true }
}
// 4. 真正把 LLM 的 stream 一段一段轉成 text 事件
const llm = await groq.chat.completions.create({
messages: [...history, { role: "user", content: message }],
model: MODEL,
stream: true,
})
for await (const chunk of llm) {
yield { type: "text", content: chunk.choices[0]?.delta?.content ?? "" }
}
// 5. 收尾:整理出目標或特質的事件
yield { type: "goal_decomposed", decomposition: goal }
yield { type: "smart_goal", goal: smartGoal }
yield { type: "profile_update", traits: inferredTraits }
}
外層的 ReadableStream 只是在 for await,把每個 yield 出來的事件原封不動變成 data: ... 傳出去,它不知道、也不需要知道事件裡面是什麼。
Client 端則反過來,依 type 分派到不同的 UI 動作:
// src/lib/useSSEChat.ts(簡化)
export async function connectSSE(res: Response, callbacks) {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ""
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 以「\n\n」切成完整事件,最後一段若被截斷,留到下次 chunk 再接上
const events = buffer.split("\n\n")
buffer = events.pop() ?? ""
for (const raw of events) {
const data = raw.replace(/^data: /, "")
if (data === "[DONE]") return content
const parsed = JSON.parse(data)
if (parsed.type === "text") {
content += parsed.content
callbacks.onText?.(content)
} else if (parsed.type === "status") {
callbacks.onStatus?.(parsed) // 讓上層顯示「工具執行中」
} else if (parsed.type === "goal_decomposed") {
callbacks.onGoalDecomposed?.(parsed.decomposition)
}
}
}
return content
}
這個「async generator 交錯產出事件、client 依型別分派」的設計,讓「文字」和「過程狀態」共用同一條串流:答案逐字出現的同時,狀態訊息也在隨時更新。通訊協定只負責把物件搬過去,語意留在兩端各自解讀。
為什麼不用瀏覽器原本的 EventSource?因為 CareerWise 的請求是 POST,而且帶 Firebase 的 Authorization header,EventSource 只能傳 GET 也沒有 header 可以掛,所以改用 fetch + Response.body 自己解析。這算是「沿用 SSE 的線格式、套用 fetch 讀流」的變種。
這個變種的好處不只是「解決 POST 與 header」:
- 認證資訊完整:
Authorizationheader 直接跟著 request 走,不需要把 token 塞進 query string(那會留在 log 裡)。 - 可控制性更高:
fetch是標準 Promise API,可以自己決定 timeout、前置verifyAuth、錯誤時取消,EventSource只會默默重連。 - 中斷時機自己掌握:
[DONE]或錯誤發生時主動reader.cancel(),不像EventSource靠關閉連線。 - 保留 HTTP 穿透性:本質上仍是純 HTTP,proxy、CDN、負載平衡、curl 除錯都照樣可用。
換句話說,這條串流保留了 SSE「純 HTTP、單向、可分段傳輸」的精神,但把傳輸的控制權拿回自己手上,又能帶上認證資訊。
Server 端在對話流程的不同階段掛上不同事件,Client 收到就各做各的事:
| 事件型態 | 產生情境 | Client 動作 |
|---|---|---|
text |
LLM 逐 token 吐出的答案 | 累加到對話氣泡 |
status |
LLM 執行工具 / 整理答案 | 塞進 Agent 活動流 |
goal_decomposed |
自動探討職涯目標 | 記到 Firestore goals |
discovery_active |
正在探索目標 | 顯示「探索中」按鈕 |
smart_goal |
完成 SMART 目標 | 儲存並更新目標面板 |
profile_update |
分析出使用者特質 | 寫回 profile |
強調一件事:這些事件不是同一個訊息格式換內容,而是真正不同的資料形狀。text 帶 content 字串、goal_decomposed 帶 decomposition 陣列、smart_goal 帶整個目標物件。Client 的 if / else 分派,其實是在替這些不同形狀的資料找各自的歸宿。上表是完整的事件清單,前面程式碼為了篇幅,只示範了其中三種的處理。
這樣接,使用者看到了什麼
對話框的使用者體驗,其實是被這條串流塑造的:
對話框(使用者看到的畫面)
┌─────────────────────────────────────────┐
│ 我:我想轉職成前端工程師,該怎麼規劃? │
├─────────────────────────────────────────┤
│ 🤖 正在分析你的履歷… ← status │
│ 🤖 正在執行:履歷檢視工具… ← status │
├─────────────────────────────────────────┤
│ 🤖 轉職前端的第一步,可以先從 │
│ JavaScript 最重要的核心概念 │ ← text 逐字累積
│ 開始掌握。接著建議…… │
└─────────────────────────────────────────┘
文字一段一段出現,使用者會看到「它正在變長」,等待變成可預期的過程。而 status 事件把「思考中」三個字變成有進度的 Agent 活動流,等待被拆成短暫又看得懂的單位。
profile_update 這類「偷偷發生在背景」的事件,UI 上要更謹慎:它改寫的是使用者身上的特質標籤,不能無預警地整塊替換,否則使用者會覺得「我什麼都沒做,資料怎麼變了」。作法是把即時更新的標籤加上過渡動畫、再把特質面板的儲存時機與這個事件對齊,讓更新發生在對話進行的節奏裡,而不是突兀的跳變。
串流真正接上畫面後,還有三個只在前端看得到的細節,直接決定使用者覺得「這系統是活的,還是不小心壞了」。
1. 答案邊長邊捲動,但使用者往上讀時要停手
text 事件一段段進來,對話框的捲動高度跟著改變。若無腦捲到底,使用者想回讀前面的內容時,反而一直被剛到的 token 拉走:
對話框(使用者正在往上回讀)
┌──────────────────────────┐
│ 我:我想轉職成前端… │
├──────────────────────────┤
│ 🤖 正在執行履歷工具… │
│ 🤖 轉職的第一步,可以先… │ ← 讀到一半,新文字持續長出來
│ ▍接著建議……續寫中 │
└──────────────────────────┘
常見的作法是把「捲動」綁在使用者行為上:只有當使用者本來就貼著底部時,新 token 才把畫面往下帶;一旦使用者自己往上捲,自動捲動就停手,不打斷閱讀節奏〔如下〕:
function followStream(container: HTMLElement) {
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight
// 距離底部 80px 內才自動捲,使用者一往上捲就自然停手
if (distanceToBottom < 80) {
container.scrollTop = container.scrollHeight
}
}
// text 事件累進 content 時呼叫
callbacks.onText = (content) => {
setContent(content)
requestAnimationFrame(() => followStream(chatBoxRef.current))
}
2. 生成中的游標,替「等待」做即時回饋
text 逐字累加,本身就已經是進度條——畫面愈長,代表系統還活著。我們會再補一個閃爍的 ▍ 游標放在最後一行,讓使用者明確知道「還在生成」。游標在串流開始時出現、收到 [DONE] 或 reader 正常結束時移除,簡單的 useState<boolean> 就能做到。少了它,生成間歇停頓的瞬間看起來就像「卡住了」。
3. 中斷與錯誤,要把已收到的內容留下
網路不可能永遠穩定,串流隨時會斷。最糟的 UX 是把整段回答清空,重新轉圈。作法是把「已累積的 content」和「生成狀態」分開存:斷線時保留 content,只把狀態標記成錯誤,顯示重試:
⚠ 連線中斷,回答只送出一半
轉職前端的第一步,可以先從
開始掌握。接著建議……
[重試] [從頭再來]
export async function connectSSE(res: Response, callbacks) {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ""
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// ... 依前節的 buffer 解析,把文字累進 content ...
}
return content
} catch (err) {
// 錯誤時保留 content,讓上層停住「生成中」並顯示重試
callbacks.onError?.(err, content)
throw err
}
}
重試就是重新發一次 POST /api/chat,server 重新把流程跑一遍。這樣一次網路抖動不會讓使用者眼睜睜看著就到手的答案消失,錯誤也從「死胡同」變成「可以再試一次的岔路」。
為什麼不是 WebSocket
這個最常被問的問題,拆成幾個決策點看,每個都指向了 SSE:
1. 資料流向是「單行道」 CareerWise 的場景是——我們送一則訊息,server 回一個長長的回應。「Server → Client 持續傳送」正是 SSE 的強項;做 WebSocket 代表我們要建一個雙向通訊的接口,但雙向我們其實用不上。
| 決策點 | SSE | WebSocket |
|---|---|---|
| 雙向通訊 | ✗ 單向,但正好符合需求 | ✓ 全雙工 |
| 協定 | 純 HTTP,隨手可用 | 需 upgrade handshake |
| 中繼/CDN | proxy、CDN、load balancer 都支援 | 代理、負載平衡器常需額外設定 |
| 斷線重連 | 本質是 HTTP,中斷後重新發一次 request 即可 | 需自己寫重連邏輯 |
| 驗證方式 | 可用 header / cookie | 常只能用 query / subprotocol |
| 連線狀態 | stateless,請求各自獨立 | 需維護 server 端連線 |
| Serverless | 對 Route Handler 友善 | 長連線與 serverless 格格不入 |
2. 部署環境是 Firebase App Hosting / Next.js Serverless WebSocket 需要伺服器長時間維持連線、保管連線狀態,這跟 serverless 的「每個請求獨立、用完即散」天性相牴觸;SSE 就只是長一點的 HTTP response,serverless 平台完全吃得下。
講得更白話:Route Handler 的壽命,就是「一個 request 從進來回覆完為止」。SSE 把這個「回覆完」的時刻延後到整個串流結束,等於在 serverless 允許的模型裡,把生命週期撐到最長;Firebase App Hosting 對這種長時間的 streaming response 有支援,不需要我們額外開長駐的 server、也不用管連線池。
3. 斷線重連成本低
EventSource 天生內建「斷線自動重連」的路徑,使用者網路一抖,回來後機制會自己把連線拉回來;就算透過 fetch 繞過它,SSE 的線路仍是熟悉的 HTTP,中斷後重新發一次 request 的成本也比 WebSocket 的斷線管理低很多。
4. 對前端除錯較友善 SSE 是純 HTTP,可以用熟悉的 DevTools → Network、curl、代理工具直接看內容;WebSocket 的 frame 反而要在工具鏈上多掛 Plugin。對每天要調試的 Chat 功能,這點差很多。
SSE 也不是沒有代價,幾個限制要誠實對待
好用歸好用,SSE 仍有限制:
- 單向:如果真要即時的雙向互動(例如線上會議),WebSocket 仍必要。
- 事件大小:單一事件過長會被瀏覽器切割,連續文字拆成小塊較保險。
- 跨來源:跨域分享時要另外處理 CORS 與 proxy,比同源多一層設定。
SSE 有另一個容易在正式環境才爆出來的問題:proxy 閒置逾時。route handler 回 text/event-stream 後,如果長時間沒有任何資料,中間的 proxy、load balancer(尤其 Firebase App Hosting 底層的 GCP LB)可能判定連線閒置而把它切掉。使用者只是停頓沒打字,或 LLM 思考特別久,連線就在畫面背後被靜靜收起。
解法是用 heartbeat(SSE 術語也叫 keep-alive comment):定期發一個 SSE comment,內容是 : ping\n\n。comment 不是事件,瀏覽器會忽略它,但對 proxy 來說它仍是「有流量」,連線就不會被判定閒置。片段要放進前面 start(controller) 的作用域裡,並在 controller.close() 前 clearInterval(heartbeat):
async start(controller) {
// 迴圈開始前就掛上心跳,才來得及覆蓋 LLM 長時間停頓的區間
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(": ping\n\n"))
}, 15_000)
for await (const event of getReplyStream(message, history, opts)) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
clearInterval(heartbeat)
controller.close()
}
15 秒是常見的間隔,比多數 LB 的 idle timeout(通常 30–60 秒)小,又不會太頻繁。這是 SSE 在 serverless 上要不要順利的隱藏關鍵。
這個問題也直接影響 UI/UX:連線被 proxy 悄悄收掉時,畫面上的游標會停在最後一個字,使用者以為生成卡住了。而 heartbeat 正是把「以為斷了,其實機器沒斷」這種錯覺擋在畫面外的第一道防線——對使用者來說,連線穩不穩,最後都反映在「游標有沒有一直動」這個小細節上。
這些限制在 CareerWise 的「server 單向傳輸」情境裡,都還踩不到;真正需全雙工的需求出現時,再換 WebSocket 也不遲。
總結
CareerWise 的對話是單向、長文字、serverless 部署,SSE 就是最貼合的真需求;WebSocket 的全雙工與 server 端狀態管理,反而是在不需要它的場景裡多堆了一層複雜度。未來如果對話要加「中途修正」「多人同時在線」,這種雙向需求真的浮現時,再往 WebSocket 升級也不遲。技術不是愈強愈好,而是跟資料流向與部署環境合得起來的,才是該用的。
如果把 SSE 拉到企業級的 proxy 場景(token 計費、內容安檢、錯誤處理),可參考這裡-AI 如何像真人般一字一句說話?用 SSE 實現 Stream 流渲染。