很多初学者最容易犯的错误,是把"聊天记录持久化"理解成"往一张 message 表里插数据"。但在一个 Agent 系统里,"会话"这个词同时对应两种完全不同的东西: 1. 用户在界面上看到的逐字聊天记录(谁说了什么、什么时候、能不能点赞); 2. 大模型下一轮推理时需要的结构化上下文(历史消息、工具调用 ToolUse、工具结果 ToolResult、被压缩的摘要……)。 这两者的消费者、数据格式、生命周期、一致性要求都不一样。gogo-agent 的做法是把它们彻底拆开,再加上一层纯内存的运行时缓存,形成三层持久化架构。理解了"为什么要拆三层",后面的代码就都顺理成章了。 | 层 | 存什么 | 存哪里 | 谁读 | 掉电会怎样 | | --- | --- | --- | --- | --- | | L1 业务会话/消息 | 给人看的逐字记录 | MySQL | 前端 | 不丢(永久) | | L2 Agent 对话记忆 | 给模型喂的上下文 | MySQL | LLM | 不丢(会话级) |
贯穿三层的纽带是一个 ID:前端的 sessionId == chat_conversation.conversation_id == AgentScope Session 的 key 前缀。一个 ID 打通三层,省掉了所有映射表。 下面从最外层(用户可见)往里层(框架内部)讲。
环境与配置:持久化的地基
在读业务代码前,先看清楚"框架帮我们自动做了哪些事",否则很多代码会显得"凭空生效"。(后面的MySQL、MyBatis-Plus等 相关的这部分就不重复讲了)
数据源与 MyBatis-Plus 全局配置(application.yml)
spring:
datasource: # Druid 连接池 + MySQL
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://localhost:3306/gogo_travel?...&serverTimezone=Asia/Shanghai...
data:
redis: # Redis 用于跨节点信令(打断广播、熔断计数)
host: ${REDIS_HOST:localhost}
mybatis-plus:
type-handlers-package: com.gogo.travel.config # 自动扫描 InstantTypeHandler
configuration:
map-underscore-to-camel-case: true # created_at ↔ createdAt 自动映射
global-config:
db-config:
id-type: input # 主键默认业务传入(不自增)
logic-delete-field: deleted # 全局逻辑删除字段
logic-delete-value: 1 # 删除后置 1
logic-not-delete-value: 0 # 未删为 0
这段配置解释了后面几个配置: - logic-delete-field: deleted 让所有查询自动追加 WHERE deleted = 0,删除变成 UPDATE ... SET deleted = 1——用户数据永不物理删除。 - id-type: input 与实体上的 @TableId(type = IdType.INPUT) 呼应,主键由业务层生成(会话用前端 sessionId,消息用 msg_ + UUID)。
L1:业务会话与消息持久化(给人看的记录)
这是最贴近产品的一层,支撑"历史会话列表 / 点开看全部消息 / 点赞点踩 / 改标题 / 删会话"。 它遵循经典的分层结构,读代码建议按这个顺序:
Controller → Service → Repository(接口) → RepositoryImpl → Mapper → Entity → 表
ChatController ChatHistoryService ChatHistoryRepository ...Impl ChatXxxMapper ChatXxx
表结构
chat_conversation表示会话,chat_message表示一次会话中的多条消息。根据role可以分为user、agent、system等。chat_conversation和chat_message是一对多的关系,即一次会话会有多条消息。
CREATE TABLE `chat_conversation` (
`conversation_id` VARCHAR(64) NOT NULL COMMENT '会话ID(同前端 sessionId)',
`user_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(256) NOT NULL DEFAULT '新对话',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`conversation_id`),
KEY `idx_user_id` (`user_id`), -- "我的会话"过滤
KEY `idx_updated_at` (`updated_at`) -- "按最近更新倒序"排序
);
CREATE TABLE `chat_message` (
`message_id` VARCHAR(64) NOT NULL,
`conversation_id` VARCHAR(64) NOT NULL,
`role` VARCHAR(32) NOT NULL COMMENT 'user/agent/system',
`content` TEXT,
`agent_name` VARCHAR(128) COMMENT 'role=agent 时是哪个子智能体',
`extra` JSON COMMENT '进度快照/推荐问题等非结构化扩展',
`feedback` VARCHAR(16) COMMENT 'LIKE / DISLIKE / NULL',
`feedback_at` DATETIME,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`deleted` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`message_id`),
KEY `idx_conversation_id` (`conversation_id`), -- 拉取某会话全部消息
KEY `idx_created_at` (`created_at`) -- 会话内按时间正序
);
实体层:三个"自动化"能力
ChatConversation / ChatMessage 用 MyBatis-Plus 注解声明映射。以会话实体为例:
@TableName(value = "chat_conversation", autoResultMap = true)
public class ChatConversation {
@TableId(value = "conversation_id", type = IdType.INPUT) // ① 业务主键
private String conversationId;
private String userId;
private String title;
@TableField(fill = FieldFill.INSERT, typeHandler = InstantTypeHandler.class)
private Instant createdAt; // ② 仅 INSERT 自动填
@TableField(fill = FieldFill.INSERT_UPDATE, typeHandler = InstantTypeHandler.class)
private Instant updatedAt; // ② INSERT/UPDATE 都填
@TableLogic @TableField("deleted")
private Integer deleted; // ③ 逻辑删除
}
支撑上面注解的两个基础设施类: ① InstantTypeHandler——Java Instant ↔ MySQL DATETIME(MyBatis 原生不认 Instant,必须自定义):
@MappedTypes(Instant.class)
@MappedJdbcTypes(JdbcType.TIMESTAMP)
public class InstantTypeHandler extends BaseTypeHandler<Instant> {
public void setNonNullParameter(PreparedStatement ps, int i, Instant v, JdbcType t) throws SQLException {
ps.setTimestamp(i, Timestamp.from(v)); // 写:Instant → Timestamp
}
public Instant getNullableResult(ResultSet rs, String col) throws SQLException {
Timestamp ts = rs.getTimestamp(col);
return ts != null ? ts.toInstant() : null; // 读:Timestamp → Instant
}
// getNullableResult(int) / (CallableStatement) 两个重载同理
}
② AuditMetaObjectHandler——时间全局自动填充(业务代码永远不用手写 createdAt/updatedAt):
@Component
public class AuditMetaObjectHandler implements MetaObjectHandler {
public void insertFill(MetaObject m) { // INSERT 时
Instant now = Instant.now();
strictInsertFill(m, "createdAt", Instant.class, now);
strictInsertFill(m, "updatedAt", Instant.class, now);
}
public void updateFill(MetaObject m) { // UPDATE 时
strictUpdateFill(m, "updatedAt", Instant.class, Instant.now());
}
}
服务层:ChatHistoryService(L1 的业务大脑)
Service 承担四类职责:事务边界、权限校验、标题策略、视图转换。 (A)保存用户消息 = 惰性建会话 + 落库 + 首条自动标题
@Transactional(rollbackFor = Exception.class)
public void saveUserMessage(String conversationId, String userId, String content) {
ensureConversationExists(conversationId, userId); // 会话不存在就用"新对话"建
if (content != null && !content.isBlank())
updateTitleFromFirstUserMessage(conversationId, content); // 仍是默认标题→截前24字符
chatHistoryRepository.saveMessage(
new ChatMessage(generateMessageId(), conversationId, "user", content, null, null));
}
private void ensureConversationExists(String conversationId, String userId) {
if (chatHistoryRepository.findConversationById(conversationId).isEmpty())
chatHistoryRepository.saveConversation(new ChatConversation(conversationId, userId, "新对话"));
}
private String generateMessageId() { return "msg_" + UUID.randomUUID().toString().replace("-", ""); }
关键理念:会话是被第一条消息"惰性创建"的,前端不需要单独调"新建会话"接口。 saveAssistantMessage(role=agent,带 agentName 与 extra)、saveSystemMessage(role=system)逻辑对称。 (B)权限校验:一切读写先验 userId 归属,防越权:
public List<MessageView> listMessages(String conversationId, String userId) {
ChatConversation c = chatHistoryRepository.findConversationById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("会话不存在"));
if (!Objects.equals(c.getUserId(), userId))
throw new IllegalArgumentException("无权访问该会话");
return chatHistoryRepository.findMessagesByConversationId(conversationId)
.stream().map(this::toMessageView).toList();
}
改标题、删会话、改反馈同样先做这道校验。 (C)反馈(点赞/点踩):updateFeedback 校验归属后写 feedback+feedbackAt,normalizeFeedback 把空串/null/CLEAR 统一归一为"清空"(设置为null)。
写入时机:谁在调 L1
L1 的写入点分散在两处编排代码里,覆盖对话所有分支:
ChatController#chat → saveUserMessage 每次收到用户输入
ChatAgentExecutor#handleAgentResult → saveAssistantMessage Agent 产出最终答案(先脱敏)
ChatAgentExecutor#resume → saveUserMessage HITL:用户回复 ask_user 也算一条用户消息
ChatController#chat 体现"先落库、再执行、落库失败只告警不阻断"的策略:
@PostMapping("/{sessionId}")
@SaCheckLogin
public SseEmitter chat(@PathVariable String sessionId, @RequestBody ChatRequest request) {
String userId = StpUtil.getLoginIdAsString(); // Sa-Token 取登录态
AgentSessionContextHolder.set(new AgentSessionContext(userId, sessionId)); // 存 ThreadLocal 供工具用
try {
chatHistoryService.saveUserMessage(sessionId, userId, message);
} catch (Exception e) {
logger.warn("[CHAT] 保存用户消息失败: {}", e.getMessage()); // 持久化非关键路径
}
agentExecutor.interruptPrevious(sessionId); // 打断同会话残留执行
SseEmitter emitter = agentExecutor.createEmitter();
// 命中 continuation → 续跑活跃 Agent;否则交 MasterAgent 协调(见 3.4)
...
}
ChatAgentExecutor#handleAgentResult中,AI 回复落库前先做敏感信息脱敏(库里存脱敏后的展示文本):
String text = SensitiveMasker.mask(result.getTextContent());
chatHistoryService.saveAssistantMessage(sessionId, userId, text, agentName, null);
sseNotifier.sendMessage(emitter, text);
ChatAgentExecutor是一个对话相关的执行器,这里面会在agent运行结束后,调用handleAgentResult把结果保存。
至此 L1 闭环:惰性建会话 → 逐条落库 → 自动/智能标题 → 权限隔离读取 → 逻辑删除。
L2:Agent 对话记忆持久化(给模型喂的上下文)
L1 存"给人看的文本",但 LLM 下一轮需要的是结构化消息序列(含 ToolUse / ToolResult / 压缩摘要)。 我们就是利用的 AgentScope 的 Session 机制:
✅AgentScope Java特性:多轮会话&会话持久化
AgentScope Java 的多轮会话由两个核心机制协作完成:Memory(短期会话记忆)负责维护当前对话上下文,Session(会话持久化)负责将状态保存/恢复到外部存储。两者结合实现了"跨请求的连续对话"和"跨重启的会话恢复"。 多 LLMentor
存储载体:MysqlSession + agentscope_session 表
配置类把 AgentScope 的 Session 声明为复用项目 Druid 数据源的 MySQL 实现:
@Bean
public Session agentSession(DataSource dataSource) {
// 参数:数据源、库名、表名、autoCreate=true(不存在则自动建表,与 schema.sql 双保险)
return new MysqlSession(dataSource, "gogo_travel", "agentscope_session", true);
}
表用 (session_id, state_key, item_index) 三段复合主键,state_data 存 JSON:
CREATE TABLE agentscope_session (
session_id VARCHAR(255) NOT NULL,
state_key VARCHAR(255) NOT NULL,
item_index INT NOT NULL DEFAULT 0, -- 列表型状态(如消息序列)的下标
state_data LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (session_id, state_key, item_index)
);
同一张表被三种状态复用,靠 session_id 后缀 / state_key 隔离: | 用途 | Session Key(session_id 列的值) | state_key | 写入者 | | --- | --- | --- | --- | | Agent 对话记忆 | sessionId:agentName | memory_messages | SessionPersistenceHook | | 路由态(活跃 Agent) | sessionId:router | activeAgent | ActiveAgentPersistenceHook | | HITL 暂停态 | sessionId:pending_tool | pendingTool | PendingToolSessionStore |
之前我们讲Session机制的时候,提到过,我们需要手动的来做Session的保存:
// 如果之前有保存的会话,加载它(恢复历史上下文)
agent.loadIfExists(session, sessionId);
// ... agent运行 ...
// 保存会话(下次启动时可恢复)
agent.saveTo(session, sessionId);
但是我们的项目中,有很多个Agent,总不能每一个Agent都手写这一坨代码吧,那于是我们就做了个Hook。
✅AgentScope Java特性:Hook
就像Spring AI Alibaba一样,ASJ中也提供了完善的Hook机制,Hook 是一个事件拦截器链,横跨 ReActAgent 的"推理-行动-总结"全生命周期。他可以说是ASJ的一个基石。很多功能都要基于这个Hook机制来实现。 LLMentor
SessionPersistenceHook
SessionPersistenceHook是 L2 的核心实现。gogo-agent 里每个 Agent 都是 prototype——每次请求都是全新实例、内存为空,所以必须靠 Hook 在调用前后"外挂"记忆。它挂在 AgentBase.call() 的两个生命周期事件上: - PreCallEvent(调用前):内存为空 → 从 Session 还原上一轮历史注入内存 → Agent 因此"有记忆"。 - PostCallEvent(调用后):内存已含"历史 + 本轮输入 + 本轮回复" → 写回 Session → 供下一轮加载。
public class SessionPersistenceHook implements Hook {
public int priority() { return 10; }
public <T extends HookEvent> Mono<T> onEvent(T event) {
if (!(event instanceof PreCallEvent) && !(event instanceof PostCallEvent))
return Mono.just(event); // 只关心 Pre/Post Call
return Mono.deferContextual(ctx -> {
String sessionId = ctx.getOrDefault("sessionId", null);
if (sessionId == null) return Mono.just(event);
String agentName = event.getAgent().getName();
String sessionKey = sessionId + ":" + agentName;
boolean resuming = Boolean.TRUE.equals(ctx.getOrDefault("resuming", false));
if (event.getAgent() instanceof ReActAgent reActAgent)
persistReActAgent(event, reActAgent, sessionKey, sessionId, agentName, resuming);
else if (event.getAgent() instanceof AgentBase)
persistStatelessAgent(event, sessionKey, sessionId, agentName, resuming);
return Mono.just(event);
});
}
}
如果是ReActAgent,因为他自身实现了 StateModule,loadIfExists/saveTo 就是对 Memory 的存取,Hook 只做编排就行了:
private void persistReActAgent(HookEvent event, ReActAgent agent, String sessionKey, String sessionId, String agentName, boolean resuming) {
if (event instanceof PreCallEvent) {
if (!resuming) agent.loadIfExists(session, sessionKey); // 首轮 key 不存在会自动跳过
} else {
Memory memory = reActAgent.getMemory();
memory.saveTo(session, sessionKey);
}
}
(3)无状态 AgentBase——手工维护"影子历史":QueryRewritingAgent、IntentRecognitionAgent 这类"单次调用、不持有 Memory"的分析型 Agent。这个单独介绍。
✅非ReActAgent如何做Session持久化
无状态 Agent,比如我们的QueryRewritingAgent、IntentRecognitionAgent 这类"单次调用、不持有 Memory"的分析型 Agent。我们也需要针对他们做session的持久化,但是这类Agent他 LLMentor
sessionId 是如何一路传到 Hook 的(响应式关键)
前面的代码中,有个非常关键参数,sessionId,L2 的所有 Hook 都需要知道当前 sessionId,但 Hook 是框架回调、拿不到 HTTP 上下文。项目的解法是把 sessionId 写入 Reactor Context,Hook 用 deferContextual 读取。链路如下:
ChatController#chat / respond
└─ ChatAgentExecutor.executeAgent(...)
└─ execution.contextWrite(Context.of("sessionId", sessionId)) ← 注入点①(直接续跑)
└─ 或 AgentPipelineService.executeFullPipeline(...)
└─ ....contextWrite(Context.of("sessionId", sessionId, "userId", userId)) ← 注入点②(完整流水线)
↓(Reactor 订阅链向上游传播 Context)
Agent.call() 触发 Hook
└─ Mono.deferContextual(ctx -> {
String sessionId = ctx.getOrDefault("sessionId", null); ← Hook 在此读取
})
AgentPipelineService 里每条执行路径末尾都统一 contextWrite,例如:
public Mono<Msg> executeFullPipeline(List<Msg> inputMessages, String sessionId, String userId) {
return queryRewritingAgent.call(...)
// ...改写 → 意图识别 → 按意图 dispatch 子 Agent...
.doFinally(signal -> executionRegistry.remove(sessionId))
.contextWrite(Context.of("sessionId", sessionId, "userId", userId)); // 关键
}
HITL 恢复时还会多写一个 resuming=true 标志(见 3.3 第 5 点)。记住这条链路,L2 所有 Hook 才"活"起来。