源码小说CodeStory
登录注册
上一章

第十二章 · 起居注:ChatMemory、窗口裁剪与档案库房

下一章
字体

主题

版式

19,884 字 · 约 50 分钟

卷五 · 起居(记忆)

第⼗⼆章 · 起居注:ChatMemory、窗⼝裁剪与档案库房"我只认⼀个名字——你的会话编号。⾄于该记多少、忘掉哪些、存去哪间库房,我⼀概不管,⾃有⼈替我操⼼。" —— ChatMemory 接⼝的⾃⽩模型是健忘的。你前⼀句说"我叫阿芽",下⼀句问"我叫什么",它⼀脸茫然——因为每⼀次请求,在番邦诸侯眼⾥都是⼀封孤零零的国书,没有前情、没有上下⽂。要让对话连贯,客官只能在每封国书⾥把"之前都聊了啥"⼀并附上。

这件苦差,译馆设了⼀处专⻔的衙⻔来料理,唤作起居注。古时帝王身边有起居郎,⼨步不离地记录⾔⾏,写成《起居注》供⽇后查阅。译馆的起居注做的也是这桩事:把客官与模型的每⼀轮往来记下来,下次递国书时,⾃动把旧账翻出来⼀并附上。

可这衙⻔有个出奇的脾⽓:它本身⼏乎什么都不做。这⼀章,我们就来看这处"⽆为⽽治"的衙⻔,是怎么⽤三层正交的拆分,把"记什么、忘什么、存哪⾥"这三件事彻底分了家。

⼀、起居郎的契约:只认会话编号,不管怎么裁先看这处衙⻔的总章程—— ChatMemory 接⼝。它短得令⼈起疑:

// spring-ai-model/.../chat/memory/ChatMemory.java:36

String CONVERSATION_ID = "chat_memory_conversation_id";

// ChatMemory.java:50

void add(String conversationId, List<Message> messages);

// ChatMemory.java:55

List<Message> get(String conversationId);

// ChatMemory.java:60

void clear(String conversationId);

整个契约,就⼀个维度、三个动作。维度是 conversationId ——⼀串会话编号,起居注靠它把张三的账和李四的账分开。三个动作是写( add )、读( get )、清

( clear )。另有⼀个单条便捷重载 add(String, Message) ( ChatMemory.java:41-45 ),内部委托给 List 版本,并先⽤ Assert.hasText / Assert.notNull

把空参挡在⻔外。

把这份契约和你脑⼦⾥"理想的记忆系统"对照⼀下,会发现它少了⼀⼤堆东⻄:没有 getLastN(int) ,没有分⻚,没有按时间窗⼝查,没有"给我最近 4000 个 token"。

get 只有⼀个⽆参重载,要么全给你,要么不给。

这是有意为之。 ChatMemory.java:53-55 的注释把话说死了: get 返回的是"该会话的(已经裁剪过的)消息"。换句话说,裁剪发⽣在写⼊侧,不在读取侧。读的时候你拿到的就已经是成品,起居郎不提供"我只要⼀部分"的服务。

🦴 ⻣灰级细节:为什么 CONVERSATION_ID 是个字符串常量,⽽不是⽅法参数?

注意 CONVERSATION_ID 是定义在接⼝上的⼀个 public static final String ,值为 "chat_memory_conversation_id" 。它不是给 add/get/clear ⽤的(那⼏个⽅法直接收 String conversationId ),⽽是给记忆驿丞⽤的——驿丞要从 ChatClientRequest 的 context ⾥按这个 key 把会话编号取出来(详⻅第四节)。也就是说,这个常量是"衙⻔"与"驿丞"之间约定的暗号:客官在调⽤时往 context ⾥塞⼀个 CONVERSATION_ID -> "user-42" ,驿丞照这个暗号去 context ⾥捞,捞出来再调 chatMemory.get("user-42") 。⼀个常量,把"会话身份的传递协议"钉死在了接⼝层,谁都别想⾃⼰发明 key。

这份极简契约的代价与好处,要到下⼀节才看得真切:接⼝越瘦,实现就得越能扛。

⼆、窗⼝裁剪的⼿艺: MessageWindowChatMemoryChatMemory 全书只有⼀个内置实现—— MessageWindowChatMemory 。名字直⽩:维持⼀个固定⼤⼩的"消息窗⼝",超了就把旧的挤出去。

