AI Agent的记忆力是怎么实现的——LangChain4j Memory机制深度解析

AI Agent的记忆力是怎么实现的——LangChain4j Memory机制深度解析

一个让人抓狂的场景

你有没有跟某个AI助手聊过一段很长的对话?

聊到第十轮的时候,你问它:”我刚才说的那个需求,你觉得用什么方案好?”

它回你一句:”请问您指的是什么需求呢?”

——就好像前面的对话完全没发生过一样。

这就是大语言模型的”先天缺陷”:它没有记忆。每次调用它,它都是从零开始的。你之前说了什么,它压根不知道。更准确地说,LLM 本身是一个无状态的函数——你给它输入,它给你输出,然后就忘了。

那问题来了:我们用 LangChain4j 构建 AI Agent 的时候,它是怎么让 Agent “记住”上下文的?记忆系统的底层原理是什么?有哪些策略可以选择?Java 开发者该怎么用?

这篇文章,一次性给你讲透。

先搞清楚一件事:LLM 真的没有记忆吗?

严格来说,LLM 不是”没有记忆”,而是没有跨请求的状态

单次推理的时候,你给它一个 Prompt,它会基于这个 Prompt 里的每一个 token 来生成回复。这意味着——如果你把之前所有的对话历史都塞进 Prompt 里,LLM 就能”看到”之前聊了什么。

所以,所谓的”记忆”,本质上是一个工程问题

在每次调用 LLM 之前,把相关的历史对话拼到 Prompt 里去。

就这么简单。但”简单”不意味着”好做”。因为这里有几个关键问题需要解决:

  1. 历史对话放多少? 全放进去?还是只放最近几轮?
  2. 对话太长了怎么办? LLM 的上下文窗口是有上限的。
  3. 哪些信息重要,哪些可以丢掉? 用户三小时前说了他叫张三,这个要保留;他问了一句”今天星期几”,这个可能就不重要了。

这三个问题,分别对应了 Memory 系统的三种策略。

LangChain4j 的三种记忆策略

LangChain4j 提供了开箱即用的记忆实现,核心接口是 ChatMemory。它内置了三种策略:

1. MessageWindowChatMemory —— 滑动窗口

这是最常用的策略。思路非常直觉:只保留最近 N 条消息

想象你在一个群里聊天,群消息刷了几千条。你不可能把所有消息都读一遍才能参与讨论,你只关心最近几十条说了什么。MessageWindowChatMemory 就是这个逻辑。

1
2
3
4
5
6
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;

ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20) // 只保留最近20条消息
.build();

优点:实现简单,Token 消耗可控。
缺点:早期的重要信息可能被”滑”出去了。如果用户在第十轮说了”我的数据库是 PostgreSQL”,但在第三十轮才问数据库相关的问题,这个信息可能已经丢了。

2. TokenWindowChatMemory —— Token 级窗口

跟滑动窗口类似,但不是按”消息条数”来限制,而是按 Token 数量 来限制。

为什么要这样?因为 LLM 收费和限制都是按 Token 来算的。你想精确控制成本,就得按 Token 来管理。

1
2
3
4
5
6
import dev.langchain4j.memory.chat.TokenWindowChatMemory;
import dev.langchain4j.model.openai.OpenAiTokenizer;

ChatMemory chatMemory = TokenWindowChatMemory.builder()
.maxTokens(4000, new OpenAiTokenizer("gpt-4o"))
.build();

优点:精确控制 Token 消耗,不会超出模型上下文窗口。
缺点:需要指定 Tokenizer,不同模型的 Tokenizer 不一样,换模型可能需要调整。

3. 持久化记忆 —— 存到数据库

前面两种都是内存里的,应用重启就没了。如果你想让 Agent 跨会话记住用户信息(比如用户偏好、历史订单),就需要把记忆持久化。

LangChain4j 的 ChatMemory 接口设计得很巧妙——它底层依赖一个 ChatMemoryStore 接口,默认实现是内存版的,但你可以替换为任何存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import dev.langchain4j.data.message.ChatMessage;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* 基于内存的自定义 Store(演示用,生产环境换成 Redis/DB)
*/
public class InMemoryChatMemoryStore implements ChatMemoryStore {

private final Map</**memoryId**/Object, List<ChatMessage>> store = new ConcurrentHashMap<>();

@Override
public List<ChatMessage> getMessages(Object memoryId) {
return store.getOrDefault(memoryId, List.of());
}

@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
store.put(memoryId, messages);
}

