P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
系列第四篇。P1 的 Agent 会「记住你」,但状态落在本地文件里;P3 的多智能体团队跑起来后,上下文越滚越长。这一篇解决「能不能上生产」的问题:状态集中存储、上下文自动压缩、记忆分层沉淀。
你能获得什么
- 理解 AgentScope-Java 的状态存储体系:两种内置实现 + Redis 扩展,以及
AgentStateStore接口的统一抽象 - 10 分钟把会话状态从「本地 JSON 文件」切到 Redis——一行 builder 配置,业务代码零改动
- 搞懂 上下文压缩(compaction)的触发机制,以及它和分层记忆怎么配合
- 拿到一套选型建议:开发期用什么、单机部署用什么、多实例生产环境用什么
一、JsonFileAgentStateStore 的三个局限
P1 里我们验证过:Agent 的会话状态自动落盘到 ~/.agentscope/state/<agentName>/<userId>/<sessionId>/agent_state.json,进程重启也能恢复。这个默认实现(JsonFileAgentStateStore)开发体验极好,但它有三个天花板:
- 状态跟着机器走。状态散落在每个实例的本地磁盘上,一旦部署两个实例做负载均衡,用户的这次请求打到实例 A、下次打到实例 B,B 上没有状态——会话直接「失忆」。
- 没有集中管理能力。想看「现在有多少活跃会话」?得去每台机器
ls目录。想清理过期会话?写脚本删文件夹。 - 文件系统就是上限。状态文件越来越大时 JSON 全量读写的开销、并发写同一个文件的冲突,都是文件方案绕不开的坑。
解法和微服务里 session 共享是同一个思路:把状态外置到一个集中存储。AgentScope-Java 为此定义了统一接口 AgentStateStore,Redis 是官方扩展之一。
二、状态存储体系:一个接口,三种世界
先看全貌。核心 jar 里内置两种实现,Redis 在独立扩展里:
| 实现 | 所在模块 | 适用场景 |
|---|---|---|
InMemoryAgentStateStore | agentscope-core | 测试、临时脚本 |
JsonFileAgentStateStore(默认) | agentscope-core | 开发期、单机部署 |
RedisAgentStateStore | agentscope-extensions-redis | 生产、多实例 |
JedisAgentStateStore / RedissonAgentStateStore | 同上 | 你的技术栈已用 Jedis/Redisson 时 |
所有实现都实现同一个接口(save / get / getList / exists / delete / listSessionIds),切换实现不改变任何业务代码——这正是 P1 里「跨进程恢复」演示能无痛升级的原因。
两点值得在选型前知道的细节:
- Redis 扩展提供三种客户端适配(Lettuce / Jedis / Redisson),默认的
RedisAgentStateStore基于 Lettuce;如果你的项目里已经有 Redisson,用RedissonAgentStateStore复用连接池更合理。 - 扩展坐标是
agentscope-extensions-redis,和 harness 用同一条 2.0.x 版本线。中央仓库里还有个agentscope-extensions-session-redis,那是 1.x 时代的旧模块,别引错——这是我踩的第一个坑,后面细说。
三、10 分钟切到 Redis
1. 加依赖
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-redis</artifactId>
<version>2.0.1</version>
</dependency>
Lettuce 客户端会随这个依赖传递引入,不需要单独加。
2. 本地起一个 Redis
docker run -d --name agentscope-redis -p 6379:6379 redis:7-alpine
3. 构建 store 并挂到 Agent
完整代码来自仓库的 RedisStateDemo.java(节选):
// 1. 连接自检:连不上给出可直接复制的启动命令,而不是甩一屏异常
String uri = System.getenv().getOrDefault("REDIS_URI", "redis://localhost:6379");
RedisClient redisClient = RedisClient.create(uri);
try {
RedisCommands<String, String> sync = redisClient.connect().sync();
System.out.println("Redis 连接成功(PING=" + sync.ping() + "): " + uri);
} catch (Exception e) {
System.err.println("无法连接 Redis:" + uri);
System.err.println("请先启动一个本地 Redis,例如:");
System.err.println(" docker run -d --name agentscope-redis -p 6379:6379 redis:7-alpine");
redisClient.shutdown();
return;
}
// 2. 构建 Redis 状态存储并挂到 Agent(替代默认的 JsonFileAgentStateStore)
RedisAgentStateStore store = RedisAgentStateStore.builder()
.lettuceClient(redisClient)
.keyPrefix("agentscope:demo:")
.build();
HarnessAgent agent = HarnessAgent.builder()
.name("note-taker")
.sysPrompt("你是一个帮助用户记录笔记的助手,需要记住用户提到的关键信息。")
.model(ModelFactory.build())
.workspace(Paths.get(".agentscope/workspace"))
.stateStore(store) // ← 唯一的新增配置
.build();
三个 API 细节:
- Redis 客户端要自己建、自己传:
builder().lettuceClient(redisClient)。框架不会替你从 URI 创建连接,好处是连接池、超时、密码都由你掌控(远程 Redis 用redis://:password@host:6379格式)。 keyPrefix按环境隔离:开发、测试、生产连同一个 Redis 时,用不同前缀隔离数据,避免测试会话污染生产。- 挂载只此一行:
.stateStore(store)。Agent 的其余代码和 P1 完全一致。
4. 跑起来验证
玩法和 P1 的 SessionResumeDemo 一样,但这次状态来自 Redis:
=== AgentScope-Java Redis 状态存储演示 ===
Redis 连接成功(PING=PONG): redis://localhost:6379
(第一次运行)> 我叫 MK,在写 AgentScope-Java 系列文章
[agent] 好的 MK,已记下……
(exit 后重新运行)
(第二次运行)> 我叫什么,在做什么
[agent] 你叫 MK,正在写 AgentScope-Java 系列文章。
退出时 demo 还会打印一行验证信息:
已退出。Redis 中 note-taker 名下的会话:[redis-demo-session]
这是调 store.listSessionIds("note-taker") 拿到的——会话清单现在可以直接从 Redis 查询,这是文件方案做不到的。
四、Redis 里到底存了什么
用 redis-cli 看一眼(这步建议读者跟着做,也是文章的干货点):
redis-cli keys "agentscope:demo:*"
1) "agentscope:demo:session:note-taker:mk:redis-demo-session:_keys"
2) "agentscope:demo:session:note-taker:mk:redis-demo-session"
key 结构一目了然:
<keyPrefix>:session:<agentName>:<userId>:<sessionId>
- 主 key 是一个 hash,存会话状态本体(各维度 State:会话消息、任务上下文、工具上下文等)
_keys等后缀 key 记录状态维度清单,用于恢复时枚举
理解这个结构有两个实际价值:一是排查问题时可以直接定位某个用户某个会话的数据;二是 keyPrefix + agentName + userId + sessionId 四段天然构成了多租户、多 Agent、多会话的隔离维度——做 SaaS 化时不用重新设计。
五、上下文压缩:compaction 为什么是刚需
状态有了着落,另一个工程问题浮出水面:上下文会越滚越长。
P3 的多智能体编排就是典型场景:总编的上下文里塞着三次委派、三份子 Agent 回传(大纲、全文初稿、审校清单),几轮下来轻松破万 token。模型上下文窗口有限,超过后轻则报错,重则隐性劣化——关键指令被稀释在大量中间过程里。
AgentScope-Java 的解法是 compaction(上下文压缩),配置就两个参数(这个配置我们其实从 P1 起一直在用):
.compaction(CompactionConfig.builder()
.triggerMessages(30) // 消息数达到 30 条触发压缩
.keepMessages(10) // 压缩时保留最近 10 条原文
.build())
工作方式:消息数到达阈值时,框架把较早的消息蒸馏成一份摘要塞回上下文,只保留最近 keepMessages 条原文。效果是上下文规模有了硬上限,而关键信息(最近对话 + 历史摘要)仍在。
配置经验:
| 参数 | 调大 | 调小 |
|---|---|---|
triggerMessages | 少压缩、省 token,但上下文峰值高 | 压缩频繁,成本上升 |
keepMessages | 近期细节保留多 | 更省,但容易丢「刚才说过什么」的连续性 |
多轮工具调用的编排 Agent 建议 triggerMessages 别太大——压缩晚触发一次,可能就是一次上下文溢出。
六、和分层记忆怎么配合
compaction 解决「这一场对话里」的上下文膨胀,跨会话的长期记忆则是 workspace 的职责。P1 介绍过 workspace 目录(.agentscope/workspace/),这里把三层职责讲全:
sessions/ 原始对话日志,只追加不压缩 —— 审计与回溯的事实底座
memory/ 跨会话长期记忆,重要事实的沉淀层
AGENTS.md 人格与行为准则,每次推理都生效
三者分工:compaction 管「当前对话的窗口」,被压缩掉的内容仍完整躺在 sessions/ 里;memory 管「跨对话记住什么」,值得长期保留的事实从这里沉淀;AGENTS.md 管「你是谁、按什么规矩做事」。状态存储(Redis)则解决「这些会话状态放在哪」——它是物理层,上面三层是逻辑层,正交不冲突。
理清这个分层后,选型就清晰了:开发期用默认 JsonFile(零依赖、看得见摸得着),生产用 Redis(集中、可管理、可水平扩展),上下文压缩和分层记忆无论哪个环境都开着。
七、踩坑记录
坑 1:agentscope-extensions-redis 和 agentscope-extensions-session-redis 是两回事。
我按直觉搜「agentscope redis」引了后者,结果 2.0.0-RC1 之后再无更新,类名和 API 都对不上。真相:Redis 状态存储在 agentscope-extensions-redis 里(与 harness 同版本线),session-redis 是 1.x 旧线遗产。引依赖前到中央仓库确认一下版本线,能省半小时。
坑 2:Redis 客户端要外部传入,不会自动创建。
RedisAgentStateStore.builder() 没有传 URI 就 build 的重载——lettuceClient() 必须给一个已经创建好的 RedisClient。第一次我以为它能像很多框架那样「配个 URL 就行」,空指针半天才反应过来。这个设计其实有道理:连接池参数不该被框架代管。
坑 3:换了 keyPrefix,旧会话「集体失忆」。
我把前缀从默认值改成 agentscope:demo: 后再跑,Agent 完全不认识之前聊过的内容——不是状态丢了,是 key 换了段前缀,旧数据躺在旧前缀下面。生产上改前缀等于换库,要么做数据迁移,要么明确接受「历史会话作废」。
八、下一篇预告
工程底盘就绪后,把 Agent 暴露成服务就水到渠成:P5 我们把 streamEvents() 的事件流包进 Spring Boot 接口,用 SSE 推给前端——一个打字机效果的聊天页面,前后端代码一并奉上。
仓库地址:github.com/525128633/a… 系列文章:P1 上手实测 · P2 工具调用实战 · P3 多智能体编排 · P4 本篇