专栏:AI 全栈开发|11
|---------------------------------------------------------------------------------------------------------------------------------|
| 前面几篇我们一直把 HTTP Response 当成"服务器处理完以后一次返回"。但生成式 AI 有一个新特点:答案可能需要几秒甚至更久才生成完,而且中间结果可以不断产生。Streaming 的价值,就是让浏览器不用等全部完成,先开始接收已经生成的部分。 |
一、先看最普通的一次性实现哪里让人难受
后端如果这样写:
answer = model.generate(prompt)
return {"answer": answer}
服务器只有等 `model.generate()` 完全结束,才能把完整 JSON 返回。
如果模型 8 秒后才生成完,页面前 8 秒几乎拿不到正文内容。
Streaming 改变的是返回方式:
|------------------------------------|
| 模型生成一点 ↓ 后端转发一点 ↓ 浏览器接收一点 ↓ 页面展示一点 |
这样用户不需要等到最终答案全部完成,已经生成的内容可以先显示。
图 1 Streaming 不会让总生成时间凭空消失,但可以让用户更早看到第一批结果
二、不是所有 AI 接口都必须 Streaming
这是原稿里最需要收回的一句话。
对长文本生成、聊天、代码生成这类交互,Streaming 通常很有价值,因为输出本来就是逐步产生的。
但下面这些场景完全可以一次性返回:
• 分类:只返回一个类别。
• Embedding:返回一个向量结果。
• 很短的 Structured Output。
• 后台批处理任务,本来就不要求用户盯着页面等。
所以正确结论是:
**生成时间较长,而且中间结果对用户有价值时,Streaming 往往能显著改善交互体验。**
三、HTTP Streaming 到底是什么意思
先不要急着背 `Transfer-Encoding: chunked`。
从应用开发者角度,HTTP Streaming 可以先理解成:
HTTP Response 建立以后,Response Body 不需要等全部数据准备好再一次性发送,而可以持续产生、持续被客户端读取。
不同 HTTP 版本实现底层传输的方式不同:
|----------|------------------------------------------------------|
| 协议 | 对应用层最重要的认识 |
| HTTP/1.1 | 未知长度的响应可以使用 chunked transfer coding 等机制传输 |
| HTTP/2 | 使用自己的帧和 Stream 机制;不使用 `Transfer-Encoding: chunked` |
| HTTP/3 | 基于 QUIC 的 HTTP Stream;也不是 HTTP/1.1 的 chunked 报文 |
|--------------------------------------------------------------------------------------------------------------------------|
| 因此"HTTP Streaming = `Transfer-Encoding: chunked`"是不准确的。chunked 是 HTTP/1.1 的一种消息传输方式;现代应用通常把这些底层细节交给 Web Server / 框架处理。 |
四、模型 token、后端 yield、浏览器 read() 不是一一对应
图 2 模型输出、后端应用块和网络字节块是不同层;不要假设"一次 token = 一个 HTTP chunk = 一次 read()"
很多教程会说:
"模型生成一个 token,服务器就发一个 chunk,浏览器就收到一个 chunk。"
作为直觉可以理解,但工程上不能依赖这件事。
真实链路可能是:
• 模型 SDK 一次回调给你几个字符或一个 delta。
• 你的后端把多个 delta 合并后再 `yield`。
• Web Server、HTTP/2 帧、代理、TLS 都可能重新分段。
• 浏览器 `reader.read()` 拿到的字节块也可能被合并或拆分。
因此真正需要稳定的是**应用协议**,而不是网络 chunk 边界。下一篇 SSE 就是给流式数据加结构化事件边界的一种常见方式。
五、先用 FastAPI 做一个最小裸 Streaming
后端不接真实模型,先用异步生成器模拟:
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def fake_stream():
for piece in ["你好", ",", "这是", "一个", "流式", "回答。"]:
await asyncio.sleep(0.3)
yield piece.encode("utf-8")
@app.get("/stream")
async def stream():
return StreamingResponse(
fake_stream(),
media_type="text/plain; charset=utf-8",
)
启动后用:
|--------------------------------------|
| curl -N http://127.0.0.1:8000/stream |
你应该看到内容陆续出现,而不是等全部字符串都生成完才一次打印。
这里没有手工写 HTTP/1.1 chunk 长度,也没有自己设置 `Transfer-Encoding`,因为这类传输细节应该交给 ASGI Server 和协议栈。
六、浏览器怎样消费一个 Streaming Response
`fetch()` 返回的 `Response.body` 是一个 `ReadableStream`。可以逐步读取:
const resp = await fetch("/stream");
if (!resp.body) {
throw new Error("response body is not readable");
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
output.textContent = text;
}
text += decoder.decode(); // flush decoder
最后这句 `decoder.decode()` 很容易被忽略:如果多字节字符刚好跨两个字节块,`TextDecoder` 的 streaming 模式会保留残余状态,结束时最好 flush 一次。
同样要记住:一次 `reader.read()` 返回的是一段字节,不保证刚好是一句话、一个 token 或一次后端 `yield`。
七、Streaming 和 SSE 到底是什么关系
3 HTTP Streaming 解决"能持续传";SSE 在流上增加事件格式和浏览器约定
裸 Streaming 只解决:正文可以持续到达。至于每段数据代表什么,由应用自己约定。
例如我们现在只是连续发送普通文本:
|--------------|
| 你好,这是一个流式回答。 |
如果以后想表达:
|-----------------------|
| 开始 文本片段 Tool 状态 错误 完成 |
就需要一层更明确的事件协议。SSE 是浏览器 Web 生态里很常见的一种方式,使用 `text/event-stream` 和 `data:` 等字段组织事件。
下一篇 12 专门讲 SSE,这一篇先不把 EventSource、event/id、自动重连全部提前塞进来。
八、Streaming 为什么在代理后面可能"看起来不流了"
本地直连后端时,每隔 300ms 都能看到新内容;一挂代理,结果可能攒一会儿再一起到。
原因可能来自多层缓冲:
• 应用代码自己先攒数据。
• 压缩/日志等中间件可能缓冲。
• 反向代理或 CDN 可能改变转发节奏。
• 客户端本身也可能有显示缓冲。
所以"后端已经 yield"不代表用户一定立刻看到。
现在只记住调试方法:用 `curl -N` 先直连后端测试,再经过代理测试。如果两边节奏明显不同,优先检查中间层缓冲。
Nginx 的具体配置、超时和部署问题放到后面的生产部署章节继续讲。
九、流开始以后,错误处理为什么变得不一样
普通接口可以在执行失败时返回:
|---------------------------|
| HTTP 500 {"error": "..."} |
但流式响应一旦已经发送了 200 响应头和部分正文,后面再失败,就不能把 HTTP 状态码"改回 500"。
这意味着流式协议通常要提前约定:
• 流内怎样表示错误。
• 怎样表示完成。
• 客户端怎样知道当前响应是正常结束还是中途失败。
这正是下一篇 SSE Event Protocol 会自然解决的问题之一。
十、客户端断开以后,上游模型会自动停止吗
不一定。
浏览器关页面、Abort fetch 或网络断开,只说明客户端不再继续消费这条连接。
如果后端没有把取消继续传播到上游模型请求,Provider 端仍可能继续生成和计费。
所以完整取消链路应该是:
|--------------------------------------------------------------|
| Browser cancel ↓ Backend 发现取消 / 断开 ↓ 停止读取并尽可能取消上游模型请求 ↓ 释放资源 |
这一篇只建立这个边界。AbortController、`request.is_disconnected()`、Provider 是否真正支持取消,会在后面的取消专题单独做。
十一、几个最容易学错的说法
• 误区 1:所有 AI 接口都必须流式。分类、Embedding、短 JSON 等场景未必需要。
• 误区 2:Streaming 会减少模型总生成时间。它主要改变结果到达用户的节奏。
• 误区 3:HTTP Streaming 就是 `Transfer-Encoding: chunked`。HTTP/2/3 有不同的流传输机制。
• 误区 4:后端一次 yield 就等于浏览器一次 read。中间层可以拆分、合并或缓冲数据。
• 误区 5:Content-Length 和 Streaming 永远互斥。很多流式场景确实未知总长度,但"是否流式"不是仅由一个 Header 判断。
• 误区 6:浏览器断开就一定停止模型生成。取消必须沿后端继续传播,上游还要支持取消。
十二、这一篇只记住 4 句话
• Streaming 的价值是让尚未完成的响应可以逐步到达客户端。
• 它特别适合长时间生成且中间结果有价值的交互,不是所有 AI 请求的必选项。
• 模型 token、后端 chunk 和浏览器读到的字节块不是一一对应。
• 裸 Streaming 只解决"持续传输",SSE 会在下一篇解决"持续传输的数据怎样组织成事件"。
十三、下一篇
下一篇 12《SSE 到底是什么?》会在这条 Streaming 链路上加一层事件协议:`text/event-stream`、`data:`、事件结束、错误以及 EventSource 为什么能自动重连。