LLM 是无状态的。ChatMemory 负责在多次调用之间维护对话上下文。本章深入解析逐出策略、持久化实现、多用户隔离、以及 Memory vs History 的关键区别。
6.1 Memory vs History
LangChain4j 做一个关键区分:
arduino
History(历史) --- 用户看到的完整对话记录
"用户和 AI 之间的所有消息,保持不变"
Memory(记忆) --- 发给 LLM 以模拟"记住"的信息
可能被逐出、摘要、改写、注入额外信息
当前 LangChain4j 只提供 Memory,不提供 History。如果你需要展示完整对话历史给用户,需要自己维护。
6.2 为什么需要逐出策略?
三个核心驱动力:
- Context Window 限制:每个模型有最大 Token 限制(如 GPT-4o 128K,但超长 context 会降低质量)
- 成本控制:每个 Token 都计费,历史对话越长越贵
- 延迟控制:更多 Token 意味着更慢的推理速度
6.3 两种内置实现
MessageWindowChatMemory(按消息数)
java
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
// 保留最近 10 条消息(简单但不精确,无法控制 Token 数)
TokenWindowChatMemory(按 Token 数)
java
ChatMemory memory = TokenWindowChatMemory.builder()
.maxTokens(2000, new OpenAiTokenCountEstimator("gpt-4o-mini"))
.build();
// 保留最近 2000 Token 的消息
// 消息不可分割:一条消息如果超出剩余空间,会被整体逐出
对比
| 特性 | MessageWindow | TokenWindow |
|---|---|---|
| 粒度 | 消息条数 | Token 数量 |
| 精确性 | 低(消息长度差异大) | 高(精确控制 Token) |
| 性能 | 快(无 Token 计数) | 需要 TokenCountEstimator |
| 适用场景 | 快速原型 | 生产环境 |
6.4 持久化:ChatMemoryStore
默认情况下,消息存储在 ArrayList 中,服务重启即消失。实现 ChatMemoryStore 接口实现持久化:
java
public interface ChatMemoryStore {
List<ChatMessage> getMessages(Object memoryId);
void updateMessages(Object memoryId, List<ChatMessage> messages);
void deleteMessages(Object memoryId);
}
Redis 实现示例
java
class RedisChatMemoryStore implements ChatMemoryStore {
private final RedisClient redis;
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String json = redis.get("chat:" + memoryId);
if (json == null) return new ArrayList<>();
// 使用框架提供的反序列化工具
return ChatMessageDeserializer.messagesFromJson(json);
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String json = ChatMessageSerializer.messagesToJson(messages);
redis.set("chat:" + memoryId, json);
}
@Override
public void deleteMessages(Object memoryId) {
redis.del("chat:" + memoryId);
}
}
// 使用
ChatMemory memory = MessageWindowChatMemory.builder()
.id("user-12345")
.maxMessages(20)
.chatMemoryStore(new RedisChatMemoryStore())
.build();
updateMessages() 的调用时机
scss
用户发消息 → updateMessages() 被调用(新增 UserMessage)
LLM 回复 → updateMessages() 被调用(新增 AiMessage)
逐出发生 → updateMessages() 被调用(逐出后的完整列表)
即:每次与 LLM 交互,updateMessages() 通常被调用 2 次
重要语义
updateMessages()的参数是完整的消息列表(不是增量)- 逐出后的消息也会从 ChatMemoryStore 中同步删除
getMessages()的memoryId就是 builder 中设置的id
6.5 多用户/多会话隔离
AI Services 中的 @MemoryId
java
interface Assistant {
String chat(@MemoryId int memoryId, @UserMessage String message);
}
ChatMemoryProvider provider = memoryId ->
MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(20)
.chatMemoryStore(new PersistentChatMemoryStore())
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemoryProvider(provider)
.build();
// 每个 memoryId 独立一份记忆
assistant.chat(100, "我叫张三"); // 用户100的记忆
assistant.chat(200, "我叫李四"); // 用户200的记忆(完全隔离)
⚠️ 并发警告 :同一个
@MemoryId不要并发调用!ChatMemory内部使用ArrayList,没有同步保护。
运行时访问记忆
java
interface Assistant extends ChatMemoryAccess {
String chat(@MemoryId int memoryId, @UserMessage String message);
}
// 读取某用户的对话历史
List<ChatMessage> messages = assistant.getChatMemory(1).messages();
// 清除某用户的记忆
assistant.evictChatMemory(2);
6.6 SystemMessage 的特殊处理
SystemMessage 在 Memory 中有特殊待遇:
- 始终保留:不受逐出策略影响
- 单例:同一时间只能有一个 SystemMessage
- 替换规则:内容相同时忽略,不同时替换旧的
- 位置控制:
java
// 默认:新 SystemMessage 追加到列表末尾
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
// 始终放在开头(推荐,因为 LLM 更关注开头内容)
ChatMemory memory = MessageWindowChatMemory.builder()
.maxMessages(10)
.alwaysKeepSystemMessageFirst(true)
.build();
6.7 工具消息的关联逐出
如果一条 AiMessage 包含 ToolExecutionRequest 被逐出,框架会自动逐出对应的 ToolExecutionResultMessage。
原因:某些 Provider(如 OpenAI)禁止请求中出现孤立的工具结果消息(没有对应工具调用的结果消息)。
css
逐出前: [AiMessage(toolCall: getWeather), ToolExecutionResultMessage(...), UserMessage, AiMessage]
逐出后: [UserMessage, AiMessage]
↑ 两个与 getWeather 相关的消息被一起删除
6.8 ChatMemory 在低层 API 中的使用
如果你使用低层 API(非 AI Services),可以独立使用 ChatMemory:
java
ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
// 添加消息
memory.add(UserMessage.from("你好"));
memory.add(AiMessage.from("你好!有什么可以帮你的?"));
// 获取上下文消息
List<ChatMessage> context = memory.messages();
// 传给 LLM
ChatResponse response = model.chat(context.toArray(new ChatMessage[0]));
// 把回复也加进去
memory.add(response.aiMessage());
6.9 检索内容是否存入记忆
在 RAG 场景下,一个重要的配置:
java
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemory(memory)
.retrievalAugmentor(augmentor)
.storeRetrievedContentInChatMemory(false) // 默认 true
.build();
| 值 | 行为 |
|---|---|
true(默认) |
增强后的 UserMessage(问题 + 检索内容)存入记忆。LLM 收到什么,下次就记住什么 |
false |
只存原始的 UserMessage(不含检索内容),但当前推理仍用增强版本 |
选择 false 的原因:避免检索内容污染下次对话(下次可能检索到更相关的新内容)。
6.10 与 Agent 集成
在 Agent 场景中,supervisorContextStrategy 决定 Supervisor 如何利用历史:
java
public enum SupervisorContextStrategy {
CHAT_MEMORY, // 使用完整的 ChatMemory
SUMMARIZATION, // 使用对话摘要(节省 Token)
CHAT_MEMORY_AND_SUMMARIZATION // 两者结合
}
这块后续章节会进行补充讲解。
6.11 最佳实践
| 场景 | 推荐配置 |
|---|---|
| 客服机器人(简单) | MessageWindowChatMemory.withMaxMessages(20) |
| 长对话 + 成本敏感 | TokenWindowChatMemory.maxTokens(4000) |
| 多用户 SaaS | ChatMemoryProvider + ChatMemoryStore(持久化) |
| RAG 问答 | storeRetrievedContentInChatMemory(false) |
| 高并发 | 禁止同一 MemoryId 并发;考虑用 ConcurrentHashMap 包装 |