@Override
public void deleteMessages(Object memoryId) {
store.remove(memoryId);
}
}

然后把它注入到 ChatMemory 里:

1
2
3
4
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20)
.store(new InMemoryChatMemoryStore()) // 替换底层存储
.build();

生产环境的话,你可以很轻松地写一个 RedisChatMemoryStore 或者 JdbcChatMemoryStore,把对话历史存到 Redis 或 MySQL 里。

动手实战:用 LangChain4j + Spring Boot 构建有记忆的对话助手

光说不练假把式。我们来写一个完整的 Spring Boot 项目,实现一个有记忆的对话助手。

第一步:添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<!-- LangChain4j Spring Boot Starter -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
<version>1.0.0-beta1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

第二步:配置 API Key

1
2
3
4
5
6
7
# application.yml
langchain4j:
open-ai:
chat-model:
api-key: ${OPENAI_API_KEY}
model-name: gpt-4o
temperature: 0.7

第三步:编写对话服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.model.openai.OpenAiChatModel;
import org.springframework.stereotype.Service;

@Service
public class ChatService {

// 定义 AI 接口
interface Assistant {
@SystemMessage("你是一个友好的技术助手,用中文回答问题。")
String chat(@MemoryId String sessionId, @UserMessage String message);
}

private final Assistant assistant;

public ChatService(OpenAiChatModel chatModel) {
// 构建有记忆的 AI 服务
this.assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(chatModel)
.chatMemoryProvider(memoryId ->
MessageWindowChatMemory.builder()
.maxMessages(20)
.id(memoryId)
.build()
)
.build();
}

public String chat(String sessionId, String message) {
return assistant.chat(sessionId, message);
}
}

这段代码有几个关键点:

  1. **@MemoryId**:这是记忆的分区键。不同用户、不同会话用不同的 sessionId,各自的对话历史互相隔离。
  2. **chatMemoryProvider**:这是一个 Lambda,每次有新会话进来,自动创建一个独立的 ChatMemory
  3. **@SystemMessage**:定义 Agent 的”人设”——它是谁,该怎么做。这部分每次都会出现在 Prompt 的最前面。

第四步:写 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
@RequestMapping("/api/chat")
public class ChatController {

private final ChatService chatService;

public ChatController(ChatService chatService) {
this.chatService = chatService;
}

@PostMapping
public Map<String, String> chat(@RequestBody ChatRequest request) {
String reply = chatService.chat(request.sessionId(), request.message());
return Map.of("reply", reply);
}

record ChatRequest(String sessionId, String message) {}
}

第五步:测试

1
2
3
4
5
6
7
8
9
# 第一轮:自我介绍
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId": "user-001", "message": "我叫小明,是个Java开发,最近在学Spring AI"}'

# 第二轮:测试记忆
curl -X POST http://localhost:8080/api/chat \
-H "Content-Type: application/json" \
-d '{"sessionId": "user-001", "message": "你还记得我是做什么的吗?"}'

如果一切正常,第二轮的回答里会提到你叫小明、是 Java 开发、在学 Spring AI。这就是记忆在起作用——Agent 的 Prompt 里拼上了之前的历史消息。

进阶:记忆的”黑科技”——摘要压缩

滑动窗口虽然好用,但有个硬伤:早期重要信息会丢失

为了解决这个问题,社区里有一种常见的做法:摘要压缩(Conversation Summary)。思路是:

  1. 当对话历史快超出窗口时,把前面的内容让 LLM 做一次摘要
  2. 用摘要替换掉原始的历史消息
  3. 这样既保留了关键信息,又控制了 Token 消耗

LangChain4j 没有内置这个功能,但我们可以自己实现一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.model.chat.ChatLanguageModel;
import java.util.ArrayList;
import java.util.List;

