用Spring AI实现多轮对话记忆,别再让AI每次都"失忆"

用Spring AI实现多轮对话记忆,别再让AI每次都"失忆"


做AI对话功能,最容易被用户吐槽的一句话是什么?

"你刚才不是说过了吗?怎么又问我?"

大模型天生是"金鱼记忆"------每次调用都是独立的,上一次说了什么,它完全不记得。

这个问题我上周刚解决。用的Spring AI自带的ChatMemory机制,20行代码搞定。今天把方案拆干净。

问题有多蠢

先看一个真实场景。用户跟AI聊天:

复制代码
用户:我叫张三,做Java开发的
AI:你好张三!Java开发挺不错的。
用户:推荐几本适合我的书?
AI:请问您是从事什么行业的呢?

用户直接炸了:我刚说了我做Java的你没听到?

这不是模型笨,是架构问题。每次调API,你只传了当前这一句话进去,历史对话根本没带。模型看不到之前的内容,当然不记得。

最蠢的解法:自己拼字符串

很多人第一反应是:把聊天记录拼起来不就行了?

java 复制代码
// 别这么干
List<String> history = new ArrayList<>();

public String chat(String userId, String userInput) {
    history.add("用户:" + userInput);
    
    // 把所有历史拼成一个长字符串
    String fullPrompt = String.join("\n", history);
    
    String response = chatClient.prompt()
        .user(fullPrompt)
        .call()
        .content();
    
    history.add("AI:" + response);
    return response;
}

能用。但3个问题:

  1. token爆炸 --- 聊了50轮后,每次调用都带上全部历史,token费用线性增长
  2. 没隔离 --- 用户A和用户B的对话混在一起(上面代码是全局变量,真实场景更隐蔽)
  3. 没上限 --- 聊了500轮,prompt长度超过模型上下文窗口,直接报错

Spring AI的正解:ChatMemory

Spring AI内置了一套对话记忆机制,核心就3个东西:

作用
ChatMemory 存储对话历史的接口
MessageChatMemoryAdvisor 自动把历史注入Prompt的拦截器
InMemoryChatMemory 内存版实现(生产环境换持久化版)

第一步:配置ChatMemory

java 复制代码
@Configuration
public class AiConfig {

    // 对话记忆存储(内存版,重启丢失)
    @Bean
    public ChatMemory chatMemory() {
        return new InMemoryChatMemory();
    }

    // ChatClient配置,挂上记忆拦截器
    @Bean
    public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder
            .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory)
                    .chatMemoryRetrieveSize(20)  // 最多带最近20条消息
                    .build()
            )
            .build();
    }
}

关键参数是chatMemoryRetrieveSize(20)------每次调用最多带最近20条消息(10轮对话),防止token爆炸。

第二步:对话时传入conversationId

java 复制代码
@Service
public class ChatService {

    @Autowired
    private ChatClient chatClient;

    public String chat(String userId, String userInput) {
        return chatClient.prompt()
            .user(userInput)
            .advisors(a -> a.param(
                ChatMemory.CONVERSATION_ID,  // 关键:用userId做会话隔离
                userId
            ))
            .call()
            .content();
    }
}

CONVERSATION_ID就是会话隔离的关键。不同userId对应不同的对话历史,互不干扰。

第三步:验证效果

ini 复制代码
用户(userId=1001):我叫张三,做Java开发的
AI:你好张三!Java开发挺好的方向。

用户(userId=1001):推荐几本适合我的书?
AI:根据你的Java开发背景,推荐以下几本:
    1.《Spring实战》------ Spring生态必读
    2.《Effective Java》------ 进阶必备
    ...

这次AI记住了。因为Spring AI自动把上一轮的对话历史注入到了Prompt里。

生产环境:换成持久化存储

InMemoryChatMemory重启就丢数据。生产环境必须持久化。

Spring AI提供了一个简洁的接口,自己实现就行:

java 复制代码
public class JdbcChatMemory implements ChatMemory {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Override
    public void add(String conversationId, List<Message> messages) {
        // 把消息存到数据库
        for (Message msg : messages) {
            jdbcTemplate.update(
                "INSERT INTO chat_memory (conversation_id, role, content, created_at) VALUES (?, ?, ?, ?)",
                conversationId, msg.getMessageType().getValue(), msg.getText(), LocalDateTime.now()
            );
        }
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        // 查最近N条消息
        return jdbcTemplate.query(
            "SELECT role, content FROM chat_memory WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?",
            (rs, rowNum) -> {
                String role = rs.getString("role");
                String content = rs.getString("content");
                if ("user".equals(role)) {
                    return UserMessage.builder().text(content).build();
                }
                return AssistantMessage.builder().text(content).build();
            },
            conversationId, lastN
        );
    }

    @Override
    public void clear(String conversationId) {
        jdbcTemplate.update("DELETE FROM chat_memory WHERE conversation_id = ?", conversationId);
    }
}

建张表就行:

sql 复制代码
CREATE TABLE chat_memory (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    conversation_id VARCHAR(64) NOT NULL,
    role VARCHAR(16) NOT NULL,
    content TEXT NOT NULL,
    created_at DATETIME NOT NULL,
    INDEX idx_conv_time (conversation_id, created_at)
);

换掉Bean配置就完事了:

