Browse Source

feat(agent): 会话状态改用 Redis 存储(RedisAgentStateStore)

AgentService / InsightAgentFactory 一直以 Optional<AgentStateStore> 注入并回落 InMemoryAgentStateStore,
agentscope-extensions-redis 依赖加了但 bean 从未注册,对话历史只存在进程内存里,重启即丢。

- 新增 AgentStateStoreConfig:注册 RedisAgentStateStore bean,key 前缀 qing_jian:agent:{agentscope}:session:
  - 不以 qing_jian_ai 开头:管理端「清缓存」按 qing_jian_ai* 批量删键,会话历史不是缓存,不能被顺手清掉
  - {agentscope} 是 Cluster hash tag:2.0.4 写入走 Lua 脚本,一条脚本操作 payload/version/_keys 三个 key,
    集群下必须同 slot,否则 CROSSSLOT
- 新增 StringRedisTemplateClientAdapter:复用容器 StringRedisTemplate 的适配器。不用官方 lettuceClient() 的原因:
  它会立即 connect()(Redis 不可用则应用启动失败),且 close() 会 shutdown 传入的 client(共享 client 会连
  sa-token 登录态的连接一起关死);自定义适配器懒连接、close 空实现、不额外建连接池
- 删会话时同步清理 Redis 记忆(聊天 + 研判两条链路),best-effort:Redis 抖动只记日志,不回滚数据库事务
- 研判槽位 ID 抽成 stateSessionId(),避免 RuntimeContext 与删除逻辑两处拼法漂移
- 修正 AgentChatServiceImpl 类注释:记忆 userId 实际取登录用户 ID(StpUtil),旧注释写死的
  RUNTIME_USER_ID 常量已是死代码,删除,避免按注释清理时传错 userId 静默删不到
- 新增 RedisAgentStateStoreLiveTest:默认跳过,-Dagent.state.live=true 时对真实 Redis 验证写入/读回/
  版本自增/列表读写/SCAN 索引/删除,并断言 key 落在本项目前缀下
cc 1 week ago
parent
commit
fc3daa8d0f

+ 2 - 2
AI_AGENT.md

@@ -60,7 +60,7 @@ zsjz-ai/
 | 缓存 | Redis |
 | 缓存 | Redis |
 | 图库 | Neo4j(配置存在,非全链路强依赖) |
 | 图库 | Neo4j(配置存在,非全链路强依赖) |
 | 鉴权 | Sa-Token 1.46 |
 | 鉴权 | Sa-Token 1.46 |
-| Agent | **AgentScope 2.0.1**(harness + dashscope/ollama/openai + mem0 + rag-simple + redis) |
+| Agent | **AgentScope 2.0.4**(harness + dashscope/ollama/openai + mem0 + rag-simple + redis) |
 | 检索 | Elasticsearch 8.18(spring-boot-starter-data-elasticsearch,单索引+caseId 隔离) |
 | 检索 | Elasticsearch 8.18(spring-boot-starter-data-elasticsearch,单索引+caseId 隔离) |
 | 其他 | fesod-sheet(Excel)、Hutool、Guava、MapStruct 1.6.3、commons-compress、JNA |
 | 其他 | fesod-sheet(Excel)、Hutool、Guava、MapStruct 1.6.3、commons-compress、JNA |
 | 启动类 | `com.zsjz.ai.App` |
 | 启动类 | `com.zsjz.ai.App` |
@@ -145,7 +145,7 @@ ai-server/src/main/resources/mappers/<domain>/<Xxx>Mapper.xml
 |---|---|
 |---|---|
 | **master**(PostgreSQL + pgvector) | 平台元数据、`table_info`/`table_field`、Agent 会话/消息、模型与 Provider 配置、RAG 索引状态、向量检索 |
 | **master**(PostgreSQL + pgvector) | 平台元数据、`table_info`/`table_field`、Agent 会话/消息、模型与 Provider 配置、RAG 索引状态、向量检索 |
 | **slave**(DuckDB) | **按工作空间切换**的案情库;Agent 的 `execute_sql` 只在这里跑 |
 | **slave**(DuckDB) | **按工作空间切换**的案情库;Agent 的 `execute_sql` 只在这里跑 |
