P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战

0 阅读8分钟

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)开发体验极好,但它有三个天花板:

  1. 状态跟着机器走。状态散落在每个实例的本地磁盘上,一旦部署两个实例做负载均衡,用户的这次请求打到实例 A、下次打到实例 B,B 上没有状态——会话直接「失忆」。
  2. 没有集中管理能力。想看「现在有多少活跃会话」?得去每台机器 ls 目录。想清理过期会话?写脚本删文件夹。
  3. 文件系统就是上限。状态文件越来越大时 JSON 全量读写的开销、并发写同一个文件的冲突,都是文件方案绕不开的坑。

解法和微服务里 session 共享是同一个思路:把状态外置到一个集中存储。AgentScope-Java 为此定义了统一接口 AgentStateStore,Redis 是官方扩展之一。

二、状态存储体系:一个接口,三种世界

先看全貌。核心 jar 里内置两种实现,Redis 在独立扩展里:

实现所在模块适用场景
InMemoryAgentStateStoreagentscope-core测试、临时脚本
JsonFileAgentStateStore(默认)agentscope-core开发期、单机部署
RedisAgentStateStoreagentscope-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 本篇