Appearance
HTTP 流式响应:SSE 与 Stream
元信息
- 目标:打破"响应是一份完整文档"的时间维度假设——理解流式响应"边生成、边传输、边处理"的价值;能在 SSE 与 fetch stream 间按场景取舍,读懂 LLM API 逐字打出效果的底层实现
- 关键概念:流式响应、chunk、SSE、EventSource、text/event-stream、ReadableStream、getReader、首字延迟、stream: true
- 关联阶段:cp3
- 常见误区:以为 HTTP 必须 Content-Length 整块交付(协议天然支持逐块发送);AI 对话场景选 EventSource(它只能 GET,装不下带对话历史的 POST,只能用 fetch stream);以为流式更快(总时间没变,降的是首字延迟——感知速度比总速度更重要)
问题的提出:普通 HTTP 请求的"一次交付"
前面学的 ajax/fetch(ajax、网络请求API)都是同一种模式:发请求 → 等服务器把响应全部发完 → 一次性拿到结果。这对小数据毫无问题,但两类场景会把它逼到墙角:
- 生成很慢的内容:AI 生成一段长回答要几十秒——让用户盯着空白等全部完成,还是边生成边显示?
- 体量很大的内容:下载大文件时,是等全部到齐再处理,还是到一块处理一块?
普通请求的痛点在于"响应是一份完整文档"的隐含假设(和浏览器应用里"一次请求=一份完整文档"同源——只不过这次是时间维度的整块交付)。流式响应打破它:响应体可以分成很多块(chunk),边生成、边传输、边处理。
HTTP 协议本身天然支持(Content-Length 可缺省,连接保持、逐块发送),浏览器提供了两套消费手段:SSE 和 fetch stream。
两种方式
SSE(Server-Sent Events):约定好的流式协议
SSE 是 HTML 标准的一部分:服务器以 text/event-stream 类型持续发送"事件",浏览器用内建的 EventSource 对象接收:
服务器发的报文体是极简单的文本格式,一行一条:
plain
data: 第一条消息
data: 第二条消息特点:只能 GET、只能服务器→客户端单向推送;换来的是极简 API 和自动重连。适合通知、进度、行情这类"服务器单方面说"的场景。
fetch stream:通用的流式读取
fetch 拿到的 response.body 是一个可读流(ReadableStream),可以逐块读取:
特点:任意方法(POST 也行)、可带请求体、可自定义协议格式;代价是重连、解析都要自己写。
对比普通请求
| 普通 ajax/fetch | SSE | fetch stream | |
|---|---|---|---|
| 交付方式 | 整块一次交付 | 逐块持续推送 | 逐块持续推送 |
| 请求方法 | 任意 | 仅 GET | 任意(可 POST+请求体) |
| 方向 | 一问一答 | 服务器→客户端单向 | 一问一答,但答案分段送达 |
| 断线重连 | 不适用 | 浏览器自动 | 自己实现 |
| 典型场景 | 表单、列表、配置 | 通知、进度条 | AI 对话、大文件处理 |
取舍逻辑一句话:能用普通请求就用普通请求;需要服务器持续说→SSE;需要复杂请求(如带对话历史的 POST)又要流式答案→fetch stream。
LLM API 中的应用
你每天见到的 AI 对话"逐字打出"效果,就是流式响应的当代最大应用。DeepSeek、OpenAI 等主流 LLM API 的做法高度一致:
- 请求时加
"stream": true(POST + 完整对话历史——所以只能用 fetch stream,EventSource 的 GET 装不下) - 服务器以 SSE 格式逐块返回生成的 token,每块形如
data: {"choices":[{"delta":{"content":"你"}}]} - 生成结束时发一块
data: [DONE],客户端据此收尾
客户端的最小实现,就是在 fetch stream 循环里解析这些行:
为什么 LLM 非要用流式?生成速度是逐 token 的(一次几个字),SSE 逐块转发把首字延迟从"全文生成完"降到"第一个 token 生成完"——几十秒的等待变成 1 秒内的开始响应。这也是体验层的一课:感知速度往往比总速度更重要。
边界
- 双向持续通信(客户端也随时说)不是 SSE/stream 的领地,那是 WebSocket(见第 15 周延伸话题)
- 大文件上传的分块与本篇的流式响应是两个方向,用到时再查
- 生产代码中注意超时与重连(fetch stream 需自行实现,SSE 自带)