MessageWindowChatMemory

在大模型开发的过程中,往往需要模型记录会话的信息,这个时候需要消息的窗口函数可以解决这个问题。这个核心的会话消息窗口函数,可以用于用户-大模型会话的消息存储。
它主要分为以下2个属性:

  private static final int DEFAULT_MAX_MESSAGES = 20;

	private final ChatMemoryRepository chatMemoryRepository;

	private final int maxMessages;

窗口的最大消息条数以及会话记忆的持久化策略。

源码的注释很好的解释了这个窗口函数的基本特性以及功能
在这里插入图片描述

  1. 严格的数量限制 (Hard Limit on Count)
    原文:“…maintains a message window of a specified size, ensuring that the total number of messages does not exceed the specified limit.”
    🔍 特征:这是一个基于 计数(Count-based) 的窗口,而不是基于时间(Time-based)的窗口。
    ⚙️ 行为:
    无论消息发送的时间跨度多大(是 1 分钟前还是 1 小时前),只要总数超过了设定的 maxMessages(例如 10 条),就会立即触发清理机制。
    系统只关心数量,不关心消息的“年龄”。
  2. 系统提示词指令的“唯一性”与“更新机制” (System Message Uniqueness & Update)
    原文:“Messages of type SystemMessage are treated specially: if a new SystemMessage is added, all previous SystemMessage instances are removed from the memory.”
    🔍 特征:互斥性。内存中同一时刻只能存在 一个 有效的 SystemMessage。
    ⚙️ 行为:
    采用 “覆盖更新” 策略。
    如果您发送了一个新的系统提示词(例如:“现在你是一个翻译助手”),代码会自动检测并删除之前所有的旧系统提示词(例如:“你是一个编程助手”)。
    目的:防止多个系统指令在上下文中“打架”,导致模型困惑(例如既想当翻译又想当程序员)。
  3. 系统提示词指令的“优先保留权” (Priority Preservation)
    原文:“Also, if the total number of messages exceeds the limit, the SystemMessage messages are preserved while evicting other types of messages.”
    🔍 特征:特权保护。在资源不足(需要删除旧消息)时,SystemMessage 拥有 “免死金牌”。
    ⚙️ 行为:
    当消息总数超标时,算法会优先删除普通的用户消息(UserMessage)和助手消息(AssistantMessage)。
    即使那个唯一的 SystemMessage 是很久以前发的(位于列表头部),它也不会被当作“最旧的消息”被删除。
    目的:确保大模型永远不会丢失“人设”或“核心指令”,这是对话能正常进行的基础。

一句话总结
它是一个“保人设、去旧存新”的智能过滤器:
始终只保留最近的 N 条 对话,但会自动替换旧人设,并且在空间不足时优先牺牲普通对话死保当前人设不丢失

其中消息添加的add方法的最核心逻辑为:process 方法
add 方法源码

	@Override
	public void add(String conversationId, List<Message> messages) {
		Assert.hasText(conversationId, "conversationId cannot be null or empty");
		Assert.notNull(messages, "messages cannot be null");
		Assert.noNullElements(messages, "messages cannot contain null elements");

		List<Message> memoryMessages = this.chatMemoryRepository.findByConversationId(conversationId);
		List<Message> processedMessages = process(memoryMessages, messages);
		this.chatMemoryRepository.saveAll(conversationId, processedMessages);
	}

process 滑动窗口算法核心逻辑

	private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) {
		List<Message> processedMessages = new ArrayList<>();

		Set<Message> memoryMessagesSet = new HashSet<>(memoryMessages);
		boolean hasNewSystemMessage = newMessages.stream()
			.filter(SystemMessage.class::isInstance)
			.anyMatch(message -> !memoryMessagesSet.contains(message));

		memoryMessages.stream()
			.filter(message -> !(hasNewSystemMessage && message instanceof SystemMessage))
			.forEach(processedMessages::add);

		processedMessages.addAll(newMessages);

		if (processedMessages.size() <= this.maxMessages) {
			return processedMessages;
		}

		int messagesToRemove = processedMessages.size() - this.maxMessages;

		List<Message> trimmedMessages = new ArrayList<>();
		int removed = 0;
		for (Message message : processedMessages) {
			if (message instanceof SystemMessage || removed >= messagesToRemove) {
				trimmedMessages.add(message);
			}
			else {
				removed++;
			}
		}

		return trimmedMessages;
	}

