cd ../back to blog
$Guide//June 4, 2026//7 min read

用 OpenAI SDK 串流:SSE、真實權杖用量,與兩種無聲的破綻

透過 OpenAI 相容端點串流的完整作法:SSE 增量區塊、用 include_usage 拿到真實權杖數,並揪出假串流與用量遺失。

串流,是「聊天框枯坐八秒毫無動靜」和「不到一秒就開始打字」之間的 差別。不論你 base_url 背後的模型是 Claude、Gemini 還是 GPT,機制都一樣:設定 stream=True、迭代各個增量區塊、讀 取每一個區塊上的 delta,並在 [DONE] 哨符處停下。因為 Brievio 講的是 OpenAI Chat Completions 協定,同一個 迴圈在每一個真正的第一方模型上都能原封不動地運作 — 你只改 model 字串,其他一律不動。

這篇文章會說明 Server-Sent Events 串流透過 OpenAI 相容端點究竟是怎麼 運作的、如何用 stream_options 在最後一個區塊拿到精確的 權杖用量、Python 與 Node 上完全相同的寫法,以及那兩種會讓串流 看起來正常、卻在背地裡坑你的無聲失敗模式:假的(緩衝式) 串流,以及用量遺失。

HTTP 上的「串流」是什麼意思

非串流的呼叫是一個請求對一個回應:伺服器思考個幾秒,然後把整段完成 內容一次交給你。串流則會讓 HTTP 連線保持開啟,在模型生成的同時一片 一片把答案推給你,用的是 Server-Sent Events(SSE)。 在傳輸層上,每一片都以一行開頭為 data: 後面接一個 JSON 物件的形式抵達,而整個串流以一行字面上的 data: [DONE] 作結。

你幾乎永遠不必自己去解析那段文字 — SDK 會替你處理。你在程式碼裡拿到 的是一個由增量區塊組成的可迭代物件。每一個區塊看起來都像 一個正常的完成物件,只是內容放在 choices[0].delta 而不是 choices[0].message 裡,而且只裝著自上一個區塊以來新生成 的那一小段。把每一個 delta.content 依序串接起來,你就重 建出了完整訊息。這裡唯一要緊的指標是首個權杖到達時間(TTFB):第一個非空增量出現之前要等多久。那個數字,正是你會選擇串流 的全部理由。

Python 寫法

以下就是全部 — 一個真正會串流、同時也擷取用量的迴圈。唯一一行 Brievio 專屬的設定,就是 base_url:

stream.py
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",   # 同一個 base_url,真正的第一方模型
)

# stream=True 會把回應切換成 Server-Sent Events 串流。
# 你迭代這個物件;每一個元素都是一個攜帶部分「delta」的增量區塊。
stream = client.chat.completions.create(
    model="claude-sonnet-4-6",               # 或 gemini-2.5-flash、gpt-... 等等
    messages=[{"role": "user", "content": "Explain Raft consensus in 200 words."}],
    stream=True,
    stream_options={"include_usage": True},  # 要求在「最後一個」區塊附上用量
)

usage = None
for chunk in stream:
    # [DONE] 之前的最後一個 data 事件會帶著用量,且 choices 是空清單。
    if chunk.usage is not None:
        usage = chunk.usage
        continue
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)     # 權杖一抵達就即時渲染

print()
# usage 之所以有值,純粹是因為設了 include_usage。這些都是真實數量。
print(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens)

有三個細節最容易讓人踩坑。第一,內容增量在某些區塊上可能是 None 或空的(開頭那個區塊往往只是設定 role),所以印出 之前要先擋一下。第二,攜帶 usage 的那個區塊,會在內容 結束之後才出現,而且 choices 是空清單 — 這就是 為什麼範例會先檢查 chunk.usagecontinue。 第三,你不必自己去找 [DONE];SDK 會消化掉那個哨符,並替 你結束迭代器。如果你是用原始的 requestsfetch 直接打端點,那你才需要自己 依換行符切割、並在 [DONE] 處手動跳出。

Node 上的同一個迴圈

Node SDK 把串流暴露成一個可非同步迭代的物件,所以結構一模一樣 — 把 for 換成 for await ... of,把會即時輸出的 print 換成 process.stdout.write:

stream.mjs
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: "sk-brievio-...",
  baseURL: "https://api.brievio.com/v1",
});

// Node 端是同一套約定:stream=true 會回傳一個可非同步迭代的區塊序列。
const stream = await client.chat.completions.create({
  model: "claude-sonnet-4-6",
  messages: [{ role: "user", content: "Explain Raft consensus in 200 words." }],
  stream: true,
  stream_options: { include_usage: true },
});