-| Redis | 会话/缓存、Sa-Token、mem0 |
+| Redis | Sa-Token 登录态、业务缓存(`qing_jian_ai*`)、mem0、**Agent 会话状态**(`qing_jian:agent:{agentscope}:session:*`,由 `RedisAgentStateStore` 存对话历史) |
 | Neo4j | 图谱(配置在 `application-dev.yaml`) |
 | Neo4j | 图谱(配置在 `application-dev.yaml`) |
 
 
 - 表/字段元数据三表链路:`meta_raw_sheet` → `table_info` → `table_field`
 - 表/字段元数据三表链路:`meta_raw_sheet` → `table_info` → `table_field`

+ 18 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/insight/agent/InsightAgentFactory.java

@@ -176,6 +176,24 @@ public class InsightAgentFactory {
         lastAccessAt.remove(key);
         lastAccessAt.remove(key);
     }
     }
 
 
+    /**
+     * 删除研判会话在 {@link AgentStateStore} 里的记忆。
+     *
+     * <p>会话状态落 Redis 后是长期数据,{@link #evict} 只丢实例不删记忆,
+     * 会话删掉后记忆会一直留着。best-effort:Redis 抖动不能影响会话删除主流程。
+     *
+     * @param userId    与研判时 {@code RuntimeContext} 的 userId 一致(槽位按 userId 隔离,传错等于没删)
+     * @param sessionId 与研判时 {@code RuntimeContext} 的 sessionId 一致
+     */
+    public void deleteMemory(String userId, String sessionId) {
+        try {
+            agentStateStore.delete(userId, sessionId);
+            log.info("已清理研判会话记忆: userId={}, sessionId={}", userId, sessionId);
+        } catch (Exception e) {
+            log.warn("清理研判会话记忆失败(不影响会话删除): userId={}, sessionId={}", userId, sessionId, e);
+        }
+    }
+
     /**
     /**
      * 构建 HarnessAgent。
      * 构建 HarnessAgent。
      *
      *

+ 13 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/insight/service/impl/InsightServiceImpl.java

@@ -99,6 +99,16 @@ public class InsightServiceImpl implements InsightService {
     /** 固定运行时用户标识(项目无用户体系,仅供 AgentScope 记忆/文件隔离维度使用) */
     /** 固定运行时用户标识(项目无用户体系,仅供 AgentScope 记忆/文件隔离维度使用) */
     private static final String RUNTIME_USER_ID = "default";
     private static final String RUNTIME_USER_ID = "default";
 
 
+    /**
+     * 研判会话在 {@code AgentStateStore} 里的槽位 ID。
+     *
+     * <p>加前缀是为了与通用对话的槽位(直接是会话主键)区分开——两边 ID 都来自各自的自增/雪花主键,
+     * 不隔离就可能撞到同一份对话历史。
+     */
+    private static String stateSessionId(Long sessionId) {
+        return "insight-" + sessionId;
+    }
+
     private static final String STATUS_ACTIVE = "ACTIVE";
     private static final String STATUS_ACTIVE = "ACTIVE";
     private static final String STATUS_STREAMING = "STREAMING";
     private static final String STATUS_STREAMING = "STREAMING";
 
 
@@ -254,6 +264,8 @@ public class InsightServiceImpl implements InsightService {
         // 消息由 fk_aim_session 的 ON DELETE CASCADE 连带清理
         // 消息由 fk_aim_session 的 ON DELETE CASCADE 连带清理
         sessionMapper.deleteById(session.getId());
         sessionMapper.deleteById(session.getId());
         agentFactory.evict(session.getId());
         agentFactory.evict(session.getId());
+        // 会话状态已落 Redis:只 evict 实例不够,记忆(对话历史)不跟着删会永久残留
+        agentFactory.deleteMemory(RUNTIME_USER_ID, stateSessionId(session.getId()));
         // 闸门清理只在「未被占用」时做:流式进行中删会话时,无条件 remove 会让新请求
         // 闸门清理只在「未被占用」时做:流式进行中删会话时,无条件 remove 会让新请求
         // computeIfAbsent 出第二个闸门,两个闸门互不互斥(应用闸门防线形同虚设)。
         // computeIfAbsent 出第二个闸门,两个闸门互不互斥(应用闸门防线形同虚设)。
         // 占用中的条目极小,宁可留在 map 里也不破坏互斥(内存影响可忽略)。
         // 占用中的条目极小,宁可留在 map 里也不破坏互斥(内存影响可忽略)。
@@ -392,7 +404,7 @@ public class InsightServiceImpl implements InsightService {
 
 
         RuntimeContext ctx = RuntimeContext.builder()
         RuntimeContext ctx = RuntimeContext.builder()
                 .userId(RUNTIME_USER_ID)
                 .userId(RUNTIME_USER_ID)
-                .sessionId("insight-" + session.getId())
+                .sessionId(stateSessionId(session.getId()))
                 .build();
                 .build();
 
 
         List<Msg> msgs = List.of(Msg.builder().role(MsgRole.USER).textContent(userText).build());
         List<Msg> msgs = List.of(Msg.builder().role(MsgRole.USER).textContent(userText).build());

+ 20 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -168,6 +168,26 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         this.embeddingProvider = embeddingProvider;
         this.embeddingProvider = embeddingProvider;
     }
     }
 
 
