Spring Boot 流式处理:从 SSE、WebClient 到大模型 Stream
最近开始学习 Spring AI,在接触大模型对话的时候,经常会看到这样的代码:
chatClient.prompt()
.user("介绍一下 Redis")
.stream()
.content();第一次看到 .stream(),可能会有一个疑问:
大模型的“流式输出”到底是怎么实现的?Spring Boot 为什么能够让我们像打字一样,一段一段地看到 AI 的回答?
如果直接从 Spring AI 的 API 开始学习,很容易只知道怎么调用,却不知道底层到底发生了什么。
所以这篇文章不直接讲 Spring AI,而是先从 Spring Boot 的流式处理出发,自己实现一次大模型的流式调用。
理解了下面这条链路:
HTTP
↓
SSE
↓
WebClient
↓
Flux
↓
大模型 Stream再去学习 Spring AI,会轻松很多。
一、普通 HTTP 请求是怎么返回数据的?
我们平时写 Spring Boot 接口,最常见的是:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello World";
}
}客户端发送:
GET /hello服务端处理完成后,一次性返回:
Hello World可以简单理解成:
客户端
│
│ HTTP Request
▼
Spring Boot
│
│ 执行业务逻辑
▼
Controller
│
│ 返回完整结果
▼
客户端如果业务执行需要 3 秒:
@GetMapping("/hello")
public String hello() throws InterruptedException {
Thread.sleep(3000);
return "Hello World";
}那么客户端通常需要等待这 3 秒,才能拿到完整结果。
请求
│
├── 等待
├── 等待
├── 等待
│
▼
Hello World对于普通 CRUD 接口来说,这完全没有问题。
但有一些场景就不一样了。
例如:
- AI 对话
- AI 写文章
- AI 翻译
- 实时日志
- 任务进度
- 实时消息
这些场景有一个共同特点:
结果不是一次性产生的,而是逐渐产生的。
二、大模型为什么需要流式输出?
假设我们问大模型:
请介绍一下 Redis大模型最终可能回答:
Redis 是一个基于内存的高性能键值数据库。但是这个结果并不是瞬间生成的。
从用户体验的角度,可以把生成过程理解成:
Redis
↓
Redis 是
↓
Redis 是一个
↓
Redis 是一个基于内存的
↓
Redis 是一个基于内存的高性能
↓
Redis 是一个基于内存的高性能键值数据库。如果采用普通 HTTP:
用户
↓
请求大模型
↓
等待
↓
等待
↓
等待
↓
完整答案用户可能需要等几秒钟,最后突然看到一整段内容。
而流式输出:
用户
↓
请求大模型
↓
Redis
↓
Redis 是
↓
Redis 是一个
↓
Redis 是一个基于内存的
↓
……用户可以很快看到内容开始出现。
所以这里有一个非常重要的认识:
流式输出并不是让大模型生成得更快,而是让用户更早看到已经生成的内容。
三、Spring Boot 中怎么实现流式输出?
在传统 Spring MVC 中,一个比较经典的方案就是:
SseEmitter例如:
@GetMapping("/stream")
public SseEmitter stream() {
SseEmitter emitter = new SseEmitter(60_000L);
new Thread(() -> {
try {
emitter.send("第一段内容");
Thread.sleep(1000);
emitter.send("第二段内容");
Thread.sleep(1000);
emitter.send("第三段内容");
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}).start();
return emitter;
}这里最重要的是:
emitter.send(...)每调用一次,就可以向客户端发送一段数据。
所以客户端不是最后一次性拿到:
第一段内容第二段内容第三段内容而是可以逐步收到:
第一段内容
第二段内容
第三段内容这就是一种典型的流式输出。
四、SSE 到底是什么?
SSE 的全称是:
Server-Sent Events
它是一种基于 HTTP 的服务端向客户端持续推送数据的机制。
我们通常会看到:
Content-Type: text/event-stream例如:
@GetMapping(
value = "/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public SseEmitter stream() {
...
}它表达的是:
这个 HTTP Response 是一个事件流,客户端不要认为拿到一个 Response 就结束了,服务端还可能继续发送数据。
所以普通 HTTP 可以理解成:
Request
↓
Response
↓
结束而 SSE 更像:
Request
↓
Event 1
↓
Event 2
↓
Event 3
↓
Event 4
↓
结束五、SSE 和 stream=true 是一回事吗?
这里是学习大模型流式调用时最容易混淆的地方。
答案是:
不是一回事。
例如我们调用大模型 API 时:
{
"model": "qwen3.7-plus",
"messages": [
{
"role": "user",
"content": "你是谁?"
}
],
"stream": true
}这里:
"stream": true是在告诉上游大模型 API:
我要流式返回。
而:
Content-Type: text/event-stream描述的是:
当前这个 HTTP Response 使用 SSE 事件流格式。
所以:
stream=true和:
text/event-stream虽然经常一起出现,但职责是不一样的。
可以简单记成:
stream=true
↓
告诉大模型:
“你流式给我”
text/event-stream
↓
告诉客户端:
“我这是一个事件流”六、现在进入真正的大模型调用
前面只是模拟数据。
接下来我们真正调用大模型 API。
我的这个示例使用 JDK 自带的:
java.net.http.HttpClient来调用大模型。
pom 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>完整代码如下:
@RestController
@RequestMapping("/stream")
public class StreamController {
private static final String REQUEST_BODY = """
{
"model": "qwen3.7-plus",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "你是谁?"
}
],
"stream": true
}
""";
private final String apiKey;
private final URI apiUri;
private final HttpClient httpClient;
private final WebClient webClient;
public StreamController(@Value("${llm.api-key}") String apiKey,
@Value("${llm.api-url}") String apiUrl) {
this.apiKey = apiKey;
this.apiUri = URI.create(apiUrl);
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
this.webClient = WebClient.builder()
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.build();
}
}这里同时准备了两个 HTTP Client:
HttpClient
WebClient后面分别用它们实现两种流式方案。
七、第一种方案:HttpClient + SseEmitter
先看传统 Spring MVC 的实现。
@GetMapping("/sse")
public SseEmitter sse(HttpServletResponse response) {
response.setContentType("text/event-stream;charset=UTF-8");
SseEmitter emitter = new SseEmitter(60_000L);
CompletableFuture<Void> future = httpClient.sendAsync(HttpRequest.newBuilder()
.uri(apiUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.POST(HttpRequest.BodyPublishers.ofString(REQUEST_BODY))
.build(), HttpResponse.BodyHandlers.ofLines())
.thenAccept(upstream -> {
if (HttpStatus.OK.value() != upstream.statusCode()) {
emitter.completeWithError(new RuntimeException("API call failed, status code: " +
upstream.statusCode()));
return;
}
try (Stream<String> stream = upstream.body()) {
for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
String line = it.next();
// 空行是时间分隔符,不需要转发
if (!line.startsWith("data:")) {
continue;
}
try {
emitter.send(line.substring(5).stripLeading());
} catch (IOException e) {
emitter.completeWithError(e);
return;
}
}
emitter.complete();
}
})
.exceptionally(e -> {
emitter.completeWithError(e);
return null;
});
// 客户端断开或超时后取消对上游的消费
emitter.onCompletion(() -> future.cancel(true));
return emitter;
}响应:
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854139,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"."},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" **确定回复"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"策略**:\n"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" * "},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"直接回答身份:"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"我是通"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"义千问("},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"Qwen),由"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"阿里巴巴集团通义"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"实验室自主研发的大语言"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"模型。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"\n * "},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" 保持简洁、"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"清晰、真实。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"\n "},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" * 使用"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"与用户相同的语言"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"(中文)。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"\n3. "},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" **草拟回复"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"**:\n "},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":" * “我是"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"通义千问"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":",由阿里巴巴集团"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"通义实验室自主研发"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854140,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"的大语言模型。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"我是一个乐于助人的"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"AI思考"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"伙伴,可以帮你"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"解答问题、"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"提供创意、写"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"代码或者进行日常"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"对话。有什么我可以"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"帮你的"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"吗?”\n4"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":". **最终"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"检查**:符合"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"所有约束条件。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"身份准确,语言"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"匹配,语气真诚"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854141,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"有帮助。\n\n输出"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":"回复。"},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"我是通义","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"千问,由","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"阿里巴巴集团通义","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"实验室自主研发的大语言","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"模型。我是一个人工智能","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"助手,旨在为你","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"解答问题、提供","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"创意灵感、协助","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"编写代码或进行","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"各种日常对话。","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"有什么","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"我可以帮你的吗","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"?","reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[{"index":0,"delta":{"content":"","reasoning_content":""},"finish_reason":"stop","logprobs":null}],"usage":null}
data:{"id":"chatcmpl-f416e5eb-7e45-9a60-a787-782878aea20c","object":"chat.completion.chunk","created":1790854142,"model":"qwen3.7-plus","choices":[],"usage":{"prompt_tokens":23,"total_tokens":247,"completion_tokens":224,"prompt_tokens_details":{"cached_tokens":0,"text_tokens":23},"completion_tokens_details":{"reasoning_tokens":176,"text_tokens":224}}}
data:[DONE]代码虽然稍微长了一点,但实际上逻辑非常简单。
八、BodyHandlers.ofLines() 做了什么?
这里是一个关键点:
HttpResponse.BodyHandlers.ofLines()它告诉 JDK HttpClient:
按照行来消费上游 HTTP Response。
大模型的流式响应通常会类似:
data: {...}
data: {...}
data: {...}
data: {...}
data: [DONE]因此我们可以:
Stream<String> stream = upstream.body();然后:
for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
String line = it.next();
}一行一行地处理。
注意:
这里的
stream是 Java Stream,不是大模型 API 里的stream=true。
这两个 stream 只是名字碰巧一样,概念完全不同。
一个是:
"stream": true大模型 API 的请求参数。
另一个是:
Stream<String>Java 的数据流 API。
九、然后发生了最关键的一步:转发
我们从大模型收到:
data: xxx代码:
if (!line.startsWith("data:")) {
continue;
}过滤掉不是数据的行。
然后:
emitter.send(line.substring(5).stripLeading());把真正的数据发送给前端。
于是整个过程变成:
大模型
│
│ data: chunk1
▼
Spring Boot
│
│ emitter.send(chunk1)
▼
浏览器接下来:
大模型
│
│ data: chunk2
▼
Spring Boot
│
│ emitter.send(chunk2)
▼
浏览器所以你的 Spring Boot 实际上做了一件非常重要的事情:
它是一个流式代理(Streaming Proxy)。
它自己并不生成 AI 内容。
而是:
接收上游流
↓
读取数据
↓
马上转发
↓
下游客户端十、这时候你就能看懂整个网络链路了
实际上这里存在两个 HTTP 连接。
第一条:
Spring Boot ───────────► 大模型
Request
Spring Boot ◄─────────── 大模型
Streaming Response第二条:
浏览器 ─────────────────► Spring Boot
Request
浏览器 ◄───────────────── Spring Boot
SSE Response所以 Spring Boot 位于中间:
┌──────────┐
│ 浏览器 │
└────┬─────┘
│
│ SSE
▼
┌──────────────┐
│ Spring Boot │
└──────┬───────┘
│
│ Streaming HTTP
▼
┌──────────┐
│ 大模型 │
└──────────┘这就是很多 AI 应用最基本的流式架构。
十一、第二种方案:WebClient + Flux
如果使用 Spring WebFlux,同样的事情可以写得更加简洁。
你的代码:
@GetMapping(value = "/flux", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> flux(HttpServletResponse response) {
response.setContentType("text/event-stream;charset=UTF-8");
return webClient.post()
.uri(apiUri)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(REQUEST_BODY)
.retrieve()
.bodyToFlux(String.class);
}核心其实就几行:
return webClient.post()
.uri(apiUri)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(REQUEST_BODY)
.retrieve()
.bodyToFlux(String.class);最值得关注的是:
bodyToFlux(String.class)十二、为什么这里是 Flux?
先把 WebFlux 中两个非常重要的概念简单理解一下:
Mono
↓
0 或 1 个结果
Flux
↓
0 到 N 个结果例如普通接口:
Mono<String>最终可能只有:
Hello World而流式接口:
Flux<String>可以不断产生:
你
好
,
我
是
AI
助
手所以:
bodyToFlux(String.class)可以理解成:
不要等整个 Response 结束,把上游持续产生的数据作为一个 Flux 不断往下游传递。
十三、WebFlux 版到底发生了什么?
整个过程:
大模型
│
│ Streaming Response
▼
WebClient
│
│ bodyToFlux()
▼
Flux<String>
│
▼
Spring WebFlux
│
│ text/event-stream
▼
浏览器相比刚才的:
HttpClient
↓
Stream
↓
for循环
↓
SseEmitter.send()WebFlux 版本不需要我们自己写这个循环。
因为:
Flux 本身就代表一个持续产生数据的数据流。
Spring WebFlux 会负责订阅这个数据流,并把数据持续写入 HTTP Response。
十四、两种方案放在一起看
现在就非常清楚了。
| 方案 | 上游 HTTP Client | 流数据 | 下游 |
|---|---|---|---|
/sse | JDK HttpClient | Stream<String> | SseEmitter |
/flux | WebClient | Flux<String> | WebFlux SSE |
第一种:
大模型
↓
HttpClient
↓
Stream<String>
↓
for
↓
SseEmitter.send()
↓
浏览器第二种:
大模型
↓
WebClient
↓
Flux<String>
↓
WebFlux
↓
浏览器所以 WebFlux 的优势之一,就是:
把“持续产生的数据”抽象成了
Flux,让我们可以用响应式编程的方式处理整个流。
十五、一个特别容易误解的问题:Chunk = Token?
我们在讨论大模型流式输出的时候,经常会说:
“大模型一个 Token 一个 Token 地返回。”
这句话作为概念理解可以,但在工程实现中不要把它理解得太绝对。
例如大模型内部可能产生:
Token 1
Token 2
Token 3
Token 4但 API 对外返回的数据可能是:
chunk 1:你好
chunk 2:,我是
chunk 3:一个 AI
chunk 4:助手所以:
Token
↓
大模型内部的生成单位
Chunk / Delta
↓
API 对外暴露的增量数据
HTTP 数据
↓
网络层实际传输的数据
SSE Event
↓
服务端发送给客户端的一条事件这些概念不要简单地认为是一一对应。
十六、还有一个工程细节:为什么你的代码判断 data:?
因为大模型的流式响应通常采用 SSE 风格的数据格式,例如:
data: {"choices":[...]}
data: {"choices":[...]}
data: {"choices":[...]}
data: [DONE]所以你代码里面:
if (!line.startsWith("data:")) {
continue;
}实际上是在处理 SSE 数据。
然后:
line.substring(5)把:
data: {...}变成:
{...}不过在真正的生产代码中,还需要进一步解析 JSON,而不是简单地把字符串直接返回。
例如可能需要从:
{
"choices": [
{
"delta": {
"content": "你好"
}
}
]
}里面提取:
你好再发送给前端。
十七、所以我们现在可以重新理解 stream=true
到这里,我们可以把整个过程完整串起来。
客户端:
GET /chatSpring Boot 调用大模型:
{
"messages": [...],
"stream": true
}大模型:
开始生成
↓
产生一部分
↓
立即返回
↓
再产生一部分
↓
立即返回
↓
……Spring Boot:
接收 chunk
↓
Flux / Stream
↓
处理 chunk
↓
SSE
↓
前端最终:
大模型
│
│ stream=true
▼
流式 HTTP Response
│
▼
Spring Boot
│
┌──────┴──────┐
│ │
HttpClient WebClient
│ │
Stream<T> Flux<T>
│ │
SseEmitter │
│ │
└──────┬──────┘
▼
SSE
│
▼
浏览器这就是大模型流式输出最核心的链路。
十八、那么 Spring AI 到底做了什么?
现在再回头看 Spring AI:
chatClient.prompt()
.user("介绍一下 Redis")
.stream()
.content();是不是就容易理解很多了?
我们刚才自己写了一套:
WebClient
↓
HTTP Request
↓
stream=true
↓
流式 Response
↓
Flux
↓
SSE
↓
浏览器而 Spring AI 把很多底层细节封装起来了。
所以:
.stream()并不是什么神秘的“AI 黑魔法”。
它背后的核心思想依然是:
大模型流式生成
↓
HTTP 流式响应
↓
持续读取
↓
Flux
↓
持续向下游输出只不过 Spring AI 帮我们把大量通用代码隐藏起来了。
十九、为什么我建议学习 Spring AI 前先理解流式处理?
因为如果不了解底层,很容易出现这种情况:
看到:
chatClient
.prompt()
.stream()
.content();只知道:
“哦,这个方法可以实现流式输出。”
但是不知道:
数据从哪里来?
为什么是一段一段的?
stream=true 是干什么的?
为什么是 Flux?
SSE 又是什么?
WebClient 在哪里?而把本文的代码自己写一遍之后:
stream=true
↓
大模型持续返回
↓
WebClient
↓
Flux
↓
SSE
↓
浏览器再去看 Spring AI:
chatClient
.prompt()
.stream()
.content();你就能知道:
Spring AI 并没有改变 HTTP 流式处理的基本原理,而是在这些基础能力之上做了更高层次的抽象。
二十、从 Spring Boot 走向 Spring AI
如果把学习路线整理一下,我比较推荐这样理解:
① 普通 Spring MVC
↓
Controller
↓
HTTP Request / Response
② SSE
↓
SseEmitter
↓
服务端持续推送
③ WebFlux
↓
Mono / Flux
↓
响应式数据流
④ WebClient
↓
调用外部 HTTP 服务
↓
持续读取 Response
⑤ 大模型 API
↓
stream=true
↓
流式生成
⑥ Spring AI
↓
ChatClient
↓
.stream()这样学习下来,你不是在“背 Spring AI API”,而是在理解:
Spring AI 是如何建立在 Spring 本身的 Web、HTTP、响应式等基础能力之上的。
写在最后
大模型流式输出看起来很“AI”,但把它拆开以后,其实还是我们熟悉的 Web 技术。
它最核心的东西可以浓缩成一句话:
不要等完整结果生成以后再返回,而是结果产生一点,就传一点。
在上游:
stream=true告诉大模型:
请流式返回。
在 Spring Boot 中:
WebClient
+
Flux负责:
持续接收和处理上游数据。
在下游:
SSE
+
text/event-stream负责:
把数据持续推送给客户端。
而到了 Spring AI:
chatClient
.prompt()
.stream()
.content();这些底层细节被进一步封装起来。
所以,如果你准备开始学习 Spring AI,我认为先把本文这条链路真正搞懂,会是一个非常好的起点:
大模型
│
stream=true
│
▼
流式 HTTP Response
│
▼
WebClient
│
▼
Flux
│
▼
SSE
│
▼
浏览器
│
▼
Spring AI
│
ChatClient.stream()当你真正理解这条链路之后,再进入 Spring AI,就会从“会用 API”变成“知道 API 背后在做什么”。