SSE

5 阅读1分钟

SSE 是什么?

SSE = Server-Sent Events(服务端推送事件)。

一种 HTTP 长连接 上的数据格式:服务器持续往客户端推一行一行数据,客户端边收边处理,不用等整份响应全部下完。

和 WebSocket 的区别(面试常问):

SSE

WebSocket

方向

主要是 服务器 → 客户端(单向)

双向

协议

普通 HTTP

独立协议

典型用途

日志流、大模型逐字输出

聊天室、游戏

OpenAI 的 stream: true 用的就是这类 SSE 流:响应体不是一个大 JSON,而是很多行以 data: 开头的文本。

为什么要用 SSE 做 LLM?

大模型生成慢,若等非流式:

  • 用户盯着空白或 loading 很久才看到全文
  • 体感延迟 = 整段生成时间

流式 SSE:

  • 体感延迟 ≈ 首 token 时间(往往短很多)

  • 总时间差不多,但 体验更好

流式读 SSE

拆成两部分:

  1. 流式(streaming)
    模型每生成一小段字(一个 token 或几个字符),服务器就推一段;客户端 边收边显示,用户能更早看到「第一个字」,不用等整段回答生成完。

  2. 读 SSE
    客户端用 URLSession 的 异步字节流(例如 session.bytes(for:)),按行读响应,解析以 data: 开头的行,从里面的 JSON 取出 delta.content,拼成完整译文。

逻辑(HTTPInterpretationClient.parseSSELineData)大致是:

服务器发来一行:

data: {"choices":[{"delta":{"content":"你"}}]}

再一行:

data: {"choices":[{"delta":{"content":"好"}}]}

最后一行:

data: [DONE]

客户端:把 "你" + "好" 拼起来 → 界面上先显示「你」再显示「你好」

这就是「流式读 SSE」:用流的方式读 HTTP 响应体里的 SSE 行,并增量更新 UI。

一句话记忆

SSE = 服务器用 HTTP 一行行推数据;流式读 SSE = App 边读这些行、边从每行 JSON 里抠出新增文字、边更新界面,而不是等一整块 JSON 下载完再解析。

async/await 和 SSE

概念

解决什么问题

SSE / 流式

数据怎么从服务器来——一块一块推,不是一次给全

async/await

代码怎么等 I/O——等的时候不堵死线程,用挂起/恢复

它们不是同一种东西,但在 App 里经常一起用:用 async/await 去等待并读取 SSE 流里的每一块数据。

关系可以概括成一句话

SSE 是传输形态;async/await 是 Swift 里读这种传输的写法。

没有 async/await,你仍可以用回调读 SSE;有了 async/await,可以用 for try await 按字节/行读,代码更直、也好和 Task 取消配合。

实际项目里是怎么合在一起的?

听译通用模式(我自己开发的一款翻译软件)走 HTTPInterpretationClient.streamPlainTranslation,典型顺序是:

1. async 函数里发起请求(await session.bytes(...))

2. 得到 AsyncSequence(字节流)

3. for try await byte in bytes { ... } 逐字节读

4. 拼出一行 → 解析 "data: {...}" → 取出 delta.content

5. onPartial(已拼接的全文) 更新 UI

对应含义:

步骤

async/await 在干什么

SSE 在干什么

await session.bytes(for: urlRequest)

挂起,等 HTTP 连接建立、开始收到 body

响应体是长流,不是一次性 Data

for try await byte in bytes

每来一个字节就恢复一次循环体

从 SSE 流里增量读

onPartialTranslation(assembled)

常在 Task { @MainActor in } 里改 UI

每解析出一段译文就推一次界面

所以:await 的是「下一批 I/O 就绪」;SSE 决定「就绪时来的是一小段 delta,不是整包 JSON」。

和非流式对比(同是 async/await)

同文件里两种路径都用 async,差别在读响应的方式:

// 非流式:一次 await,拿完整 Data

let (data, response) = try await session.data(for: urlRequest)

// 再 JSONDecoder 解析 choices[0].message.content

// 流式 SSE:await 打开流,再 for try await 读很多遍

let (bytes, response) = try await session.bytes(for: urlRequest)

for try await byte in bytes { ... parseSSELineData ... }

  • 都是 async I/O
  • 流式 = 一次 await 开门 + 很多次 await 下一个字节/行(for try await
  • 非流式 = 通常 一次 await 等整包结束

面试可说:流式并没有取代 async/await,而是让 同一个 async 函数里出现更长的「多次挂起」循环。

和 UI、Task 的关系

  1. 按钮:Task { await client.interpret(...) } —— 进入异步上下文。
  2. interpret 内部 await 读 SSE,partial 回调里可能再 Task { @MainActor in 更新双栏 }
  3. 用户点停止:Task.cancel()for try await 会抛取消错误,循环结束,不再读 SSE。

取消是 async/await + Task 的能力;边读边显示是 SSE 的能力;两者一起才能保证「停得掉、又不卡 UI」。

常见误解

误解 1:「用了 SSE 就不用 async/await了」
→ 错。读网络流仍是 I/O,本项目仍用 async 函数 + await / for try await

误解 2:「async/await 会自动变成流式」
→ 错。必须请求里 "stream": true,且用 bytes + 解析 data: 行;data(for:) 是一次性读完。

误解 3:「流式 = 在后台线程」
→ 不准确。await 时让出执行权;是否在主线程由 @MainActor 决定。SSE 解析可在 async 上下文里跑,改 UI 再回 MainActor。

类比(帮助记忆)

  • SSE:水龙头一直滴水(服务器一直推 data: 行)。
  • async/await:你站在旁边,不用傻站着干等(不阻塞 UI);每滴到了你就处理一下(for try await),桶里水(assembled 译文)慢慢变满。