+    /**
+     * 删除某个会话在 {@link AgentStateStore} 里的记忆。
+     *
+     * <p>会话状态落 Redis 后是长期数据:删会话只删 DB 行的话记忆会一直留着
+     * (进程内存储时代重启就没了,现在不会)。best-effort —— Redis 抖动不能把
+     * 「删除会话」这个数据库事务拖回滚,失败只记日志。
+     *
+     * @param userId    与对话时 {@code RuntimeContext} 的 userId 一致(记忆槽位按 userId 隔离,
+     *                  传错等于没删)
+     * @param sessionId 与对话时 {@code RuntimeContext} 的 sessionId 一致
+     */
+    public void deleteSessionMemory(String userId, String sessionId) {
+        try {
+            agentStateStore.delete(userId, sessionId);
+            log.info("已清理会话记忆: userId={}, sessionId={}", userId, sessionId);
+        } catch (Exception e) {
+            log.warn("清理会话记忆失败(不影响会话删除): userId={}, sessionId={}", userId, sessionId, e);
+        }
+    }
+
     /**
     /**
      * 取或建 agent 实例(池化,按 工作空间×agent×模型 组合去重)。
      * 取或建 agent 实例(池化,按 工作空间×agent×模型 组合去重)。
      *
      *

+ 6 - 6
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -63,17 +63,14 @@ import java.util.stream.Collectors;
  * 自动加载/保存;会话转录与 sessions.json 索引由官方 {@code TranscriptMiddleware}
  * 自动加载/保存;会话转录与 sessions.json 索引由官方 {@code TranscriptMiddleware}
  * 在每轮对话结束后自动写入 agent 工作目录。
  * 在每轮对话结束后自动写入 agent 工作目录。
  *
  *
- * <p>本项目已移除用户体系:会话按 {@code caseId}(工作空间)归属,不做用户级隔离。
- * AgentScope 侧仍需要一个"运行时用户标识"作为记忆/文件隔离维度,统一取
- * {@link #RUNTIME_USER_ID} 常量(非登录态)。
+ * <p>会话按 {@code caseId}(工作空间)归属,但 AgentScope 侧的记忆/文件隔离维度取
+ * <b>登录用户 ID</b>({@code StpUtil.getLoginIdAsString()}):同一会话被不同用户打开时,
+ * 各自的对话历史互不串。删会话时清理记忆也必须用同一个 userId,否则删不到。
  */
  */
 @Slf4j
 @Slf4j
 @Service
 @Service
 public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, AgentChatSession> implements AgentChatService {
 public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, AgentChatSession> implements AgentChatService {
 
 
-    /** 固定运行时用户标识(项目无用户体系,仅供 AgentScope 记忆/文件隔离维度使用) */
-    private static final String RUNTIME_USER_ID = "default";
-
     /** 会话默认标题({@link #createSession} 的初始值),首条消息并发时用它判断「标题是否还没被写过」 */
     /** 会话默认标题({@link #createSession} 的初始值),首条消息并发时用它判断「标题是否还没被写过」 */
     private static final String DEFAULT_TITLE = "新对话";
     private static final String DEFAULT_TITLE = "新对话";
 
 
@@ -199,6 +196,9 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
 
 
         // 删除会话
         // 删除会话
         chatSessionMapper.deleteById(sessionId);
         chatSessionMapper.deleteById(sessionId);
+        // 记忆槽位与对话时一致(RuntimeContext 的 userId = 登录用户 ID,sessionId = 会话主键),
+        // 会话状态已落 Redis,不跟着删就会永久残留
+        agentService.deleteSessionMemory(StpUtil.getLoginIdAsString(), String.valueOf(sessionId));
         log.info("删除会话成功: {}", sessionId);
         log.info("删除会话成功: {}", sessionId);
     }
     }
 
 