滑动窗口裁剪逻辑深度解析

A. 遍历方向:从头到尾 (FIFO)

  • 数据结构:processedMessages 是一个 ArrayList
  • 排列顺序:
[最旧的消息, ..., 较旧的消息, ..., 最新的消息]
  • 执行逻辑:
    • for (Message message : processedMessages) 从索引 0 开始向后遍历。
    • 结论:最先被检查的是最旧的消息。removed++ 计数时同时丢弃消息

B. 裁剪逻辑推演

📋 假设场景

  • 限制数量:maxMessages = 5
  • 当前列表(共 8 条):
[Sys(), User1, AI1, User2, AI2, User3, AI3, Sys()]

(注:假设 Sys(新) 包含在 newMessages 中)

  • 需要删除数量:
messagesToRemove = 8 - 5 = 3

🔄 循环执行过程

步骤 当前消息 判断条件 动作 状态 (removed)
1 Sys(旧) instanceof SystemMessage 为 true ✅ 保留 0
2 User1 非 System 且 0 < 3 ❌ 删除 1
3 AI1 非 System 且 1 < 3 ❌ 删除 2
4 User2 非 System 且 2 < 3 ❌ 删除 3
5 AI2 非 System 但 3 >= 3 (配额用完) ✅ 保留 3
6+ User3, AI3, Sys(新) removed >= 3 始终成立 ✅ 全部保留 3

🏁 最终结果列表

[Sys(), AI2, User3, AI3, Sys()] 
// 共 5 条消息

核心代码逻辑

// 这段代码只负责把旧列表中的 SystemMessage 过滤掉(如果有新的话)
memoryMessages.stream()
    .filter(message -> !(hasNewSystemMessage && message instanceof SystemMessage))
    .forEach(processedMessages::add);

List<Message> trimmedMessages = new ArrayList<>();
int removed = 0;
// 关键点:这里是按顺序遍历 processedMessages
for (Message message : processedMessages) { 
    if (message instanceof SystemMessage || removed >= messagesToRemove) {
        trimmedMessages.add(message); // 保留
    }
    else {
        removed++; // 丢弃(计数器+1)
    }
}

C. 这个顺序的含义

  1. 优先删除最旧的普通对话

    • 由于是从头(最旧端)开始遍历,最早发生的 User/AI 对话会最先被牺牲。
    • 这符合“保留最新上下文”的直觉
  2. SystemMessage 的“不死金身”

  • 机制:只要代码执行到 if (message instanceof SystemMessage …) 为真,该消息就会被无条件添加到结果列表中,完全不消耗 removed 配额。
  • 潜在 Bug/特性:
    • 如果前面的 filter 逻辑失效(例如 equals 比较失败),导致 Sys(旧) 没有被移除,这个循环会强行保留它。
    • 这意味着即使是最旧的消息,只要是 SystemMessage,也能插队存活。
  1. 正常情况下的防御性编程
  • 理想流程:前面的 filter 逻辑应该已经把所有旧的 SystemMessage 都剔除了。
  • 实际进入循环时:列表中理论上只剩下一个位于末尾的 Sys(新)。
  • 代码意图:此时的 if 判断主要是一种防御性编程。
    • 虽然 Sys(新) 位于列表末尾,按顺序本来也轮不到删除它。
    • 但加上这个判断,可以确保万一逻辑出现异常或边界情况,系统指令依然拥有最高优先级,绝不会被误删。

总结图示

原始列表 (Head -> Tail):
[旧Sys, 旧User1, 旧AI1, 旧User2, 旧AI2, 新User3, 新AI3, 新Sys]
  ^      ^       ^       ^       ^       ^       ^       ^
  |      |       |       |       |       |       |       |
  |      删      删      删      保      保      保      保
  |      (1)     (2)     (3)     (够数)  (够数)  (够数)  (Sys保护)
  |
  保留 (Sys保护)

最终列表:
[旧Sys*, 旧AI2, 新User3, 新AI3, 新Sys]
(*注:正常情况下旧Sys应在第一步被 filter 移除,若未移除则会在此处被强制保留)

遍历顺序是从旧到新。策略是**“杀掉最旧的 N 个普通人,但绝不杀系统指令**”。这确保了大模型永远能收到最新的人设指令,同时尽可能保留最近的对话上下文。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