Day 5 結尾承認的第一個限制就是它:回應一次成形,gpt-5-mini 這種 reasoning model 動輒好幾秒,client 盯著空白畫面等。解法大家都知道——streaming,不就是 SDK 加個 stream=True。但今天的重點不是把 token 一顆顆吐出來,而是吐到一半上游死掉時你怎麼辦:HTTP 200 已經送出去了,status code 用不了,Day 5 那張錯誤映射表和 error envelope 全部進不了場。
這篇要設計本系列的 SSE 事件詞彙表(三個事件、一條終端保證、一道兩段式錯誤邊界),把「誰的錯」從 status code 搬進 stream 裡。讀完你會發現 streaming 改變的不是體感,是錯誤合約的載體。
LLM 回覆是典型的單向下行:client 問一次,server 連續送一串。Server-Sent Events 就是為這種形狀生的:純 HTTP、一條長連線、server 單向推事件;WebSocket 給你雙向通道,但代價是協定升級與另一套生命週期管理,而我們沒有「client 中途插話」的需求。用不到的能力不要付錢,SSE。
但有個常被跳過的細節:教科書的 SSE 是瀏覽器原生 EventSource 打 GET endpoint,而 chat 需要送 JSON body,EventSource 做不到(它只會發 GET、不能帶 body;查核 2026-07,WHATWG SSE 標準)。
所以我們做的其實是「POST 上的 SSE framing」:response 是標準的 text/event-stream,client 用 fetch() + ReadableStream 自己讀。
wire 格式維持 SSE 規格:每個 frame 是 event: 行加 data: 行,以一個空白行結尾。沒讀到空白行的殘缺 frame 不算數,這個細節在斷線語意那節會回來。
Day 5 選 Responses API 時埋過一句:它的串流是型別化的語意事件。實際打開看,上游會送 response.output_text.delta、response.completed、response.incomplete、response.failed,還有幾十種你多半用不到的型別(查核 2026-07,Responses API 參考)。
最省事的做法是原樣轉發:上游送什麼,client 收什麼。這條路被否決的理由跟 Day 5 拒絕透傳上游錯誤訊息是同一條:上游的詞彙不是你的合約。透傳之後,client 要學的是 OpenAI 的事件家族,哪天換供應商或上游改版,斷的是你 client 的 parser;而且上游事件夾帶的欄位(完整 response 物件、sequence number、內部 id)你根本不想承諾。Day 5 花一整篇把上游擋在 adapter 後面,不能在 streaming 這裡開個洞。
所以自己定,而且刻意定得小:
| 事件 | data | 語意 |
|---|---|---|
message.delta |
{"text": "..."} |
一段增量文字,依序拼接就是完整回覆 |
message.done |
{"status": "completed" | "incomplete", "incomplete_reason"?, "correlation_id"} |
唯一的成功終端事件 |
error |
{"error": {"code", "message"}, "correlation_id"} |
唯一的失敗終端事件,形狀就是 Day 3 的 error envelope |
三個設計註記。其一,message. 前綴是給未來留的命名空間。Day 16 的 tool calling 會加 tool_call.* 家族;加新事件是 additive change,前提是合約裡寫死一條規則:client 必須忽略不認識的事件名。這條規則就是 Day 5「上游加欄位與我無關」換了邊站:這次我們是別人的上游。
其二,error 不掛前綴:它不屬於 message 家族,是 stream 層級的事件。data 直接沿用 error envelope 的形狀,client 從 Day 5 學會的那套「認 code、拿 correlation_id 回報」在 stream 裡原封可用,只是換了信封的投遞方式。
其三,沒有 data: [DONE]。用過 chat completions 串流的人都認得這個哨兵:它不是 JSON、不是事件,是一個要 client 特判的魔法字串。有了具名終端事件,這種歷史包袱就不必繼承。
錯誤處理的分界線其實是天然的:client.responses.create(stream=True) 這個 await。它 raise 的時候(401、429、輸入被 content filter 攔),我們還沒送出任何 byte,Day 5 的整張映射表原封沿用,client 拿到的是正常的 4xx/5xx 加 envelope。它成功回來之後,才進入真正的 mid-stream 地帶。
@router.post("/chat/stream", response_class=EventStreamResponse, responses=_STREAM_RESPONSES)
async def stream_chat(
payload: StreamingChatRequest,
request: Request,
service: Annotated[ChatService, Depends(get_chat_service)],
) -> EventStreamResponse:
# Two-phase boundary: pre-stream failures raise here → HTTP envelope.
events = await service.open_stream(payload.message)
return EventStreamResponse(
_render_sse(events, request.state.correlation_id),
headers={"Cache-Control": "no-cache"},
)
(EventStreamResponse 只是把 media_type = "text/event-stream" 定在 class 層的 StreamingResponse,讓 OpenAPI 把 200 記成正確的型別。)
這段 code 最重要的是一件看不見的事:open_stream 在 StreamingResponse 建立之前就 await 完了。這不是風格選擇:route 一把 StreamingResponse 交回去,framework 就先送出 status line 與 headers,然後才開始迭代你的 generator。如果你把 create(stream=True) 寫在 generator 裡面(幾乎每篇教學都這樣寫,因為短),所有 pre-stream 錯誤都會發生在 200 送出之後。
忍喵:「API key 打錯,client 收到 200。quota 爆了,client 還是收到 200。你 Day 5 立的整張映射表,被一個寫在 generator 裡的 create 全數作廢。」
所以 adapter 的 open_stream 是「先開再回」:eager 地 await 上游、把失敗翻譯成 Day 5 那組領域例外往上丟,成功才回傳 iterator。Protocol 的簽名把這件事釘進型別:
class ChatService(Protocol):
async def complete(self, message: str) -> ChatResult: ...
async def open_stream(self, message: str) -> AsyncIterator[ChatStreamEvent]: ...
await open_stream() 拿到的才是 iterator:開連線跟讀事件是兩個階段,型別上就分開。從此 200 的語意變了:它不再表示成功,只表示「串流確實開始了」。成功與否,要看終端事件。
Mid-stream 的失敗有三種長相:SDK 丟 exception(連線斷、read timeout);上游送 response.failed 事件;上游送 error 事件。後兩種是型別化事件不是例外:只寫 try/except 接不到它們,adapter 要在迭代裡把事件本身翻譯成領域例外。三種死法在 API 層匯流成同一個出口:
async def _render_sse(
events: AsyncIterator[TextDelta | StreamDone], correlation_id: str
) -> AsyncIterator[str]:
"""Serialize domain events, enforcing the exactly-one-terminal guarantee."""
try:
async for event in events:
if isinstance(event, TextDelta):
yield _sse("message.delta", {"text": event.text})
else:
data: dict[str, Any] = {"status": event.status, "correlation_id": correlation_id}
if event.status == "incomplete":
data["incomplete_reason"] = event.incomplete_reason or "other"
yield _sse("message.done", data)
return # terminal sent: no further event may follow
except UpstreamError as exc:
logger.warning(
"mid-stream upstream failure code=%s correlation_id=%s detail=%s",
exc.code,
correlation_id,
exc.upstream_detail,
)
yield _error_event(exc.code, exc.message, correlation_id)
return
# Upstream EOF without a terminal event: the contract still owes the
# client exactly one terminal, so the gap itself is an upstream failure.
logger.warning(
"upstream stream ended without a terminal event correlation_id=%s", correlation_id
)
fallback = UpstreamServiceError()
yield _error_event(fallback.code, fallback.message, correlation_id)
這是一個小型狀態機,強制執行合約裡最重要的一條:client 保持連線、且 stream 由應用程式正常結束時,必須收到恰好一個終端事件(message.done 或 error);送出終端之後不再輸出任何東西;連上游默默 EOF、一個終端都沒給的情況,都要補成 upstream_error。合約欠 client 一個結局,上游賴帳就由我們認列成上游故障。
注意措辭是「正常結束時」。物理斷線沒有任何協定能保證投遞:TCP 連線死在半路,最後一個 frame 可能永遠到不了。所以合約的另一半寫在 client 側:讀到 EOF 卻沒收到終端事件,一律視為失敗。SSE 的 frame 以空白行收尾這個細節在這裡發揮作用:殘缺的 frame 不會被 dispatch,client 不會把斷掉的半個事件當成完整資料。
還有一種結局介於成功與失敗之間。上游的 response.incomplete 表示「串流正常結束了,但輸出不完整」,並附帶原因:max_output_tokens 或 content_filter(查核 2026-07,Responses API 參考)。
這時 client 手上已經有一段 token 了,發 error 叫它全部丟掉並不誠實,所以走 message.done,但 status: "incomplete" 且必帶 incomplete_reason。
必帶的理由是三種原因的處置完全不同,client 沒有原因就沒法做對的事:
max_output_tokens:內容是好的,只是被長度截斷;client 可以保留部分內容,提示使用者「回覆未完」。content_filter:Azure 的串流過濾有非同步模式,違規內容可能在部分已經送出之後才被回報(查核 2026-07,content streaming 文件)。client 必須丟棄或依安全政策遮蔽已收到的內容,「自行決定去留」在這裡不是選項。other(上游未來新增的原因,我們一律折疊成這個值):保守處理,視為不可用。Day 5 說過「200 不等於拿到完整輸出」。streaming 把這句話升級了:連 message.done 都不等於完整輸出,但至少現在它會告訴你為什麼,以及該怎麼收。
反方向的失敗也要處理:token 吐到一半,使用者關了頁面。Server 側的表現是 generator 被取消。這不是錯誤,不需要(也來不及)送 error 事件,但有一件事必須做:把上游的 stream 關掉。上游不知道你的 client 走了,你不關,它就繼續生成、繼續計費。adapter 的翻譯層用 finally 保證這件事:
try:
async for event in stream:
...
finally:
await stream.close()
忍喵:「client 都走了,你的 worker 還在替空氣付 token 錢。finally裡那行 close,是這篇最便宜的一行成本控制。」
這條線 Day 9 算成本時會正式結案;今天先把水龍頭裝上。
順帶結掉 Day 5 埋的一個伏筆:「streaming 之後 timeout 的算法整個會變」。Day 5 設的 30 秒是 per-attempt 上限,管的是「一次完整回應多久要到」。
streaming 之下這個語意變成 read timeout:SDK 把 timeout 交給底層的 httpx,而 read timeout 管的是兩個 chunk 之間的最長沉默,總時長不再有單一上限(查核 2026-07,openai-python、httpx timeouts)。
retry 也一樣換了意義:max_retries 只保護到串流建立為止,200 之後斷線 SDK 不會自動續傳。那是一個 error 終端事件,重來與否是 client 的決定。體感指標從「總時長」換成「首 token 延遲」,這正是 streaming 存在的理由。
Day 5 把整張錯誤映射表寫進 OpenAPI responses 讓 CI 看守,這招在 streaming 這裡只剩一半好使。OpenAPI 可以標 200 的 media type 是 text/event-stream、附上 wire example、把事件詞彙寫進 description,但它表達不了「delta 之後才有 done」「終端事件恰好一個」「終端之後不再有事件」這類順序與基數不變量。
OAS 3.2 新增了 SSE 的 itemSchema 能描述單一事件的形狀,但事件之間的規則仍在規格能力之外(查核 2026-07,OpenAPI SSE registry)。
而且 drift check 只保證「spec 沒變」,不保證「spec 是對的」。開發這個 endpoint 時我們就踩了一次:一個 class 層的 media_type 讓 OpenAPI 把所有錯誤 response 都標成 text/event-stream,但 runtime 送的明明是 JSON envelope,drift check 全程綠燈。
最後是用一條 semantic contract test 釘死:200 只有 text/event-stream,每個錯誤狀態碼只有 application/json。
寫不進 spec 的合約,就寫進可執行的測試。BDD feature 直接把保證講成人話:
Scenario: Upstream failure mid-stream ends the stream with an error event
Given the upstream fails after streaming part of the answer
And a valid streaming chat request
When I submit the request to the streaming endpoint
Then the response status code should be 200
And the stream should end with exactly one terminal "error" event
And the error event data should use the error envelope shape
注意第一個斷言:mid-stream 失敗的 status code 就是 200。這條測試存在的意義,是讓「200 不代表成功」從一句提醒變成被 CI 看守的合約。
環境前提:day-06 tag 的程式碼、Python 3.13 + uv(macOS,2026-07 實測);fake 路徑不碰 Azure,真實路徑沿用 Day 4 的 resource 與 deployment。預設 USE_FAKE_LLM=true,起服務直接打(-N 關掉 curl 的緩衝,不加會變成一次到貨):
curl -sN localhost:8000/api/v1/chat/stream -X POST \
-H 'content-type: application/json' -d '{"message": "ping"}'
預期輸出(fake adapter 是決定性的):
event: message.delta
data: {"text": "[fake-llm] "}
event: message.delta
data: {"text": "ping"}
event: message.done
data: {"status": "completed", "correlation_id": "558cc947-…"}
切真的:.env 設 USE_FAKE_LLM=false 加 Day 4 的三個值,同一條 curl 會看到 gpt-5-mini 的 token 依序抵達,結尾同樣是一個 message.done。
BDD 這邊共五條情境:正常串流、pre-stream 失敗回 503 envelope、mid-stream 失敗以 error 事件收尾、content filter 截斷回 incomplete 加原因、422 進 envelope。mid-stream 的失敗注入用的是測試層替換的 scripted fake,不碰魔法字串。完整程式碼與測試在 day-06 tag。
開發時最常見的三個錯:
create(stream=True) 寫進 generator → 症狀:憑證錯誤、quota 爆量全部變成「200 + error 事件」,Day 5 的 status code 映射整張作廢。解法:eager open,route 先 await open_stream(),成功才建 StreamingResponse。-N 是 client 側;本機直連 uvicorn 正常、上了環境才變一次到貨,多半是中間的 proxy 或 gateway 在 buffer response,要對那一層關掉 buffering。EventSource 接不上 → 它只能 GET、帶不了 JSON body。這個 endpoint 的正確讀法是 fetch() + ReadableStream,自己按空白行切 frame。串流端點現在多了一條非串流 API 沒有的判定規則:HTTP 200 只表示 stream 已開始,終端事件才決定結果。三個事件的詞彙表、兩段式錯誤邊界、serializer 的終端保證,以及 disconnect 時關閉上游,共同守住這條規則;error envelope 雖然換成 SSE 載體,code 與 correlation_id 的語意沒有改變。
目前不支援斷線續傳,client 只能整個重問;API 又沒有記憶,因此重問也得重送整段上下文。conversation_id 收了兩天仍是保留欄位。這兩件事指向同一個問題:狀態該放哪裡、由誰擁有。明天(Day 7)會兌現 Day 5 選擇 store=False 後留下的狀態主權。
(本篇無新增雲端資源——沿用 Day 4 建立的 resource 與 deployment,純 token 計費。)
| 工程需求 | Azure / Microsoft 對應服務 | 本篇怎麼用 |
|---|---|---|
| LLM 串流推論 | Azure OpenAI in Microsoft Foundry Models | 沿用 Day 4 的 chat-mini deployment,Responses API stream=True 的 typed events 翻譯成自有 SSE 詞彙表 |
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。