【第21篇】 Chat Memory Example

一、概述

1.1 核心目标

本项目是一个基于 Spring AI Alibaba 生态构建的对话记忆(Chat Memory)示例,旨在演示如何为 LLM(大语言模型)应用赋予"上下文记忆"能力,使多轮对话能够连贯进行,而非 Stateless 的单轮问答。

关键价值点

  • 会话隔离:每个用户/每次对话拥有独立的记忆上下文,避免信息串扰
  • 透明集成:通过 Spring AI 的 Advisor 机制,记忆管理对业务代码无侵入
  • 可插拔存储:支持 Redis、内存、数据库等多种后端存储
  • 生产就绪:包含完整的配置管理、健康检查、部署脚本

1.2 技术栈全景

层级 技术选型 版本/说明
基础框架 Spring Boot 3.2.x(基于 Jakarta EE,需 JDK 17+)
AI 框架 Spring AI + Spring AI Alibaba 提供 ChatClient、Advisor、ChatMemory 抽象
模型服务 阿里云百炼 / 通义千问 默认 Qwen 系列,支持 DashScope API
记忆存储 Redis 会话级缓存 + 消息持久化
持久化 MySQL/PostgreSQL(可选) 长期消息审计、用户画像
通信协议 HTTP REST / WebSocket 实时推送与异步响应

注意:Spring AI Alibaba 是 Spring AI 在阿里云生态的扩展实现,核心依赖阿里云百炼(DashScope)作为模型提供方。虽然理论上可通过 Ollama 本地部署,但生产环境推荐直接使用百炼 API,以获得更好的性能与稳定性。


二、项目架构设计

2.1 整体架构图

原架构图存在分层混乱 的问题(如 MemoryController 被错误归入 Service Layer,Memory Manager 与 Service Layer 并列不合理)。以下是修正后的架构:
模型提供方
存储层
Spring AI 框架层
记忆管理层
业务服务层
控制器层
网关与安全层
客户端层
Web 浏览器 / APP
WebSocket 连接
HTTP REST 请求
身份认证与鉴权
限流与熔断
ChatController
MemoryController
ChatService
MemoryService
PluginService
ChatMemoryManager
ContextWindowManager
TokenCounter
ChatClient
PromptChatMemoryAdvisor
Prompt Template
Redis

会话缓存
Database

持久化
本地文件

日志/快照
阿里云百炼 DashScope

2.2 核心模块职责划分

模块 职责 关键类/接口 修正说明
Controller 暴露 HTTP/WebSocket 端点,接收用户输入 ChatController, MemoryController MemoryController 属于控制层,不应放在 Service Layer
Service 业务编排、会话管理、插件调度 ChatService, MemoryService, PluginService 原表中的 MemoryController 已移除
MemoryManager 记忆核心:读写、压缩、淘汰策略 ChatMemoryManager, ContextWindowManager 应作为独立子系统,被 Service 层调用
Repository 数据访问抽象 SessionRepository, MessageRepository 建议采用 Spring Data JPA/Redis
Advisor Spring AI 的横切扩展点 PromptChatMemoryAdvisor 原文完全遗漏,这是 Spring AI Memory 的核心机制
Plugin 功能扩展点(如工具调用) PluginRegistry, FunctionCallback 对应 Spring AI 的 Function Calling

三、核心技术原理详解

3.1 Spring AI ChatMemory 机制深度解析

3.1.1 核心接口与实现

Spring AI 提供了 ChatMemory 接口,用于抽象对话记忆的存储与检索:

java 复制代码
package org.springframework.ai.chat.memory;

import java.util.List;

public interface ChatMemory {
    /**
     * 获取指定会话的所有消息
     */
    List<Message> get(String conversationId, int lastN);

    /**
     * 添加消息到指定会话
     */
    void add(String conversationId, List<Message> messages);

    /**
     * 添加单条消息
     */
    default void add(String conversationId, Message message) {
        add(conversationId, List.of(message));
    }

    /**
     * 清空指定会话的记忆
     */
    void clear(String conversationId);
}

关键实现类

  • InMemoryChatMemory:基于内存的临时存储(开发测试用,重启丢失)
  • JdbcChatMemory / 自定义实现:基于数据库的持久化存储
  • 自定义 RedisChatMemory:本项目应实现的核心类(Spring AI 未内置 Redis 实现,需自行封装)
