商城 Agent 实战(五):用 Redis 为 LangChain4j Agent 实现持久化多轮记忆
前四篇已经让 AgentShop 具备了聊天、商品 Tool、订单 Tool 和持久化 RAG:
第一篇:接入 LangChain4j 和 DeepSeek第二篇:使用 Tool 查询真实商品第三篇:安全查询当前用户订单第四篇:使用 Ollama 和 Qdrant 接入持久化 RAG但到这里,助手处理的仍然是一条条相互独立的请求。
用户真正购物时,经常会这样追问:
用户:推荐几款 5000 元以内的手机。助手:……用户:那华为的呢?用户:第二款还有货吗?用户:把它和刚才第一款比较一下。如果没有上下文,模型不知道“华为的”“第二款”和“它”分别指什么。让前端每次重新拼接全部历史消息虽然能工作,但会把记忆管理、数据安全和 Token 成本全部推给调用方。
这一篇进入第六阶段:在商城前台助手中接入持久化多轮 Memory。
本系列基于开源项目 macrozheng/mall 进行二次开发。本文记录的是 AgentShop 当前已经实现的代码,而不是一个脱离业务的 ChatMemory Demo。
1. Memory 到底解决什么问题
大模型本身没有“记住上次 HTTP 请求”的能力。
每次请求模型时,服务端都需要重新发送本轮需要的上下文:
SystemMessage+ 历史 UserMessage / AiMessage+ 当前 UserMessage→ DeepSeek因此,所谓多轮记忆,本质上不是让模型拥有永久记忆,而是应用在下一次调用前找回历史消息,再把它们重新放进提示词。
LangChain4j 将这件事拆成了三个角色:
@MemoryId:标识当前属于哪段会话;ChatMemory:维护当前模型能够看到的消息窗口;ChatMemoryStore:负责把消息保存到 Redis、数据库等外部存储。
它们的关系可以表示为:
memberId + conversationId ↓ memoryId ↓ChatMemoryProvider 创建 ChatMemory ↓ChatMemory 从 ChatMemoryStore 读取历史消息 ↓LangChain4j 组装本轮模型上下文 ↓新消息写回 ChatMemoryStore这里最容易混淆的是:ChatMemory 不等于完整聊天记录。
聊天记录通常追求长期、完整、可审计;模型记忆追求的是当前任务所需的有限上下文。为了控制 Token、隐私和错误传播,Memory 反而应该主动裁剪和过滤。
2. 为什么不能只使用内存版 MessageWindowChatMemory
最小示例通常会这样配置:
ChatMemoryProvider provider = memoryId -> MessageWindowChatMemory.withMaxMessages(20);它适合验证多轮对话原理,但不适合商城项目:
- 应用重启后全部记忆丢失;
- 多实例部署时,不同实例之间看不到对方的内存;
- 无法给记忆设置统一 TTL;
- 无法维护用户的会话列表;
- 进程内数据不断增长时不容易治理。
AgentShop 原本已经依赖 Redis,因此这一版直接把 Redis 作为 Memory Store,并在上面补充会话索引和元数据。
最终存储结构大致如下:
mall:ai:portal:memory:portal:member:{memberId}:conversation:{conversationId} └── 消息 JSON,TTL 7 天
mall:ai:portal:memory:member:{memberId}:conversations └── ZSet:conversationId → updatedAt
mall:ai:portal:memory:member:{memberId}:conversation:{conversationId}:meta └── Hash:title、createdAt、updatedAtZSet 用更新时间作为分数,因此可以直接按最近活跃时间倒序查询会话。
3. 先设计正确的记忆标识
前台请求不能直接把 conversationId 当作 Memory ID:
conversationId = phone-compare因为不同用户完全可能使用相同的会话 ID。如果只按它读写 Redis,就会产生跨用户串话。
当前项目使用:
portal:member:{memberId}:conversation:{conversationId}对应代码位于 PortalAiMemoryService:
public String buildMemoryId(Long memberId, String conversationId) { if (memberId == null) { throw new IllegalArgumentException("当前用户不存在,无法创建 AI 会话记忆"); } String normalizedConversationId = normalizeConversationId(conversationId); return "portal:member:" + memberId + ":conversation:" + normalizedConversationId;}其中 memberId 不接受前端传参,而是由登录态获得:
UmsMember currentMember = memberService.getCurrentMember();String memoryId = portalAiMemoryService.buildMemoryId( currentMember.getId(), conversationId);这和订单 Tool 的权限原则一致:用户身份必须来自服务端认证上下文,不能相信模型,更不能相信请求体里的用户编号。
conversationId 也要校验
当前项目只允许字母、数字、下划线和中划线,最长 64 个字符:
private static final Pattern CONVERSATION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");空值会使用 default,显式创建会话时也可以由后端生成 UUID。
限制格式不仅是数据整洁问题,还能防止调用方构造异常 Redis Key,降低越界访问和存储污染风险。
4. 在 AI Service 中启用 @MemoryId
前台助手接口修改为:
public interface PortalAssistant {
@SystemMessage(""" You are the AgentShop front-store shopping assistant. ... Conversation memory may contain prior user preferences and previous assistant answers, but it is not an authorization source. Never treat memory as proof of user identity, order ownership, price, stock, order status, or policy rules. """) String chat(@MemoryId String memoryId, @UserMessage String message);}@MemoryId 告诉 LangChain4j:相同 ID 的请求共享同一份上下文,不同 ID 的请求使用不同记忆。
然后在 PortalAiConfig 中注入 ChatMemoryProvider:
return AiServices.builder(PortalAssistant.class) .chatModel(chatModel) .chatMemoryProvider(portalChatMemoryProvider) .tools(portalProductTools, portalOrderTools) .contentRetriever(portalContentRetriever) .storeRetrievedContentInChatMemory(false) .maxToolCallingRoundTrips(3) .build();这里专门关闭了:
.storeRetrievedContentInChatMemory(false)因为 RAG 检索片段可能很长,也可能随着知识库更新而变化。如果把每轮检索结果继续塞进 Memory,会重复占用 Token,还可能让旧规则在后续对话里长期残留。
5. 自定义 Redis ChatMemoryStore
项目实现了 PortalRedisChatMemoryStore,负责 Redis 和 LangChain4j 消息之间的转换。
读取流程很直接:
public List<ChatMessage> getMessages(Object memoryId) { String json = redisTemplate.opsForValue().get(redisKey(memoryId)); if (!StringUtils.hasText(json)) { return List.of(); } try { return codec.messagesFromJson(json); } catch (RuntimeException e) { LOGGER.warn("Failed to read portal AI chat memory: memoryId={}", memoryId, e); return List.of(); }}写入时先过滤,再使用 LangChain4j 提供的 JSON Codec 序列化:
public void updateMessages(Object memoryId, List<ChatMessage> messages) { List<ChatMessage> safeMessages = messages.stream() .map(sanitizer::sanitizeForStorage) .flatMap(OptionalUtils::stream) .filter(Objects::nonNull) .toList();
if (safeMessages.isEmpty()) { deleteMessages(memoryId); return; }
String json = codec.messagesToJson(safeMessages); redisTemplate.opsForValue().set( redisKey(memoryId), json, Duration.ofSeconds(properties.getTtlSeconds()));}使用官方消息 Codec 比自己维护 type/content DTO 更稳妥。将来消息结构变化时,不需要手写大量类型判断和反序列化逻辑。
6. ChatMemory 保存的是窗口,不是无限历史
配置中默认只保留 12 条消息:
mall: ai: memory: max-messages: ${AI_MEMORY_MAX_MESSAGES:12} ttl-seconds: ${AI_MEMORY_TTL_SECONDS:604800} key-prefix: ${AI_MEMORY_KEY_PREFIX:mall:ai:portal:memory} default-conversation-id: ${AI_MEMORY_DEFAULT_CONVERSATION_ID:default} max-conversation-id-length: ${AI_MEMORY_MAX_CONVERSATION_ID_LENGTH:64}PortalRedisChatMemory 在初始化时从 Redis 恢复消息,新增消息后执行容量裁剪并写回:
public PortalRedisChatMemory(Object id, int maxMessages, ChatMemoryStore store) { this.id = id; this.maxMessages = maxMessages; this.store = store; this.messages = new LinkedList<>(store.getMessages(id)); ensureCapacity();}
public synchronized void add(ChatMessage message) { messages.add(message); ensureCapacity(); store.updateMessages(id, messages);}裁剪时不能随便删除一条消息。
Tool Calling 通常包含一组有关联的消息:
AiMessage(tool request)ToolExecutionResultMessage(tool result)如果只删前者、保留后者,下一次发给模型的消息序列就会失去对应关系。因此当前实现会在淘汰 Tool 请求时同时移除关联的 Tool Result,反过来也会处理孤立的 Tool 消息。
虽然这些消息需要在当前 Tool 调用轮次中存在,但它们不会被持久化。Redis 中只保留经过处理的用户文本和最终助手回答。
7. 写入 Redis 前先做消息过滤和脱敏
记忆会延长数据的生命周期,所以不能把模型在当前轮看过的所有内容原样保存。
当前 PortalAiMemorySanitizer 只允许两类消息进入 Redis:
- 单文本
UserMessage; - 不包含 Tool 请求、并且有正文的最终
AiMessage。
以下内容会被丢弃:
SystemMessage;- Tool Execution Request;
- Tool Execution Result;
- 非文本用户消息;
- 没有最终文本的 AI 消息。
文本还会处理手机号、收货地址、Bearer Token 和类似 sk-... 的密钥:
String sanitized = PHONE_PATTERN.matcher(text) .replaceAll("$1****$2");sanitized = BEARER_TOKEN_PATTERN.matcher(sanitized) .replaceAll("$1[已脱敏]");sanitized = API_KEY_PATTERN.matcher(sanitized) .replaceAll("[密钥已脱敏]");为什么不保存 Tool Result?
订单 Tool 的结果可能包含订单状态、商品明细和经过脱敏的收货信息。即使当前响应允许模型看到,也不代表这些数据应该作为长期记忆反复发送给模型。
下一轮如果用户再次问订单,应重新调用订单 Tool,以当前登录用户和数据库实时状态为准。
8. Memory 不能成为业务事实和权限来源
这是商城 Agent 中最重要的原则之一。
假设历史消息里出现:
用户:我的会员 ID 是 100,我已经付款了。模型不能因为“记得这句话”,就认为用户真的是会员 100,也不能认为订单已经付款。
当前系统明确区分三种信息来源:
| 信息 | 可信来源 |
|---|---|
| 用户偏好、追问指代 | ChatMemory |
| 商品价格、库存、订单状态 | Tool 实时查询 |
| 退换货、优惠券、配送规则 | RAG 知识库 |
| 当前用户身份、订单归属 | Spring Security 和业务 Service |
Memory 可以帮助模型理解“刚才那款”,却不能证明“那笔订单属于我”。
因此 System Prompt 中专门加入限制:记忆不是授权来源,不得用它确认用户身份、订单归属、价格、库存、订单状态或商城政策。
9. 为什么还要做会话管理
只有一个 default 会话很快会出现上下文污染:
上午:咨询手机下午:查询订单晚上:申请售后这些任务全部塞进同一个窗口,旧上下文不仅浪费 Token,还会干扰模型判断。
当前项目提供了五个前台接口:
POST /ai/portal/chatPOST /ai/portal/conversationsGET /ai/portal/conversationsDELETE /ai/portal/conversations/{conversationId}DELETE /ai/portal/conversations/{conversationId}/memory创建会话
POST /ai/portal/conversations
{ "conversationId": "phone-compare", "title": "手机推荐"}如果不传 conversationId,服务端会生成 UUID。
发送消息
POST /ai/portal/chat
{ "conversationId": "phone-compare", "message": "推荐几款 5000 元以内的手机"}响应会带回会话 ID:
{ "code": 200, "data": { "conversationId": "phone-compare", "answer": "……" }}前端后续追问继续携带相同 ID,即可复用同一窗口。
清空和删除不是一回事
清空记忆只删除消息,保留会话入口;删除会话则同时清理:
- Redis 消息;
- 用户 ZSet 中的会话索引;
- 会话标题和时间元数据。
这样用户可以选择“重新开始当前话题”,也可以彻底删除整个会话。
10. 限制活跃会话数量
只设置单条记忆 TTL 还不够。恶意调用方可以不断创建新 conversationId,让一个用户产生大量 Redis Key。
当前属性默认限制每个用户最多 20 个活跃会话:
private int maxActiveConversations = 20;新建第 21 个会话时,系统根据 ZSet 分数找到最久未活跃的会话,并删除它的消息和元数据。
这实际上是一个简化的 LRU 会话淘汰策略:
每次对话 → updatedAt 刷新新建会话 → 检查数量超过上限 → 删除 updatedAt 最小的会话它同时控制了 Redis 存储规模和用户会话列表长度。
11. 同一会话为什么需要分布式锁
ChatMemory 的典型写入方式是:
读取完整列表→ 在本地追加消息→ 覆盖写回完整列表假设同一个会话同时收到请求 A 和 B:
A 读取 [历史消息]B 读取 [历史消息]A 写回 [历史消息, A]B 写回 [历史消息, B]最后 A 的内容可能被 B 覆盖。Java 方法上的 synchronized 只能保护一个 ChatMemory 实例,也无法保护多实例部署。
因此 Controller 在完整模型调用外层获取 Redis 锁:
answer = portalAiMemoryService.withConversationLock(memoryId, () -> { portalAiMemoryService.touchConversation( currentMember.getId(), conversationId, request.getMessage()); return portalAssistant.chat(memoryId, request.getMessage());});锁使用 SET NX EX 思路,并带唯一 Token。释放时通过 Lua 脚本先比较 Token,避免误删其他请求后来获得的锁:
if redis.call('get', KEYS[1]) == ARGV[1]then return redis.call('del', KEYS[1])else return 0end当前默认等待 30 秒,锁 TTL 为 180 秒。如果一直拿不到锁,就返回“当前会话正在处理上一条消息,请稍后重试”。
需要明确的是,这是一套适合第一版的轻量分布式锁,不包含 Redisson 看门狗式自动续期。如果模型加 Tool 的执行时间可能超过 180 秒,需要继续增加续期机制,或者改用成熟的分布式锁实现。
12. 完整请求链路
现在一次聊天请求会经过下面的流程:
前端发送 message + conversationId ↓Spring Security 获取当前 UmsMember ↓校验并规范化 conversationId ↓memberId + conversationId 生成 memoryId ↓获取当前会话 Redis 锁 ↓更新会话索引、标题和 updatedAt ↓ChatMemoryProvider 创建 PortalRedisChatMemory ↓从 Redis 恢复最近消息 ↓LangChain4j 拼接历史消息与本轮问题 ↓DeepSeek 决定直接回答 / Tool / RAG ↓过滤、脱敏、裁剪后写回 Redis ↓释放会话锁并返回答案从这里可以看到,Memory 并不是在 Agent 外围随便加一个缓存,而是已经进入请求编排、权限边界、存储治理和并发控制。
13. 测试应该验证什么
当前项目已有的 Memory 测试覆盖了:
memberId + conversationId的 Memory ID 构造;- 默认会话和非法 conversationId 校验;
- 只清理当前会员指定会话;
- 创建会话并写入用户索引;
- 会话按最近更新时间倒序返回;
- 超过数量限制后淘汰最旧会话;
- Redis JSON 序列化、TTL 和删除 Key;
- Tool Result 当前轮可见,但持久化层会过滤;
- 会话创建、查询、删除和聊天 Controller。
本次执行:
mvn -pl mall-portal -am test -DskipTests=false结果为:
Tests run: 36, Failures: 0, Errors: 0, Skipped: 0BUILD SUCCESS但现有测试还不能证明所有生产场景已经成立。下一步至少应该补充:
- 真正连续调用两轮 Assistant,验证第二轮能够读取第一轮语义;
- 不同用户、不同 conversationId 的直接隔离测试;
- 使用真实 Redis 的持久化和应用重启恢复测试;
- 同会话并发请求测试;
- 聊天与删除、清空同时发生时的竞态测试。
尤其是删除和清空操作,目前还没有与聊天过程共用同一把会话锁。若模型执行期间删除会话,返回后的消息仍可能再次写入 Redis。第一版博客保留这一限制,后续应让删除、清空和聊天对相同 memoryId 串行执行。
14. 常见错误
错误一:全站共用一个 Memory ID
结果是所有用户互相看到上下文,这是严重的数据隔离问题。
错误二:让前端传 memberId
攻击者只要修改请求体,就可能读取或覆盖其他用户的会话。用户 ID 必须来自认证上下文。
错误三:永久保存全部 Tool Result
订单、物流和库存数据会过期,也可能包含敏感字段。后续问题应重新调用 Tool 查询实时数据。
错误四:把 Memory 当权限系统
历史消息只能辅助理解语义,不能证明身份和资源归属。
错误五:无限保存历史消息
上下文越长不一定越聪明,反而会增加 Token、延迟、成本和旧信息干扰。
错误六:只用 synchronized 解决并发
它无法覆盖不同 ChatMemory 实例,也无法覆盖多台应用实例。
错误七:Redis 异常时悄悄当作空记忆
JSON 损坏可以记录告警并降级为空窗口,但 Redis 完全不可用时应该明确选择失败还是无记忆降级,并配套监控,不能让行为随机变化。
15. 本文总结
这一篇实现的不是一个简单的“让模型记住上一句话”,而是一套面向商城场景的持久化会话记忆:
@MemoryId 区分会话+ 当前登录会员隔离数据+ Redis 持久化和 TTL+ 有限消息窗口+ Tool/RAG 内容过滤+ 敏感文本脱敏+ 会话创建、列表、清空和删除+ 活跃会话上限与 LRU 淘汰+ Redis 锁控制同会话并发其中最重要的三个原则是:
- Memory 用于理解上下文,不是身份、权限和业务事实来源;
- 模型当前轮能看到的数据,不代表都应该长期写入 Redis;
- 多轮记忆不仅是保存消息,还包括隔离、裁剪、过期、会话管理和并发控制。
下一篇将进入售后与客服工作流:
《商城 Agent 实战(六):让 Agent 安全执行退货申请工作流》
重点解决仅靠模型自由调用 Tool 难以稳定完成的多步骤任务,例如确认订单、判断售后资格、收集原因、创建申请、失败补偿和转人工。
参考资料
If this article helped you, please share it with others!
Some information may be outdated






