CodeGym /課程 /ChatGPT Apps /串流通道:SSE 與 HTTP/stream——何時以及如何使用

串流通道:SSE 與 HTTP/stream——何時以及如何使用

ChatGPT Apps
等級 13 , 課堂 1
開放

1. 在 ChatGPT App 架構中「串流」到底出現在哪裡

在爭論 SSEHTTP-stream 哪個更好之前,先了解一下,我們的技術堆疊裡究竟有哪些層級存在串流

大致可以分為三個層級。

其一:ChatGPT 與模型層。 模型本身就會以 token 串流方式輸出回應:你會看到回應文字像是被逐字「打出來」。 這也是一種串流,但它完全由 OpenAI 控制,與你的程式碼並無直接關係。

其二:MCP 層。 當 ChatGPT 連到你的 MCP 伺服器時,通常會保持一條SSE 連線: 伺服器會往裡面推 MCP 的 JSON‑RPC 訊息(回應與通知),而 ChatGPT 則會對獨立的 HTTP endpoint 發送請求, 例如 /messages。以 MCP 的術語來說,這是基礎傳輸層。

其三:Apps SDK 與你的後端。 你的 React 小工具 GiftGenius 在 ChatGPT 的沙盒裡執行,並透過 HTTP 與你的後端/MCP gateway 通訊: 使用一般的 fetch、帶串流的 fetchReadableStream), 或使用 SSE 訂閱(EventSource)。

重要的是不要把這些層級混為一談。 MCP 事件是 ChatGPT 與伺服器之間的「導線」;而小工具與你的 HTTP 後端之間的 SSE/HTTP-stream, 則是你要自行設計的那一段。

可以用圖示表示。

flowchart TD
  subgraph ChatGPT
    UI[ChatGPT UI + 模型]
    W[GiftGenius Widget]
  end

  subgraph YourInfra[開發者基礎設施]
    GW[MCP Gateway / Backend]
    MCP[MCP Server]
  end

  UI -- "tool-call / 回應\n(內部 token 串流)" --> W

  UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP

  W <-- "HTTP / fetch / SSE / stream" --> GW

  GW <-- "JSON-RPC MCP" --> MCP

今天我們會把重點放在 Widget ↔ Backend 這條箭頭上,並順帶回顧 MCP 的傳輸本身也是建構在 SSE 之上。

正是在這一段——Widget ↔ Backend——我們必須決定該如何通訊: 使用單純的 HTTP 請求,還是使用串流。下一節我們會看看為什麼在這裡「一般」HTTP 很快就不夠用。

2. 為什麼一般的 HTTP‑請求不夠用

標準的 HTTP 模型是「請求 → 一個回應」。用戶端提出要求,伺服器回應一次,連線就關閉。

對許多任務這已足夠:取得目前的 job 狀態、儲存使用者設定、 或讀取資料庫裡已經準備好的禮物清單。

但一旦你執行長耗時操作,一切就會開始卡卡的。

想像一下 GiftGenius 需要:

  • 從多個來源收集訊號(購買紀錄、願望清單、社群),
  • 經過數個 LLM 請求處理,
  • 從上百個候選項目建立個人化排名。

這可能要花上數十秒。如果你維持一個一般的 HTTP 請求 40 秒並且中途沒有任何輸出, 使用體驗就像老式瀏覽器:使用者看著旋轉的轉圈圈,不知道應用程式是掛了還是在「思考」。

除了 UX,還有純技術層面的問題:

  • ChatGPT、Vercel、各種代理上的逾時;
  • 無法傳回進度、partial results 等等;
  • 無法妥善處理斷線並恢復。

因此自然的解法是:從一個大型回應改為一串可逐步送出的微小片段, 伺服器可以在就緒時立即推送。

這些片段可以是:

  • 事件(job.progressjob.completed)——這是 SSE
  • 單一大型負載的片段(報告文字、以 NDJSON 逐行送出的禮物項目)——這是 HTTP-stream

3. SSE (Server‑Sent Events):事件訂閱

先從 SSE 開始,因為它在很多方面與 MCP「更接近」:MCP 自身就用 SSE 連線在 HTTP 之上,來將伺服器事件推送給用戶端。

SSE 模型淺談

SSE 是建構在一般 HTTP 之上的協定:

  1. 用戶端對一個 endpoint 發出 GET 請求,該端點以 Content-Type: text/event-stream 回應;
  2. 伺服器不關閉連線,而是週期性地寫入如下一些行:
