不灭的焱

革命尚未成功,同志仍须努力 下载Java21

作者:AlbertWen  添加时间:2026-06-02 19:58:30  修改时间:2026-06-16 04:36:04  分类:16.编程基础/Web安全  编辑

下面把 “普通 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 只负责收尾

一句话概括:

普通返回像“等菜全部做好再上桌”;流式返回像“边炒边端,先让你吃上第一口”。