Serverless 上的長連線:CareerWise 用 SSE 傳送 AI 的回答

Serverless 上的長連線:CareerWise 用 SSE 傳送 AI 的回答

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」:

換句話說,這條串流保留了 SSE「純 HTTP、單向、可分段傳輸」的精神,但把傳輸的控制權拿回自己手上,又能帶上認證資訊。

Server 端在對話流程的不同階段掛上不同事件,Client 收到就各做各的事:

事件型態 產生情境 Client 動作
text LLM 逐 token 吐出的答案 累加到對話氣泡
status LLM 執行工具 / 整理答案 塞進 Agent 活動流
goal_decomposed 自動探討職涯目標 記到 Firestore goals
discovery_active 正在探索目標 顯示「探索中」按鈕
smart_goal 完成 SMART 目標 儲存並更新目標面板
profile_update 分析出使用者特質 寫回 profile

強調一件事:這些事件不是同一個訊息格式換內容,而是真正不同的資料形狀。textcontent 字串、goal_decomposeddecomposition 陣列、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 仍有限制:

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 流渲染

參考資料


Server-Sent Events SSE WebSocket CareerWise Agentic AI Serverless Next.js AI AI Engineering Firebase Firebase Hosting LLM Streaming LLM

Summer Tang
Sponsored
Summer Tang 一對一諮詢
Summer|TSMC 主任工程師|擁有 15 年資深 Web 架構經驗,職涯歷經跨國頂尖企業與新創。專精於高流量企業級應用開發,核心技術涵蓋微前端、效能優化、測試自動化與 SEO。曾出版多本技術著作,並多次擔任 MOPCON、WebConf 等大型技術年會講者。
了解更多 →