承上:上一篇我们让AI乖乖返回了结构化JSON,接口终于像个正经API了。但体验上还差一口气------用户问一个问题,得盯着空白页面等8秒,然后突然蹦出一大段文字。这哪叫AI对话?这叫"开盲盒"。今天,我们要让AI学会"边想边写"。
1. 问题场景:同步调用的"假死感"
先回顾一下我们现在的调用方式:
java
String response = chatClient.prompt()
.user(message)
.call()
.content(); // 阻塞等待,直到AI全部写完才返回
调用链是这样的:
txt
用户发请求 → 服务器等待 → AI思考3秒 → AI写5秒 → 返回完整结果
↑
这8秒里用户啥也看不到
这种同步调用在生成大段文字时体验极差。用户盯着空白页面等8秒,然后突然冒出来一篇800字的文章。这不叫AI对话,这叫"AI盲盒"------你不知道它在干嘛,也不知道要不要继续等。
后端老鸟的直觉反应:这不是和文件下载一个道理吗?大文件不能一次性读到内存里,要用流。
2. 核心代码:一句话切换到流式
2.1. Service实现
Spring AI的流式调用,改动量小到离谱:
java
/**
* 同步调用
*/
public String chatString(String message) {
return chatClient.prompt()
.system("你是一个专业的助手,可以回答用户的问题")
.user(message)
.call()
.content();
}
/**
* 流式调用
*/
public Flux<String> chatFlux(String message) {
return chatClient.prompt()
.system("你是一个专业的助手,可以回答用户的问题")
.user(message)
.stream()
.content();
}
就一个变化:.call() → .stream(),返回类型从 String 变成 Flux<String>。
Flux是啥? Project Reactor的核心类,和你在WebFlux里用的是同一套。简单理解:
Flux<String>就是一个"会随时间不断吐出字符串的流",每吐出一个Token,订阅者就能立刻收到。
2.2. Controller适配SSE
java
package com.yunxi.ai.controller;
import com.yunxi.ai.service.ChatService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
/**
* 流式对话接口
* produces = TEXT_EVENT_STREAM_VALUE 是关键,告诉Spring返回SSE格式
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message) {
return chatService.chatFlux(message);
}
}
关键注解 :
produces = MediaType.TEXT_EVENT_STREAM_VALUE
这告诉Spring MVC:
- 响应Content-Type是
text/event-stream - 数据格式遵循SSE规范:
data: 内容\n\n
2.3. 测试效果
用curl测试(curl原生支持SSE,-N 参数禁用缓冲):
perl
http://localhost:9999/chat/stream?message=%E5%86%99%E4%B8%80%E9%A6%96%E5%85%B3%E4%BA%8Ejava%E7%9A%84%E4%BA%8B
你会看到输出是逐词往外蹦的:
txt
data:我想
data:您说的"事
data:"
data:应该是"诗"
data:的笔误。
data:Java 作为编程
data:界最经典、
data:最长寿的语言之一
data:,
data:承载了无数程序
data:员的青春与汗水
data:。
data:
data:为您创作
data:了一首关于
data: Java 的现代诗
data: **《致Java
data::一杯不冷的
data:咖啡》**,
Token ≠ 汉字:中文Token有时一个字,有时一个词。这取决于模型的分词策略,不用纠结。
3. 前端消费
3.1. 上代码(AI写的)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流式输出示例</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px 32px;
color: white;
}
.header h1 {
font-size: 24px;
font-weight: 600;
}
.header p {
font-size: 14px;
opacity: 0.9;
margin-top: 4px;
}
.content {
padding: 32px;
}
.input-group {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.input-group input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-group input:focus {
border-color: #667eea;
}
.input-group button {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.input-group button:hover {
background: #5a6fd6;
}
.input-group button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.result-area {
background: #f9fafb;
border-radius: 8px;
padding: 20px;
min-height: 200px;
font-size: 14px;
line-height: 1.8;
color: #374151;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid #e5e7eb;
}
.result-area:empty::before {
content: '等待输入...';
color: #9ca3af;
}
.streaming-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
color: #667eea;
font-size: 12px;
margin-bottom: 8px;
}
.streaming-indicator span {
display: inline-block;
width: 6px;
height: 6px;
background: #667eea;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out both;
}
.streaming-indicator span:nth-child(1) { animation-delay: -0.32s; }
.streaming-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes pulse {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.clear-btn {
margin-top: 12px;
padding: 8px 16px;
background: #f3f4f6;
color: #6b7280;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.clear-btn:hover {
background: #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>流式输出 Demo</h1>
<p>基于 Server-Sent Events (SSE) 的实时响应</p>
</div>
<div class="content">
<div class="input-group">
<input type="text" id="prompt" placeholder="请输入问题,例如:介绍一下 Spring AI">
<button id="sendBtn">发送</button>
</div>
<div id="streamingIndicator" class="streaming-indicator" style="display: none;">
<span></span><span></span><span></span> 正在生成中...
</div>
<div id="result" class="result-area"></div>
<button id="clearBtn" class="clear-btn" style="display: none;">清空结果</button>
</div>
</div>
<script>
const promptInput = document.getElementById('prompt');
const sendBtn = document.getElementById('sendBtn');
const resultArea = document.getElementById('result');
const streamingIndicator = document.getElementById('streamingIndicator');
const clearBtn = document.getElementById('clearBtn');
sendBtn.addEventListener('click', () => {
const prompt = promptInput.value.trim();
if (!prompt) return;
sendBtn.disabled = true;
resultArea.textContent = '';
streamingIndicator.style.display = 'flex';
clearBtn.style.display = 'none';
const encodedMessage = encodeURIComponent(prompt);
const url = `/chat/stream?message=${encodedMessage}`;
const eventSource = new EventSource(url);
eventSource.onmessage = function(event) {
if (event.data) {
resultArea.textContent += event.data;
resultArea.scrollTop = resultArea.scrollHeight;
}
};
eventSource.onerror = function() {
if (eventSource.readyState === EventSource.CLOSED) {
sendBtn.disabled = false;
streamingIndicator.style.display = 'none';
clearBtn.style.display = 'block';
} else {
resultArea.textContent += '\n连接异常';
sendBtn.disabled = false;
streamingIndicator.style.display = 'none';
clearBtn.style.display = 'block';
}
eventSource.close();
};
});
clearBtn.addEventListener('click', () => {
resultArea.textContent = '';
clearBtn.style.display = 'none';
});
promptInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendBtn.click();
}
});
</script>
</body>
</html>
3.2. 效果描述

用户点击"发送"后:
- 页面不转圈,不白屏
- 文字像打字机一样逐词出现在页面上
这就是ChatGPT同款体验。
4. 流式的工程价值(不只是"好看")
4.1. 首字响应时间对比
| 调用方式 | 首字到达时间 | 完整响应时间 | 用户体验 |
|---|---|---|---|
同步 .call() |
8秒 | 8秒 | 盯白屏等8秒 |
流式 .stream() |
0.3秒 | 8秒 | 立刻开始阅读 |
0.3秒 vs 8秒,这是"秒开"和"用户流失"的区别。
4.2. 支持中途取消
用户看到AI开始跑偏,可以立刻中断,不浪费Token:
java
public Flux<String> streamChatWithCancel(String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.doOnCancel(() -> {
// 用户关闭了连接,可以在这里记录日志或释放资源
log.info("用户中断了对话,停止Token消耗");
});
}
前端关闭EventSource → 后端 doOnCancel 触发 → Token不再消耗。真金白银省下来了。
4.3. 实时监控Token消耗
java
public Flux<String> streamChatWithMonitoring(String message) {
AtomicReference<Usage> usageRef = new AtomicReference<>();
AtomicReference<StringBuilder> fullContent = new AtomicReference<>(new StringBuilder());
return chatClient.prompt()
.user(message)
.stream()
.chatResponse()
.doOnNext(response -> {
// 提取内容(根据实际框架调整)
String chunk = extractContent(response);
if (chunk != null) {
fullContent.get().append(chunk);
}
// 尝试获取 token(通常在最后一块才有)
Usage usage = response.getMetadata().getUsage();
if (usage != null && usage.getTotalTokens() > 0) {
usageRef.set(usage);
}
})
.doOnComplete(() -> {
// 流结束后记录 token 和完整内容
if (usageRef.get() != null) {
log.info("=== Token Usage for message: {} ===",
message.length() > 50 ? message.substring(0, 50) + "..." : message);
log.info("Prompt Tokens: {}", usageRef.get().getPromptTokens());
log.info("Completion Tokens: {}", usageRef.get().getCompletionTokens());
log.info("Total Tokens: {}", usageRef.get().getTotalTokens());
log.info("Response length: {} characters", fullContent.get().length());
} else {
log.warn("No token usage data available for this response");
}
})
.map(response -> extractContent(response)); // 直接返回内容字符串
}
// 辅助方法:安全提取内容
private String extractContent(ChatResponse response) {
if (response == null || response.getResult() == null) {
return "";
}
Object output = response.getResult().getOutput();
if (output == null) {
return "";
}
// 根据实际类型处理
if (output instanceof String) {
return (String) output;
} else if (output instanceof AssistantMessage) {
AssistantMessage msg = (AssistantMessage) output;
return msg.getText() != null ? msg.getText() : "";
} else {
// 其他类型,尝试 toString
return output.toString();
}
}
注意 :部分模型在流式模式下,Token统计只在最后一条响应中返回。中间过程可能为null,要判空。
yaml
=== Token Usage for message: 你好,做一首诗 ===
Prompt Tokens: 15
Completion Tokens: 913
Total Tokens: 928
Response length: 275 characters
5. 本篇避坑指南
5.1. 坑1:浏览器直接访问流式接口,页面一直转圈
浏览器地址栏不支持SSE渲染。用curl测试,或者写一个简单的HTML页面。
bash
# 正确测试方式
curl -N http://localhost:8080/chat/stream?message=你好
5.2. 坑2:Nginx反向代理截断了流式效果
如果Nginx开启了响应缓冲,会等后端全部返回后再一次性发给前端。
解决:在Nginx配置中关闭缓冲:
ini
location /chat/stream {
proxy_buffering off;
proxy_cache off;
proxy_pass http://backend;
}
5.3. 坑3:Spring Security拦截了SSE请求
java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/chat/stream/**").permitAll() // 放行SSE
// ...
);
return http.build();
}
6. 本篇小结
这一篇我们解决了"AI盲盒"问题:
| 改动 | 效果 |
|---|---|
.call()→ .stream() |
一字之差,体验天翻地覆 |
String→ Flux<String> |
返回类型变了,编程模型不变 |
produces = TEXT_EVENT_STREAM_VALUE |
告诉Spring这是SSE |
前端 EventSource |
原生支持,零依赖 |
现在我们拥有了:
- ✅ 工程化的项目结构(第1篇)
- ✅ 配置管理的提示词系统(第2篇)
- ✅ 结构化的JSON响应(第3篇)
- ✅ 流式打字机体验(本篇)
但AI依然是"嘴强王者"------说得天花乱坠,但查不了数据库、调不了接口。下一篇,我们给AI装上手脚,让它能真正干活。Function Calling,启动!
本文与DeepSeek协作完成