拆解 OpenAI 的 SSE 数据流

2026-09-26 curl 8.5.0 / Python 3.12 下面每个字节都是真抓的

"stream": true 这几个字符,落到线路上就是:一个普通 HTTP 响应,Content-Type: text/event-stream,body 永远不结束,服务器一行一行往里写。没有新协议、没有长连接握手、没有分帧协商——它就是 SSE(Server-Sent Events),WHATWG HTML 规范第 9.2.5 节那个老古董。

麻烦在于规范只定义了「行」,各家实现却对「一条事件什么时候算完整」各有默契。这一篇把它拆开:先讲规范里真正规定的东西,再给一组实测抓包,把「文档没写但一定会撞到」的部分钉死。

实测环境

一个 OpenAI 兼容网关(POST /v1/chat/completions,new-api 系,模型 agnes-3.0-flash / agnes-2.5-flash),HTTP/2。不是官方 api.openai.com。凡是我判断「这条属于网关行为而不是协议」的,都会单独标出来——这类差异恰恰是最容易踩的。

1 · SSE 的线格式:只有 5 个字段

规范里,事件流是以 \n / \r\n / \r 结尾的行,每行 field: value。字段一共四个加注释:

字段作用OpenAI 用不用
data:负载。多行会被用 \n 拼接成一个 data✅ 唯一实际使用的
event:事件类型(默认 message)❌ Chat Completions 不用;Responses API 用(event: response.output_text.delta)
id:设置「最后事件 ID」,供重连时 Last-Event-ID 头带回❌ 没有 → 断线续传无从谈起
retry:告诉客户端重连间隔(纯数字,毫秒)❌
: xxx注释,客户端必须忽略。常被拿来当心跳保活⚠️ 有时能收到(代理链路过长时被用来防超时)

三条被反复写错的规定:

2 · 真实字节长什么样

一次最小请求(max_tokens: 48),响应头:

HTTP/2 200
content-type: text/event-stream
cache-control: no-cache
x-request-id: a5d7756c6f0674ee5509ca9e85d06a0b
server: cloudflare

同一请求强制走 HTTP/1.1 时,body 是标准的 chunked 编码;走 HTTP/2 时没有 Transfer-Encoding,SSE 事件被塞进 DATA 帧里:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked          # h1:SSE 事件 不是 一个事件一个 chunk,别拿 chunk 当边界
Cache-Control: no-cache
Connection: keep-alive

结论:传输层的分帧和 SSE 的事件边界毫无关系。h1 的 chunk、h2 的 DATA 帧、TLS record、TCP 段,全都是「字节」,不是「事件」。

body 的前 4 行,用 cat -A 显示($ = LF,^M 会出现如果是 CRLF):