+ 55 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/state/AgentStateStoreConfig.java

@@ -0,0 +1,55 @@
+package com.zsjz.ai.module.agent.state;
+
+import io.agentscope.core.state.AgentStateStore;
+import io.agentscope.extensions.redis.state.RedisAgentStateStore;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * Agent 会话状态存储装配:把 {@link AgentStateStore} 落到 Redis。
+ *
+ * <p>{@code AgentService} / {@code InsightAgentFactory} 都以 {@code Optional<AgentStateStore>}
+ * 注入、取不到就回落 {@code InMemoryAgentStateStore};这里提供 bean 后,对话历史改由 Redis 保存,
+ * 进程重启、agent 实例被池淘汰都不再丢会话记忆。
+ *
+ * <p><b>key 布局</b>({@code RedisAgentStateStore} 自己拼的槽位:{@code {userId}/{sessionId}}):
+ * <ul>
+ *   <li>{@code qing_jian:agent:{agentscope}:session:{userId}/{sessionId}:{stateKey}} —— 单个状态(JSON)</li>
+ *   <li>{@code .../...:{stateKey}:list} / {@code :list:_hash} —— 列表状态与其变更检测 hash</li>
+ *   <li>{@code .../...:_keys} —— 该会话写过哪些 key 的索引集合</li>
+ * </ul>
+ *
+ * <p>两个前缀上的讲究:
+ * <ul>
+ *   <li>刻意<b>不</b>用 {@code qing_jian_ai} 开头:管理端 {@code SystemService.cleanCache()}
+ *       按 {@code qing_jian_ai*} 批量删缓存,会话历史不是缓存,不能被"清缓存"顺手清掉;</li>
+ *   <li>{@code {agentscope}} 是 Redis Cluster 的 hash tag。该扩展的写入走 Lua 脚本,
+ *       一条脚本同时操作 payload/version/_keys 三个 key,集群模式下必须同 slot 否则 CROSSSLOT 报错。
+ *       代价是所有会话默认落同一个 slot(单机部署无影响)。</li>
+ * </ul>
+ *
+ * @author cc
+ * @since 2026/10/29
+ */
+@Slf4j
+@Configuration
+public class AgentStateStoreConfig {
+
+    /**
+     * 会话状态 key 前缀({@code {agentscope}} 为 Cluster hash tag,勿删)
+     */
+    static final String KEY_PREFIX = "qing_jian:agent:{agentscope}:session:";
+
+    @Bean
+    public AgentStateStore agentStateStore(StringRedisTemplate redisTemplate) {
+        AgentStateStore store = RedisAgentStateStore.builder()
+                .keyPrefix(KEY_PREFIX)
+                // 自定义适配器而非 lettuceClient():复用容器连接池、懒连接、close 不影响共享 client
+                .clientAdapter(new StringRedisTemplateClientAdapter(redisTemplate))
+                .build();
+        log.info("Agent 会话状态存储:Redis,key 前缀 {}", KEY_PREFIX);
+        return store;
+    }
+}

+ 154 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/state/StringRedisTemplateClientAdapter.java

