通过测试用例掌握打字机效果的流式响应
本章通过 8 个完整的测试用例,带你掌握 Spring AI 的流式响应功能。 你将学会如何使用 Reactor Flux 处理流式数据,实现打字机效果的 AI 回复。
stream() 方法获取流式数据Flux 的基本用法StepVerifier 测试响应式流非流式(传统方式):
用户提问 → 等待... → 一次性返回完整回答
流式(打字机效果):
用户提问 → "你" → "好" → "!" → "我" → "是" → ...创建文件:src/test/java/com/example/chatbot/knowledge/Test05_StreamingResponse.java
package com.example.chatbot.knowledge;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* 测试类:流式响应
*
* 本测试类演示如何使用流式响应实现打字机效果
*
* 核心概念:
* - Flux:响应式流,可以发射多个元素
* - stream():ChatModel 的流式调用方法
* - StepVerifier:测试响应式流的工具
*/
@SpringBootTest(classes = com.example.chatbot.AiChatbotApplication.class)
@DisplayName("知识点5:流式响应")
public class Test05_StreamingResponse {
@Autowired
private ChatModel chatModel;
// 测试方法将在这里添加
}Flux - 响应式流StepVerifier - 测试响应式流的工具AtomicInteger - 线程安全的计数器Duration - 时间间隔第一个流式响应测试,了解基本用法。
/**
* 测试 5.1:基础流式响应
*
* 知识点:
* - stream() 方法返回 Flux<ChatResponse>
* - Flux 会逐块发射数据
* - 使用 subscribe() 订阅流
*/
@Test
@DisplayName("5.1 基础流式响应")
public void test01_BasicStreaming() {
System.out.println("\n========== 测试 5.1:基础流式响应 ==========");
// 步骤1:创建 Prompt
Prompt prompt = new Prompt("用一句话介绍 Spring AI");
System.out.println("👤 用户: " + prompt.getContents());
// 步骤2:调用流式 API
System.out.println("🤖 AI (流式): ");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 步骤3:订阅并打印每个数据块
StringBuilder fullResponse = new StringBuilder();
stream.subscribe(
// onNext: 每收到一块数据
response -> {
String content = response.getResult().getOutput().getText();
System.out.print(content); // 打印这一块
fullResponse.append(content);
},
// onError: 发生错误
error -> {
System.err.println("\n❌ 错误: " + error.getMessage());
},
// onComplete: 流结束
() -> {
System.out.println("\n✅ 流式响应完成");
System.out.println("📝 完整回复: " + fullResponse.toString());
}
);
// 步骤4:等待流完成
try {
Thread.sleep(5000); // 等待5秒
} catch (InterruptedException e) {
e.printStackTrace();
}
// 步骤5:验证
assertFalse(fullResponse.toString().isEmpty(),
"应该收到完整的回复");
System.out.println("✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test01_BasicStreaming========== 测试 5.1:基础流式响应 ==========
👤 用户: 用一句话介绍 Spring AI
🤖 AI (流式):
Spring AI 是...(逐字显示)
✅ 流式响应完成
📝 完整回复: Spring AI 是一个...
✅ 测试通过stream() 返回 Flux<ChatResponse>subscribe() 订阅流,处理每个数据块onNext 处理每个数据块onComplete 流结束时调用学习使用 StepVerifier 测试响应式流。
/**
* 测试 5.2:使用 StepVerifier
*
* 知识点:
* - StepVerifier 是测试响应式流的专用工具
* - 可以验证流的行为
* - 更适合单元测试
*/
@Test
@DisplayName("5.2 使用 StepVerifier")
public void test02_WithStepVerifier() {
System.out.println("\n========== 测试 5.2:使用 StepVerifier ==========");
// 步骤1:创建流
Prompt prompt = new Prompt("说一个数字");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 步骤2:使用 StepVerifier 验证
System.out.println("🔍 使用 StepVerifier 验证流...");
StepVerifier.create(stream.take(1))
// 只验证“至少能收到一个 chunk”,避免流式多次 onNext 导致 expectComplete 失败
.expectNextMatches(resp -> {
String text = resp.getResult().getOutput().getText();
return text != null && !text.isEmpty();
})
.expectComplete()
// 验证(最多等待30秒)
.verify(Duration.ofSeconds(30));
System.out.println("✅ StepVerifier 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test02_WithStepVerifierStepVerifier.create() 创建验证器expectNextCount() 期望元素数量expectComplete() 期望流正常结束verify() 执行验证统计流式响应的数据块数量和字符数。
/**
* 测试 5.3:统计数据块数量
*
* 知识点:
* - 流式响应会分多个块发送
* - 可以统计收到的块数
* - 了解流式传输的特点
*/
@Test
@DisplayName("5.3 统计数据块数量")
public void test03_CountChunks() {
System.out.println("\n========== 测试 5.3:统计数据块数量 ==========");
// 步骤1:创建计数器
AtomicInteger chunkCount = new AtomicInteger(0);
AtomicInteger totalChars = new AtomicInteger(0);
// 步骤2:创建流
Prompt prompt = new Prompt("用50个字介绍 Java");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 步骤3:订阅并统计
System.out.println("📊 开始统计...");
stream.subscribe(
response -> {
String content = response.getResult().getOutput().getText();
int count = chunkCount.incrementAndGet();
totalChars.addAndGet(content.length());
System.out.println("📦 块 " + count + ": \"" + content +
"\" (长度: " + content.length() + ")");
},
error -> System.err.println("❌ 错误: " + error.getMessage()),
() -> {
System.out.println("\n📊 统计结果:");
System.out.println(" - 总块数: " + chunkCount.get());
System.out.println(" - 总字符数: " + totalChars.get());
System.out.println(" - 平均每块: " +
(totalChars.get() / chunkCount.get()) + " 字符");
}
);
// 步骤4:等待完成
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 步骤5:验证
assertTrue(chunkCount.get() > 0, "应该收到至少一个数据块");
assertTrue(totalChars.get() > 0, "应该收到一些字符");
System.out.println("✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test03_CountChunksAtomicInteger 进行线程安全的计数对比流式和非流式响应的区别。
/**
* 测试 5.4:对比流式和非流式
*
* 知识点:
* - 流式:逐块返回,体验更好
* - 非流式:一次性返回,等待时间长
* - 内容相同,只是返回方式不同
*/
@Test
@DisplayName("5.4 对比流式和非流式")
public void test04_StreamingVsNonStreaming() {
System.out.println("\n========== 测试 5.4:对比流式和非流式 ==========");
String question = "用一句话介绍 Spring Boot";
Prompt prompt = new Prompt(question);
// 步骤1:非流式调用
System.out.println("\n【非流式调用】");
System.out.println("👤 用户: " + question);
long startTime1 = System.currentTimeMillis();
ChatResponse response1 = chatModel.call(prompt);
long endTime1 = System.currentTimeMillis();
String content1 = response1.getResult().getOutput().getText();
System.out.println("🤖 AI: " + content1);
System.out.println("⏱️ 耗时: " + (endTime1 - startTime1) + "ms");
System.out.println("💡 特点: 等待后一次性返回");
// 步骤2:流式调用
System.out.println("\n【流式调用】");
System.out.println("👤 用户: " + question);
System.out.println("🤖 AI (流式): ");
long startTime2 = System.currentTimeMillis();
StringBuilder content2 = new StringBuilder();
Flux<ChatResponse> stream = chatModel.stream(prompt);
stream.subscribe(
response -> {
String chunk = response.getResult().getOutput().getText();
System.out.print(chunk);
content2.append(chunk);
},
error -> {},
() -> {
long endTime2 = System.currentTimeMillis();
System.out.println("\n⏱️ 耗时: " + (endTime2 - startTime2) + "ms");
System.out.println("💡 特点: 逐字显示,体验更好");
}
);
// 等待流完成
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 步骤3:验证内容相似
System.out.println("\n📊 对比结果:");
System.out.println(" - 非流式长度: " + content1.length());
System.out.println(" - 流式长度: " + content2.length());
assertFalse(content1.isEmpty());
assertFalse(content2.toString().isEmpty());
System.out.println("✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test04_StreamingVsNonStreaming使用 StepVerifier 验证流的完整性。
/**
* 测试 5.5:流式响应完整性
*
* 知识点:
* - 使用 StepVerifier 验证流的完整性
* - expectComplete() 确保流正常结束
* - 验证流没有中断或出错
*/
@Test
@DisplayName("5.5 流式响应完整性")
public void test05_StreamingCompleteness() {
System.out.println("\n========== 测试 5.5:流式响应完整性 ==========");
Prompt prompt = new Prompt("说'你好'");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 使用 StepVerifier 验证流的完整性
StepVerifier.create(stream)
.thenConsumeWhile(response -> {
String content = response.getResult().getOutput().getText();
System.out.print(content);
return true;
})
.expectComplete()
.verify(Duration.ofSeconds(30));
System.out.println("\n\n💡 知识点:");
System.out.println("- StepVerifier 可以验证流的完整性");
System.out.println("- expectComplete() 确保流正常结束");
System.out.println("- 如果流中断或出错,测试会失败");
System.out.println("\n✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test05_StreamingCompleteness学习如何设置超时时间。
/**
* 测试 5.6:流式响应的超时处理
*
* 知识点:
* - 可以设置超时时间
* - 超时后会触发错误
* - 使用 timeout() 操作符
*/
@Test
@DisplayName("5.6 流式响应的超时处理")
public void test06_StreamingTimeout() {
System.out.println("\n========== 测试 5.6:流式超时处理 ==========");
// 步骤1:创建流
Prompt prompt = new Prompt("讲一个长故事");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 步骤2:设置超时(10秒)
System.out.println("⏰ 设置10秒超时...");
stream
.timeout(Duration.ofSeconds(10))
.subscribe(
response -> {
String content = response.getResult().getOutput().getText();
System.out.print(content);
},
error -> {
if (error instanceof java.util.concurrent.TimeoutException) {
System.out.println("\n⏰ 超时!");
} else {
System.err.println("\n❌ 错误: " + error.getMessage());
}
},
() -> {
System.out.println("\n✅ 在超时前完成");
}
);
// 等待
try {
Thread.sleep(12000); // 等待12秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test06_StreamingTimeout学习如何取消正在进行的流。
/**
* 测试 5.7:流式响应的取消
*
* 知识点:
* - 可以取消正在进行的流
* - 使用 Disposable 控制订阅
* - 节省资源
*/
@Test
@DisplayName("5.7 流式响应的取消")
public void test07_StreamingCancellation() {
System.out.println("\n========== 测试 5.7:流式取消 ==========");
// 步骤1:创建流
Prompt prompt = new Prompt("讲一个很长的故事");
Flux<ChatResponse> stream = chatModel.stream(prompt);
// 步骤2:订阅并保存 Disposable
System.out.println("📡 开始订阅...");
AtomicInteger count = new AtomicInteger(0);
java.util.concurrent.atomic.AtomicReference<reactor.core.Disposable> disposableRef = new java.util.concurrent.atomic.AtomicReference<>();
reactor.core.Disposable disposable = stream.subscribe(
response -> {
int current = count.incrementAndGet();
if (current > 5) {
return;
}
String content = response.getResult().getOutput().getText();
String printable = content == null ? "null" : content.replace("\n", "\\n");
System.out.println("\n📦 第" + current + "块: " + printable);
// 收到5个块后取消
if (current == 5) {
System.out.println("\n🛑 收到5个块,立即取消订阅...");
reactor.core.Disposable d = disposableRef.get();
if (d != null && !d.isDisposed()) {
d.dispose();
}
}
},
error -> System.err.println("\n❌ 错误: " + error.getMessage()),
() -> System.out.println("\n✅ 流完成")
);
disposableRef.set(disposable);
// 步骤3:等待一会儿后取消
try {
Thread.sleep(2000); // 等待2秒
if (!disposable.isDisposed()) {
System.out.println("🛑 取消订阅");
disposable.dispose();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
// 步骤4:验证已取消
assertTrue(disposable.isDisposed(), "订阅应该已被取消");
System.out.println("✅ 测试通过");
}mvn test -Dtest=Test05_StreamingResponse#test07_StreamingCancellation运行完整测试类,验证所有流式响应功能:
mvn test -Dtest=Test05_StreamingResponseTests run: 7, Failures: 0, Errors: 0, Skipped: 0
✅ 所有测试通过!| 测试编号 | 测试名称 | 核心知识点 |
|---|---|---|
| 5.1 | 基础流式响应 | stream() 和 subscribe() |
| 5.2 | 使用 StepVerifier | 测试响应式流 |
| 5.3 | 统计数据块数量 | AtomicInteger 计数 |
| 5.4 | 对比流式和非流式 | 性能和体验对比 |
| 5.5 | 流式响应完整性 | 验证流正常结束 |
| 5.6 | 超时处理 | timeout() 操作符 |
| 5.7 | 取消订阅 | Disposable 控制 |
请完成下面的练习,巩固本章的“流式响应”核心机制与测试验证能力
⚠️ 提示:本章高频点集中在 Flux、流式分块、StepVerifier、超时/取消、以及 SSE/WebFlux 的工程化落地。
chatModel.stream(prompt) 使用 StepVerifier,至少 expectNextCount(1),并 expectComplete()。
附加:设置合理的 verify(Duration) 超时,避免测试挂死。
Flux<String> stream = chatModel.stream(prompt)
.map(resp -> resp.getResult().getOutput().getText())
.filter(s -> s != null && !s.isBlank());
// 用 take(1) 保证一定会 complete,避免真实 stream 长时间不断流导致用例挂死
StepVerifier.create(stream.take(1))
.expectNextMatches(s -> !s.isBlank())
.verifyComplete();stream 写 expectNextCount(1).expectComplete(),
在“模型会继续吐后续 chunk”的情况下,可能会因为流迟迟不结束而导致测试卡住。
AtomicInteger 统计 chunkCount;记录首块到达耗时(TTFB)与总耗时;
最终打印对比结果:为什么流式的“首块时间”对体验更重要。
AtomicInteger chunkCount = new AtomicInteger();
AtomicLong firstChunkMs = new AtomicLong(-1);
long start = System.currentTimeMillis();
Flux<String> stream = chatModel.stream(prompt)
.map(resp -> resp.getResult().getOutput().getText())
.filter(s -> s != null && !s.isBlank())
.doOnNext(s -> {
int idx = chunkCount.incrementAndGet();
if (idx == 1) {
firstChunkMs.set(System.currentTimeMillis() - start);
}
});
stream.doFinally(sig -> {
long total = System.currentTimeMillis() - start;
System.out.println("chunkCount=" + chunkCount.get());
System.out.println("firstChunkTimeMs=" + firstChunkMs.get());
System.out.println("totalTimeMs=" + total);
}).blockLast();timeout(Duration.ofSeconds(x));超时后给出一条兜底信息(例如“本次响应超时,请稍后重试”)。
用测试验证:超时场景下不会无限等待。
Flux<String> safe = stream
.timeout(Duration.ofSeconds(10))
.onErrorResume(java.util.concurrent.TimeoutException.class,
e -> Flux.just("本次响应超时,请稍后重试"));
StepVerifier.create(safe)
.thenConsumeWhile(s -> true)
.verify(Duration.ofSeconds(12));stream.take(N);
方式 B:保留 Disposable 并手动 dispose()。
重点说明:为什么取消是必要能力(用户离开页面、网络断开、节省资源)。
// 方式 A:take(N) 自动取消上游订阅
stream.take(5)
.doOnNext(s -> System.out.println("chunk=" + s))
.doOnCancel(() -> System.out.println("已取消订阅"))
.blockLast();Flux<String> 或 Flux<ServerSentEvent<String>> 的接口;
前端使用 EventSource 接收并逐字渲染,形成“打字机效果”。
@GetMapping(value = "/sse", produces = org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> sse() {
return chatModel.stream(prompt)
.map(resp -> resp.getResult().getOutput().getText())
.filter(s -> s != null && !s.isBlank());
}const es = new EventSource('/api/sse');
es.onmessage = (e) => { document.querySelector('#out').textContent += e.data; };Flux + SSE/WebFlux;一次性返回是普通 JSON/文本。
expectNextCount、expectNextMatches、expectComplete、expectError、thenCancel。
expectNext/expectNextMatches(内容),
expectNextCount(数量),
expectComplete/expectError(终止信号),
thenCancel(取消),
verify(Duration)(防挂死)。
take(n) 或 thenCancel() 限制测试范围,并设置超时。doOnCancel/take(n)/Disposable.dispose() 等方式停止订阅并记录一次日志。继续学习 第6章:项目三 · 流式聊天API, 将本章学到的流式响应知识应用到实际项目中, 实现打字机效果的聊天接口!