3.1.2 Advisor 机制:无侵入的记忆注入

这是原文完全遗漏 的核心机制。Spring AI 通过 Advisor 模式实现横切关注点(如记忆注入、日志、重试),无需修改业务代码。
Redis DashScope LLM PromptChatMemoryAdvisor ChatClient ChatService ChatController Redis DashScope LLM PromptChatMemoryAdvisor ChatClient ChatService ChatController User POST /chat {conversationId: "abc", message: "今天天气如何?"} chat(request) 构建 ChatClient 执行 Advisor 链 get("abc", lastN=10) [历史消息列表] 组装 Prompt: System Prompt + History + User Message 发送完整 Prompt "北京今天晴,15°C..." add("abc", AssistantMessage) 返回响应 ChatResponse 封装结果 JSON 响应 User

Advisor 的核心价值

  1. 无侵入 :业务代码只需调用 chatClient.prompt().advisors(advisor).call(),记忆自动注入
  2. 可组合:多个 Advisor 可链式组合(如记忆 + 日志 + 限流)
  3. 可替换:切换记忆策略只需更换 Advisor 实现
3.1.3 完整记忆流转流程

用户输入
创建

UserMessage
Advisor

拦截
从 Redis

读取历史
Token 估算与

窗口裁剪
组装 System Prompt
注入

历史消息
追加当前

UserMessage
调用 LLM API
接收

AssistantMessage
存入 Redis
返回给用户

3.2 Redis 缓存设计优化

3.2.1 键结构设计(生产级)

原文的键设计过于简单,缺乏命名空间与过期策略:

复制代码
# 原文设计(问题:无命名空间,无过期,无版本控制)
session:{conv_id}:messages

# 优化设计(遵循 Redis 命名规范)
chat:memory:v1:{conversationId}:messages    # 消息列表 (Redis List / Stream)
chat:memory:v1:{conversationId}:metadata    # 会话元数据 (Redis Hash)
chat:memory:v1:{conversationId}:summary     # 摘要缓存 (Redis String)
chat:user:v1:{userId}:sessions              # 用户会话索引 (Redis Set)
chat:user:v1:{userId}:preferences           # 用户偏好 (Redis Hash)

设计理由

  • chat:memory:v1::业务前缀 + 版本号,便于未来迁移
  • :messages:使用 Redis List(按时间序)或 Redis Stream(支持消费组)
  • :metadata:存储创建时间、最后活跃时间、消息数量,便于 TTL 管理
  • :summary:存储对话摘要,用于长会话的上下文压缩
3.2.2 存储方案深度对比
方案 数据结构 优点 缺点 适用场景
Redis List LPUSH/RPUSH + LRANGE 简单直观,O(1) 插入 大数据量时 LRANGE 性能下降 短会话(<100 条)
Redis Stream XADD + XREAD 天然有序,支持消费组,自动 ID 内存占用较高,复杂度高 实时聊天、消息队列
Redis JSON RedisJSON 模块 支持复杂查询,原子更新 需 Redis 模块支持 需要按字段检索
Redis + DB 混合 Redis 缓存热点 + DB 持久化 兼顾性能与可靠性 架构复杂,需同步机制 生产环境首选
纯 MySQL/PostgreSQL 关系表 持久化强,支持复杂分析 高并发读取性能差 审计、数据分析

推荐生产方案
<= 50 条
> 50 条
ChatMemoryManager
消息数量?
Redis List

全量缓存
分层存储
Redis: 最近 20 条
MySQL: 完整历史
摘要: 早期对话总结

3.3 上下文窗口管理机制

原文的上下文窗口实现存在严重问题

  1. 简单的字符除 4 估算极不准确(中文 token 占比远高于英文)
  2. 没有处理 System Prompt 的 token 预算
  3. 没有实现摘要压缩策略
3.3.1 Token 估算的科学与艺术

为什么需要精确估算?

  • LLM 有固定的上下文窗口(如 Qwen-Turbo 为 8K,Qwen-Max 为 32K)
  • 超过窗口会导致截断报错
  • 预留足够的输出 token(通常 1K-2K)

优化后的 Token 估算策略

java 复制代码
@Component
public class TokenBudgetManager {

    @Value("${spring.ai.dashscope.chat.options.model:qwen-turbo}")
    private String modelName;