可它有个反常之处:这位起居郎⾃⼰不存账。它⼿⾥攥着的不是消息列表,⽽是另⼀个衙⻔的牌⼦:

// MessageWindowChatMemory.java:45-56

private static final int DEFAULT_MAX_MESSAGES = 20;
private final ChatMemoryRepository chatMemoryRepository;
private final int maxMessages;
private MessageWindowChatMemory(ChatMemoryRepository chatMemoryRepository, int maxMessages) {
Assert.notNull(chatMemoryRepository, "chatMemoryRepository cannot be null");
Assert.isTrue(maxMessages > 0, "maxMessages must be greater than 0");
this.chatMemoryRepository = chatMemoryRepository;
this.maxMessages = maxMessages;
}

默认窗⼝ 20 条;构造时校验 maxMessages > 0 。真正存账的活,全甩给组合进来的 ChatMemoryRepository (那是"档案库房",第三节细说)。

MessageWindowChatMemory 只负责⼀件事:窗⼝裁剪——决定"记多少、忘哪些"。存哪⾥它不碰。

这就是起居注衙⻔"⽆为"的真相:它把"策略"(怎么裁)和"存储"(存哪⾥)拆成了两层。 MessageWindowChatMemory 是策略层, ChatMemoryRepository 是存储层,两层正交,可以任意搭配——内存窗⼝、JDBC 窗⼝、Redis 窗⼝,换库房不换策略,换策略不换库房。

读-改-写:每次 add 都重写整本账看它的 add 怎么⼲活:

// MessageWindowChatMemory.java:58-67@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);
}

三步,典型的读-改-写:先把整会话的旧账读出来( findByConversationId ),与新消息合并裁剪( process ),再整本覆盖写回( saveAll )。

请把这⼀点刻进脑⼦:这⾥没有 append,只有覆盖。哪怕你只新增⼀条⽤户消息,起居郎也会把整本账读出来、裁⼀遍、再整本写回去。 get / clear 则⼲脆直接透

传给库房( MessageWindowChatMemory.java:69-79 ),⾃⼰不掺和。

这个设计简单、⾃洽,但它的暗礁我们留到末尾算总账——先把"裁剪"这⻔⼿艺看明⽩。

process() 的三条裁剪法度裁剪逻辑全在 process() ⼀个私有⽅法⾥( MessageWindowChatMemory.java:81-130 )。它对 SystemMessage(训谕)有⼀套特殊礼遇,加上⼀条精巧的"切点对⻬",共三条法度。

法度⼀:新训谕到,旧训谕去——SystemMessage 去重替换// MessageWindowChatMemory.java:84-93

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);

逻辑是:若新消息⾥出现了⼀条"内存中尚不存在的"训谕,就把旧账⾥所有训谕统统剔除,再把新消息拼上。说⼈话:换了新的系统提示,就别留着旧的系统提示占地⽅。否则你每轮都带⼀份 system prompt,⼗轮下来账本⾥堆了⼗份⼀模⼀样的训谕,既浪费窗⼝⼜可能让模型困惑。注意判定⽤的是!memoryMessagesSet.contains(message) ——只有当新训谕确实和旧的不同时才触发替换;若新旧训谕完全相等, hasNewSystemMessage 为 false,旧训谕原样保留,不会被误删。

法度⼆:训谕永不被驱逐合并完若没超窗⼝( processedMessages.size() <= maxMessages , :95-97 ),直接返回。⼀旦超了,才开始驱逐。但驱逐有禁区——先把所有"⾮训谕"消息的下标挑出来:

// MessageWindowChatMemory.java:99-106

List<Integer> nonSystemIndices = new ArrayList<>();
for (int i = 0; i < processedMessages.size(); i++) {
if (!(processedMessages.get(i) instanceof SystemMessage)) {
nonSystemIndices.add(i);
}
}

后续所有裁剪只在 nonSystemIndices ⾥挑⼑⼝。SystemMessage ⾃始⾄终在删除名单之外——哪怕窗⼝爆满,训谕也铁打不动。这很合理:系统提示是对话的"宪法",丢了它模型就失了⼈设。