@@ -0,0 +1,154 @@
+package com.zsjz.ai.module.agent.state;
+
+import io.agentscope.extensions.redis.state.RedisClientAdapter;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.connection.ReturnType;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 基于 {@link StringRedisTemplate} 的 Redis 适配器,供
+ * {@code io.agentscope.extensions.redis.state.RedisAgentStateStore} 存储会话状态。
+ *
+ * <p><b>为什么不直接用官方的 {@code LettuceClientAdapter}</b>:
+ * <ol>
+ *   <li>官方的 {@code of(RedisClient)} 会立刻 {@code connect()},Redis 不可用时整个应用启动失败;
+ *       本适配器复用 Spring 连接工厂,连接按需建立(对齐 {@code CaseDataCache} 的降级约定);</li>
+ *   <li>官方适配器 {@code close()} 会 {@code client.shutdown()},把传进去的 client 关掉——
+ *       若传的是 Spring 管理的共享 client,等于把 sa-token 登录态依赖的连接一起关死;</li>
+ *   <li>复用容器里的同一个连接工厂与序列化配置,不额外造第二个连接池。</li>
+ * </ol>
+ *
+ * <p>key/value 一律走 {@code StringRedisTemplate} 的 String 序列化:{@code RedisAgentStateStore}
+ * 存的是 JSON 文本,键是它自己拼的字符串,不需要对象序列化。
+ *
+ * @author cc
+ * @since 2026/10/29
+ */
+public class StringRedisTemplateClientAdapter implements RedisClientAdapter {
+
+    /**
+     * 单次 SCAN 的建议条数(只影响分批大小,不是硬上限)
+     */
+    private static final long SCAN_BATCH = 1000;
+
+    private final StringRedisTemplate redis;
+
+    public StringRedisTemplateClientAdapter(StringRedisTemplate redis) {
+        this.redis = Objects.requireNonNull(redis, "StringRedisTemplate 不能为空");
+    }
+
+    @Override
+    public void set(String key, String value) {
+        redis.opsForValue().set(key, value);
+    }
+
+    @Override
+    public String get(String key) {
+        return redis.opsForValue().get(key);
+    }
+
+    @Override
+    public void rightPushList(String key, String value) {
+        redis.opsForList().rightPush(key, value);
+    }
+
+    @Override
+    public List<String> rangeList(String key, long start, long end) {
+        // Lettuce 对不存在的 key 返回空集合,这里对齐:调用方按"列表为空"分支处理,不能给 null
+        List<String> range = redis.opsForList().range(key, start, end);
+        return range == null ? List.of() : range;
+    }
+
+    @Override
+    public long getListLength(String key) {
+        return nullToZero(redis.opsForList().size(key));
+    }
+
+    @Override
+    public void deleteKeys(String... keys) {
+        if (keys == null || keys.length == 0) {
+            return;
+        }
+        redis.delete(List.of(keys));
+    }
+
+    @Override
+    public void addToSet(String key, String member) {
+        redis.opsForSet().add(key, member);
+    }
+
+    @Override
+    public Set<String> getSetMembers(String key) {
+        Set<String> members = redis.opsForSet().members(key);
+        return members == null ? Set.of() : new HashSet<>(members);
+    }
+
+    @Override
+    public long getSetSize(String key) {
+        return nullToZero(redis.opsForSet().size(key));
+    }
+
+    @Override
+    public boolean keyExists(String key) {
+        return Boolean.TRUE.equals(redis.hasKey(key));
+    }
+
+    @Override
+    public Set<String> findKeysByPattern(String pattern) {
+        Set<String> keys = new HashSet<>();
+        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(SCAN_BATCH).build();
+        // SCAN 游标必须关闭,否则连接不归还
+        try (Cursor<String> cursor = redis.scan(options)) {
+            cursor.forEachRemaining(keys::add);
+        }
+        return keys;
+    }
+
+    /**
+     * 执行 Lua 脚本。
+     *
+     * <p>走底层 {@link RedisConnection#scriptingCommands()}({@link ReturnType#INTEGER})而不是
+     * {@code template.execute(RedisScript, ...)}:后者会拿 template 的 value 序列化器去反序列化
+     * 整数回复,String 序列化器下容易踩到类型转换,这里直接用原生的整数返回类型。
+     */
+    @Override
+    public long evalScript(String script, List<String> keys, List<String> args) {
+        byte[] scriptBytes = script.getBytes(StandardCharsets.UTF_8);
+        List<byte[]> keysAndArgs = new ArrayList<>(keys.size() + args.size());
+        for (String key : keys) {
+            keysAndArgs.add(key.getBytes(StandardCharsets.UTF_8));
+        }
+        for (String arg : args) {
+            keysAndArgs.add(arg.getBytes(StandardCharsets.UTF_8));
+        }
+        Long result = redis.execute((RedisConnection connection) ->
+                connection.scriptingCommands().eval(scriptBytes, ReturnType.INTEGER, keys.size(),
+                        keysAndArgs.toArray(new byte[0][])));
+        if (result == null) {
+            throw new IllegalStateException("Lua 脚本未返回整数结果");
+        }
+        return result;
+    }
+
+    /**
+     * 不做任何事:连接工厂与连接池归 Spring 管理,随容器关闭;
+     * 这里主动关会把整个应用共享的连接池关掉。
+     */
+    @Override
+    public void close() {
+        // no-op
+    }
+
+    private static long nullToZero(Long value) {
+        return value == null ? 0L : value;
+    }
+}

+ 100 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/state/RedisAgentStateStoreLiveTest.java

