用 Spring AI 接一个流式对话,跑通很快。麻烦在跑通之后------最高频的两个坑都不报错。
这篇把两个坑的机制、实测数据和修法都写出来,代码可以照抄。
结论
- 断连不停生成:客户端断开后,与模型之间的流仍在消费,输出 token 照付。
- 会话记忆无上限 :
InMemoryChatMemoryRepository只限制单会话消息条数,不限制会话数量。 - 实测 :10,000 个会话(每会话 20 条 × 150 汉字)→ 无上限 115 MB ,约 11 KB/会话;设 1000 上限后 11 MB。
- 换算:10 万个会话约 1.1 GB 堆内存,且没有任何告警。
环境
| 项 | 值 |
|---|---|
| Java | 17 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 模型 | deepseek-chat(实际路由 deepseek-flash) |
| 传输 | Spring MVC + SseEmitter |
问题一:客户端断开后,模型还在生成
现象
流式接口的调用链是:浏览器 ⟷ 服务端(SSE)⟷ 模型(流式 HTTP)。
浏览器关掉页面,断的只是前一段。服务端与模型之间的流还在跑,token 继续回来,只是没有下游可推。
模型按输出 token 计费。用户看不到的那部分,你照样付。
识别
SseEmitter.send() 在对面已断开时抛 IOException 或 IllegalStateException。把它包一层,用返回值表示连接是否还在:
vbnet
/**
* @return false 表示客户端已断开或流已结束,调用方应停止后续发送
*/
private boolean send(SseEmitter emitter, SseEmitter.SseEventBuilder event) {
try {
emitter.send(event);
return true;
} catch (IOException | IllegalStateException ex) {
log.info("SSE 发送失败,客户端可能已断开: {}", ex.getMessage());
return false;
}
}
修复
在 token 回调里检查返回值,为 false 就释放订阅:
scss
Disposable subscription = chatService.stream(conversationId, request.message())
.subscribe(
token -> {
if (finished.get()) {
return;
}
if (!send(emitter, SseEmitter.event().data(token))) {
// 客户端已断开:停止生成,避免继续消耗 token
finished.set(true);
dispose(subscriptionRef);
}
},
error -> finishWithError(emitter, finished, error),
() -> finish(emitter, finished));
dispose 之后 Reactor 会取消上游订阅,HTTP 连接关闭,模型侧停止生成。
为什么不容易发现
不抛异常,不影响正常请求,日志只有一行 info。只有真去比对「模型侧输出 token 数」和「用户实际收到的字数」才会发现差额。
问题二:会话数量没有上限
机制
单会话的消息数由 MessageWindowChatMemory 控制:
scss
@Bean
ChatMemory chatMemory(ChatMemoryRepository repository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(MEMORY_WINDOW_SIZE) // 20
.build();
}
这层看起来是完备的。但它限制的是一个会话内的消息条数 ,不是会话数量。
默认仓库 InMemoryChatMemoryRepository 是一个 ConcurrentHashMap<String, List<Message>>------每来一个新的 conversationId 就多一个键,永不回收。
对外暴露的对话接口如果允许客户端自带 conversationId,匿名请求每次换一个 ID 就能无限写入。
实测
写了一个纯 JUnit 测试,不启 Spring 上下文:
ini
private long measure(int conversations, int maxConversations) throws Exception {
BoundedChatMemoryRepository repository =
new BoundedChatMemoryRepository(maxConversations, Duration.ofDays(365));
long before = usedHeap();
for (int i = 0; i < conversations; i++) {
repository.saveAll("conv-" + i, messages(i));
}
long after = usedHeap();
return Math.max(0L, after - before);
}
private long usedHeap() throws InterruptedException {
System.gc();
TimeUnit.MILLISECONDS.sleep(200);
Runtime runtime = Runtime.getRuntime();
return runtime.totalMemory() - runtime.freeMemory();
}
消息内容为约 150 个汉字,每会话 20 条。maxConversations 传 Integer.MAX_VALUE 模拟无上限。
结果:
| 配置 | 10,000 个会话的堆增长 |
|---|---|
| 无上限 | 115 MB |
| 上限 1000 | 11 MB |
差值 103 MB。折算每会话约 11 KB。
| 会话数 | 堆占用 |
|---|---|
| 1,000 | 11 MB |
| 10,000 | 115 MB |
| 100,000 | 1.1 GB |
修复
用 Caffeine 替代默认仓库,给会话数和闲置时间都加上界:
typescript
public class BoundedChatMemoryRepository implements ChatMemoryRepository {
private final Cache<String, List<Message>> cache;
public BoundedChatMemoryRepository(int maxConversations, Duration expireAfterAccess) {
this.cache = Caffeine.newBuilder()
.maximumSize(maxConversations)
.expireAfterAccess(expireAfterAccess)
.build();
}
@Override
public List<Message> findByConversationId(String conversationId) {
List<Message> messages = cache.getIfPresent(conversationId);
return messages == null ? List.of() : messages;
}
@Override
public void saveAll(String conversationId, List<Message> messages) {
cache.put(conversationId, List.copyOf(messages));
}
@Override
public void deleteByConversationId(String conversationId) {
cache.invalidate(conversationId);
}
@Override
public List<String> findConversationIds() {
return List.copyOf(cache.asMap().keySet());
}
}
配置:
yaml
app:
chat-memory:
max-conversations: 1000
ttl: 2h
maximumSize 按最近最少使用淘汰,expireAfterAccess 处理长期不活跃的会话。
注意 saveAll 里用了 List.copyOf------调用方传入可变列表时不会被事后修改。
另外三个坑
超时无信号
SseEmitter 构造时传超时时间(这里是 5 分钟),到点连接直接关闭,前端只看到流停止,无从判断是结束还是超时。补一个事件:
scss
emitter.onTimeout(() -> {
if (finished.compareAndSet(false, true)) {
send(emitter, SseEmitter.event().name("timeout").data("响应超时,内容可能不完整"));
emitter.complete();
}
dispose(subscriptionRef);
});
生命周期回调的注册顺序
onCompletion / onTimeout / onError 必须在发起订阅之前注册。顺序反了的话,若订阅已完成、回调才注册,那次清理不会触发。代码里为此留了注释。
订阅建立前的空窗
subscriptionRef 在 subscribe() 返回之后才赋值。如果客户端恰好在这个窗口内断开,回调里的 dispose 拿到 null,清理落空。补偿方式:
scss
subscriptionRef.set(subscription);
if (finished.get()) {
subscription.dispose();
}
顺带:温度实验
同一道题「用比喻解释 RAG」,各跑 5 次:
| temperature | 结果 |
|---|---|
| 0.1 | 5 次只有 3 种表达,出现逐字重复 |
| 1.2 | 5 次全部不同,但核心比喻仍然是「开卷考试」 |
温度改变的是表层多样性,不改变知识本身。想要更好的推理,换模型或改 prompt,调温度无效。
局限
- 内存数是数量级,不是精确值。GC 时机影响读数,测试取的是 GC 后差值。
- 消息长度固定为 150 汉字,真实对话长短不一,11 KB/会话只是标尺。
- 温度实验 5 次样本,定性观察,未做统计检验。
- 未测断连造成的实际 token 浪费,只验证了机制。
- 单项目、单机、单模型,结论不外推。
复现
项目未公开,以下是我当时的做法,可以照搬到自己的工程里。
一、内存实测(不需要 API Key)
纯 JUnit,不启 Spring 上下文,几秒钟跑完:
bash
./mvnw -o test -Dtest=MemoryFootprintTest
测试逻辑:构造 N 个会话(每会话 20 条、每条约 150 汉字),GC 前后各读一次堆,差值除以 N。 maxConversations 传 Integer.MAX_VALUE 得到「无上限」版本,传 1000 得到有界版本。 注意 System.gc() 只是建议,JVM 不保证立刻回收,所以结果是数量级而非精确值。
二、断连验证(需要 DeepSeek API Key)
arduino
export DEEPSEEK_API_KEY=sk-xxx
./mvnw spring-boot:run
另开一个终端,发流式请求,出来几行后按 Ctrl-C 中断:
ruby
curl -N -X POST http://127.0.0.1:8080/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"message":"用 500 字解释 RAG"}'
-N 关闭 curl 的缓冲,否则看不到逐个 token 的效果。
然后看服务端日志,只需确认一件事:
出现「SSE 发送失败,客户端可能已断开」之后,不应再有新的正文输出。
如果还在输出,说明 dispose 没有生效,模型仍在生成------也就是这个坑没修好。
数据与判定由作者人工核对。这是一个个人项目的单次实验,样本量和语料都有明显边界,结论请按自己的工程形态打折使用。