下面把 “普通 HTTP 返回” 和 “流式返回” 放到 HTTP 协议、报文、客户端处理、AI/Codex 类场景里完整拆开讲。
先记住一句话:
普通 HTTP 返回:客户端通常等整个 body 收完后再解析。
流式返回:客户端在 body 还没结束时,就边收边解析、边展示。
它们本质上都还是 HTTP 响应,只是 服务端发送 body 的时机 和 客户端消费 body 的方式 不同。
1. HTTP 响应的基本结构
一个 HTTP 响应大体由三部分组成:
状态行 响应头 headers 响应体 body
例如:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 38
{"code":0,"message":"ok","data":"hello"}
其中:
HTTP/1.1 200 OK
是状态行。
Content-Type: application/json Content-Length: 38
是响应头。
{"code":0,"message":"ok","data":"hello"}
是响应体。
Content-Length 表示 body 的字节长度;它要求发送 headers 之前就知道 body 大小,所以对动态生成或流式生成的内容不总是合适。MDN 对 Content-Length 的解释也是:它表示消息体字节大小,但消息大小必须预先知道;HTTP/1.1 中分块发送的响应可以用 Transfer-Encoding: chunked 替代。(MDN 文档)
2. 什么是“普通 HTTP 返回”?
所谓 普通 HTTP 返回,不是 HTTP 标准里的正式术语,而是工程里常说的:
服务端先把完整结果生成好,然后一次性或近似一次性返回给客户端;客户端等 body 全部读完后,再解析、处理、展示。
典型场景:
查询用户信息 登录接口 普通 JSON API 一次性 AI 回答 普通文件元数据查询 订单详情接口
2.1 普通返回的处理流程
客户端发送请求
↓
服务端处理请求
↓
服务端生成完整结果
↓
服务端发送 HTTP 响应头 + 完整响应体
↓
客户端读完整个 body
↓
客户端解析 JSON / XML / HTML
↓
客户端展示结果
例如一个普通 AI 问答接口:
POST /api/chat HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Length: <request-body-bytes>
{"prompt":"介绍一下 HTTP 流式返回"}
服务端等模型生成完完整回答后,再返回:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: <response-body-bytes>
{
"id": "resp_001",
"text": "HTTP 流式返回是指服务端在生成内容的同时逐步发送给客户端……",
"finish_reason": "stop"
}
客户端拿到的是一个完整 JSON,然后再解析。
3. 普通 HTTP 返回的客户端代码例子
3.1 JavaScript fetch 普通返回
async function normalRequest() {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
prompt: "介绍一下 HTTP 流式返回"
})
});
// 这里会等整个 body 读取完成后,再解析 JSON
const data = await response.json();
console.log(data.text);
}
重点是:
await response.json()
这一步通常会等响应体完整读完,再把整个 JSON 解析出来。
3.2 Java 普通返回
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{"prompt":"介绍一下 HTTP 流式返回"}
"""))
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
// 这里拿到的是完整响应体
String body = response.body();
System.out.println(body);
这里的 BodyHandlers.ofString() 表示把响应体完整读成字符串。
3.3 Netty 普通返回
如果不用 HttpObjectAggregator,Netty 中可能收到:
HttpResponse HttpContent HttpContent LastHttpContent
普通 JSON 接口一般会把 body 拼起来,等 LastHttpContent 到了再解析:
private final StringBuilder bodyBuilder = new StringBuilder();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof HttpResponse response) {
System.out.println("status = " + response.status());
}
if (msg instanceof HttpContent content) {
String chunk = content.content().toString(StandardCharsets.UTF_8);
bodyBuilder.append(chunk);
}
if (msg instanceof LastHttpContent) {
String fullBody = bodyBuilder.toString();
// 普通 HTTP 返回:这里才解析完整 JSON
parseJson(fullBody);
bodyBuilder.setLength(0);
}
}
如果你加了聚合器:
pipeline.addLast(new HttpObjectAggregator(10 * 1024 * 1024));
Netty 会尽量把:
HttpResponse + 多个 HttpContent + LastHttpContent
聚合成:
FullHttpResponse
普通 HTTP 返回适合这种方式,因为业务逻辑本来就是“拿完整 body 再处理”。
4. 什么是“流式返回”?
流式返回 指的是:
服务端不等完整结果全部生成完,而是先发响应头,然后不断把新生成的一小段内容写入响应体;客户端收到一段就处理一段。
典型场景:
AI 模型逐字/逐 token 输出 Codex / AI 编程助手实时输出 SSE 事件推送 (Server-Sent Events) 日志实时查看 大文件下载 视频播放 长任务进度推送 股票行情推送
流式返回的核心目标是降低等待时间。
普通返回:
等 10 秒 → 一次性看到完整结果
流式返回:
等 0.5 秒 → 看到第一段 继续接收 → 第二段 继续接收 → 第三段 …… 最后结束
OpenAI 官方文档也把这点讲得很直观:默认情况下,请求会等模型生成完整输出后,再作为一个 HTTP 响应返回;而 streaming 可以让你在模型继续生成完整结果的同时,先处理输出开头。OpenAI 的当前 API streaming 文档说明其 HTTP streaming 使用 SSE,并通过 stream=true 开启。(OpenAI开发者)
5. 流式返回的处理流程
客户端发送请求
↓
服务端立即返回响应头
↓
服务端生成第一段内容
↓
发送第一段 body
↓
客户端立即处理第一段
↓
服务端生成第二段内容
↓
发送第二段 body
↓
客户端立即处理第二段
↓
……
↓
服务端发送结束标记 / 关闭响应体
↓
客户端完成流处理
注意:流式返回不是多个 HTTP 响应。
它通常还是一个 HTTP 响应:
一个请求 一个响应头 一个持续写入的响应体 一个结束点
6. HTTP/1.1 中的流式传输:Transfer-Encoding: chunked
HTTP/1.1 最常见的流式响应方式之一是:
Transfer-Encoding: chunked
这表示响应体不是一次性给出完整长度,而是拆成多个 chunk 发送。RFC 9112 说明,chunked transfer coding 会把内容按一系列带长度标记的 chunk 传输,这样发送方可以在内容大小未知时持续传输,同时接收方仍能知道消息何时完整结束。(IETF Datatracker)
6.1 原始 chunked 报文示例
HTTP/1.1 200 OK Content-Type: text/plain Transfer-Encoding: chunked 5 hello 1 5 world 0
上面的 body 不是直接:
hello world
而是被 HTTP/1.1 chunked 编码成:
5\r\n hello\r\n 1\r\n \r\n 5\r\n world\r\n 0\r\n \r\n
含义是:
5 后面 5 个字节:hello 1 后面 1 个字节:空格 5 后面 5 个字节:world 0 结束,没有更多 chunk
RFC 9112 规定,chunk size 为 0 的 chunk 表示 chunked 编码完成,后面可能跟 trailer,最后以空行结束。(IETF Datatracker)
客户端 HTTP 库解码之后,应用层看到的是:
hello world
而不是 chunk size。
7. chunked 不等于业务流式
这一点很重要:
Transfer-Encoding: chunked 是 HTTP 传输层面的分块;
SSE、NDJSON、AI token delta 才是应用层面的“业务消息”。
例如服务端发送:
data: {"delta":"Hel"}
data: {"delta":"lo"}
在 HTTP/1.1 底层,可能被拆成:
chunk 1: data: {"de
chunk 2: lta":"Hel"}\n\n
chunk 3: data: {"delta":"lo"}\n\n
也可能被合并成:
chunk 1: data: {"delta":"Hel"}\n\ndata: {"delta":"lo"}\n\n
所以客户端不能假设:
一个 HttpContent = 一个业务消息 一个 TCP 包 = 一个业务消息 一个 chunk = 一个 JSON
正确做法是:
按应用层协议解析边界
比如 SSE 用空行 \n\n 分隔事件;NDJSON 用换行 \n 分隔 JSON;自定义协议可以用长度前缀。
8. SSE:AI 客户端最常见的流式返回格式(SSE: Server-Sent Events)
AI 客户端、聊天机器人、Codex 类客户端经常用 SSE,Server-Sent Events。
SSE 的特点是:
基于 HTTP 服务端向客户端单向推送 Content-Type 通常是 text/event-stream 每条消息用空行分隔 浏览器可以用 EventSource 处理
MDN 对 SSE 的说明是:服务器可以在任意时间把新数据推给网页,浏览器把这些消息作为事件和数据处理;SSE 服务端响应需要使用 text/event-stream,每个通知是文本块,并用一对换行结束。(MDN 文档) (MDN 文档)
8.1 SSE 响应示例
客户端请求:
GET /api/chat-stream HTTP/1.1 Host: api.example.com Accept: text/event-stream
服务端响应:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
data: {"delta":"你"}
data: {"delta":"好"}
data: {"delta":","}
data: {"delta":"我是"}
data: {"delta":"AI"}
data: [DONE]
客户端不是等所有内容结束后再处理,而是每收到一条 SSE 消息就处理:
收到 {"delta":"你"} → 页面显示:你
收到 {"delta":"好"} → 页面显示:你好
收到 {"delta":","} → 页面显示:你好,
收到 {"delta":"我是"} → 页面显示:你好,我是
收到 {"delta":"AI"} → 页面显示:你好,我是AI
收到 [DONE] → 流结束
8.2 SSE 消息格式
SSE body 是文本流,常见字段有:
event: 事件名 data: 数据 id: 事件 ID retry: 重连时间
最简单的是 data-only message:
data: hello data: world
也可以带事件名:
event: progress
data: {"percent":10}
event: progress
data: {"percent":20}
event: done
data: {"result":"ok"}
MDN 说明 SSE 事件流必须使用 UTF-8 编码,消息之间用一对换行分隔;每条消息由一行或多行字段组成,字段形式是字段名、冒号和值。(MDN 文档)
9. AI 流式返回示例
假设用户输入:
写一个冒泡排序
9.1 普通 HTTP 返回
服务端等完整答案生成后,返回:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: <bytes>
{
"text": "下面是一个 Java 冒泡排序示例:\n\npublic class BubbleSort {...}"
}
客户端体验:
等待几秒 一次性看到完整答案
9.2 流式返回
服务端马上返回响应头:
HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Transfer-Encoding: chunked
然后不断推送:
data: {"delta":"下面是"}
data: {"delta":"一个 Java"}
data: {"delta":" 冒泡排序"}
data: {"delta":" 示例:\n\n"}
data: {"delta":"public class"}
data: {"delta":" BubbleSort"}
data: {"delta":" {"}
data: [DONE]
客户端体验:
先看到:下面是 再看到:下面是一个 Java 再看到:下面是一个 Java 冒泡排序 继续逐步出现代码 最后结束
这就是 AI 客户端常见的“打字机效果”。
10. JavaScript 客户端处理流式返回
10.1 使用 EventSource 处理 SSE
适合 GET 请求的 SSE:
const es = new EventSource("/api/chat-stream");
es.onmessage = (event) => {
if (event.data === "[DONE]") {
es.close();
console.log("stream finished");
return;
}
const data = JSON.parse(event.data);
appendToPage(data.delta);
};
es.onerror = (err) => {
console.error("SSE error", err);
es.close();
};
服务端返回类似:
data: {"delta":"你"}
data: {"delta":"好"}
data: [DONE]
10.2 使用 fetch 读取流
适合 POST 请求流式响应:
async function streamRequest() {
const response = await fetch("/api/chat-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream"
},
body: JSON.stringify({
prompt: "写一个冒泡排序"
})
});
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += value;
// SSE 用空行分隔事件
let index;
while ((index = buffer.indexOf("\n\n")) >= 0) {
const rawEvent = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
handleSseEvent(rawEvent);
}
}
}
function handleSseEvent(rawEvent) {
const lines = rawEvent.split("\n");
for (const line of lines) {
if (!line.startsWith("data:")) {
continue;
}
const data = line.slice("data:".length).trim();
if (data === "[DONE]") {
console.log("finished");
return;
}
const json = JSON.parse(data);
appendToPage(json.delta);
}
}
关键点是:
buffer += value;
因为一次 reader.read() 读到的内容可能是半条 SSE,也可能是多条 SSE。
11. Node.js 服务端普通返回 vs 流式返回
11.1 普通返回
app.post("/api/chat", async (req, res) => {
const fullText = await generateFullAnswer(req.body.prompt);
res.json({
text: fullText,
finish_reason: "stop"
});
});
流程:
generateFullAnswer 完整执行完
↓
res.json 一次性返回完整 JSON
客户端必须等完整结果。
11.2 SSE 流式返回
app.post("/api/chat-stream", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// 尽快把响应头发出去
res.flushHeaders?.();
for await (const token of generateTokens(req.body.prompt)) {
res.write(`data: ${JSON.stringify({ delta: token })}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
});
这里的关键是:
res.write(...)
不是等所有 token 生成完,而是生成一个写一个。
12. Netty 中普通返回和流式返回的区别
12.1 普通 HTTP 返回:等 LastHttpContent
private final StringBuilder body = new StringBuilder();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof HttpContent content) {
body.append(content.content().toString(StandardCharsets.UTF_8));
}
if (msg instanceof LastHttpContent) {
String fullBody = body.toString();
// 普通返回:完整 body 到了之后再解析
parseFullJson(fullBody);
body.setLength(0);
}
}
适合:
application/json application/xml text/html 普通页面 普通 REST API
12.2 流式返回:不要等 LastHttpContent 才处理
private final StringBuilder buffer = new StringBuilder();
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof HttpContent content) {
String chunk = content.content().toString(StandardCharsets.UTF_8);
buffer.append(chunk);
// 每收到一段都尝试解析 SSE 事件
parseAvailableSseEvents();
}
if (msg instanceof LastHttpContent) {
// HTTP body 真正结束
System.out.println("HTTP stream finished");
if (!buffer.isEmpty()) {
// 处理残留内容,视协议而定
handleRemain(buffer.toString());
buffer.setLength(0);
}
}
}
private void parseAvailableSseEvents() {
int index;
while ((index = buffer.indexOf("\n\n")) >= 0) {
String event = buffer.substring(0, index);
buffer.delete(0, index + 2);
handleSseEvent(event);
}
}
private void handleSseEvent(String event) {
for (String line : event.split("\n")) {
if (!line.startsWith("data:")) {
continue;
}
String data = line.substring("data:".length()).trim();
if ("[DONE]".equals(data)) {
System.out.println("business stream done");
return;
}
// data 可能是 JSON
System.out.println("delta = " + data);
}
}
重点:
普通返回:LastHttpContent 到了再处理业务数据 流式返回:HttpContent 到了就尝试处理,LastHttpContent 只表示 HTTP 层结束
13. HTTP/2 下的流式返回
HTTP/2 不使用 HTTP/1.1 的:
Transfer-Encoding: chunked
RFC 9113 明确说明,HTTP/2 使用 DATA frames 承载消息内容,HTTP/1.1 的 chunked transfer encoding 不能在 HTTP/2 中使用。(IETF Datatracker)
HTTP/2 的响应大概是:
HEADERS frame DATA frame DATA frame DATA frame END_STREAM
RFC 9113 说明,HTTP/2 响应可由 HEADERS 帧、零个或多个 DATA 帧组成,最后一个帧带 END_STREAM 表示该 stream 结束。(IETF Datatracker)
所以在 HTTP/2 中,流式响应仍然存在,只是底层不是:
Transfer-Encoding: chunked
而是:
HTTP/2 DATA frames
应用层仍然可以是:
text/event-stream application/x-ndjson 自定义二进制协议
14. 普通返回 vs 流式返回对比
| 对比项 | 普通 HTTP 返回 | 流式返回 |
|---|---|---|
| 服务端生成方式 | 先生成完整结果 | 边生成边发送 |
| 客户端处理方式 | 等 body 完整后解析 | 收到一段处理一段 |
| 常见 Content-Type | application/json、text/html | text/event-stream、application/x-ndjson、text/plain |
| 常见长度方式 | Content-Length | HTTP/1.1 常见 Transfer-Encoding: chunked;HTTP/2 用 DATA frames |
| 首字节等待时间 | 可能较长 | 通常更短 |
| 实时展示 | 不适合 | 适合 |
| 错误处理 | 成功就是完整结果,失败通常无结果 | 可能收到半截内容后连接中断 |
| JSON 解析 | 通常一次性 JSON.parse | 需要按事件/行/帧增量解析 |
| 适合场景 | REST API、登录、查询 | AI 输出、日志、进度、行情、长任务 |
15. 一个完整对照例子:AI 问答
15.1 普通返回
用户问:
解释一下 TCP 三次握手
请求:
POST /v1/chat HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
Content-Length: <bytes>
{"message":"解释一下 TCP 三次握手"}
响应:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: <bytes>
{
"answer": "TCP 三次握手包括 SYN、SYN-ACK、ACK 三个步骤……"
}
客户端表现:
等待服务端生成完整 answer 一次性展示完整 answer
适合:
短回答 后台任务结果 对实时性要求不高 希望简单解析 JSON
15.2 流式返回
请求:
POST /v1/chat HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: text/event-stream
Content-Length: <bytes>
{"message":"解释一下 TCP 三次握手","stream":true}
响应头:
HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Transfer-Encoding: chunked
响应体逐步发送:
data: {"delta":"TCP"}
data: {"delta":" 三次握手"}
data: {"delta":" 包括"}
data: {"delta":" SYN、"}
data: {"delta":"SYN-ACK、"}
data: {"delta":"ACK"}
data: {"delta":" 三个步骤。"}
data: [DONE]
客户端表现:
TCP TCP 三次握手 TCP 三次握手 包括 TCP 三次握手 包括 SYN、 TCP 三次握手 包括 SYN、SYN-ACK、 TCP 三次握手 包括 SYN、SYN-ACK、ACK TCP 三次握手 包括 SYN、SYN-ACK、ACK 三个步骤。
适合:
AI 聊天 AI 编程助手 长文本生成 实时用户反馈
16. “结束”的含义:协议结束 vs 业务结束
流式返回里有两个“结束”概念。
16.1 HTTP 协议层结束
例如 HTTP/1.1 chunked:
0\r\n \r\n
表示 HTTP body 结束。
Netty 里通常对应:
LastHttpContent
HTTP/2 里通常对应:
END_STREAM
16.2 业务层结束
例如 SSE 里:
data: [DONE]
或者:
event: done
data: {"finish_reason":"stop"}
业务层结束不一定等于 TCP 连接马上关闭,也不一定等于 HTTP 底层最后一个数据帧。
AI 客户端一般更关心业务层结束:
收到 [DONE] → 模型输出结束 收到 LastHttpContent → HTTP 响应体结束
通常 [DONE] 会出现在 LastHttpContent 之前或同一个最后片段里,但不要把两者混为一谈。
17. 常见坑
坑 1:以为一个 HttpContent 就是一条消息
错误:
if (msg instanceof HttpContent content) {
String json = content.content().toString(StandardCharsets.UTF_8);
JSON.parse(json); // 可能失败
}
原因:
一个 JSON 可能被拆成多个 HttpContent 多个 JSON/SSE 事件也可能合在一个 HttpContent
正确做法:
先放入 buffer 再按应用层分隔符解析
坑 2:SSE 事件可能被拆开
服务端业务上发送:
data: {"delta":"hello"}
客户端可能分两次读到:
data: {"del
和:
ta":"hello"}
所以必须 buffer。
坑 3:流式接口却使用 response.json()
错误:
const res = await fetch("/api/chat-stream");
const data = await res.json();
这样会等整个流结束,失去实时效果。
流式应该用:
const reader = res.body.getReader();
或者 SSE 用:
new EventSource("/api/chat-stream")
坑 4:服务端写了 res.write,但客户端还是不实时
可能原因:
Nginx / 网关开启了 buffering 服务端压缩导致小块数据被缓存 客户端使用了 readAll 类 API 没有及时 flush 浏览器或代理缓冲 HTTP 库自动聚合 body
SSE 服务端常见头:
Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no
MDN 的 SSE 服务端示例也包含 Content-Type: text/event-stream、Cache-Control: no-cache,并演示了关闭 Nginx buffering 的 X-Accel-Buffering: no。(MDN 文档)
坑 5:HTTP/2 下找不到 Transfer-Encoding: chunked
这是正常的。HTTP/2 不能使用 HTTP/1.1 的 chunked transfer encoding,而是用 DATA frames 承载响应内容。(IETF Datatracker)
18. 总结
普通 HTTP 返回:
请求 → 服务端生成完整结果 → 返回完整 body → 客户端一次性解析
适合:
普通 JSON API 查询类接口 登录注册 短文本结果 不需要实时展示的场景
流式返回:
请求 → 服务端先发响应头 → 边生成边写 body → 客户端边收边处理
适合:
AI 输出 Codex 类代码生成 聊天机器人 日志推送 进度推送 大文件/音视频 实时事件
最关键的工程判断是:
普通返回:等 LastHttpContent / 完整 body 后再解析 流式返回:每个 HttpContent 到来就喂给增量解析器,LastHttpContent 只负责收尾
一句话概括:
普通返回像“等菜全部做好再上桌”;流式返回像“边炒边端,先让你吃上第一口”。