博客

生產環境中的流式處理:SSE 事件、空閒超時與用量核算

流式看起來只是改一行程式碼,實際表現卻像四個生產問題:事件模型、超時策略、用量核算,以及回答中途斷流時該怎麼辦。

生產環境中的流式處理:SSE 事件、空閒超時與用量核算

LLM API 的 streaming 是最容易新增、也最容易做錯的功能之一。在演示裡它只是一個開關,stream: true,再加上一個列印 token 的迴圈。在生產環境中,它變成四個獨立的問題:事件模型(如何解析而不脆弱)、超時策略(多長時間的沉默意味著流已死)、用量核算(當響應分片到達時如何拿到 token 計數),以及當一條流在回答中途斷掉時會發生什麼。這四項裡任何一項做錯,症狀都很眼熟:回答卡住、賬單與儀表盤對不上,或者一次抖動之後出現重複生成。本文是構建者系列的流式章節,接在結構化輸出和工具呼叫指南之後。

LLM API 流式事件模型,兩種形態

兩家主要供應商都通過 Server-Sent Events 進行流式傳輸,也都在傳輸層之上疊加了語義化事件型別,但形態差異會改變你的解析器。

OpenAI。預設情況下,API 會先生成模型的完整輸出,再以單個響應返回;streaming 讓你可以在生成繼續的同時開始列印或處理。官方流式指南聚焦於基於 SSE 的 HTTP 流式(stream=true)。另有獨立的 WebSocket 傳輸,用於需要持久連線並經 previous_response_id 提供增量輸入的場景;那是另一種傳輸選擇,不是解析器細節。在 HTTP 路徑上,Responses API 使用語義化事件:每個事件都帶預定義 schema 的型別,因此你可以只監聽關心的那幾種。幾乎每個整合都要處理的事件包括:用於增量文本的 response.output_text.delta,用於已完成響應的 response.completed,用於流內錯誤的 error,以及 response.created。有些生命週期事件只發出一次,其他則會隨響應生成反覆發出。如果流式輸出對使用者可見,還要把該指南中的 Moderation 風險一節納入上線評審。

Anthropic。每個事件都帶一個 SSE 事件名,例如 event: message_stop,以及其 data 中對應的 type。流式文件 定義了完整流程:message_start 最先到達,其 content 為空;隨後,對每個內容塊,會有一個 content_block_start,一個或多個 content_block_delta 事件,以及一個 content_block_stop;然後是一個或多個 message_delta 事件,攜帶對最終訊息的頂層變更;最後是一個 message_stop。每個內容塊都帶有一個 index,對應它在最終 content 陣列中的位置,一旦響應混合了文本、工具呼叫和思考,這一點就很重要:delta 型別包括 text_delta、input_json_delta 和 thinking_delta。

解析來自 LLM API 的 server-sent events:四個陷阱

傳輸層很簡單,這正是失敗模式如此隱蔽的原因。大多數壞掉的解析器都栽在四個陷阱上:

  1. 把 ping 當成內容。Anthropic 的文件指出,事件流可能包含任意數量的 ping 事件。一個假設“每個事件都帶著我需要的東西”的解析器,要麼崩潰,要麼輸出垃圾。要顯式處理 ping:跳過它們,可選地為保活邏輯統計其數量。
  2. 累加累計型計數器。token 計數位於 usage 欄位中,屬於 message_delta 事件,它們是累計的。如果你把每次出現的值加起來,就會重複計算兩三遍。正確的習慣是覆蓋:保留最新值,在最後使用它。這是 token 儀表盤與供應商控制台對不上最常見的原因。
  3. 拒絕未知事件型別。按照版本策略,可能會新增事件型別,程式碼應當優雅地處理未知型別。用嚴格白名單列舉事件名的解析器,會在供應商更新時失效。給每個分發加一個預設分支:記錄未知型別並繼續。
  4. 把 HTTP 200 當作成功。錯誤可能出現在事件流內部。Anthropic 的文件給出了典型例子:在使用量很高時,你可能會在流內收到一個 overloaded_error,它在非流式場景下通常對應 HTTP 529。所以狀態行成功只意味著“流已開啟”,而不是“回答已完成”。你的迴圈必須同時檢查兩件事:流內錯誤事件,以及完成事件(response.completed 事件,或 message_stop 事件)是否真的到達了。

流式超時處理:看空閒間隔,而不是總時長

一條流會給你帶來三種不同的時鐘,它們需要三種不同的策略:

  • 連線建立。等待 HTTP 響應頭的時間。這一項可以保持嚴格,因為它衡量的是一次請求,而不是一次生成。
  • 空閒時間:事件之間的間隔。這才是流式處理中真正重要的那一個,而且應當按每次間隔來度量,而不是從第一個位元組到最後一個位元組。一個持續穩定產出事件的長回答是健康的,哪怕它跑上幾分鐘;一條已經沉默的流則不是。
  • 總時長。作為兜底的硬上限,依據你自己的負載資料來設定,而不是靠期望。