@@ -0,0 +1,100 @@
+package com.zsjz.ai.module.agent.state;
+
+import io.agentscope.core.state.AgentStateStore;
+import io.agentscope.core.state.State;
+import io.agentscope.core.state.VersionedState;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link AgentStateStoreConfig} 的真实 Redis 冒烟测试:写入 → 读回 → 版本号 → 索引 → 删除。
+ *
+ * <p><b>默认不执行</b>:需要 {@code spring.data.redis.*} 指向真实 Redis(会写测试专用的
+ * {@code __live_test_user__} 槽位,断言结束即删)。手动跑:
+ * <pre>
+ * mvn -pl ai-server test -Dtest=RedisAgentStateStoreLiveTest -Dagent.state.live=true
+ * </pre>
+ *
+ * <p>覆盖的是本项目自己那层适配器({@link StringRedisTemplateClientAdapter}),
+ * 包括最容易被序列化细节坑到的 Lua 脚本执行、列表读写、SCAN 索引与删除重建。
+ */
+@SpringBootTest
+@EnabledIfSystemProperty(named = "agent.state.live", matches = "true")
+class RedisAgentStateStoreLiveTest {
+
+    private static final String TEST_USER = "__live_test_user__";
+
+    @Autowired
+    private AgentStateStore agentStateStore;
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    /**
+     * 测试用状态:State 是标记接口,用 record 保证 JSON 往返后仍能相等
+     */
+    record DemoState(String text, int count) implements State {
+    }
+
+    @Test
+    @DisplayName("会话状态真的落到 Redis:写入-读回-版本自增-列表-索引-删除")
+    void roundTripThroughRedis() {
+        String sessionId = "live-" + UUID.randomUUID();
+        try {
+            // 1) 单条状态:写入即走 Lua 脚本(版本从 1 开始)
+            DemoState memory = new DemoState("你好", 7);
+            agentStateStore.save(TEST_USER, sessionId, "memory", memory);
+
+            assertTrue(agentStateStore.exists(TEST_USER, sessionId), "写入后会话应存在");
+            Optional<DemoState> loaded = agentStateStore.get(TEST_USER, sessionId, "memory", DemoState.class);
+            assertTrue(loaded.isPresent(), "必须能读回刚写入的状态");
+            assertEquals(memory, loaded.get(), "JSON 往返后内容必须一致");
+
+            // 2) key 必须落在本项目的前缀下(证明写的是 Redis,不是别的存储)
+            String expectedKey = AgentStateStoreConfig.KEY_PREFIX + TEST_USER + "/" + sessionId + ":memory";
+            assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(expectedKey)),
+                    "会话状态 key 必须带 " + AgentStateStoreConfig.KEY_PREFIX + " 前缀,实际查 " + expectedKey);
+
+            // 3) 版本号:第二次写自增到 2
+            agentStateStore.save(TEST_USER, sessionId, "memory", new DemoState("你好", 8));
+            VersionedState<DemoState> versioned =
+                    agentStateStore.getVersioned(TEST_USER, sessionId, "memory", DemoState.class);
+            assertEquals(2L, versioned.version(), "第二次 save 的版本号应为 2");
+
+            // 4) 列表状态:走 rpush/lrange
+            agentStateStore.save(TEST_USER, sessionId, "msgs",
+                    List.of(new DemoState("a", 1), new DemoState("b", 2)));
+            List<DemoState> msgs = agentStateStore.getList(TEST_USER, sessionId, "msgs", DemoState.class);
+            assertEquals(List.of(new DemoState("a", 1), new DemoState("b", 2)), msgs);
+
+            // 5) 会话索引:SCAN + Set 成员
+            assertTrue(agentStateStore.listSessionIds(TEST_USER).contains(sessionId),
+                    "listSessionIds 应能扫到刚写入的会话");
+
+            // 6) 删除:payload / 列表 / 版本 key / 索引一起清掉
+            agentStateStore.delete(TEST_USER, sessionId);
+            assertFalse(agentStateStore.exists(TEST_USER, sessionId), "删除后会话不应再存在");
+            assertFalse(agentStateStore.listSessionIds(TEST_USER).contains(sessionId),
+                    "删除后索引里不应再出现该会话");
+        } finally {
+            // 断言失败也不留垃圾
+            try {
+                agentStateStore.delete(TEST_USER, sessionId);
+            } catch (Exception ignored) {
+                // 清理失败不影响测试结论
+            }
+        }
+    }
+}