gogo-agent

会话与消息的持久化

很多初学者最容易犯的错误,是把"聊天记录持久化"理解成"往一张 message 表里插数据"。但在一个 Agent 系统里,"会话"这个词同时对应两种完全不同的东西: 1. 用户在界面上看到的逐字聊天记录(谁说了什么、什么时候、能不…

TL;DR

很多初学者最容易犯的错误,是把"聊天记录持久化"理解成"往一张 message 表里插数据"。但在一个 Agent 系统里,"会话"这个词同时对应两种完全不同的东西: 1. 用户在界面上看到的逐字聊天记录(谁说了什么、什么时候、能不…

很多初学者最容易犯的错误,是把"聊天记录持久化"理解成"往一张 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 才"活"起来。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。