event: job.progress
data: {"jobId":"123","percent":40}

event: job.completed
data: {"jobId":"123","resultCount":12}
  1. 瀏覽器端使用 EventSource,它會:
    • 自動處理連線的重新啟動;
    • 解析 event: + data: + 以空白行分隔的格式;
    • 觸發 onmessage / addEventListener("job.progress", ...) 等處理器。

關鍵點:通道是單向的。只有伺服器會向用戶端發送事件。 用戶端不會透過這條連線傳送資料。

對 ChatGPT Apps 而言,當小工具只想依 jobId「訂閱」事件, 並對進度與完成進行回應時,這個模型就非常合適。

Next.js 16 的極簡 SSE 端點範例

假設我們有一個用於 Job 進度事件的 route handler:

app/api/gift-jobs/[jobId]/events/route.ts

import { NextRequest } from "next/server";

export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
  const jobId = params.jobId;

  const stream = new ReadableStream({
    start(controller) {
      // 用於發送 SSE 事件的工具函式
      const send = (event: string, data: unknown) => {
        const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(new TextEncoder().encode(payload));
      };

      send("job.started", { jobId });

      let percent = 0;
      const interval = setInterval(() => {
        percent += 20;
        if (percent >= 100) {
          send("job.completed", { jobId, totalGifts: 10 });
          clearInterval(interval);
          controller.close();
        } else {
          send("job.progress", { jobId, percent });
        }
      }, 1000);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream", // 這裡設定為 SSE
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

這只是玩具示範:每秒增加一次百分比,最後送出 job.completed。 之後你會把這個計時器替換成實際的 worker/佇列事件,但整體結構不變。

用戶端:在 GiftGenius 小工具中訂閱 SSE

在 React 小工具內,只要有 jobId,我們就能訂閱這個串流。 提醒一下,小工具的 API 在 ChatGPT 的沙盒中執行,但 EventSource 與一般瀏覽器相同可用。

import { useEffect, useState } from "react";

export function GiftJobProgress({ jobId }: { jobId: string }) {
  const [percent, setPercent] = useState(0);

  useEffect(() => {
    const url = `/api/gift-jobs/${jobId}/events`;
    const es = new EventSource(url);

    es.addEventListener("job.progress", (event) => {
      const data = JSON.parse((event as MessageEvent).data);
      setPercent(data.percent);
    });

    es.addEventListener("job.completed", () => {
      setPercent(100);
      es.close();
    });

    es.onerror = () => {
      // 這裡可以顯示「連線異常,正在嘗試重新連線」
    };

    return () => es.close();
  }, [jobId]);

  return <div>禮物推薦進度:{percent}%</div>;
}

現在你可以把它與 MCP 工具串起來。工具 start_gift_job 會回傳 jobId,而在你的小工具 ToolOutput 中只需渲染 GiftJobProgress

自動重連與 Last‑Event‑ID

依據標準,EventSource 會在連線中斷時嘗試自動重新連線。 伺服器可以在事件中使用標準的 SSE 欄位 id:,用戶端可透過標頭 Last-Event-ID 在重連後補上錯過的事件。

對簡單的 GiftGenius 而言,你暫時不必實作 id: 或獨立的事件識別子, 允許在重連時有一點點進度「回退」即可。 但在生產環境,特別是高負載時,你會需要:

  • 在每個 SSE 事件中加入標準欄位 id:,以便用戶端在重連時帶上 Last-Event-ID
  • 在事件 payload 中加入應用層級的 event_id,並在用戶端/後端以此進行等冪性處理。

這與等冪性直接相關:即使同一個 job.progress 事件到達兩次, 處理器看到熟悉的 event_id 就不會再次觸發副作用。

總之,SSE 提供了圍繞 jobId 的便利事件訂閱,具備自動重連與用事件識別子控制重複事件。 接著我們來看第二種串流——當我們有單一請求,但回應非常大,想要分段傳回時怎麼辦。

4. HTTP‑streaming:對單一請求逐步回應

若說 SSE 是「針對獨立事件的訂閱」,那麼 HTTP 串流就是「一個請求,一個回應,但回應隨時間分段傳回」。

當你使用 OpenAI API 並設定 stream : true 時,看到的正是這種機制: 伺服器會傳回一連串 JSON 分段(常見以 SSE 形式承載,但語意上是「單一請求 ↔ 部分回應的串流」),用戶端把它們組成最終文字。

在你自己的 API 中,你也可以這麼做,適用於:

  • 大型文字報告(例如解釋為何選了這些禮物),
  • 很長的禮物清單(分段串流,而不是讓使用者乾等)。

最簡的 HTTP 串流端點(Next.js)

假設我們需要產生「結果說明」,LLM 會寫出長文。 我們希望在生成過程中把內容以串流方式送到小工具。

app/api/gift-report/route.ts

import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      controller.enqueue(encoder.encode("開始分析...\n"));

      // 這裡本來可以是實際的 LLM 分段產生
      for (const line of ["蒐集偏好...\n", "計算預算...\n", "最終建議...\n"]) {
        await new Promise((r) => setTimeout(r, 1000));
        controller.enqueue(encoder.encode(line));
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Transfer-Encoding": "chunked", // 這裡宣告這是 HTTP/stream
    },
  });
}