data: {"id":"39b6f4a051c64151b5656d699d245302","object":"chat.completion.chunk","c$
   "created":1790411641,"model":"agnes-3.0-flash","choices":[{"index":0,"delta":{$
data: {"id":"39b6f4a051c64151b5656d699d245302","object":"chat.completion.chunk","$
   "choices":[{"index":0,"delta":{"content":"Server"}}]}$
$

本次响应共 52 条 data: 行,形状是「一条 JSON + 一个空行」。统计出来的事实:

观察项结果
event: 行0 条
注释/心跳行(: 开头)0 条
行结束符纯 LF(无 CR)
"id" 字段52 条全部相同 ⇒ 一个 completion 一个 id
结尾data: [DONE] + 空行,随后连接关闭

3 · chunk 的 JSON 形状,以及「别按字符串猜」

三行完整样本,分别是第一行、中间行、倒数第三行:

// 首块:只给你 role,content 是空串
{"id":"39b6f4…","object":"chat.completion.chunk","created":1790411641,"model":"agnes-3.0-flash",
 "choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}

// 中间块:delta 里只有 content,一个词
{"id":"39b6f4…","object":"chat.completion.chunk","created":1790411641,"model":"agnes-3.0-flash",
 "choices":[{"index":0,"delta":{"content":"Server"}}]}

// 收尾块:delta 变空对象,finish_reason 出现
{"id":"39b6f4…","object":"chat.completion.chunk","created":1790411641,"model":"agnes-3.0-flash",
 "choices":[{"index":0,"delta":{},"finish_reason":"length"}]}

然后是最后一块,注意它和上面三块字段顺序不同:

{"id":"39b6f4…","created":1790411641,"model":"agnes-3.0-flash","object":"chat.completion.chunk",
 "choices":[{"index":0,"delta":{}}],
 "usage":{"completion_tokens":48,"prompt_tokens":80,"total_tokens":128,
          "completion_tokens_details":{"reasoning_tokens":0}}}
字段顺序会变

前 51 块是 id → object → created → model → choices,最后一块是 id → created → model → object → choices → usage。它是网关在收尾时重新序列化的产物,不是同一个对象加了一个字段。结论:任何形式的 line.startswith('data: {"usage"')、按位置切字符串、或者「第一个字段永远是 id」的假设都会崩。老实 json.loads。

4 · 三个不同的「结束」,别混

流里有三种收尾信号,语义各不相同:

信号含义可靠性
choices[i].finish_reason第 i 路生成结束,值 ∈ stop / length / tool_calls / content_filter每路恰好一次;但 n>1 时你会看到多块分别带自己的 finish_reason
带 usage 的块token 统计(此时 delta 是 {})chunk 里的 choices 形状不统一:官方这里常是空数组 [],本网关给的是 [{"index":0,"delta":{}}] ⇒ 一律写 for ch in obj.get("choices") or []。官方需要 stream_options:{"include_usage":true} 才有;很多网关无条件补一条(本例即使不传也发了)⇒ 不能当作「开关生效」的判断依据
data: [DONE]整个流结束,close 连接它不是 JSON,json.loads 必炸,必须先特判;出现即代表正常完成

实测三种 finish_reason 都出现过:

"finish_reason":"stop"     # 正常说完(max_tokens=16,模型只用了 6 token)
"finish_reason":"length"   # 撞到 max_tokens=48 被截断
"finish_reason":"tool_calls" # 见第 7 节

所以判断「这个回答完整吗」不能只看有没有 [DONE]——[DONE] 只代表传输结束,length 代表内容被截了。两个维度都要读。

5 · 读到字节 ≠ 读到事件

这是所有 bug 的源头。用 read(37) 这种奇怪的长度去读同一个流(故意不用行迭代器):

read#1  37B  t=0.49s  b'data: {"id":"314c8ee02b5e4707a54957a5'
read#2  37B  t=0.49s  b'e0ebc14f","object":"chat.completion.c'
read#3  37B  t=0.49s  b'hunk","created":1790411718,"model":"a'
read#4  37B  t=0.49s  b'gnes-3.0-flash","choices":[{"index":0'
read#5  37B  t=0.50s  b',"delta":{"role":"assistant","content'
read#6  37B  t=0.50s  b'":""}}]}\n\ndata: {"id":"314c8ee02b5e47'   ← 一次读里含完整事件 + 下一条的开头
read#7  37B  t=0.50s  b'07a54957a5e0ebc14f","object":"chat.co'

看 read#6:它同时包含「上一条事件的空行结尾」和「下一条事件的前半截」。任何「一次 read 就是一条 data」的写法都是错的。

还有两个连环坑,都是实测出来的:

坑 A:read(n) 会阻塞到攒够 n 字节

# Python urllib,read(8192),一个总共约 2 万字节的流
read#1  8192B  t=1.06s   尾部 b'id":"ea88955236a642b3a24'     ← 停在 JSON 中间
read#2  8192B  t=1.56s   尾部 b'hat.completion.chunk","c'      ← 停在单词中间
read#3  3592B  t=1.73s   尾部 b'ns":0}}}\n\ndata: [DONE]\n\n'   # 直到 EOF 才拿到剩下的

每次都刚好 8192 字节 ⇒ read(n) 是「攒够才返回」。一个短回答(<8KB)你会一直等到流结束才看到第一个字节,打字机效果当场消失。要「来多少读多少」就用 read1() / os.read() / reader.read()(fetch)这类单次原始读。

坑 B:一次读会把一个汉字劈成两半

# read1(19):故意用奇怪长度,检查截断处的字节尾巴
@read20  … {"content":"ä¸            ← 三个字节的中文字符,只送到前两个
@read30  … {"content":"ï¼
@read40  … {"content":"äº

所以「decode() 每块字节」必然偶发 UnicodeDecodeError 或者吞字。要么像我上面那样先攒字节、按 \n\n 切出完整事件、再整块解码(这样最稳,因为事件边界一定在字符边界之后),要么用流式解码器(TextDecoder({stream:true})、Python codecs.getincrementaldecoder("utf-8")()),并保留未消费的余量。

正确骨架只有一个:

buffer = b""
while True:
    chunk = conn.read1(65536)         # 单次原始读,别用 read(n) 攒够才返回
    if not chunk:
        break                         # EOF:buffer 里可能还剩残包,见第 6 节
    buffer += chunk
    while b"\n\n" in buffer:          # 只按空行切事件
        raw, buffer = buffer.split(b"\n\n", 1)
        data_lines = []
        for line in raw.split(b"\n"):
            line = line.rstrip(b"\r")             # 容忍 CRLF
            if not line or line.startswith(b":"):  # 空行 / 心跳注释
                continue
            field, _, value = line.partition(b":")
            if value.startswith(b" "):
                value = value[1:]                 # 协议规定:去掉那一个空格
            if field == b"data":
                data_lines.append(value)
        if data_lines:
            handle(b"\n".join(data_lines))        # 完整事件才解码/parse

6 · [DONE] 没来就 EOF

我起了一个 max_tokens: 400 的长流,然后让客户端提前关掉(head -c 240),看最后落到磁盘的字节:

curl: (23) Failure writing output to destination
bytes=240
--- 尾部 60 字节 ---
ntent":""}}]}$
$
data: {"id":"97b5d283c2794677b3e9d6a33c5052d8     ← 断在这里,无 LF、无 }、无 [DONE]

两个必须处理的分支:

至于原因:SSE 规范自带重连(浏览器 EventSource 会带着 Last-Event-ID 回来),但 Chat Completions 不发 id: 字段(第 2 节实测 0 条),所以没有任何续传锚点——断了只能重发整个请求,或者接受残缺。

7 · tool_calls 是分片到达的字符串

强制模型调一次工具,抓到的完整流(8 块):

1  "delta":{"role":"assistant","content":""}
2  "delta":{"tool_calls":[{"id":"call_e44af56fcb6c4fccb2a3903a",
                          "function":{"arguments":"","name":"get_weather"}, … "index":0}]}
3  "delta":{"tool_calls":[{"function":{"arguments":"{"},"type":"function","index":0}]}
4  "delta":{"tool_calls":[{"function":{"arguments":"\"city\": \"北京\""},"type":"function","index":0}]}
5  "delta":{"tool_calls":[{"function":{"arguments":"}"},"type":"function","index":0}]}
6  "delta":{},"finish_reason":"tool_calls"
7  "usage":{"completion_tokens":22,"prompt_tokens":283,"total_tokens":305}
8  data: [DONE]

规则:

8 · n>1:delta 会交错

一次 "n": 2 的流,把每块的 choices[] 打出来:

idx1:{"role": "assistant", "content": "   ← 第一块居然是 index 1
idx1:{"content": "苹果"}
idx0:{"content": "苹果"}
idx0:{} fr=stop
idx0:{} fr=None  | usage

两个结论:到达顺序不保证按 index,而且每块只携带发生变化的那一路(不是完整数组)。所以必须用 choices[].index 做 key 维护 N 个累加器,别拿 choices[0] 当「当前这一路」。

9 · 时间维度:TTFB 抖 4 倍,中途还会静默 1.3 秒

同一个脚本、同一个模型,连续 5 次请求,记录每次原始读的到达时间:

#1  reads= 48  TTFB= 0.62s  total=1.08s   gaps: median=10ms   max=  30ms
#2  reads= 29  TTFB= 0.51s  total=2.02s   gaps: median=10ms   max=  581ms
#3  reads= 36  TTFB= 2.02s  total=3.59s   gaps: median= 9ms   max=1326ms
#4  reads= 86  TTFB= 0.65s  total=1.88s   gaps: median=15ms   max=  30ms
#5  reads= 89  TTFB= 1.27s  total=2.16s   gaps: median=10ms   max=  41ms
# 另外单独抓到过一次极端值:TTFB = 14.37s,之后 8 条 data 全挤在 56ms 内到达

这张表能推出四条实操结论:

10 · 错误不会走 SSE

最容易写出「解析器炸在 HTML/JSON 错误页上」的地方。实测两类失败:

# 不存在的模型 —— 注意是 503,不是 4xx
status=503 content-type: application/json; charset=utf-8
{"error":{"code":"model_not_found","message":"分组 default 下模型 … 无可用渠道(distributor)","type":"…_error"}}

# 无效令牌
status=401 content-type: application/json; charset=utf-8
{"error":{"code":"","message":"无效的令牌","type":"…_error"}}

也就是说:

11 · 厂商扩展字段:白名单解析

各家会在 chunk 里塞自己的东西,最常见的几类:

字段来源处理建议
delta.reasoning_contentDeepSeek R1、Qwen、Kimi 等推理模型(以及透传它们的网关)和 content 分开累加;不要混进最终答案,它是另一路文本
usage.completion_tokens_details.reasoning_tokens同上计费/展示用;本次实测两个模型都返回 0(非推理路径),所以我没抓到非零样本
logprobs / content_filter_resultsOpenAI / Azure可能整块缺失,永远 get() 而不是 []
metadata、system_fingerprint各家自定义忽略未知字段;json.loads 用宽容模式,不要 strict schema

规则一句话:解析时只认你需要的字段,其余全部忽略;反过来说,任何「chunk 一定有这些 key」的假设都不成立——第 3 节那个字段顺序变化就是证据。

12 · 一个能直接抄的客户端(两份都真跑过)

下面两段不是伪代码:把它们从本页抠出来直接跑,同一个「必须调用工具」的请求,Python 3.12 与 Bun 都返回 finish="tool_calls" + arguments={"city": "北京"}(参数在流里被切成 { / "city": "北京" / } 三片,见第 7 节);纯文本请求分别返回 finish="stop" 与 finish="length"(后者撞上 max_tokens: 40),usage 只在最后一块出现。要点全在前面各节,这里只是把它们缝起来。

Python(只用标准库)

import json
import urllib.request


def chat_stream(payload, base, key, idle_timeout=90):
    req = urllib.request.Request(
        base.rstrip("/") + "/chat/completions",
        data=json.dumps({**payload, "stream": True,
                         "stream_options": {"include_usage": True}}).encode(),
        headers={"Authorization": f"Bearer {key}",
                 "Content-Type": "application/json",
                 "Accept": "text/event-stream"})

    content = ""
    tool_calls = {}          # index -> {id, name, arguments(str)}
    usage, finish, saw_done, buf = None, None, False, b""

    with urllib.request.urlopen(req, timeout=idle_timeout) as r:   # 空闲超时
        if "event-stream" not in (r.headers.get("content-type") or ""):
            return json.loads(r.read().decode() or "{}")            # 错误或被降级成一次性 JSON

        while True:
            chunk = r.read1(65536)                                   # 单次原始读,见第 5 节坑 A
            if not chunk:
                break
            buf += chunk
            while b"\n\n" in buf:
                raw, buf = buf.split(b"\n\n", 1)
                lines = []
                for line in raw.split(b"\n"):
                    line = line.rstrip(b"\r")
                    if not line or line.startswith(b":"):             # 心跳 / 空行
                        continue
                    if not line.startswith(b"data:"):
                        continue                                       # 本 API 不发 event:
                    v = line[5:]
                    lines.append(v[1:] if v.startswith(b" ") else v)   # 去掉那一个空格
                if not lines:
                    continue
                data = b"\n".join(lines).decode("utf-8")               # 完整事件才解码
                if data.strip() == "[DONE]":
                    saw_done = True
                    continue
                obj = json.loads(data)
                if obj.get("usage"):
                    usage = obj["usage"]
                for ch in obj.get("choices") or []:                    # 可能压根没有 choices
                    delta = ch.get("delta") or {}
                    if delta.get("content"):
                        content += delta["content"]
                    for tc in delta.get("tool_calls") or []:
                        acc = tool_calls.setdefault(
                            tc.get("index", 0), {"id": None, "name": "", "arguments": ""})
                        acc["id"] = tc.get("id") or acc["id"]
                        fn = tc.get("function") or {}
                        if fn.get("name"):
                            acc["name"] += fn["name"]                  # name 也可能分片
                        if fn.get("arguments"):
                            acc["arguments"] += fn["arguments"]         # 只拼字符串,先别 parse
                    if ch.get("finish_reason"):
                        finish = ch["finish_reason"]

    if not saw_done:
        raise ConnectionError("stream ended without [DONE]")           # 第 6 节:这是异常

    parsed = {i: {"id": v["id"], "name": v["name"],
                  # 只有到这里 arguments 才是合法 JSON
                  "arguments": json.loads(v["arguments"] or "{}")}
              for i, v in tool_calls.items()}
    return {"content": content, "tool_calls": parsed, "usage": usage, "finish": finish}

TypeScript / Bun(fetch + ReadableStream)

type Streamed = {
  content: string;
  toolCalls: Record<number, { id: string | null; name: string; arguments: unknown }>;
  usage: unknown;
  finish: string | null;
};

export async function chatStream(payload: Record<string, unknown>,
                                 base: string, key: string): Promise<Streamed> {
  const res = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ ...payload, stream: true,
                           stream_options: { include_usage: true } }),
  });

  const ct = res.headers.get("content-type") ?? "";
  if (!res.ok || !ct.includes("text/event-stream")) {        // 第 10 节:先判,再进循环
    throw new Error(`HTTP ${res.status} ${ct}: ` +
                    JSON.stringify(await res.json().catch(() => null)));
  }

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();                  // 必须配 {stream:true},见第 5 节坑 B
  let buf = "", sawDone = false, content = "", usage: unknown = null, finish: string | null = null;
  const calls = new Map<number, { id: string | null; name: string; arguments: string }>();

  for (;;) {
    const { value, done } = await reader.read();      // read() 本来就是「来多少给多少」
    if (done) break;
    buf += decoder.decode(value, { stream: true });

    let sep: number;
    while ((sep = buf.search(/\r?\n\r?\n/)) >= 0) {   // LF 和 CRLF 的空行都算
      const block = buf.slice(0, sep);
      buf = buf.slice(sep).replace(/^\r?\n\r?\n/, ""); // 吃掉分隔符本身

      const dataLines: string[] = [];
      for (const line of block.split(/\r?\n/)) {
        if (!line || line.startsWith(":")) continue;   // 空行 / 心跳
        if (!line.startsWith("data:")) continue;       // 本 API 不发 event:
        const v = line.slice(5);
        dataLines.push(v.startsWith(" ") ? v.slice(1) : v);   // 去掉那一个空格
      }
      if (!dataLines.length) continue;
      const data = dataLines.join("\n");
      if (data.trim() === "[DONE]") { sawDone = true; continue; }  // 不是 JSON,先特判

      const obj = JSON.parse(data);
      if (obj.usage) usage = obj.usage;
      for (const ch of obj.choices ?? []) {              // usage chunk 里可能是 []
        const d = ch.delta ?? {};
        if (d.content) content += d.content;
        for (const tc of d.tool_calls ?? []) {
          const i = tc.index ?? 0;                    // 按 index 分组,不是数组下标
          const acc = calls.get(i) ?? { id: null, name: "", arguments: "" };
          if (tc.id) acc.id = tc.id;
          if (tc.function?.name) acc.name += tc.function.name;
          if (tc.function?.arguments) acc.arguments += tc.function.arguments;
          calls.set(i, acc);                           // 只拼字符串
        }
        if (ch.finish_reason) finish = ch.finish_reason;
      }
    }
  }
  if (!sawDone) throw new Error("stream ended without [DONE]");

  const toolCalls = Object.fromEntries([...calls].map(([i, v]) =>
    [i, { ...v, arguments: JSON.parse(v.arguments || "{}") }]));   // 到这儿才是合法 JSON
  return { content, toolCalls, usage, finish };
}

curl 侧记两件事:流式一定加 -N/--no-buffer,否则你会以为「没输出」;想验证到达节奏,curl -N … | while IFS= read -r l; do printf '%s %s\n' "$(date +%T.%N)" "$l"; done。想直接看有没有 [DONE]:… | tail -c 200 | cat -A。

13 · Checklist

出处

SSE 语义:WHATWG HTML — §9.2.5 Server-sent events(字段、空行 dispatch、去一个空格、Last-Event-ID、retry:)。载荷形状:OpenAI Chat Completions / Responses API 参考文档。第 2–10 节的数字与片段来自本文写作时本机 curl 8.5.0 / Python 3.12 / Bun 的抓包(原始文件留在 /tmp/sse/:完整流 s1.raw、工具调用 c.raw、n=2 g.raw、错误体 d1.body/d2.body、h1 头 i.hdr);第 12 节两段代码是从本页复制出去跑的(Python 3.12 + urllib / Bun + fetch),工具调用与纯文本两条路径都验证过。凡标注「网关行为」的条目与官方实现可能不同,换实现时请重测那几条。

· 相关