法度三:⼑⼝向后吸附到最近的 USER——按完整回合裁剪这是 process() ⾥最⻅功⼒的⼀笔:

// MessageWindowChatMemory.java:108-122//按数量算出"该删多少条⾮训谕消息"

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

//把切点向后吸附到最近⼀条 USER消息,//保证保留的窗⼝总是从⼀个完整的回合开始

while (cutIndex < nonSystemIndices.size()
&& processedMessages.get(nonSystemIndices.get(cutIndex)).getMessageType() != MessageType.USER) {
cutIndex++;
}
cutIndex = Math.min(cutIndex, nonSystemIndices.size());
Set<Integer> removeIndices = new HashSet<>(nonSystemIndices.subList(0, cutIndex));

先按"超出多少条"算出⼀个粗略⼑⼝ cutIndex (它是 nonSystemIndices ⾥的下标,⽽⾮原始列表下标——这层间接很容易看漏)。然后那个 while 循环是灵魂:

它会把⼑⼝⼀路向后挪,直到落在⼀条 USER 消息上。

为什么?设想窗⼝⾥的对话是 ... [user问A] [assistant答A] [user问B] [assistant答B] 。如果⼑⼝粗暴地落在 [assistant答A] 上,你保留的窗⼝就成了[assistant答A] [user问B] [assistant答B] ——开头那句"答A"成了⽆源之⽔,触发它的"问A"已被删掉,模型看到⼀句没头没尾的回复,只会困惑。

吸附到 USER,就保证保留的窗⼝永远从⼀个完整的⽤户回合开头:要么 [user问A] 整段留下,要么从 [user问B] 开始,绝不会留下半截 assistant 回复或孤⼉ tool结果。源码注释把这点写得很清楚:"prevents keeping an assistant reply or tool result without the user message that originated its turn"。

✨ 爽点时刻:这三条法度的分⼯极⼲净——法度⼀管"去重"、法度⼆管"保命"、法度三管"对⻬"。三者叠加,得到的是⼀个语义正确的窗⼝:系统提示恒在、对话以完整

回合为单位滑动。⽐起"⽆脑砍最早 N 条"的朴素实现,这⾥多想了⼀层"回合完整性",是真功夫。

构建⽤ builder() ( MessageWindowChatMemory.java:132 ),默认库房是 new InMemoryChatMemoryRepository() ( :138 ),可链式
chatMemoryRepository() / maxMessages() 覆盖。

⚠ 暗礁(第⼀处):按"条数"裁,⽽⾮按"token"裁。

窗⼝的单位是消息条数( maxMessages ),不是 token 数。这意味着:20 条短消息和 20 条各含⼀篇⻓⽂的消息,占⽤的上下⽂⻓度天差地别,但在起居郎眼⾥"⼀样满"。

你⽆法⽤ MessageWindowChatMemory 精确控制"别超过模型上下⽂上限"——它根本不数 token。真要按 token 裁,你得⾃⼰实现⼀个 ChatMemory (这正是接⼝极简留下的扩展⼝⼦,⻅末尾)。这不是 bug,是这个内置实现刻意的简化;但若不知情,⻓⽂对话很容易悄悄超出模型上下⽂窗⼝⽽报错。

三、档案库房: ChatMemoryRepository 与它的三种实现策略层看完,转到存储层。 ChatMemoryRepository 就是"档案库房"——只管 CRUD,不管窗⼝:

// ChatMemoryRepository.java:31-41

List<String> findConversationIds();
List<Message> findByConversationId(String conversationId);
void saveAll(String conversationId, List<Message> messages);
void deleteByConversationId(String conversationId);

关键是 saveAll 的语义——注释钉死了:"替换该会话的全部消息"(全量覆盖)。这正好和上⼀节 MessageWindowChatMemory 的"读-改-写"严丝合缝:策略层裁好整本账,库房层整本替换。两层的契约咬合得很紧。

库房之⼀: InMemoryChatMemoryRepository (默认)

// InMemoryChatMemoryRepository.java:35

Map<String, List<Message>> chatMemoryStore = new ConcurrentHashMap<>();

// :42-47@Override

public List<Message> findByConversationId(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
List<Message> messages = this.chatMemoryStore.get(conversationId);
return messages != null ? new ArrayList<>(messages) : List.of();
}

⼀个 ConcurrentHashMap 撑起整间库房。两处细节值得点名:其⼀, findByConversationId 返回的是防御性拷⻉ new ArrayList<>(messages) ——不把内部列表的引⽤漏给外⾯,免得调⽤⽅乱改污染库存;查⽆此会话则返回 List.of() 空列表⽽⾮ null。其⼆, saveAll 就⼀句 put 覆盖( :50-55 ), delete 就⼀句

remove ( :58-60 )。

线程安全靠 ConcurrentHashMap 兜底,但⽆持久化——进程⼀重启,所有起居注灰⻜烟灭。它适合开发、测试、单机短会话,⽣产持久化得换库房。

库房之⼆: JdbcChatMemoryRepository (把账本落进数据库)

这是把起居注真正"刻进⽯碑"的实现。先看碑⽂的格式——表结构:

-- schema-postgresql.sql

CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,

content TEXT NOT NULL,

type VARCHAR(10) NOT NULL CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')),

"timestamp" TIMESTAMP NOT NULL,sequence_id BIGINT NOT NULL

);
--外加两个复合索引:(conversation_id, timestamp)与 (conversation_id, sequence_id)

注意这张表没有主键,靠 (conversation_id, sequence_id) 来排序与定位; sequence_id 是消息在会话内的位置序号。 type 列还带了个 CHECK 约束,只认四种⻆⾊。

存取实现的核⼼是 saveAll ,它把"全量替换"翻译成了数据库的"删旧插新":

// JdbcChatMemoryRepository.java:107-127@Override

public void saveAll(String conversationId, List<Message> messages) {

// ...

List<Message> persistableMessages = messages.stream()
.filter(m -> !(m instanceof ToolResponseMessage)
&& !(m instanceof AssistantMessage am && am.hasToolCalls()))
.toList();
if (logger.isWarnEnabled() && persistableMessages.size() < messages.size()) {
logger.warn(

"JdbcChatMemoryRepository does not support tool call messages. "

+ "Some messages were filtered out for conversation: " + conversationId);
}
this.transactionTemplate.executeWithoutResult(status -> {
deleteByConversationId(conversationId);
this.jdbcTemplate.batchUpdate(this.dialect.getInsertMessageSql(),
new AddBatchPreparedStatement(conversationId, persistableMessages));
});
}

两件事值得说。第⼀,整个 saveAll 包在⼀个事务⾥:先 delete 整会话,再 batchUpdate 批量插⼊——删旧插新原⼦完成,中途崩了会回滚,不会留下半本残账。事

务管理器若未显式注⼊,会⽤ DataSource ⾃建⼀个 DataSourceTransactionManager ( :84-87 )兜底。

第⼆——也是本章最⼤的⼀颗雷——那段 filter 。我们留到暗礁⾥专⻔审判。

批量填参藏在 AddBatchPreparedStatement 这个 record ⾥,有两处巧思:

// JdbcChatMemoryRepository.java:152-158

Object messageTs = message.getMetadata().get(CONVERSATION_TS);
Instant timestamp = (messageTs instanceof Instant instant) ? instant : Instant.now();
ps.setTimestamp(4, Timestamp.from(timestamp));

// sequence_id⽤批次下标

ps.setLong(5, i);

🦴 ⻣灰级细节:删旧插新,怎么不把历史时间戳刷新?

这正是 CONVERSATION_TS 这个 metadata key( JdbcChatMemoryRepository.java:70 定义)存在的理由。想象⼀下:每次 add 都"删整会话再重插",如果时间戳⼀律取 Instant.now() ,那本来三天前发的第⼀句话,会被反复刷成"现在"——历史时间全乱套。于是设计者让回读时把数据库⾥的原始时间塞进 metadata 的

CONVERSATION_TS ( MessageRowMapper , :174 ),重新写⼊时优先复⽤这个原始时间戳,只有真正的新消息(metadata ⾥没有这个 key)才打 Instant.now() 。⼀个metadata key,化解了"删旧插新"模式天然会带来的时间戳污染。

⽽ sequence_id 直接⽤批次下标 i ( :158 )——因为 saveAll 每次都删表重插整会话,批次下标天然就是稳定、跨数据库可移植的排序键。源码注释⾃⼰点明了这个逻辑( :155-157 )。回读时按 ORDER BY sequence_id (⻅⽅⾔默认 SQL)还原顺序。

⽅⾔⼯⼚:⼀张 switch ,通吃七种数据库不同数据库的 SQL 有⽅⾔差异(⽐如 timestamp 在 Postgres 是保留字,得加双引号)。译馆⽤ JdbcChatMemoryRepositoryDialect 这个接⼝抽掉差异,接⼝本身给了四条默认 SQL( :38-61 ),各⽅⾔只覆盖⾃⼰不⼀样的那⼏条。

最妙的是⾃动选⽅⾔的⼯⼚⽅法:

// JdbcChatMemoryRepositoryDialect.java:79-89

return switch (productName) {
case "PostgreSQL" -> new PostgresChatMemoryRepositoryDialect();
case "MySQL", "MariaDB" -> new MysqlChatMemoryRepositoryDialect();
case "Microsoft SQL Server" -> new SqlServerChatMemoryRepositoryDialect();
case "HSQL Database Engine" -> new HsqldbChatMemoryRepositoryDialect();
case "SQLite" -> new SqliteChatMemoryRepositoryDialect();
case "H2" -> new H2ChatMemoryRepositoryDialect();
case "Oracle" -> new OracleChatMemoryRepositoryDialect();
default -> // Add more as needed
new PostgresChatMemoryRepositoryDialect();
};
from(DataSource) ( :66-90 )通过 DatabaseMetaData.getDatabaseProductName() 探测你连的是哪家数据库,switch 命中就返回对应⽅⾔;探测失败或⼚商名

为空,打个 warn,默认回退 Postgres( :76-77 、 :88 )。Builder 那边的 resolveDialect ( :251-259 )逻辑配套:你不显式指定就⾃动探测;你显式指定了但和探测结果对不上,它会打 warn 提醒你"我会⽤你指定的,但你确定吗"( :265-274 )。

新增⼀种数据库的成本因此极低:写⼀个 Dialect 类、在 switch ⾥加⼀⾏。这是典型的"对扩展开放"。同⽬录下还并列着 redis / cassandra / mongodb / neo4j 等独⽴库房模块,各凭本事。

⚠ 暗礁(第⼆处,本章主雷):JDBC 库房会静默丢弃⼯具调⽤消息。

回到刚才那段 filter 。它把两类消息直接过滤掉、不⼊库: ToolResponseMessage (差役回执),以及"带 tool calls 的 AssistantMessage "(模型申请派差的那条馆答)。理由是这张表的 schema 压根没有存 tool call 结构的字段—— content TEXT 装不下结构化的⼯具调⽤参数和回执。

问题在于,它的提示只是⼀⾏ warn ⽇志( :116-120 ),然后若⽆其事地继续。 MessageRowMapper 那边对 TOOL 类型也直接返回 null ( :183 ),由调⽤⽅过滤。后果很现实:⼀个会⽤⼯具的 Agent,配上 JDBC 起居注,它的⼯具上下⽂会⽆声蒸发。模型这⼀轮查了天⽓、调了数据库,下⼀轮起居注⾥却只剩⼲巴巴的⽂字回复,差役⼲过的活、拿回的回执,全没了。对纯聊天应⽤⽆伤,对 Agent 却是硬伤——⽽它连个异常都不抛,只在⽇志⻆落⾥嘀咕⼀句。⽣产⾥若没⼈盯⽇志,这就是⼀个会让Agent "选择性失忆"的隐形坑。这是本章我最想拍桌⼦的地⽅:该报错的场景,却⽤ warn 糊过去了。

四、记忆驿丞:历史是怎么被"塞进"国书的库房和策略都备⻬了,可还差最后⼀环:谁在每次请求时,⾃动把旧账翻出来、附进国书,⼜把模型的新回复存回去?答案是记忆驿丞——⼀类特殊的 Advisor。

它们都站在第⼋、九章讲过的 Advisor 责任链上,共享同⼀套模板⻣架 BaseAdvisor :

// BaseAdvisor.java:46-54@Override

default ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {

// ...

ChatClientRequest processedChatClientRequest = before(chatClientRequest, callAdvisorChain);
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(processedChatClientRequest);
return after(chatClientResponse, callAdvisorChain);
}

before → nextCall → after 三段式,雷打不动。记忆驿丞要做的,⽆⾮是在 before ⾥取历史 + 写⽤户消息,在 after ⾥写回 AI 回复。流式版

adviseStream ( :56-74 )则在 boundedElastic 调度器上跑 before ,等流命中 finishReason 时再触发 after 。

它们还共享⼀个取会话编号的⼯具⽅法:

// BaseChatMemoryAdvisor.java:38-43

default String getConversationId(Map<String, @Nullable Object> context) {
Assert.notNull(context, "context cannot be null");
Assert.noNullElements(context.keySet().toArray(), "context cannot contain null keys");
Assert.notNull(context.get(ChatMemory.CONVERSATION_ID), "conversationId cannot be null");
return context.get(ChatMemory.CONVERSATION_ID).toString();
}

看到了吗——这就是第⼀节那个暗号 CONVERSATION_ID 的兑现处。驿丞从 context ⾥按这个 key 捞会话编号,捞不到直接抛异常( Assert.notNull )。所以你⽤记忆驿丞却忘了往请求⾥塞 conversationId,不会静默退化成"⽆记忆",⽽是当场报错——这点⽐ JDBC 那个静默丢消息明⽩多了。

记忆驿丞现存两位,路数迥异。

版本提示:⽼读者可能记得⼀个 PromptChatMemoryAdvisor ——本版本它已被移除。 spring-ai-client-chat 的 advisor ⽬录下如今只剩MessageChatMemoryAdvisor ⼀个记忆驿丞,另⼀位 VectorStoreChatMemoryAdvisor 搬到了独⽴的 vector-store-advisor 模块。"把历史拼进 system ⽂本"这⼀路数,由后者继承了下来。

驿丞甲: MessageChatMemoryAdvisor ——历史以"消息列表"前置第⼀位的做法最直观:把历史消息当作⼀串独⽴的 Message ,原样前置到当前 prompt 之前。

// MessageChatMemoryAdvisor.java:74-95@Override

public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
String conversationId = getConversationId(chatClientRequest.context());

// 1.取历史

List<Message> memoryMessages = this.chatMemory.get(conversationId);

// 2.历史 +当前 prompt拼成新列表(先去重,避免重复注⼊)

List<Message> promptMessages = chatClientRequest.prompt().getInstructions();
List<Message> processedMessages = new ArrayList<>();
if (!isMemoryAlreadyInPrompt(promptMessages, memoryMessages)) {
processedMessages.addAll(memoryMessages);
}
processedMessages.addAll(promptMessages);

// 2.1确保 SystemMessage排到⾸位

for (int i = 0; i < processedMessages.size(); i++) {
if (processedMessages.get(i) instanceof SystemMessage) {
Message systemMessage = processedMessages.remove(i);
processedMessages.add(0, systemMessage);
break;
}
}

// ...

}

四步⾛:取历史( :78 )→ 历史前置( :82-86 ,先⽤ isMemoryAlreadyInPrompt 做前缀匹配去重,逻辑在 :109-134 ,避免同⼀批历史被重复塞两遍)→ 把训谕挪到列表⾸位( :88-95 )→ 最后把"最后⼀条 user/tool 消息"写进起居注:

// MessageChatMemoryAdvisor.java:102-104

Message userMessage = processedChatClientRequest.prompt().getLastUserOrToolResponseMessage();
this.chatMemory.add(conversationId, userMessage);

after 则把模型的全部 assistant 输出写回( :136-148 );流式版⽤ ChatClientMessageAggregator 把碎⽚聚合成完整回复后再落库( :150-163 )——不然你存进起居注的就是⼀堆零碎 token。

它的优先级默认是 DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER (Builder :171 ),其值为 Ordered.HIGHEST_PRECEDENCE + 200 ( Advisor.java:39 )。靠前,但

特意留了 200 的身位给更该抢先的驿丞——记忆要早做,但不必是第⼀个。

这⼀路的好处:历史以 typed Message 承载,⻆⾊(user/assistant/system)清清楚楚,模型⼀眼能分辨"这是历史对话"⽽⾮"这是给我的指令";还天然⽀持多模态(消息结构原样保留)。Agent / ⼯具场景⾸选它。

驿丞⼄: VectorStoreChatMemoryAdvisor ——历史拼进 system ⽂本第⼆位⾛的是完全不同的路:它不维护"窗⼝",⽽是把每条消息写进藏经阁(VectorStore),下次按当前问题做相似度检索,只把最相关的 topK 条召回——这是"⻓期记忆/ 知识沉淀"的路⼦,不是"最近 N 轮"。

// VectorStoreChatMemoryAdvisor.java:132-149@Override

public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
String conversationId = getConversationId(request.context());
String query = Objects.requireNonNullElse(request.prompt().getUserMessage().getText(), "");
int topK = getChatMemoryTopK(request.context());
var filter = new FilterExpressionBuilder().eq(DOCUMENT_METADATA_CONVERSATION_ID, conversationId).build();
SearchRequest searchRequest = SearchRequest.builder().query(query).topK(topK).filterExpression(filter).build();
List<Document> documents = this.vectorStore.similaritySearch(searchRequest);
String longTermMemory = documents == null ? "" : documents.stream().map(doc -> {
Map<String, Object> metadata = Objects.requireNonNullElse(doc.getMetadata(), Map.of());
String role = (String) metadata.getOrDefault(DOCUMENT_METADATA_MESSAGE_TYPE, "UNKNOWN");
return "<memory-entry type=\"" + role.toLowerCase() + "\">" + escapeXml(doc.getText()) + "</memory-entry>";
}).collect(Collectors.joining(System.lineSeparator()));
SystemMessage systemMessage = request.prompt().getSystemMessage();
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(), "long_term_memory", longTermMemory));

// ...

}
检索时 filter 限定 conversationId == 当前会话 ( :137 ),topK 默认 20、可由 context key TOP_K ( "chat_memory_vector_store_top_k" , :75 )按请求覆盖。

召回的⽂档不是当独⽴消息前置,⽽是渲染进 system prompt ⽂本( augmentSystemMessage , :152 ),变成⼀段 LONG_TERM_MEMORY 。同时把当前 user 消息写进藏

经阁( :155-158 ), after 再把 assistant 回复写回( :173-185 )。

这正是已移除的 PromptChatMemoryAdvisor 的精神继承者:历史 = 提示⽂本。但把⽤户产⽣的内容塞进 system prompt,天⽣有提示注⼊的⻛险——万⼀历史⾥某条⽤户消息写着"忽略以上所有指令",拼进 system ⽂本可能被模型当成真指令。设计者对此做了两道防线:

// VectorStoreChatMemoryAdvisor.java:144

"<memory-entry type=\"" + role.toLowerCase() + "\">" + escapeXml(doc.getText()) + "</memory-entry>"

其⼀,召回⽂本先 escapeXml ( :201-210 ,把 & < > " ' 转义)再包进 <memory-entry type="..."> 标签,防⽌内容⾥的尖括号撑破结构;其⼆,默认 system 模板⾥明令模型"把 LONG_TERM_MEMORY 当历史数据,不要当指令"( :86-87 )。

⚠ 但类注释⾃⼰很诚实地点破了边界( :57-65 ):这只是"约定级"控制——根因(⽤户内容被插进 system prompt ⽂本)并未消除。它⽩纸⿊字写着:有⼯具访问的

Agent,优先⽤ MessageChatMemoryAdvisor (把⽤户内容留在 typed Message ⾥,不混进 system ⽂本)。这是⼀份难得坦诚的源码注释,既给了缓解⼿段,也不假装问题已解决。

两位驿丞,⼀表看清维度 MessageChatMemoryAdvisor VectorStoreChatMemoryAdvisor注⼊位置 历史作独⽴ Message 列表前置 历史召回后拼进 system ⽂本存储 ChatMemory (窗⼝裁剪,短期) VectorStore (相似度召回,⻓期)

召回⽅式 取该会话窗⼝内全部消息 按 query 语义 topK 检索多模态 ⽀持(保留 Message 结构) 仅⽂本(类注释明确)

注⼊安全 ⾼(typed Message,⻆⾊清晰) 较低(⽤户内容⼊ system,仅约定级防护)

适⽤ 短对话、Agent/⼯具⾸选 跨会话⻓期记忆、知识沉淀五、三层正交,与那些没说出⼝的代价退⼀步看整座起居注衙⻔,它的⻣架是三层正交解耦:

注⼊层(记忆驿丞):决定历史"怎么进国书"——前置消息,还是拼进 system ⽂本。

裁剪层( ChatMemory / MessageWindowChatMemory ):决定"记多少、忘哪些"。

存储层( ChatMemoryRepository ):决定"存哪⾥"——内存、JDBC、Redis……三层各管⼀段、互不越界,任意⼀层都能单独替换。想按 token 裁?⾃⼰实现⼀个 ChatMemory 换掉裁剪层。想存 Redis?实现 ChatMemoryRepository 换掉存储层。想换注⼊策略?⾃定义⼀个 BaseChatMemoryAdvisor 。接⼝极简带来的代价(契约太瘦),正是被这种"扩展点遍地"的好处赎回的。

但夸要挣来,这⼀章也确实有⼏处该敲打的:

读-改-写⾮原⼦( MessageWindowChatMemory.add , :64-66 ):"先读旧账、裁完再整本写回"这三步之间没有锁。若两个实例(或两个线程)同时往同⼀个conversationId 写,后写的会⽤⾃⼰读到的旧快照覆盖掉对⽅刚写的新消息——经典的并发丢更新。单机单线程⽆碍,多副本部署同会话并发就有⻛险。

saveAll 全量删插的写放⼤:每加⼀条消息都要"删整会话 + 重插全部"。20 条窗⼝的短会话⽆所谓;但若有⼈把窗⼝调到⼏百条,每轮对话都是⼏百⾏的删表重插,写放⼤相当可观。

JDBC 静默丢⼯具消息(第⼆处暗礁):该报错的地⽅只给了 warn,Agent + JDBC 会⽆声失忆。

按条数⽽⾮ token 裁(第⼀处暗礁):⽆法精确控制上下⽂⻓度。

这些都不是"框架写错了",⽽是简单设计必然附带的取舍——把它们摆到台⾯上,才不会在⽣产⾥被偷袭。

🎯 三句带⾛

1. ChatMemory 只认 CONVERSATION_ID ⼀个维度,提供 add/get/clear;裁剪逻辑下沉到唯⼀内置实现 MessageWindowChatMemory (默认窗⼝ 20),它本身不

存账,组合 ChatMemoryRepository 做存储, add 是"读旧账→裁剪→整本覆盖写回"。

2. 裁剪三法度:新 SystemMessage 去重替换旧的、SystemMessage 永不被驱逐、切点向后吸附到最近 USER 以按完整回合裁剪; ChatMemoryRepository 的saveAll 语义是"替换整会话",InMemory ⽤ ConcurrentHashMap + 防御拷⻉,JDBC 在⼀个事务内"先删整会话再 batch insert",⽅⾔由

JdbcChatMemoryRepositoryDialect.from 按⼚商⾃动选(默认回退 Postgres)。
3. 两位记忆驿丞共享 BaseAdvisor 的 before→nextCall→after 模板: MessageChatMemoryAdvisor 把历史当独⽴ Message 列表前置(注⼊安全、Agent ⾸
选), VectorStoreChatMemoryAdvisor 按 query topK 检索后拼进 system ⽂本(⻓期记忆,仅约定级防注⼊);本版本 PromptChatMemoryAdvisor 已被移

除。已知坑:JDBC 静默丢⼯具调⽤消息、saveAll 写放⼤、读-改-写并发覆盖、按条数⽽⾮ token 裁。

承上启下起居注衙⻔虽巧,可它存进库房、塞进国书的那些 Message ,终究只是⼀封封"标准格式的中⽂公⽂"。真到了递出译馆⼤⻔、送往番邦诸侯案头的那⼀刻,这封公⽂还得经⼀道关键的⼿续——交到驻馆翻译官⼿⾥,翻成对⽅听得懂的⽅⾔:OpenAI 的 JSON ⻓什么样、 createRequest 那个巨型⽅法如何把通⽤⽂牒拆成番邦私货、流式密信⼜是被哪位书记官⼀⽚⽚拼回完整回函的。

我们已经看遍了译馆内部的馆⼼规矩,接下来该跟着⼀封公⽂,亲眼看它如何被翻译、被发送、被回收。下⼀章,我们就推开那间最繁忙的值房,看「第⼗三章 · 驻馆翻译官:OpenAI Provider 解剖」。