    // 不同模型的上下文窗口配置
    private static final Map<String, Integer> MODEL_CONTEXT_LIMITS = Map.of(
        "qwen-turbo", 8192,
        "qwen-plus", 32768,
        "qwen-max", 32768,
        "qwen-long", 1000000
    );

    // 输出预留 token(避免输入占满导致无法输出)
    private static final int OUTPUT_RESERVE = 1024;

    // System Prompt 固定开销
    private static final int SYSTEM_PROMPT_OVERHEAD = 256;

    /**
     * 计算可用于历史消息的 token 预算
     */
    public int calculateHistoryBudget(List<Message> messages) {
        int modelLimit = MODEL_CONTEXT_LIMITS.getOrDefault(modelName, 8192);
        return modelLimit - OUTPUT_RESERVE - SYSTEM_PROMPT_OVERHEAD;
    }

    /**
     * 更精确的 token 估算(针对中文优化)
     * 
     * 原理:
     * 1. 中文:通常 1 个汉字 ≈ 1-1.5 个 token(Qwen 使用 BPE 分词)
     * 2. 英文:通常 1 个单词 ≈ 1.3 个 token
     * 3. 特殊符号、格式化(如 JSON、Markdown)会增加 token
     * 4. 每条消息有固定开销(role 标识、分隔符等)
     */
    public int estimateTokens(Message message) {
        String content = message.getText();
        int charCount = content.length();
        int chineseCount = countChineseCharacters(content);
        int englishWordCount = countEnglishWords(content);

        // 混合估算公式(经 Qwen 模型实测校准)
        double estimatedTokens = chineseCount * 1.2          // 中文字符
                               + englishWordCount * 1.3      // 英文单词
                               + (charCount - chineseCount - englishWordCount) * 0.5  // 符号/数字
                               + 4;                          // 每条消息固定开销(role + 分隔符)

        return (int) Math.ceil(estimatedTokens);
    }

    private int countChineseCharacters(String text) {
        return (int) text.chars().filter(c -> Character.UnicodeBlock.of(c) == 
            Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS).count();
    }

    private int countEnglishWords(String text) {
        return text.split("\\s+").length;
    }
}

更优方案 :生产环境应集成 jtokkit(OpenAI 的 tiktoken Java 版)或调用阿里云百炼的 Token 计算 API,而非自行估算。

3.3.2 滑动窗口 + 摘要压缩策略

当历史消息超过 token 预算时,需要智能裁剪而非简单丢弃:






收到用户新消息
计算当前历史总 Token
总 Token < 预算?
直接追加
触发压缩策略
会话消息数 > 阈值?
对早期消息生成摘要
移除最旧的消息对
用摘要替换原始消息
保留最近 N 条完整消息
重新计算 Token
仍超预算?

摘要生成实现思路

java 复制代码
@Service
public class MemoryCompressionService {

    @Autowired
    private ChatClient chatClient;

    /**
     * 对早期对话生成摘要,替代原始消息以节省 token
     * 
     * 策略:
     * 1. 取最旧的 M 条消息(如前 20 条)
     * 2. 调用 LLM 生成精炼摘要
     * 3. 将摘要作为 System Message 注入
     * 4. 从 Redis 中删除原始消息,保留摘要
     */
    public Message summarizeMessages(String conversationId, List<Message> oldMessages) {
        String conversationText = oldMessages.stream()
            .map(m -> m.getMessageType() + ": " + m.getText())
            .collect(Collectors.joining("\n"));

        String summaryPrompt = String.format(
            "请对以下对话进行摘要,保留关键信息、用户偏好和上下文。" +
            "摘要应简洁,不超过 200 字。\n\n对话内容:\n%s",
            conversationText
        );

        String summary = chatClient.prompt()
            .user(summaryPrompt)
            .call()
            .content();

        // 将摘要存储为 System Message 或特殊标记的 Assistant Message
        return new SystemMessage("[历史摘要] " + summary);
    }
}

四、项目设计评估

4.1 优点分析(修正版)