官方文件中的兩個細節塑造了空閒策略。第一,ping:既然 ping 事件可能出現任意多次,就把任何事件(包括 ping)都視為會重置空閒時鐘的活動;不要在存活檢測中把 ping 過濾掉。第二,工具使用:Anthropic 的文件指出,使用工具時,模型工作期間流式事件之間可能有延遲,因為工具引數是以分塊的部分 JSON 形式、逐個鍵拼裝後到達的。按純文本調好的超時,在工具進入迴圈的那天就會變成誤判的斷連。實操做法是:對帶工具的輪次放寬空閒閾值,或為它們單獨設一個閾值,並把工具側的拼裝規則留在工具呼叫指南放置的位置。

用量核算:Anthropic 的 message_delta usage 與 OpenAI 的 completed 事件

兩條流式路徑上都有用量資料,但只出現在明確定義的時刻,而這決定了核算紀律。

在 OpenAI 一側,最終的用量數字是已完成響應的一部分,因此從 response.completed 事件中讀取。在 Anthropic 一側,usage 欄位(屬於 message_delta 事件)攜帶計數,而且要記住它們是累計的:取最後一次出現的值。在完成時刻之前的一切都不具權威性,由此引出兩條讓成本資料保持可信的規則:

  1. 只在完成事件到達時計入用量。在回答中途死掉的流從未交付最終數字;如果你做了估算或外推,就把這次呼叫標記為估算值,而不是實測值。(這是成本中樞那條“只有已完成的工作才計入”的規則在流式場景下的推論。)
  2. 把被中斷的流放進單獨的桶裡。不要把部分流的呼叫與正常呼叫合併到同一個聚合裡,否則你從成本的執行順序中得到的按功能成本對比就會開始騙你。把不完整流的數量和比例作為獨立指標跟蹤。

有一個相關問題本文不會從流的形態來回答:某條被中斷的流是否計費。這要由你自己的用量記錄和供應商的計費文件來回答,無法從事件順序推斷。

當流中斷時:重試,還是渲染已到達的內容

當一條流死掉或送來流內錯誤時,沒有普遍正確的答案,只有一個決策框架:

  • 重試會重新生成回答。這適合批處理和自動化流水線:那裡的消費方期望得到一個完整結果,部分結果毫無用處。把它控制在重試預算之內,並記住可重試與不可重試的區分:流內的 overloaded_error 是容量訊號,含義與其非流式場景下的 HTTP 對應物相近,而其他錯誤型別可能是策略或請求問題,重試無法解決,正如錯誤碼分診和重試預算指南所闡述的那樣。
  • 渲染部分結果則適合面向使用者的對話介面。使用者已經看到了 token;把它們抽走再從頭重新生成,比一條誠實的續接路徑更糟。這帶來的義務很窄但很重要:你的系統必須能在 UI 和資料中區分完整回答與部分回答,這樣下游任何環節都不會把被截斷的響應當作最終結果。

不該做的事很機械:不要把流內錯誤事件當作帶單一重試策略的 HTTP 層失敗。先看事件的錯誤型別,再套用上面的矩陣。

流式呼叫要多記錄哪些欄位

來自可觀測性指南的日誌欄位清單原樣適用於流式,另有五項值得從第一天就接上:

  • 完成事件是否到達(這條路徑上最有用的單一可靠性訊號);
  • 收到了多少個 delta 事件,作為粗略的響應形態指標;
  • 流內錯誤的型別(如果有),與 HTTP 狀態分開記錄;
  • 觀測到的事件間最大間隔,這是你調校空閒閾值而不是靠猜所需要的資料;
  • 是否向用戶渲染了部分結果,以便質量和履約指標能將其排除。

流式檢查清單

  1. 按語義解析:依據事件型別分支,並顯式跳過 ping。
  2. 覆蓋用量計數器;絕不累加累計型欄位。
  3. 讓未知事件型別記錄日誌並繼續;不要因為它們讓流失敗。
  4. 把 HTTP 狀態視為“流已開啟”,而不是“回答已完成”;檢查流內錯誤和完成事件。
  5. 按事件間隔設定空閒超時,任何事件(包括 ping)都重置它,並對帶工具的輪次放寬。
  6. 只在完成時刻讀取用量;其他情況標記為不完整,而不是當作資料。
  7. 按消費方型別決定重試還是渲染,每條路徑一種策略,並讓流內錯誤先走錯誤型別矩陣。
  8. 記錄上面五個流式欄位,包括事件間最大間隔。

流式並不比生產整合的其他部分更難;它只是對假設更不容忍。線協議很簡單,那四個問題長期存在,而處理得好的團隊會把“流是否完成了”當作整個技術棧中一等公民的事實。當你的呼叫經過相容端點時,端點級細節就在平臺自己的文件。