10 万个会话吃掉 1.1G 内存:Spring AI 会话记忆的两个隐藏成本

用 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,调温度无效。

局限

  1. 内存数是数量级,不是精确值。GC 时机影响读数,测试取的是 GC 后差值。
  2. 消息长度固定为 150 汉字,真实对话长短不一,11 KB/会话只是标尺。
  3. 温度实验 5 次样本,定性观察,未做统计检验。
  4. 未测断连造成的实际 token 浪费,只验证了机制。
  5. 单项目、单机、单模型,结论不外推。

复现

项目未公开,以下是我当时的做法,可以照搬到自己的工程里。

一、内存实测(不需要 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 没有生效,模型仍在生成------也就是这个坑没修好。


数据与判定由作者人工核对。这是一个个人项目的单次实验,样本量和语料都有明显边界,结论请按自己的工程形态打折使用。

相关推荐
小兔子2 小时前
getenv 返回的是别人的内存,改一下会改变整个进程的环境
后端
程序员cxuan2 小时前
通了个宵,终于把 Super-Agent 架构玩明白了
人工智能·后端·程序员
不积跬步无以至千里mmbb262 小时前
rust_analysis
开发语言·后端·rust
代龙涛3 小时前
WordPress Query 查询机制详解(WP_Query)
开发语言·后端·php·wordpress
程序员cxuan3 小时前
不是,为啥这个模型突然就爆火了??
人工智能·后端·程序员
知守观3 小时前
技术考古:iBATIS 2.3.4 时代的持久层,是如何用 Map 玩转 307 个存储过程的?
java·后端·mybatis
程序员cxuan3 小时前
国产 GPU 能不能打,重点在软件栈
人工智能·后端·程序员
the局外人3 小时前
Python 中使用 SSE 实现大模型流式输出
后端·python·llm
苍何4 小时前
用 WorkBuddy + LibTV 做 AI 视频,干活有点猛啊!(附教程)
后端