java 复制代码
@Bean
public ChatMemory chatMemory() {
    return new JdbcChatMemory();  // 替换InMemoryChatMemory
}

其他代码一行不用改。这就是接口的价值。

我踩的3个坑

坑1:System Prompt被历史消息"冲掉"

加了ChatMemory后,我发现AI的性格变了------之前System Prompt设定的"你是一个Java技术专家"不管用了。

原因:ChatMemory的消息列表里,System消息排在最前面,但历史消息多了之后,System消息的权重被稀释了。

解决 :用defaultSystem()而不是手动拼System消息。Spring AI会确保System消息始终在消息列表的最前面。

java 复制代码
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
    return builder
        .defaultSystem("你是一个Java技术专家,回答要简洁直接")  // 这个始终在最前面
        .defaultAdvisors(
            MessageChatMemoryAdvisor.builder(chatMemory).build()
        )
        .build();
}

坑2:流式响应和记忆冲突

.stream()做流式输出时,ChatMemory拿不到完整的AI回复------因为回复是一个token一个token回来的,不是一次性返回的。

结果:历史记录里存了一条空的AI回复。下一轮对话时,AI看到的上一轮回复是空的。

解决 :Spring AI 1.0+的MessageChatMemoryAdvisor已经处理了这个问题,会自动等流式输出完成后存入完整内容。但如果用的是老版本(0.9.x),需要自己处理:

java 复制代码
// 老版本需要手动存历史
public Flux<String> streamChat(String userId, String userInput) {
    // 先手动添加用户消息到记忆
    chatMemory.add(userId, List.of(new UserMessage(userInput)));
    
    StringBuilder fullResponse = new StringBuilder();
    
    return chatClient.prompt()
        .user(userInput)
        .stream()
        .content()
        .doOnNext(token -> fullResponse.append(token))
        .doOnComplete(() -> {
            // 流结束后手动存入AI回复
            chatMemory.add(userId, List.of(
                new AssistantMessage(fullResponse.toString())
            ));
        });
}

坑3:并发用户的线程安全

InMemoryChatMemory底层用ConcurrentHashMap,线程安全没问题。但我自己写的JdbcChatMemory一开始没加事务,高并发下出现了消息顺序错乱。

解决add()方法加事务,保证同一会话的消息按顺序写入:

java 复制代码
@Transactional
public void add(String conversationId, List<Message> messages) {
    for (Message msg : messages) {
        jdbcTemplate.update(...);
    }
}

实际效果

我用这套方案做了一个内部知识库问答助手,200人同时使用。

指标 无记忆 有记忆
用户满意度 3.2分 4.6分
"你刚才说了"类投诉 每天15+ 0
平均对话轮次 2.3轮 8.7轮
单次调用token 波动大(无上下文) 稳定(固定窗口)

结论很明显:对话记忆不是可选功能,是必备功能。

什么场景必须加记忆

我的经验,这几类场景不加记忆就没法用:

  1. 客服/助手类 --- 用户会连续追问,上下文关联紧密
  2. 教学类 --- 学生问问题,AI要根据学生的水平调整回答
  3. 代码审查类 --- 先给了一段代码,后面问的问题依赖前面的上下文
  4. 多步骤任务 --- 先配置A,再配置B,最后一起部署

相反,如果是"一问一答"的工具型场景(翻译、摘要、格式转换),不加也行。

小结

Spring AI的ChatMemory方案总结:

  1. 开发环境 :用InMemoryChatMemory + MessageChatMemoryAdvisor,20行代码搞定
  2. 生产环境 :实现ChatMemory接口,换成数据库/Redis存储
  3. 关键参数chatMemoryRetrieveSize控制历史消息数量,防止token爆炸
  4. 会话隔离 :用CONVERSATION_ID区分不同用户/会话

我整理了一个 Spring AI 起步模板,clone下来改3行配置就能跑: 👉 GitHub搜 spring-ai-starter(lincyang720) 想聊Java转AI的具体路线,加我微信 newboy2004 备注"AI"

相关推荐
SyMind7 小时前
如何做好 AI 的挂件
llm·ai编程
林小果17 小时前
用 Codex 做一个 API 地址诊断器:Claude Code、Codex、Gemini CLI 的 /v1 排错实战
api·ai编程·codex·claude code·gemini cli·base url·linkagi
大强同学9 小时前
Kimi Code CLI
人工智能·agent·claude·skill·kimi code cli
HjhIron9 小时前
在浏览器中跑DeepSeek-R1:用React+WebGPU实现端侧AI推理
前端·ai编程
梅头脑9 小时前
Embedding模型和LLM不是同一个东西!RAG核心组件深度剖析
ai编程
春风野草10 小时前
AI Agent 长任务实战:取消、重试、中断恢复,不是加几个按钮这么简单
aigc·ai编程
刘棕霆10 小时前
造数脚本越堆越乱:稳定的沉淀成引擎,变化的留在配置
aigc·agent·测试
JavaGuide10 小时前
Kimi K3 实战:全栈项目、Java 项目改造与 3A 游戏 Demo
后端·ai编程
带刺的坐椅10 小时前
Solon TeamAgent 协作协议:从 SEQUENTIAL 流水线到 HIERARCHICAL 主管团队
java·ai·llm·agent·solon