第2043篇:LangChain4j的对话记忆——ChatMemory的实现与选型
第2043篇:LangChain4j的对话记忆——ChatMemory的实现与选型
适读人群:在Java项目中使用LangChain4j做多轮对话的工程师 | 阅读时长:约20分钟 | 核心价值:深入理解ChatMemory的各种实现方式,选择适合生产环境的持久化方案
做客服机器人的时候,第一版上线后用户反馈最多的问题是:"你怎么记性这么差,刚说过的事情又问我一遍?"
这是对话记忆没做好的问题。LangChain4j提供了几种ChatMemory实现,但文档写得比较简单,很多工程决策需要自己踩过坑才能明白选哪个。
这篇把ChatMemory的每种实现、适用场景和生产级的持久化方案都捋一遍。
为什么需要ChatMemory
LLM本身是无状态的——每次调用都是全新的对话,它不知道你上一条消息说了什么。
ChatMemory的作用是:把历史消息管理起来,每次调用时把历史消息带上。
问题在于:消息历史不能无限增长,否则很快就超过LLM的上下文窗口限制。ChatMemory的核心职责是在有限的上下文空间内,保留最有价值的历史信息。
MessageWindowChatMemory:最常用的实现
按消息条数限制记忆窗口:
@Configuration
public class ChatMemoryConfig {
/**
* 最简单的使用方式:保留最近N条消息
* 适合:上下文依赖不强的场景,或者消息本身不太长
*/
@Bean
public ChatMemory simpleMemory() {
return MessageWindowChatMemory.withMaxMessages(20);
}
/**
* 带存储的MessageWindowChatMemory
* 适合:需要在服务重启后保留记忆
*/
@Bean
public ChatMemory persistedMemory(ChatMemoryStore memoryStore) {
return MessageWindowChatMemory.builder()
.id("user-session-001")
.maxMessages(20)
.chatMemoryStore(memoryStore)
.build();
}
}MessageWindowChatMemory的淘汰策略:保留System Message(始终不删),删除最早的用户/AI消息对。
这里有个细节很重要:它按"条数"淘汰,不按"重要性"淘汰。如果用户说了很重要的一条信息(比如"我的手机号是138xxx"),但这条消息排在窗口之外了,AI就"忘记"了。
TokenWindowChatMemory:更精确的控制
按Token数量限制,而不是消息条数:
@Configuration
@RequiredArgsConstructor
public class TokenWindowConfig {
private final Tokenizer tokenizer;
/**
* TokenWindowChatMemory:按token数限制
* 优势:精确控制发送给LLM的token数,直接对应成本
*/
@Bean
public ChatMemory tokenWindowMemory() {
return TokenWindowChatMemory.builder()
.maxTokens(3000, tokenizer) // 留3000 token给历史消息
.build();
// 提示:一般把maxTokens设为模型上下文的40-50%
// 剩余的留给当前问题和AI的回答
}
/**
* Tokenizer配置
* 使用和目标模型相同的tokenizer,保证计算精确
*/
@Bean
public Tokenizer openAiTokenizer() {
return new OpenAiTokenizer("gpt-4o-mini");
}
/**
* 对于本地模型,可以用简单的字符数估算
* 中文:约1.5 token/字,英文:约0.25 token/字
*/
@Bean
@Profile("local-model")
public Tokenizer simpleEstimator() {
return new SimpleTokenizer(); // 自定义实现
}
}
/**
* 简单的token估算器(用于本地模型)
*/
public class SimpleTokenizer implements Tokenizer {
@Override
public int estimateTokenCountInText(String text) {
if (text == null || text.isEmpty()) return 0;
// 粗略估算:中文字符按1.5计,其他按0.25计
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
int otherChars = text.length() - (int) chineseChars;
return (int) (chineseChars * 1.5 + otherChars * 0.25) + 4; // +4 for message tokens
}
@Override
public int estimateTokenCountInMessage(ChatMessage message) {
return estimateTokenCountInText(message.text()) + 4;
}
@Override
public int estimateTokenCountInMessages(List<ChatMessage> messages) {
return messages.stream()
.mapToInt(this::estimateTokenCountInMessage)
.sum() + 3; // +3 for conversation framing
}
}MessageWindow vs TokenWindow,我的选择原则:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 消息长度基本一致(如客服场景) | MessageWindow | 简单,效果差不多 |
| 消息长度差异大(有时几字,有时几百字) | TokenWindow | 按token更精确 |
| 需要精确控制API成本 | TokenWindow | 直接对应收费单位 |
| 本地模型,不在乎成本 | MessageWindow | 简单易维护 |
AI Service中的@MemoryId
实际生产中,每个用户应该有独立的对话记忆。AI Service + @MemoryId是最优雅的方式:
/**
* 带记忆的AI Service接口定义
*/
@AiService
public interface CustomerServiceBot {
@SystemMessage("""
你是XX电商平台的客服助手。
你可以查询订单状态、处理退换货申请、解答产品问题。
保持专业、友好的语气。
对于不确定的问题,诚实说明需要人工处理。
""")
String chat(@MemoryId String userId, @UserMessage String message);
}
/**
* AI Service配置:每个userId有独立的记忆实例
*/
@Configuration
@RequiredArgsConstructor
public class CustomerServiceConfig {
private final ChatLanguageModel chatModel;
private final ChatMemoryStore memoryStore; // 注入持久化存储
@Bean
public CustomerServiceBot customerServiceBot() {
return AiServices.builder(CustomerServiceBot.class)
.chatLanguageModel(chatModel)
.chatMemoryProvider(userId ->
MessageWindowChatMemory.builder()
.id(userId)
.maxMessages(20)
.chatMemoryStore(memoryStore) // 使用持久化存储
.build()
)
.build();
}
}
/**
* 使用方式
*/
@RestController
@RequiredArgsConstructor
public class CustomerServiceController {
private final CustomerServiceBot bot;
@PostMapping("/chat")
public String chat(
@RequestHeader("X-User-Id") String userId,
@RequestBody ChatRequest request) {
return bot.chat(userId, request.getMessage());
// 框架自动:
// 1. 用userId找到对应的ChatMemory实例
// 2. 从存储加载历史消息
// 3. 拼装完整的上下文发给LLM
// 4. 把AI回复追加到历史并保存
}
}chatMemoryProvider是一个Function<Object, ChatMemory>,框架在第一次收到某个userId的请求时调用它创建记忆实例。之后的请求会复用同一个实例(从存储中加载历史)。
生产级持久化:Redis实现
默认的ChatMemoryStore是内存实现,重启后对话历史全丢。生产环境必须用持久化存储:
/**
* Redis持久化的ChatMemoryStore
* 支持多实例部署(多个服务实例共享用户记忆)
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisChatMemoryStore implements ChatMemoryStore {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// 对话记忆的过期时间(默认7天)
private static final Duration MEMORY_TTL = Duration.ofDays(7);
private static final String KEY_PREFIX = "chat-memory:";
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String key = buildKey(memoryId);
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
return new ArrayList<>();
}
try {
List<StoredMessage> stored = objectMapper.readValue(json,
new TypeReference<List<StoredMessage>>() {});
return stored.stream()
.map(this::deserializeMessage)
.collect(Collectors.toList());
} catch (JsonProcessingException e) {
log.error("反序列化对话历史失败,memoryId: {}", memoryId, e);
return new ArrayList<>();
}
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String key = buildKey(memoryId);
try {
List<StoredMessage> stored = messages.stream()
.map(this::serializeMessage)
.collect(Collectors.toList());
String json = objectMapper.writeValueAsString(stored);
redisTemplate.opsForValue().set(key, json, MEMORY_TTL);
} catch (JsonProcessingException e) {
log.error("序列化对话历史失败,memoryId: {}", memoryId, e);
throw new RuntimeException("保存对话历史失败", e);
}
}
@Override
public void deleteMessages(Object memoryId) {
redisTemplate.delete(buildKey(memoryId));
}
private String buildKey(Object memoryId) {
return KEY_PREFIX + memoryId.toString();
}
/**
* 序列化:LangChain4j的ChatMessage → 可存储的格式
* ChatMessage接口不直接支持Jackson序列化,需要手动处理
*/
private StoredMessage serializeMessage(ChatMessage message) {
StoredMessage stored = new StoredMessage();
if (message instanceof SystemMessage sm) {
stored.setType("SYSTEM");
stored.setContent(sm.text());
} else if (message instanceof UserMessage um) {
stored.setType("USER");
// UserMessage支持文本和图片,这里只处理文本
stored.setContent(um.singleText());
} else if (message instanceof AiMessage am) {
stored.setType("AI");
stored.setContent(am.text());
if (am.hasToolExecutionRequests()) {
stored.setHasToolCalls(true);
stored.setToolCallsJson(serializeToolCalls(am.toolExecutionRequests()));
}
} else if (message instanceof ToolExecutionResultMessage term) {
stored.setType("TOOL_RESULT");
stored.setContent(term.text());
stored.setToolId(term.id());
}
stored.setTimestamp(System.currentTimeMillis());
return stored;
}
private ChatMessage deserializeMessage(StoredMessage stored) {
return switch (stored.getType()) {
case "SYSTEM" -> SystemMessage.from(stored.getContent());
case "USER" -> UserMessage.from(stored.getContent());
case "AI" -> {
if (stored.isHasToolCalls()) {
yield new AiMessage(stored.getContent(),
deserializeToolCalls(stored.getToolCallsJson()));
}
yield AiMessage.from(stored.getContent());
}
case "TOOL_RESULT" -> ToolExecutionResultMessage.from(
stored.getToolId(), stored.getContent());
default -> throw new IllegalStateException("未知消息类型: " + stored.getType());
};
}
private String serializeToolCalls(List<ToolExecutionRequest> requests) {
try {
return objectMapper.writeValueAsString(requests.stream()
.map(r -> Map.of("id", r.id(), "name", r.name(), "args", r.arguments()))
.collect(Collectors.toList()));
} catch (JsonProcessingException e) {
return "[]";
}
}
private List<ToolExecutionRequest> deserializeToolCalls(String json) {
// 简化实现
return List.of();
}
@Data
private static class StoredMessage {
private String type;
private String content;
private long timestamp;
private boolean hasToolCalls;
private String toolCallsJson;
private String toolId;
}
}Redis实现有几个细节要注意:
- ChatMessage接口不能直接序列化,需要手动实现序列化/反序列化逻辑
- TTL设置:对话记忆应该设过期时间,防止Redis内存无限增长
- 工具调用消息:如果你的AI Service用了Tools,历史里会有ToolExecutionRequest和ToolExecutionResultMessage,序列化时需要特别处理
数据库持久化方案
对于需要查询历史对话记录的场景(比如客服工单系统),用数据库更合适:
/**
* 数据库持久化的ChatMemoryStore
* 适合:需要查询历史记录、对话审计的场景
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DatabaseChatMemoryStore implements ChatMemoryStore {
private final ChatMessageRepository messageRepository;
@Override
public List<ChatMessage> getMessages(Object memoryId) {
return messageRepository.findBySessionIdOrderBySequenceAsc(memoryId.toString())
.stream()
.map(this::toChatMessage)
.collect(Collectors.toList());
}
@Override
@Transactional
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String sessionId = memoryId.toString();
// 获取当前DB中的消息数
long currentCount = messageRepository.countBySessionId(sessionId);
int newCount = messages.size();
if (newCount > currentCount) {
// 新增消息:只插入新增部分(增量写入,避免全量重写)
List<ChatMessageEntity> newMessages = new ArrayList<>();
for (int i = (int) currentCount; i < newCount; i++) {
ChatMessage msg = messages.get(i);
ChatMessageEntity entity = toEntity(sessionId, msg, i);
newMessages.add(entity);
}
messageRepository.saveAll(newMessages);
} else if (newCount < currentCount) {
// 消息被裁剪了(超出窗口限制),删除多余的
messageRepository.deleteBySessionIdAndSequenceGreaterThanEqual(
sessionId, newCount);
}
// newCount == currentCount: 无变化,不写DB
}
@Override
@Transactional
public void deleteMessages(Object memoryId) {
messageRepository.deleteBySessionId(memoryId.toString());
}
private ChatMessage toChatMessage(ChatMessageEntity entity) {
return switch (entity.getMessageType()) {
case "SYSTEM" -> SystemMessage.from(entity.getContent());
case "USER" -> UserMessage.from(entity.getContent());
case "AI" -> AiMessage.from(entity.getContent());
default -> throw new IllegalStateException("未知类型: " + entity.getMessageType());
};
}
private ChatMessageEntity toEntity(String sessionId, ChatMessage msg, int sequence) {
ChatMessageEntity entity = new ChatMessageEntity();
entity.setSessionId(sessionId);
entity.setSequence(sequence);
entity.setCreatedAt(LocalDateTime.now());
if (msg instanceof SystemMessage sm) {
entity.setMessageType("SYSTEM");
entity.setContent(sm.text());
} else if (msg instanceof UserMessage um) {
entity.setMessageType("USER");
entity.setContent(um.singleText());
} else if (msg instanceof AiMessage am) {
entity.setMessageType("AI");
entity.setContent(am.text());
}
return entity;
}
}
/**
* 对话消息的JPA实体
*/
@Entity
@Table(name = "chat_messages",
indexes = {
@Index(name = "idx_session_seq", columnList = "session_id, sequence"),
@Index(name = "idx_session_created", columnList = "session_id, created_at")
})
@Data
public class ChatMessageEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", nullable = false, length = 128)
private String sessionId;
@Column(name = "sequence", nullable = false)
private int sequence;
@Column(name = "message_type", nullable = false, length = 16)
private String messageType; // SYSTEM/USER/AI/TOOL_RESULT
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "created_at")
private LocalDateTime createdAt;
}数据库方案有一个实现细节值得注意:updateMessages每次会被LangChain4j调用,传入的是当前窗口内的全量消息,不是增量。如果你每次都删除再插入,对高并发场景的DB压力很大。
上面的实现用了"比较序号、只追加新消息"的方式来减少DB写入。
对话记忆清理和管理接口
生产系统里,有些场景需要主动管理对话记忆:
/**
* 对话记忆管理服务
* 提供清理、查询等管理接口
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatMemoryManagementService {
private final ChatMemoryStore memoryStore;
private final ChatMessageRepository messageRepository;
/**
* 清空用户的对话历史
* 场景:用户主动"开始新对话",或者客服会话结束
*/
public void clearUserMemory(String userId) {
memoryStore.deleteMessages(userId);
log.info("已清空用户对话历史: {}", userId);
}
/**
* 查询用户的对话历史(用于前端展示)
*/
public List<ChatHistoryItem> getUserHistory(String userId, int limit) {
return messageRepository
.findBySessionIdOrderByCreatedAtDesc(userId, PageRequest.of(0, limit))
.stream()
.filter(e -> List.of("USER", "AI").contains(e.getMessageType())) // 过滤掉系统消息
.map(e -> ChatHistoryItem.builder()
.role(e.getMessageType().toLowerCase())
.content(e.getContent())
.timestamp(e.getCreatedAt())
.build())
.collect(Collectors.toList());
}
/**
* 批量清理过期对话(定时任务调用)
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
@Transactional
public void cleanupExpiredMemories() {
LocalDateTime cutoff = LocalDateTime.now().minusDays(30);
int deleted = messageRepository.deleteByCreatedAtBefore(cutoff);
log.info("清理过期对话记录: {}条", deleted);
}
/**
* 获取记忆使用情况统计(监控用)
*/
public MemoryStats getStats(String userId) {
List<ChatMessage> messages = memoryStore.getMessages(userId);
int totalMessages = messages.size();
int userMessages = (int) messages.stream()
.filter(m -> m instanceof UserMessage)
.count();
// 估算token使用
int estimatedTokens = messages.stream()
.mapToInt(m -> m.text() != null ? m.text().length() / 3 : 0)
.sum();
return MemoryStats.builder()
.userId(userId)
.totalMessages(totalMessages)
.userMessages(userMessages)
.aiMessages(totalMessages - userMessages)
.estimatedTokens(estimatedTokens)
.build();
}
}多轮对话的一个典型坑
我遇到过一个问题:用户在一个会话里改了很多次说法,比如:
- "帮我查一下订单123456"
- "哦不对,是订单654321"
- "等等,我再确认下,其实还是123456"
对话记忆里存的是全部消息,AI在第3轮的时候看到历史,会搞不清楚用户到底要查哪个订单。
解决方式是:在System Prompt里加一条明确的规则:
@AiService
public interface OrderQueryBot {
@SystemMessage("""
你是订单查询助手。
重要规则:
- 当用户多次提到不同的订单号时,以用户**最后一次**明确指定的订单号为准
- 如果对用户的意图有任何不确定,先确认再操作
- 不要自己猜测用户想查哪个订单
""")
String query(@MemoryId String userId, @UserMessage String message);
}这类问题本质上是:对话记忆只是"原始素材",让AI从中正确理解用户意图,需要System Prompt的明确引导。
选型总结
| 实现 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 内存实现 | 开发/测试 | 零配置 | 重启丢失,单机 |
| Redis实现 | 生产对话场景 | 高性能,自动TTL | 不支持复杂查询 |
| 数据库实现 | 对话审计场景 | 可查询,持久可靠 | 性能较低,需维护 |
| TokenWindow | 消息长度差异大 | 精确控制token | 需要配置Tokenizer |
| MessageWindow | 消息长度均匀 | 简单 | 不精确 |
ChatMemory的选型不复杂,关键是想清楚这几点:服务是否多实例、历史记录是否需要查询、会话是否需要持久化。想清楚了,选哪个方案就自然了。