技術上 Next 會自行處理 chunked 編碼,你只需要回傳 ReadableStream

在小工具中用 fetch 讀取 HTTP 串流

在用戶端(小工具內)可以這樣讀取串流:

async function fetchReport(setText: (s: string) => void) {
  const res = await fetch("/api/gift-report", { method: "POST" });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();

  let acc = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    acc += decoder.decode(value, { stream: true });
    setText(acc); // 隨著資料到達更新 UI
  }
}

以及包裝元件:

import { useState } from "react";

export function GiftReport() {
  const [text, setText] = useState("");

  return (
    <div>
      <button onClick={() => fetchReport(setText)}>產生報告</button>
      <pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
    </div>
  );
}

這是經典模式:一個 POST/api/gift-report, 回應是一段文字串流,你逐步把它呈現在畫面上。

串流 JSON,而非純文字

很多時候你會想串流的不是字串,而是 JSON 物件。最受歡迎的格式是 NDJSON(Newline‑delimited JSON): 每個事件是一行 JSON,並以 \n 結尾。

伺服器端範例:

const stream = new ReadableStream({
  async start(controller) {
    const encoder = new TextEncoder();
    for (let i = 0; i < 5; i++) {
      const chunk = { type: "gift", index: i, name: `禮物 #${i}` };
      controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
      await new Promise((r) => setTimeout(r, 500));
    }
    controller.close();
  },
});

用戶端用 TextDecoder 讀取,依 \n 拆分並解析成獨立的 JSON 物件。

5. SSE 與 HTTP‑stream:差異與選擇

此時你應該已有直覺,但我們仍用一張小表整理一下。