let usage = null;
for await (const chunk of stream) {
  // 最後一個事件:choices 是空的,usage 則會出現。
  if (chunk.usage) {
    usage = chunk.usage;
    continue;
  }
  const delta = chunk.choices[0]?.delta?.content;
  if (delta) process.stdout.write(delta); // 每個權杖都即時輸出到終端機
}

console.log();
console.log(usage?.prompt_tokens, usage?.completion_tokens, usage?.total_tokens);

留意那個可選串連(chunk.choices[0]?.delta?.content)。在攜帶用量的最後一個區塊上,choices 是空的,所以少了 防護就直接索引 [0],會剛好在終點線上拋出例外。這正是 Node 串流處理函式在整段回應都運作得完美無瑕之後,卻偏偏在最後一個 事件上當掉的單一最常見原因。

在最後一個區塊拿到真實用量

預設情況下,串流回應並不包含權杖數量 — 任何一個區塊上都沒 有 usage 物件。這是 OpenAI 協定刻意的設計,而它會狠狠 咬住那些在正式環境串流、事後卻對不上帳的團隊。解法只要一個參數:

  • 設定 stream_options={"include_usage": True} (Python)或 stream_options: { include_usage: true } (Node)。
  • 接著伺服器會在 [DONE] 之前多送一個區塊,它的 choices 是空的,而它的 usage 則裝著 prompt_tokenscompletion_tokens total_tokens
  • 在 Brievio,那些是模型回報的真實數量 — 跟你用非 串流呼叫拿到的數字一模一樣,並以約低於官方費率 15% 計費。沒有灌水 的用量物件,也沒有被注入、把輸入端撐大的系統提示。

如果你略過 include_usage、卻又需要一個權杖估計值,你唯一 的選擇就是用模型的分詞器在本機自己算 — 那既是近似值,又是維護負擔。 把旗標設上就對了。

無聲的破綻:假串流與用量遺失

有兩種失敗模式能通過隨意一瞥的檢查,只有在細看時才會現形。在你把 真實流量交給一個閘道之前,這兩者都值得花 20 秒查一查。

  • 緩衝式的「假」串流。有些閘道接受 stream=True,卻會等待整段上游完成內容,再在最 後一次性把它當成一連串區塊回放給你。你的迴圈照跑,增量照來,一切 看起來都像在串流 — 但 TTFB 跟非串流呼叫一模一樣,因為在模型完成 之前根本什麼都沒送出。判斷方式很簡單:量一下從送出請求到第一個非 空增量之間的間隔。真正的串流會落在遠低於一秒內;緩衝式回放則會 等於整段生成的時間。如果首個權杖延遲跟總延遲亦步亦趨,那你不是在 串流,你是在看一段錄影。
  • 用量遺失或被捏造。一個不理會 include_usage 的閘道,會讓你在串流呼叫上完全拿不到權杖 數量 — 於是你只能對著空氣核帳。更糟的是,不誠實的閘道可以附上一個 數字被灌水的 usage 物件,因為在串流上客戶端鮮少會重新 計算。用最無趣的方式去驗證它:把同一段提示分別串流跑一 次、不串流跑一次,並確認串流最後一個區塊的用量,跟非串流的 usage 一致。兩者應該要一模一樣。
  • 看起來像乾淨收尾的串流中途錯誤。如果上游模型在中 途出錯,正確的閘道會把它在你的迴圈裡以例外的形式拋出來,而不是無 聲地截斷。在把文字當成完整內容處理之前,務必先確認你收到了一個 finish reason(或那個用量區塊)— 一個就這麼停下來的串流,跟一個真 正完成了的串流並不是同一回事。

重點整理

透過 OpenAI 相容端點串流,只有四個會動的零件: stream=True、迭代各個區塊、讀取每一個 delta, 然後讓 SDK 去處理 [DONE]。再加上 stream_options={"include_usage": True}, 你就連最後一個區塊上的誠實權杖數量也一併拿到了。同樣這十五行,在一 個 base_url 背後的 Claude Sonnet 4.6、Gemini 2.5 Flash 與 GPT 系列上都能原封不動地運作 — 換掉 model 字串,迴圈不動。

在你上線之前,量一量首個權杖到達時間,並比對串流與非串流的用量。真 正的串流會給你低於一秒的 TTFB 與一致的數量;緩衝式回放則會在延遲上 露出馬腳。在 Brievio,失敗的 4xx/5xx 呼叫不會計費,所以你可以免費跑這些 檢查。完整的參數清單請見 Chat Completions 參考文件,工具與視覺 在同一條串流上的用法請見其餘的 API 文件,非串流的基礎則請見 以 OpenAI SDK 呼叫 Claude 的指南 ,而你能讓這個迴圈指向的每一個 slug,都在 模型目錄裡。