维度 评分 详细评价
架构分层 ⭐⭐⭐⭐☆ 基于 Spring AI 的分层清晰,但 Controller 归类有误,且缺少 Advisor 层的显式设计
记忆隔离 ⭐⭐⭐⭐☆ Session 隔离机制完善,但缺少用户级权限校验
扩展性 ⭐⭐⭐⭐⭐ Spring AI 的 Advisor + Function Calling 提供了良好的插件扩展能力
存储灵活性 ⭐⭐⭐⭐☆ Redis 方案合理,但缺少冷热分层设计
容错性 ⭐⭐⭐☆☆ 异常处理较基础,缺少降级策略(如 Redis 故障时的内存兜底)
性能 ⭐⭐⭐⭐☆ Redis 缓存有效,但缺少批量写入与 Pipeline 优化
文档完整性 ⭐⭐⭐☆☆ 部署指南存在技术错误(如 OpenClaw Agent、Ollama 配置不匹配)

五、问题诊断与修订方案

5.1 严重问题(必须修复)

问题 1:会话 ID 生成存在冲突与可预测性风险

原文问题

java 复制代码
// 严重错误:UUID 没有 fromEpochTime 方法,且代码逻辑错误
public ConversationId getSafeConversationId(String userId) {
    return UUID.randomUUID().fromEpochTime(System.currentTimeMillis()).toString();
}

问题分析

  1. UUID.randomUUID() 没有 fromEpochTime 方法,代码无法编译
  2. 仅使用 UUID.randomUUID() 虽然唯一性好,但无序,不利于数据库索引
  3. 未绑定 userId,无法追溯会话归属
  4. 缺少防重放与防遍历机制

修正方案

java 复制代码
@Component
public class ConversationIdGenerator {

    private static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding();

    /**
     * 生成安全、可排序的会话 ID
     * 
     * 格式:base64(timestamp(6bytes) + random(10bytes) + userHash(2bytes))
     * 特性:
     * 1. 时间前缀:支持按时间排序,利于数据库索引和过期清理
     * 2. 随机部分:防止枚举攻击
     * 3. 用户哈希:便于快速定位用户会话(非安全校验)
     * 4. URL Safe Base64:便于在 URL/Header 中传输
     */
    public String generate(String userId) {
        ByteBuffer buffer = ByteBuffer.allocate(18);

        // 6 字节时间戳(毫秒级,可使用到 10889 年)
        long timestamp = System.currentTimeMillis();
        buffer.put((byte) ((timestamp >> 40) & 0xFF));
        buffer.put((byte) ((timestamp >> 32) & 0xFF));
        buffer.put((byte) ((timestamp >> 24) & 0xFF));
        buffer.put((byte) ((timestamp >> 16) & 0xFF));
        buffer.put((byte) ((timestamp >> 8) & 0xFF));
        buffer.put((byte) (timestamp & 0xFF));

        // 10 字节安全随机数
        byte[] randomBytes = new byte[10];
        new SecureRandom().nextBytes(randomBytes);
        buffer.put(randomBytes);

        // 2 字节用户 ID 哈希(用于快速分片,非安全校验)
        int userHash = Objects.hash(userId) & 0xFFFF;
        buffer.putShort((short) userHash);

        return BASE64_ENCODER.encodeToString(buffer.array());
    }

    /**
     * 验证会话 ID 格式合法性(防止注入攻击)
     */
    public boolean validate(String conversationId) {
        if (conversationId == null || conversationId.length() != 24) {
            return false;
        }
        try {
            byte[] decoded = Base64.getUrlDecoder().decode(conversationId);
            return decoded.length == 18;
        } catch (IllegalArgumentException e) {
            return false;
        }
    }
}
问题 2:Token 预算管理缺失

原文问题

java 复制代码
// 过于简化的估算,且未考虑模型差异
int tokens = (characters / 4) + 3 * messageCount + 256;

问题分析

  1. 中文场景下 characters / 4 严重低估(中文 1 字 ≈ 1-1.5 token)
  2. 未区分不同模型的上下文限制(Qwen-Turbo 8K vs Qwen-Max 32K)
  3. 未预留输出 token,可能导致模型无法生成回复
  4. 没有实现滑动窗口或摘要压缩

修正方案 :详见 3.3.13.3.2 节的 TokenBudgetManagerMemoryCompressionService

问题 3:Redis 键设计缺乏安全性与可维护性

原文问题

复制代码
session:{conv_id}:messages
user:{uid}:settings

问题分析

  1. 无业务前缀,易与其他系统冲突
  2. 无版本号,数据结构变更时难以迁移
  3. 无 TTL 设置,内存无限增长
  4. 未对 conv_id 做合法性校验,存在 Redis 注入风险