特性 SSE (Server‑Sent Events) HTTP‑stream(chunked)
發起者 客戶端發出 GET 並訂閱 客戶端發出請求(GET/POST),伺服器串流回應
方向 僅 伺服器 → 客戶端 伺服器對特定請求的回應
語意 訂閱事件流(pub/sub 對單一請求的分段回應
內建協定 有(event:data:id: 等) 沒有,需要自行定義格式(文字行、NDJSON、JSON)
客戶端 API EventSource fetch + ReadableStream / response.body
重連支援 內建(EventSourceLast-Event-ID 需自行實作
典型場景 jobId 的進度、狀態、通知 串流文字、大型 JSON 回應、LLM 輸出

如果簡化成「手感規則」(請謹慎使用,不要走火入魔):

  • 你有一個 job 與一堆環繞它的事件 → 用SSE
  • 你有單一工具呼叫會回傳很大的結果,並希望分段顯示 → 用HTTP‑stream

對 GiftGenius 而言:SSE 用於動態進度條與各種狀態;HTTP 串流則用於長篇文字摘要,或逐步載入很長的禮物清單。

6. 如何與 MCP 與 GiftGenius 接軌

回到講座一開始的架構:模型 ↔ MCP ↔ 小工具 ↔ 後端。我們已經看了小工具 ↔ 後端之間的串流, 現在退一步,清楚區分這裡的 MCP 與「單純 HTTP」各自扮演的角色。

MCP 定義了 ChatGPT(作為 MCP 用戶端)如何與你的 MCP 伺服器互動。其傳輸層包含:

  • ChatGPT 會開啟到 /sse 的 SSE 連線,並透過它接收 MCP 訊息(回應、通知、事件);
  • ChatGPT 會把 MCP 請求(call_toollist_tools 等)送到 /messages,通常以 POST 的 JSON‑RPC 形式。

當你把 GiftGenius 接上 ChatGPT 時,這一層你其實已經走過了。

現在,我們在小工具中加入非同步任務與串流的 UX 之後,會有兩種架構選擇。

方案一——「純 MCP」: MCP 伺服器自行產生 job.progressjob.completed 事件; ChatGPT 透過 MCP‑SSE 收到它們;接著模型自行以更新後的上下文呼叫你的小工具, 小工具渲染進度,無需直接與後端通訊。這是最「正統」的 MCP 事件路徑。

方案二——混合式: MCP 工具 start_gift_job 建立任務並回傳 jobId; 小工具拿到 jobId 後,自行以 HTTP 與後端溝通, 訂閱 SSE 端點 /api/gift-jobs/{jobId}/events,必要時再請求 HTTP 串流報告。 MCP 這一側則不需要特別改動。

在課程中我們採用混合式:它更容易整合到 App Router/Next,也更方便本機除錯。 之後你熟悉之後,還可以遷移到「純 MCP 通知」。

7. 重連、逾時與真實網路世界

到目前為止一切看似完美:打開 SSE 或串流,資料順暢地來,UX 閃閃發光。 現實中,網路常在意想不到的時候斷線,而基礎設施也會設逾時。

哪些事情可能出錯

在使用 SSEHTTP-stream 時,你遲早會遇到:

  • 代理上的 idle 逾時:「如果連線 N 秒沒有資料——就關閉」;
  • 你的後端重啟(部署、故障);
  • 使用者端的網路不穩(特別是行動網路)。

這很正常;重點是要準備好,而不是祈禱「別出事」。

SSE 的策略

SSE 在這塊有不少優點:

  • EventSource 會自動以一定延遲嘗試重連;
  • 你可以使用 id:Last-Event-ID 來補上事件。

最小實務清單:

  1. 伺服器端定期送出某種 heartbeat,避免連線被視為完全 idle。 這可以是獨立事件 event: ping,或只是註解 : keep-alive
  2. 用戶端在 onerror 中顯示清楚的狀態訊息,例如: 「連線似乎有問題,正在嘗試重新連線…」,而不是讓整個小工具崩潰。
  3. 在重連時,如果你使用了 id:,伺服器只需回送該 ID 之後的事件。 就 GiftGenius 而言,一開始甚至可以不使用 id:,直接根據最後一個收到的 job.progress/job.completed 來「重建」狀態即可。

HTTP‑stream 的策略

HTTP 串流是單一請求,因此一旦斷線,基本上就得重來:

  • 如果你串流的是文字報告,可以直接告知使用者 「未能取得完整報告,請再試一次」,然後重新開始;
  • 如果你串流的是結構化資料(NDJSON),可以考慮續傳機制: 例如在請求中帶上 offsetcursor,從指定位置繼續。

起步時先別複雜化:若回應串流未完整結束,就顯示目前已取得的內容, 再提供「繼續產生報告」的按鈕,發出新請求即可。

重點是不要讓使用者陷入「永遠等待」的狀態。

8. 套用到 GiftGenius:從頭到尾的情境

現在把我們對 SSE、HTTP 串流,以及兩種與 MCP 的架構選擇,全部放到 GiftGenius 的真實情境中——從使用者提問到產出報告。

使用者在 ChatGPT 中輸入:「幫我挑一份給桌遊迷的禮物,預算不超過 100 美元」。 模型決定呼叫 GiftGenius。應用程式/代理會對你的 MCP 伺服器進行 tool‑call:start_gift_job。 伺服器會:

  • 把 job 寫入資料庫;
  • 把它送入內部佇列(關於佇列與 worker 的細節留到下一講,目前假設有「某個角色」會執行它);
  • 同步在 tool‑call 的回應中回傳 jobId

GiftGenius 小工具收到帶有 jobIdToolOutput 並渲染元件:

function GiftGeniusRoot({ jobId }: { jobId: string }) {
  return (
    <div>
      <h2>正在尋找理想的禮物…</h2>
      <GiftJobProgress jobId={jobId} />
      <GiftReport />
    </div>
  );
}

GiftJobProgress 元件會訂閱 SSE /api/gift-jobs/{jobId}/events 並繪製進度。 每個 job.progress 更新百分比,job.completed 則設為 100%,並且可能啟用「顯示詳細報告」按鈕。

GiftReport 元件在按下按鈕時會送出 POST /api/gift-report(同時帶上 jobId),並在伺服器傳回 HTTP 串流時逐步顯示 文字報告。

當 SSE 連線中斷時,小工具會顯示溫和的提示,而 EventSource 會嘗試自動重連。 若報告串流出問題,使用者會看到已取得的部分與「繼續產生」或「再試一次」的按鈕。

就 ChatGPT 與 MCP 而言:

  • MCP 會看到一次 start_gift_job 的 tool‑呼叫,以及(可選)之後關於 job 狀態的通知;
  • 圍繞串流的 UX 主要是在小工具與你的後端之間以 HTTP 實作完成。

9. 使用 SSE 與 HTTP‑stream 的常見錯誤

錯誤 №1:把 SSE 與 HTTP‑stream 視為「同一件事」。
是的,它們底層都有 HTTP 與 chunked 回應,但語意差很多。 SSE 是訂閱獨立事件,事件可能在任何時刻到來,用戶端事前不知情。 HTTP 串流則是針對單一請求的回應,分散在時間軸上。 如果你嘗試用一條 HTTP 串流來實作多個 jobId 的訂閱, 你將不得不在位元組之上自創協定,實際上等於重造半套 SSE。

錯誤 №2:忽略 SSE 的自動重連,且不考慮等冪性。
許多人寫了「簡單」的 SSE 伺服器:只送 data: ..., 卻不加入標準的 id:(給 Last-Event-ID 用), 也沒有在事件體中加入應用層級的 event_id。 結果第一次斷線並重連後,就開始出現重複事件。 沒有設計好的 event_id 與「我已經看過此事件」的邏輯,用戶端處理器很可能會重複更新狀態, 重複顯示相同的 job.completed, 更糟的是,重複扣款/重複發放點數。

錯誤 №3:worker 的每個細碎訊號都發成獨立 SSE 事件。
如果你每毫秒都透過 SSE 發送一次任務進度,很可能會把網路與用戶端拖垮,而不是帶來順暢動畫。 更合理的做法是彙整更新,像是每 200500 ms 發送一次, 或在流程階段變更時再發送。節流與背壓的主題之後還會談,但現在就該思考事件頻率。

錯誤 №4:在 HTTP 串流上發明複雜協定,卻沒有明確格式。
常見的反模式:在沒有分隔符的情況下串流 JSON,然後嘗試「猜」一個物件何時結束下一個何時開始。 或是在同一條流裡混合文字與 JSON。 最佳做法是選擇簡單明確的格式:按行的文字、或 NDJSON(每行一個 JSON 物件), 或清楚的分隔符。如此一來用戶端的解析器才不會失控。

錯誤 №5:忘記逾時,讓串流變成「永恆」。
有時開發者會做出幾分鐘(例如 510 分)都沒有任何輸出 的 SSE 端點, 接著驚訝於連線在用戶端到伺服器的路徑上被切斷(負載平衡器、API 閘道、企業代理)。 定期的 heartbeat 事件或註解可以讓連線保持存活,也能及時偵測斷線。 而 HTTP 串流不應該變成無限延伸的回應——若要長久訂閱,請使用 SSE。

錯誤 №6:試圖用 HTTP 串流實作複雜的 pub/sub,而不使用正規事件通道。
有時會有誘惑:「我們用一條串流,同時送進度、partial results、零散日誌」。 結果是在用戶端出現一個複雜的多工器,它要分析每個分段並判斷屬於哪個 jobId。 多數情況下,使用 SSE 送 job.progressjob.completed 這種型別的事件, 並為每個 job 開一條獨立通道,比在 HTTP 串流上自創超複雜協定更簡單可靠。

錯誤 №7:把 UX 建立在「串流永不會斷」的假設上。
任何串流遲早都會中斷。如果你的小工具在這種狀況下只剩下一個永遠在動畫的進度列、且沒有任何操作選項—— UX 會被認為是「壞掉了」。 就算只是顯示一段訊息「看來連線中斷了。請嘗試重新開始禮物推薦」,再加上一個「重試」按鈕, 也遠勝過沉默不語。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION