对话之所以"像人",是因为模型每次都收到完整历史。这一话我们把
ChatMemory 拆到底:滑动窗口的裁剪算法、
为什么工具消息必须整组保留、持久化选型、
conversationId 的多租户设计,以及长期记忆的窗口 / 摘要 / 检索三层策略。
List<Message> 存起来,下次请求带上不就行了。public interface ChatMemory {
String CONVERSATION_ID = "conversationId"; //!hl
String DEFAULT_CONVERSATION_ID = "default";
void add(String conversationId, List<Message> messages); //!hl
List<Message> get(String conversationId); //!hl
void clear(String conversationId); //!hl
}
只有三个方法,非常克制。ChatMemory 本身不做裁剪策略 —— 裁剪是
MessageWindowChatMemory 这个实现类的事,而存到哪是
ChatMemoryRepository 的事。两层职责分离,你可以自由组合。
@Bean
ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository()) // 仅测试 //!hl
.maxMessages(20) // 默认就是 20,建议显式写出来 //!hl
.build();
}
20 条(约 10 轮)是"够用且不太贵"的经验值,但它没有考虑 token 长度: 10 轮里如果每轮都塞了 3000 字的代码,20 条就是 3 万 token。 正确做法是"条数 + token 预算"双闸门(见第五节)。另外注意: 用了工具调用的会话,一轮会产出 2~3 条消息(assistant + tool response), 所以条数要按"轮数 × 2.5"来估。
private List<Message> process(List<Message> interimMessages) {
List<Message> processedMessages = new ArrayList<>();
// ① SystemMessage 永远保留,且排在最前面
List<Message> systemMessages = interimMessages.stream() //!hl
.filter(m -> m.getMessageType() == MessageType.SYSTEM).toList();
processedMessages.addAll(systemMessages);
// ② 从最新往回倒着取,直到凑够 maxMessages
int messageCount = processedMessages.size();
for (int i = interimMessages.size() - 1; i >= 0; i--) { //!hl
Message message = interimMessages.get(i);
if (message.getMessageType() == MessageType.SYSTEM) continue;
if (messageCount >= this.maxMessages) break; //!hl
processedMessages.add(message);
messageCount++;
}
// ③ ② 是倒着加的,翻转回时间顺序(只翻非 system 部分) //!hl
Collections.reverse(processedMessages.subList(systemMessages.size(),
processedMessages.size()));
// ④ 移除"悬空"的 ToolResponseMessage //!hl
return removeOrphanToolResponseMessages(processedMessages); //!hl
}
private List<Message> removeOrphanToolResponseMessages(List<Message> messages) {
List<Message> result = new ArrayList<>();
Message previous = null;
for (Message m : messages) {
// 只有紧跟在 ASSISTANT 之后的 tool 响应才保留 //!hl
if (m.getMessageType() != MessageType.TOOL_RESPONSE
|| (previous != null && previous.getMessageType() == MessageType.ASSISTANT)) {
result.add(m);
}
previous = m;
}
return result;
}
上面的 removeOrphanToolResponseMessages 只处理了"孤儿的 tool 响应"这一半。
另一半更凶险:如果窗口边界正好落在 ASSISTANT(tool_calls) 与它的
TOOL_RESPONSE 之间,你发出去的消息序列就是"assistant 声明要调工具,但没人响应",
OpenAI 系会直接返回:
400 Bad Request: An assistant message with 'tool_calls' must be followed by
tool messages responding to each tool_call_id.
而且它只在"会话足够长且中途用过工具"时才出现 —— 你的联调、你的单元测试,全都不会触发。对策:
maxMessages 设大一点(如 50),并用 token 预算而非条数做真正的闸门。ChatMemory,把"一次 assistant + 它的全部 tool response"视为一个不可分割的原子组,裁剪以组为单位。下面给骨架。/** 以"工具调用组"为原子单位裁剪:assistant(tool_calls) 与它的 responses 同生共死 */
static List<Message> trimByGroup(List<Message> all, int maxMessages) {
List<Message> sys = all.stream()
.filter(m -> m.getMessageType() == MessageType.SYSTEM).toList(); //!hl
// 分组:TOOL_RESPONSE 并入上一组,其余开新组(一组 = 一轮原子对话)
List<List<Message>> groups = new ArrayList<>();
for (Message m : all) {
if (m.getMessageType() == MessageType.SYSTEM) continue;
if (m.getMessageType() == MessageType.TOOL_RESPONSE && !groups.isEmpty()) {
groups.get(groups.size() - 1).add(m); //!hl
} else { groups.add(new ArrayList<>(List.of(m))); }
}
int budget = maxMessages - sys.size();
List<Message> kept = new ArrayList<>(sys);
for (int i = groups.size() - 1; i >= 0; i--) {
if (budget <= 0 || groups.get(i).size() > budget) break; // 放不下就整组丢 //!hl
kept.addAll(groups.get(i));
budget -= groups.get(i).size();
}
Collections.reverse(kept.subList(sys.size(), kept.size())); // 恢复时间顺序 //!hl
return kept;
}
| 实现 | 依赖 | 适用与注意 |
|---|---|---|
InMemoryChatMemoryRepository | 无(核心模块自带) | 仅用于测试与单例演示。进程重启即丢,水平扩容后会话随机丢失。 |
JdbcChatMemoryRepository | spring-boot-starter-jdbc + 驱动 | 绝大多数团队的首选。支持 MySQL / PostgreSQL / SQL Server / HSQL 方言。 |
CassandraChatMemoryRepository | Cassandra 驱动 + 对应 store 模块 | 超大规模会话、写多读少、需要 TTL 原生支持的场景。 |
Neo4jChatMemoryRepository | Neo4j 驱动 + 对应 store 模块 | 需要把"实体—关系"一起持久化时(知识图谱 + 会话记忆合流)。 |
-- MySQL 8.0.16+(低版本不支持 CHECK,去掉 CHECK 那一行即可)
CREATE TABLE SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content TEXT NOT NULL, -- 序列化后的消息文本
type VARCHAR(10) NOT NULL, -- USER / ASSISTANT / SYSTEM / TOOL //!hl
`timestamp` TIMESTAMP NOT NULL,
CONSTRAINT PK_SPRING_AI_CHAT_MEMORY PRIMARY KEY (conversation_id, `timestamp`), //!hl
CONSTRAINT CHK_TYPE CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')) //!hl
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE INDEX IDX_CID ON SPRING_AI_CHAT_MEMORY (conversation_id); // 按会话清理用 //!hl
# application.yml —— initialize-schema: embedded(默认,仅内存库) | always | never //!hl
spring.ai.chat.memory.repository.jdbc.initialize-schema: never # 生产交给 Flyway //!hl
---
@Configuration
class MemoryConfig {
@Bean
ChatMemory chatMemory(JdbcTemplate jdbcTemplate) {
var repo = JdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate)
.dialect(new MysqlChatMemoryRepositoryDialect()) //!hl
.build();
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repo).maxMessages(40).build(); //!hl
}
}
initialize-schema 默认是 embedded:只有内嵌数据库(H2/HSQL)才会自动建表。
用 MySQL 时必须显式设成 always(首启建表),或设 never 交给 Flyway/Liquibase 管理 —— 生产推荐后者,把 DDL 纳入版本化迁移。| Advisor | 历史消息的处理方式 | 结论 |
|---|---|---|
MessageChatMemoryAdvisor | 把历史并入 messages 列表,保留 SYSTEM/USER/ASSISTANT/TOOL 各自的类型与顺序 | 默认选它。工具调用、多模态、角色语义全部正确。 |
PromptChatMemoryAdvisor | 把历史拼成纯文本塞进 system prompt | 丢失角色结构与工具消息;仅用于不支持完整 messages 的老模型/网关。 |
VectorStoreChatMemoryAdvisor | 按语义相似度召回历史片段 | 已废弃。需要检索记忆请自己组合"向量检索 + 消息注入",或等官方新 API。 |
@Bean
ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()).build(); //!hl
}
// 推荐:租户 + 用户 + 会话 三段式,缺一不可
String conversationId = tenantId + ":" + userId + ":" + sessionId; //!hl
chatClient.prompt().user(q)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) //!hl
.call().content();
clear(conversationId) 的粒度是会话;用 userId 就变成了"清空用户全部历史"。/** 装饰者:在窗口之外再加一层"超量即压缩" */
public class SummarizingChatMemory implements ChatMemory {
private final ChatMemory delegate; // MessageWindowChatMemory //!hl
private final ChatClient summarizer; // 廉价模型,如 gpt-4o-mini //!hl
private final int triggerSize; // 触发压缩的条数,如 40
private final int keepRecent; // 压缩后保留的近期条数,如 16
@Override
public void add(String conversationId, List<Message> messages) {
this.delegate.add(conversationId, messages);
List<Message> all = this.delegate.get(conversationId);
if (all.size() <= this.triggerSize) return;
List<Message> older = all.subList(0, all.size() - this.keepRecent);
List<Message> recent = all.subList(all.size() - this.keepRecent, all.size());
// 关键:摘要素材里剔除工具消息,否则会喂给模型一大段 JSON 噪音 //!hl
String transcript = older.stream()
.filter(m -> m.getMessageType() != MessageType.TOOL_RESPONSE)
.map(m -> m.getMessageType() + ": " + m.getText())
.collect(Collectors.joining("\n"));
String summary = this.summarizer.prompt()
.system("把以下对话压缩为不超过 300 字的中文摘要;保留已确认的事实、做出的决策、未完成的待办;忽略寒暄与重复。")
.user(transcript)
.options(ChatOptions.builder().withTemperature(0.0).build()) //!hl
.call().content();
this.delegate.clear(conversationId); //!hl
this.delegate.add(conversationId,
List.of(new SystemMessage("【历史摘要】" + summary))); //!hl
this.delegate.add(conversationId, recent);
}
@Override public List<Message> get(String conversationId) { return this.delegate.get(conversationId); }
@Override public void clear(String conversationId) { this.delegate.clear(conversationId); }
}
gpt-4o-mini / 同级模型即可,成本差 10 倍以上。@Async 或消息队列),别让用户为你的 housekeeping 买单;同时按 conversationId 串行化,避免重复压缩、摘要套摘要。// 精确计数:com.knuddels:jtokkit:1.1.0(与 OpenAI 的 BPE 一致)
static final Encoding ENC = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE); //!hl
static int countTokens(List<Message> ms) {
return ms.stream().mapToInt(m -> ENC.countTokens(m.getText()) + 7).sum(); //!hl
}
// 粗估版(零依赖,够用):中文约 1.5 字符/token,英文约 4 字符/token
static long estimateTokens(String text) {
if (text == null || text.isEmpty()) return 0;
long cjk = text.chars().filter(c -> c > 0x2E80).count(); //!hl
return Math.round(cjk / 1.5 + (text.length() - cjk) / 4.0) + 4;
}
budget = 上下文上限 − maxTokens − 系统提示 − 工具定义。忘了减工具定义,是"明明没超却报超长"的常见原因。promptTokens 与压缩次数,超阈值告警(第 10 话接进监控面板)。get → add,后写的会覆盖前面的,或造成消息乱序。对策:按 conversationId 加分布式锁,或让 ChatMemory 的 add 走 synchronized(conversationId.intern())(单实例)/ Redis 锁(多实例)。SystemMessage 放在最前,天然规避顺序问题。ChatMemory 里只存 user/assistant,把 tool 消息丢了 —— 下一轮模型就"忘了自己调过工具"。存就要存全。SPRING_AI_CHAT_MEMORY 加 TTL(定时任务按 timestamp 删除 N 天前数据,或 Cassandra 的原生 TTL),同时保留一份归档(对象存储)用于审计。MessageChatMemoryAdvisor,不用 PromptChatMemoryAdvisor。@Tool 注解、
ToolCallback,以及"模型决定调这个工具"这件事到底是怎么发生的。第 06 话 —— 工具调用(Function Calling)。
@Tool / MethodToolCallbackProvider 的注册机制、
JSON Schema 是怎么从方法签名生成的、多轮 tool 循环由谁驱动、以及工具必须幂等的工程理由。