修正方案

java 复制代码
@Component
public class RedisChatMemory implements ChatMemory {

    private static final String KEY_PREFIX = "chat:memory:v1:";
    private static final Duration DEFAULT_TTL = Duration.ofHours(24);

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private ConversationIdGenerator idGenerator;

    @Override
    public List<Message> get(String conversationId, int lastN) {
        // 1. 校验 ID 合法性
        if (!idGenerator.validate(conversationId)) {
            throw new IllegalArgumentException("Invalid conversation ID format");
        }

        String key = KEY_PREFIX + conversationId + ":messages";

        // 2. 使用 LRANGE 获取最近 N 条(-N 到 -1 表示最后 N 条)
        List<String> jsonMessages = redisTemplate.opsForList().range(key, -lastN, -1);

        if (jsonMessages == null || jsonMessages.isEmpty()) {
            return List.of();
        }

        // 3. 反序列化并刷新 TTL(续期)
        redisTemplate.expire(key, DEFAULT_TTL);

        return jsonMessages.stream()
            .map(this::deserializeMessage)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        if (!idGenerator.validate(conversationId)) {
            throw new IllegalArgumentException("Invalid conversation ID format");
        }

        String key = KEY_PREFIX + conversationId + ":messages";
        String metaKey = KEY_PREFIX + conversationId + ":metadata";

        // 使用 Pipeline 批量写入,减少网络 RTT
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (Message message : messages) {
                connection.listCommands().rPush(
                    key.getBytes(),
                    serializeMessage(message).getBytes()
                );
            }
            // 设置/刷新 TTL
            connection.keyCommands().expire(key.getBytes(), DEFAULT_TTL.getSeconds());
            connection.keyCommands().expire(metaKey.getBytes(), DEFAULT_TTL.getSeconds());
            return null;
        });

        // 更新元数据
        redisTemplate.opsForHash().put(metaKey, "lastActive", Instant.now().toString());
        redisTemplate.opsForHash().increment(metaKey, "messageCount", messages.size());
    }

    @Override
    public void clear(String conversationId) {
        String pattern = KEY_PREFIX + conversationId + ":*";
        Set<String> keys = redisTemplate.keys(pattern);
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }

    private String serializeMessage(Message message) {
        try {
            return new ObjectMapper().writeValueAsString(Map.of(
                "type", message.getMessageType().name(),
                "text", message.getText(),
                "timestamp", Instant.now().toEpochMilli()
            ));
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize message", e);
        }
    }

    private Message deserializeMessage(String json) {
        try {
            JsonNode node = new ObjectMapper().readTree(json);
            String type = node.get("type").asText();
            String text = node.get("text").asText();

            return switch (type) {
                case "USER" -> new UserMessage(text);
                case "ASSISTANT" -> new AssistantMessage(text);
                case "SYSTEM" -> new SystemMessage(text);
                default -> null;
            };
        } catch (Exception e) {
            log.warn("Failed to deserialize message: {}", json);
            return null;
        }
    }
}

5.2 中等优先级改进

改进 1:缺失身份认证与授权

