1. 在 ChatGPT App 架構中「串流」到底出現在哪裡
在爭論 SSE 或 HTTP-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、帶串流的 fetch(ReadableStream), 或使用 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.progress、job.completed)——這是 SSE;
- 單一大型負載的片段(報告文字、以 NDJSON 逐行送出的禮物項目)——這是 HTTP-stream。
3. SSE (Server‑Sent Events):事件訂閱
先從 SSE 開始,因為它在很多方面與 MCP「更接近」:MCP 自身就用 SSE 連線在 HTTP 之上,來將伺服器事件推送給用戶端。
SSE 模型淺談
SSE 是建構在一般 HTTP 之上的協定:
- 用戶端對一個 endpoint 發出 GET 請求,該端點以 Content-Type: text/event-stream 回應;
- 伺服器不關閉連線,而是週期性地寫入如下一些行:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- 瀏覽器端使用 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 |
| 重連支援 | 內建(EventSource、Last-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_tool、list_tools 等)送到 /messages,通常以 POST 的 JSON‑RPC 形式。
當你把 GiftGenius 接上 ChatGPT 時,這一層你其實已經走過了。
現在,我們在小工具中加入非同步任務與串流的 UX 之後,會有兩種架構選擇。
方案一——「純 MCP」: MCP 伺服器自行產生 job.progress 與 job.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 閃閃發光。 現實中,網路常在意想不到的時候斷線,而基礎設施也會設逾時。
哪些事情可能出錯
在使用 SSE 與 HTTP-stream 時,你遲早會遇到:
- 代理上的 idle 逾時:「如果連線 N 秒沒有資料——就關閉」;
- 你的後端重啟(部署、故障);
- 使用者端的網路不穩(特別是行動網路)。
這很正常;重點是要準備好,而不是祈禱「別出事」。
SSE 的策略
SSE 在這塊有不少優點:
- EventSource 會自動以一定延遲嘗試重連;
- 你可以使用 id: 與 Last-Event-ID 來補上事件。
最小實務清單:
- 伺服器端定期送出某種 heartbeat,避免連線被視為完全 idle。 這可以是獨立事件 event: ping,或只是註解 : keep-alive。
- 用戶端在 onerror 中顯示清楚的狀態訊息,例如: 「連線似乎有問題,正在嘗試重新連線…」,而不是讓整個小工具崩潰。
- 在重連時,如果你使用了 id:,伺服器只需回送該 ID 之後的事件。 就 GiftGenius 而言,一開始甚至可以不使用 id:,直接根據最後一個收到的 job.progress/job.completed 來「重建」狀態即可。
HTTP‑stream 的策略
HTTP 串流是單一請求,因此一旦斷線,基本上就得重來:
- 如果你串流的是文字報告,可以直接告知使用者 「未能取得完整報告,請再試一次」,然後重新開始;
- 如果你串流的是結構化資料(NDJSON),可以考慮續傳機制: 例如在請求中帶上 offset 或 cursor,從指定位置繼續。
起步時先別複雜化:若回應串流未完整結束,就顯示目前已取得的內容, 再提供「繼續產生報告」的按鈕,發出新請求即可。
重點是不要讓使用者陷入「永遠等待」的狀態。
8. 套用到 GiftGenius:從頭到尾的情境
現在把我們對 SSE、HTTP 串流,以及兩種與 MCP 的架構選擇,全部放到 GiftGenius 的真實情境中——從使用者提問到產出報告。
使用者在 ChatGPT 中輸入:「幫我挑一份給桌遊迷的禮物,預算不超過 100 美元」。 模型決定呼叫 GiftGenius。應用程式/代理會對你的 MCP 伺服器進行 tool‑call:start_gift_job。 伺服器會:
- 把 job 寫入資料庫;
- 把它送入內部佇列(關於佇列與 worker 的細節留到下一講,目前假設有「某個角色」會執行它);
- 同步在 tool‑call 的回應中回傳 jobId。
GiftGenius 小工具收到帶有 jobId 的 ToolOutput 並渲染元件:
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 發送一次任務進度,很可能會把網路與用戶端拖垮,而不是帶來順暢動畫。 更合理的做法是彙整更新,像是每 200–500 ms 發送一次, 或在流程階段變更時再發送。節流與背壓的主題之後還會談,但現在就該思考事件頻率。
錯誤 №4:在 HTTP 串流上發明複雜協定,卻沒有明確格式。
常見的反模式:在沒有分隔符的情況下串流 JSON,然後嘗試「猜」一個物件何時結束下一個何時開始。 或是在同一條流裡混合文字與 JSON。 最佳做法是選擇簡單明確的格式:按行的文字、或 NDJSON(每行一個 JSON 物件), 或清楚的分隔符。如此一來用戶端的解析器才不會失控。
錯誤 №5:忘記逾時,讓串流變成「永恆」。
有時開發者會做出幾分鐘(例如 5–10 分)都沒有任何輸出 的 SSE 端點, 接著驚訝於連線在用戶端到伺服器的路徑上被切斷(負載平衡器、API 閘道、企業代理)。 定期的 heartbeat 事件或註解可以讓連線保持存活,也能及時偵測斷線。 而 HTTP 串流不應該變成無限延伸的回應——若要長久訂閱,請使用 SSE。
錯誤 №6:試圖用 HTTP 串流實作複雜的 pub/sub,而不使用正規事件通道。
有時會有誘惑:「我們用一條串流,同時送進度、partial results、零散日誌」。 結果是在用戶端出現一個複雜的多工器,它要分析每個分段並判斷屬於哪個 jobId。 多數情況下,使用 SSE 送 job.progress、job.completed 這種型別的事件, 並為每個 job 開一條獨立通道,比在 HTTP 串流上自創超複雜協定更簡單可靠。
錯誤 №7:把 UX 建立在「串流永不會斷」的假設上。
任何串流遲早都會中斷。如果你的小工具在這種狀況下只剩下一個永遠在動畫的進度列、且沒有任何操作選項—— UX 會被認為是「壞掉了」。 就算只是顯示一段訊息「看來連線中斷了。請嘗試重新開始禮物推薦」,再加上一個「重試」按鈕, 也遠勝過沉默不語。
GO TO FULL VERSION