/**
* 带自动摘要的 ChatMemory 包装器
*/
public class SummarizingChatMemory implements ChatMemory {

private final ChatMemory delegate;
private final ChatLanguageModel model;
private final int summaryThreshold; // 触发摘要的消息数阈值
private String summary = "";

public SummarizingChatMemory(ChatMemory delegate, ChatLanguageModel model,
int summaryThreshold) {
this.delegate = delegate;
this.model = model;
this.summaryThreshold = summaryThreshold;
}

@Override
public Object id() {
return delegate.id();
}

@Override
public void add(ChatMessage message) {
delegate.add(message);
// 消息数超过阈值时,触发摘要
if (delegate.messages().size() >= summaryThreshold) {
compressHistory();
}
}

private void compressHistory() {
List<ChatMessage> messages = delegate.messages();
// 把前半部分的消息做摘要
List<ChatMessage> toSummarize = messages.subList(0, messages.size() / 2);

String summaryPrompt = "请将以下对话历史总结为一段简洁的摘要," +
"保留所有关键信息(用户身份、需求、决策结论):\n\n" +
toSummarize.toString();

String newSummary = model.generate(summaryPrompt);
this.summary = this.summary.isEmpty()
? newSummary
: this.summary + "\n" + newSummary;

// 清空并重新构建:摘要 + 后半部分消息
delegate.clear();
delegate.add(SystemMessage.from("对话历史摘要:" + summary));
List<ChatMessage> recent = messages.subList(messages.size() / 2, messages.size());
recent.forEach(delegate::add);
}

@Override
public List<ChatMessage> messages() {
return delegate.messages();
}

@Override
public void clear() {
delegate.clear();
summary = "";
}
}

这个实现的核心思想是:当消息数量超过阈值时,自动把前半部分压缩成摘要,只保留摘要 + 最近的消息。用户感知不到这个过程,但 Agent 的”记忆力”显著增强了。

Memory 的设计哲学:三层架构

总结一下,一个完整的 Agent Memory 系统通常分三层:

层级 作用 对应 LangChain4j 组件
短期记忆 当前会话的上下文 ChatMemory(窗口策略)
长期记忆 跨会话的持久化信息 ChatMemoryStore + 外部存储
工作记忆 当前任务的中间状态 Agent 的 Tool 调用结果

短期记忆保证对话连贯,长期记忆实现个性化,工作记忆支撑复杂任务。三层协作,才能让 Agent 从”工具”进化为”助手”。

实战中的几个坑

1. MemoryId 的设计

不要用随机 UUID 做 MemoryId。最好是 userId + sessionId 的组合。userId 保证跨会话的用户识别,sessionId 保证同一用户不同会话的隔离。

2. System Message 不要放 Memory

SystemMessage 定义的是 Agent 的角色和规则,应该每次固定注入,不要参与记忆的滑动窗口。LangChain4j 的实现已经帮你处理了这一点——@SystemMessage 注解的内容不会被计入消息窗口。

3. Token 预算要留余量

如果你用 TokenWindowChatMemory,别把 maxTokens 设成模型的上下文窗口上限。要留出空间给模型的回复和你的 System Prompt。一般建议:**历史消息占 60-70%,System Prompt 占 10%,留给回复 20-30%**。

4. 敏感信息不要存 Memory

用户的密码、身份证号、支付信息,永远不要存进对话记忆。如果用户不小心说了,要在存入前做脱敏处理。

总结

回顾一下这篇文章的核心内容:

  1. LLM 本身无状态,”记忆”是一个工程问题——把历史对话拼进 Prompt。
  2. LangChain4j 提供三种策略:消息窗口、Token 窗口、持久化存储。
  3. @MemoryId 是记忆分区的关键,不同会话互不干扰。
  4. 摘要压缩是进阶技巧,能在有限 Token 内保留更多关键信息。
  5. 三层架构(短期/长期/工作记忆)是构建复杂 Agent 的基础。

记忆系统看起来简单,但它是 Agent 能不能”像人一样交流”的关键。一个没有记忆的 Agent,再聪明也只是一个一次性的问答机器。有了记忆,它才能真正成为你的助手。

下一篇文章,我们来聊 Agent 的另一个核心能力——Tool Use(工具调用),看看 LangChain4j 是怎么让 Agent 从”只会聊天”进化到”能干活”的。


本文代码基于 LangChain4j 1.0.0-beta1 + Spring Boot 3.x。完整示例项目后续会开源到 GitHub,敬请关注。