原文建议的 Filter 实现存在 Spring Security 集成问题,以下是更完整的方案:

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // 若使用 JWT/API Key,可禁用 CSRF
            .sessionManagement(session -> session.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/chat/public/**").permitAll()
                .requestMatchers("/api/chat/**").authenticated()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
            )
            .addFilterBefore(new ConversationAuthFilter(), 
                UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

@Component
public class ConversationAuthFilter extends OncePerRequestFilter {

    @Autowired
    private ConversationOwnershipRepository ownershipRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {

        String conversationId = request.getHeader("X-Conversation-Id");
        String userId = extractUserIdFromToken(request);  // 从 JWT 解析

        if (conversationId != null && userId != null) {
            // 校验会话归属权
            boolean isOwner = ownershipRepository.isOwner(conversationId, userId);
            if (!isOwner) {
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.getWriter().write("{\"error\": \"Access denied to conversation\"}");
                return;
            }
            // 将会话信息存入上下文,供后续使用
            ConversationContextHolder.setCurrentConversation(conversationId, userId);
        }

        filterChain.doFilter(request, response);
    }
}
改进 2:消息防重放攻击
java 复制代码
@Component
public class IdempotencyGuard {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String IDEMPOTENCY_KEY_PREFIX = "chat:idempotency:v1:";
    private static final Duration IDEMPOTENCY_TTL = Duration.ofMinutes(5);

    /**
     * 检查并记录请求幂等性键
     * @return true 表示首次请求,false 表示重复请求
     */
    public boolean checkAndRecord(String idempotencyKey) {
        String redisKey = IDEMPOTENCY_KEY_PREFIX + idempotencyKey;
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(redisKey, "1", IDEMPOTENCY_TTL);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 生成幂等性键:userId + conversationId + messageHash + timestampWindow
     */
    public String generateKey(String userId, String conversationId, String messageContent) {
        long timeWindow = System.currentTimeMillis() / 1000 / 60;  // 分钟级时间窗口
        String raw = userId + ":" + conversationId + ":" + messageContent + ":" + timeWindow;
        return DigestUtils.sha256Hex(raw);
    }
}

5.3 进阶优化点

编号 优化方案 实现思路 预期收益
1 记忆分层压缩 近期消息全量保留 + 中期消息摘要 + 早期消息丢弃 节省 60%+ token,保持长会话连贯性
2 用户偏好学习 提取用户语气、格式偏好、常用指令,注入 System Prompt 提升回复个性化程度
3 WebSocket 状态机 设计 CONNECTED -> CHATTING -> TYPING -> DISCONNECTED 状态机 支持断线重连、消息补发
4 插件沙箱隔离 使用 Java SecurityManager 或独立进程运行第三方插件 防止恶意插件拖垮主服务
5 异步非阻塞 IO ChatClient 调用使用 WebFlux + Reactor,Redis 使用 Lettuce 提升并发处理能力 3-5 倍
6 多模型路由 根据任务复杂度自动选择模型(简单问答用 Turbo,复杂推理用 Max) 成本降低 40%+

六、替代方案深度对比

6.1 记忆管理方案全景

扩展
增强
替代方案 4
LangChain4j Memory
替代方案 3
混合架构

Redis + VectorDB
替代方案 2
图数据库

Neo4j
替代方案 1
向量数据库

Milvus/Pinecone
当前方案
Redis List

6.2 方案详细对比

方案 实现方式 核心优势 核心劣势 适用场景 成本估算
当前 Redis List LPUSH/LRANGE + JSON 序列化 简单、低延迟(<5ms)、内存可控 无语义检索能力,长列表性能下降 短会话、高并发、简单问答
向量数据库 Embedding + Similarity Search 支持语义检索记忆("找到与'预算'相关的对话") 需 GPU/CPU 计算 Embedding,延迟 50-200ms 知识库问答、长文档理解 中高
Redis Vector Redis Stack 的 Vector 类型 结合 Redis 性能与向量能力 向量维度受限,社区支持较弱 中等规模语义检索
图数据库 实体-关系-属性图 捕捉复杂实体关系("用户 A 在讨论项目 B 时提到了技术 C") 学习曲线陡峭,写入复杂 复杂业务推理、关系挖掘
LangChain4j ChatMemory + EmbeddingStore 生态丰富,集成多种模型与存储 与 Spring AI 不兼容,需额外学习 非 Spring 生态项目
混合架构(推荐) Redis(热数据)+ VectorDB(冷数据)+ DB(审计) 兼顾性能、语义能力与持久化 架构复杂,需维护一致性 生产级大流量应用 中高

6.3 推荐演进路径

阶段 4:智能化
多模态记忆

图片/音频上下文
自适应记忆策略

根据场景自动选择
阶段 3:语义增强
引入向量数据库

Milvus Lite / Redis Vector
对话摘要生成
用户画像提取
阶段 2:近期优化
Redis 分层

List + Hash 元数据
Token 预算管理
滑动窗口裁剪
阶段 1:当前状态
Redis List

全量存储


七、部署指南

7.1 前置准备

组件 版本要求 说明
JDK 17+ Spring Boot 3.2 基于 Jakarta EE,不再支持 JDK 8/11
Maven 3.9+ 或 Gradle 8.x
Redis 6.2+ 建议启用 Redis Stack(支持 JSON/Search)
MySQL 8.0+ 可选,用于消息审计与长期存储

删除原文中虚构的 "OpenClaw Agent",该工具不存在于 Spring/阿里云生态。生产部署应使用标准 CI/CD 工具(Jenkins、GitLab CI、GitHub Actions 或阿里云云效)。

7.2 构建与打包

bash 复制代码
# 进入项目目录
cd spring-ai-alibaba-chat-memory-example

# 清理并打包(生产环境跳过测试以加速构建)
mvn clean package -DskipTests -Pproduction

# 验证产物
ls -lh target/*.jar
# 预期输出:spring-ai-alibaba-chat-memory-example-0.0.1-SNAPSHOT.jar

Maven Profile 配置(pom.xml

xml 复制代码
<profiles>
    <profile>
        <id>production</id>
        <properties>
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <executable>true</executable>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

7.3 配置文件(分环境)

application.yml(通用配置)
yaml 复制代码
spring:
  application:
    name: spring-ai-alibaba-chat-memory

  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}  # 从环境变量读取,禁止硬编码
      chat:
        options:
          model: qwen-turbo          # 默认模型
          temperature: 0.7
          max-tokens: 1500

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 50
          max-idle: 20
          min-idle: 5

# 自定义记忆配置
chat:
  memory:
    max-messages-per-session: 100    # 单会话最大消息数
    token-budget-ratio: 0.8          # Token 预算使用率(预留 20% 给输出)
    default-ttl-hours: 24            # 会话默认过期时间
    compression-threshold: 50        # 触发摘要压缩的消息数阈值

logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
application-prod.yml
yaml 复制代码
server:
  port: 8080
  compression:
    enabled: true

spring:
  output:
    ansi:
      enabled: never  # 生产环境禁用 ANSI 颜色

  datasource:  # 若启用数据库持久化
    url: jdbc:mysql://${DB_HOST}:${DB_PORT}/chat_memory?useSSL=true&serverTimezone=Asia/Shanghai
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000

logging:
  level:
    root: WARN
    com.alibaba.cloud.example.memory: INFO
    org.springframework.ai: INFO
  file:
    name: /var/log/chat-memory/application.log
  logback:
    rollingpolicy:
      max-file-size: 100MB
      max-history: 30

7.4 启动与验证

bash 复制代码
# 方式 1:直接启动(前台运行,调试用)
java -jar target/spring-ai-alibaba-chat-memory-example.jar \
  --spring.profiles.active=prod

# 方式 2:生产环境启动(后台运行,指定 JVM 参数)
nohup java \
  -Xms512m -Xmx2g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -Djava.security.egd=file:/dev/./urandom \
  -jar target/spring-ai-alibaba-chat-memory-example.jar \
  --spring.profiles.active=prod \
  > /var/log/chat-memory/startup.log 2>&1 &

# 验证服务状态
curl -s http://localhost:8080/actuator/health | jq
# 预期:{"status":"UP"}

# 验证对话接口
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -d '{
    "conversationId": "CONV_ID_HERE",
    "message": "你好,请介绍一下自己"
  }'

7.5 systemd 服务配置(生产推荐)

创建 /etc/systemd/system/chat-memory.service

ini 复制代码
[Unit]
Description=Spring AI Alibaba Chat Memory Service
After=network.target redis.service

[Service]
Type=simple
User=chatapp
Group=chatapp
WorkingDirectory=/opt/chat-memory
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
Environment="DASHSCOPE_API_KEY=your-api-key-here"
Environment="REDIS_HOST=localhost"
Environment="REDIS_PASSWORD=your-redis-password"
ExecStart=/usr/lib/jvm/java-17-openjdk/bin/java \
  -Xms512m -Xmx2g \
  -XX:+UseG1GC \
  -jar /opt/chat-memory/spring-ai-alibaba-chat-memory-example.jar \
  --spring.profiles.active=prod
ExecStop=/bin/kill -SIGTERM $MAINPID
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/chat-memory/app.log
StandardError=append:/var/log/chat-memory/error.log

[Install]
WantedBy=multi-user.target

启用服务:

bash 复制代码
sudo systemctl daemon-reload
sudo systemctl enable chat-memory
sudo systemctl start chat-memory
sudo systemctl status chat-memory

7.6 问题排查速查表

现象 根因分析 解决方案
服务启动后立即退出 JVM 内存不足 / 端口冲突 / 配置错误 检查 journalctl -u chat-memory,确认 -Xmx 不超过物理内存 70%
Redis 连接超时 网络不通 / 密码错误 / 连接池耗尽 验证 redis-cli -h HOST ping,检查 lettuce.pool.max-active
AI 响应极慢(>10s) 模型选择过大(如 Qwen-Max)/ 网络延迟 降级至 qwen-turbo,或启用异步响应 + SSE 流式输出
Token 超限报错 历史消息过多未裁剪 检查 TokenBudgetManager 是否生效,降低 max-messages-per-session
会话数据丢失 Redis 未持久化 / TTL 过短 启用 Redis AOF + RDB,调整 default-ttl-hours
内存泄漏(OOM) 消息未清理 / 大对象缓存 启用 JVM Heap Dump(-XX:+HeapDumpOnOutOfMemoryError),分析 MAT

八、小结

8.1 总体评价

本项目作为 Spring AI Alibaba 的对话记忆示例,基础架构合理,核心思路正确,但在以下方面需要加强:

  1. 技术准确性:存在代码编译错误(UUID API 误用)、虚构工具(OpenClaw)等问题
  2. 架构完整性:遗漏了 Spring AI 核心的 Advisor 机制,Controller 分层归类错误
  3. 生产 readiness:缺少身份认证、防重放、Token 预算管理、降级策略等关键能力
  4. 性能优化:Redis 操作未使用 Pipeline,缺少异步非阻塞设计

8.2 优先级优化路线图

P0 - 立即修复
修正

UUID 生成
修正

Token 估算
修正

Redis 键设计
删除虚构工具引用
P1 - 本周完成
添加

身份认证
添加

防重放机制
实现

Token 预算管理
添加

Advisor 机制
P2 - 本月完成
实现记忆压缩摘要
Redis Pipeline 优化
异步非阻塞重构
数据库持久化层
P3 - 季度规划
向量数据库集成
多模型智能路由
多模态记忆支持
用户画像学习
P0
P1
P2

8.3 关键设计原则

  1. 安全优先:会话 ID 不可预测、操作需授权、输入需校验
  2. 预算意识:Token 是 LLM 应用的核心资源,必须精确管理
  3. 分层存储:热数据在内存、温数据在 Redis、冷数据在数据库
  4. 无侵入扩展:利用 Spring AI Advisor 机制,保持业务代码纯净
  5. 可观测性:全链路日志、Metrics、Tracing 缺一不可

附录:Spring AI 关键类参考

  • org.springframework.ai.chat.client.ChatClient --- 对话客户端入口
  • org.springframework.ai.chat.memory.ChatMemory --- 记忆接口
  • org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor --- 记忆注入 Advisor
  • org.springframework.ai.chat.messages.UserMessage --- 用户消息
  • org.springframework.ai.chat.messages.AssistantMessage --- 助手消息
  • org.springframework.ai.chat.messages.SystemMessage --- 系统消息
  • org.springframework.ai.chat.prompt.Prompt --- 提示词封装
相关推荐
Alex艾力的IT数字空间1 小时前
大模型的“Think 模式”(思考模式)关闭的配置方式
人工智能·机器人·web3·github·开源软件·量子计算·开源协议
国服第二切图仔1 小时前
3 分钟快速实战:基于魔珐星云 SDK 搭建低延迟可交互 AI 数字人
人工智能·交互·数字人·魔珐星云
Cxiaomu1 小时前
AI Agent 核心概念全景图:Prompt、RAG、微调、Tool Call、状态机、Workflow 与 MCP
人工智能·prompt
前端AI充电站1 小时前
第 7 篇:让 RAG 答案可追溯:展示知识库引用来源
前端·人工智能·前端框架
胖墩会武术1 小时前
【AI编程通识】从模型到Agent,从Prompt到Harness
人工智能·ai编程
kishu_iOS&AI1 小时前
NLP —— 文本预处理
人工智能·pytorch·python·自然语言处理
编程小石头1 小时前
AI提示词,整理了各个场景中比较常用的Ai编程工具的提示词
人工智能·ai作画·ai编程
MY_TEUCK1 小时前
【AI 应用】前端接口联调工程化:把 Swagger 接入沉淀成可复用 Skill
前端·人工智能·uni-app·状态模式
曦樂~1 小时前
【深度学习】张量创建
人工智能·深度学习