HTTP Streaming:为什么 AI 回答经常要边生成边返回?

专栏: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 为什么能自动重连。

相关推荐
GlobalInfo1 小时前
正文回答“是什么”,附录回答“为什么是这样”
大数据·人工智能
万岳科技系统开发1 小时前
AI直播数字人如何赋能企业营销?直播、AI与私域运营深度融合
人工智能
隔窗听雨眠1 小时前
海量知识库高效检索与智能调度:客服场景下的RAG架构设计与工程实践
人工智能
仙魁XAN1 小时前
【Codex + Deepseek】第 7 篇:如何把一个模糊想法变成可执行开发需求
人工智能·codex·deepseek·vibe coding
数字孪生视频孪生1 小时前
空间智能技术|跨镜轨迹全域可溯,无感定位无扰赋能落地
大数据·运维·人工智能·重构·架构
霸道流氓气质1 小时前
Spring AI 多模态开发指南:图片理解与语音合成
java·人工智能·spring
YOLO视觉与编程1 小时前
YOLO / Labelme目标分割数据集增强扩充软件v1.0.0适用于YOLO全版本
人工智能·深度学习·yolo·计算机视觉
angered1 小时前
「AI 应用 / AI Agent」行业日报 · 2026-09-04
人工智能·ai编程
深海鱼肝油ya1 小时前
向量数据库Elasticsearch(一)
人工智能·elasticsearch·向量数据库·es数据库增加索引·es数据库相似度查询·knn查询