AgentScope Java 实战:Agent 的状态存在哪、怎么恢复、怎么隔离?

AgentScope Java 实战:Agent 的状态存在哪、怎么恢复、怎么隔离?

本文基于 AgentScope Java 2.0.2 和一个真实可跑的订单运营助手项目。所有代码片段、字段名、测试结果都来自这个项目,不是示意伪代码。

开头:我删掉了 Agent,它却还记得那笔退款

我手头有一个订单运营助手。先让它完整跑完一笔退款------客服 kefu-a 在会话里查订单 202609001,发现运单 ZW9527 在杭州转运中心滞留超过 72 小时,于是发起 259 元退款,人工确认后,退款申请被受理。

然后我做了件"坏事":把这个 ReActAgent 实例直接丢掉,new 一个全新的 Agent。它什么参数都没拿到,我只问了一句:

刚才那笔退款到哪一步了?

它接上了。从恢复的上下文里认出"那笔"指的是订单 202609001,顺手查回退款编号 1,受理方式是人工确认(CONFIRMED)。

同样一句话,换成 kefu-b 的会话再问一遍,新 Agent 就懵了------它不知道"刚才那笔"是什么,只能反过来问我要订单号。

读到这儿大概会冒出三个疑问:旧 Agent 都被丢了,新 Agent 从哪儿找回的上下文?为什么只换一组 userId/sessionId,这段"记忆"就消失了?退款的受理状态到底是存档里读出来的,还是重新查了数据库?

这三个问题就是这篇文章的主线。答案都藏在 AgentStateStore 里。


一、先说清两个概念:AgentState 是存档内容,AgentStateStore 是存档系统

这两个类名长得像,职责完全不同,混了就什么都讲不通。

  • AgentState:某个 Agent 可以继续工作的运行现场------它看到过的对话、执行到哪一步、有没有工具在等确认;
  • AgentStateStore:保存、读取、列举和删除这些运行现场的存档系统
  • JsonFileAgentStateStore:把存档真正落到 JSON 文件里的一种实现(本文用的就是它,但它只是八种实现之一,后面第十章细说)。

可以把它想成存档柜和档案的关系:AgentStateStore 是柜子,AgentState 是柜子里的档案。

State 是被保存的内容,Store 是保存内容的机制。

注意我没有用"长期记忆"这个词。这一篇讲的是可验证的状态持久化行为,不讨论抽象的 Agent Memory 分类------那是另一个话题。

二、AgentState 保存的不只是聊天记录,而是运行现场

很多人第一眼会以为:AgentStateStore 不就是把聊天消息写进文件吗?

看看这个项目真实生成的 agent_state.json,顶层字段是这些:

json 复制代码
{
  "session_id": "a-cust-1001",
  "user_id": "kefu-a",
  "summary": "",
  "context": [ /* 对话与工具调用记录 */ ],
  "reply_id": "383067a46bfd...",
  "cur_iter": 0,
  "shutdown_interrupted": false,
  "permission_context": { /* 权限确认相关状态 */ },
  "tool_context": { /* 工具调用相关状态 */ },
  "tasks_context": { /* 任务相关状态 */ },
  "plan_mode_context": { /* Plan 模式相关状态 */ }
}

聊天记录只是 context 这一个字段。除此之外还有:当前回复位置(reply_id)、迭代计数(cur_iter)、是否处于中断状态(shutdown_interrupted)、权限确认上下文、工具上下文、任务上下文、Plan 模式上下文。

为什么要存这么多?想象一下只存聊天文本会发生什么:Agent 知道"上次聊到退款",但不知道上次执行到了哪一步、有没有一个工具调用正在等人工确认。我这个助手里,退款恰好走了 HITL 人工确认------permission_context 记录的就是这类"挂在半空"的状态。

AgentState 的目标不是"记得说过什么",而是让 Agent 恢复到可以继续工作的状态

AgentState 保存的是运行现场,聊天记录只是现场中的一部分。

三、userId/sessionId/key 决定 Agent 去哪个槽位找状态

状态不会"自动被记住",它存在具体的槽位里。三个标识构成槽位坐标:

  • userId:第一层归属,决定先打开谁的抽屉;
  • sessionId:该用户下面的具体会话文件夹;
  • key:同一个会话里保存哪一种状态,当前 Agent 使用的是 agent_state

在文件实现里,这层关系直接体现在目录结构上:

css 复制代码
.agentscope/sessions/
└─ kefu-a/
   └─ a-cust-1001/
      └─ agent_state.json

行为规则非常机械:

复制代码
相同 userId + 相同 sessionId
→ 命中同一个状态槽位
→ 可以恢复之前的运行现场

userId 或 sessionId 变化
→ 进入另一个槽位
→ 不会自动继承原会话状态

开头那个场景里 kefu-b 接不上,没有任何神秘原因------kefu-b/b-cust-1001 就是另一个槽位,里面没有 kefu-a 的历史。

还有一条边界要说清:框架负责按标识分槽,不负责替业务决定 userIdsessionId 应该采用什么业务语义。 用客服 ID 还是客户 ID、一个工单一个会话还是一个客户一个会话,是应用设计问题(第九章展开)。

四、核心 API:不要按方法背,按状态生命周期理解

AgentStateStore 的接口方法不多,按生命周期分三组:

第一组:保存与读取

  • save(...):把一个或一组状态写入指定槽位------在这个助手里,每轮对话结束后,Agent 的状态就这样写回 kefu-a/a-cust-1001
  • get(...):按 key 取回一个状态;
  • getList(...):按 key 取回状态列表。

第二组:判断与查找

  • exists(userId, sessionId):判断这个会话槽位是否存在------比如客服打开一个老客户的会话前,先判断有没有历史可续;
  • listSessionIds(userId):列出某个用户下面已经保存的会话------客服工作台"我的历史会话"列表就靠它。

第三组:清理生命周期

  • delete(userId, sessionId):删除整个会话槽位------客户数据到期、会话关闭后的清理入口;
  • delete(userId, sessionId, key):只删除槽位中的某类状态;
  • close():关闭底层资源;文件实现还额外提供 clearAllSessions()

这组 API 解决的是状态的保存、恢复、发现和清理,不负责定义业务事实。

五、接入 ReActAgent:两段代码,状态自动恢复和保存

接入方式小到出乎意料。第一段,构建 Agent 时配置存储(从项目的 AgentFactory.createOrderAgent(AgentStateStore) 中抽取):

scss 复制代码
ReActAgent.Builder builder = ReActAgent.builder()
        .name("order_ops_agent")
        .sysPrompt(SYS_PROMPT)
        .model(lab.modelId())
        .toolkit(toolkit);

if (stateStore != null) {
    builder.stateStore(stateStore);
}

对照行为:

  • 配置 stateStore:同一槽位调用时自动恢复历史、每轮结束自动保存;
  • 不配置(传 null):退回无状态行为,每次调用都是全新开始。

第二段,每次调用时提供槽位坐标:

scss 复制代码
RuntimeContext context = RuntimeContext.builder()
        .userId("kefu-a")
        .sessionId("a-cust-1001")
        .build();

整个调用链可以压缩成:

bash 复制代码
RuntimeContext 给出 userId/sessionId
→ ReActAgent 从 AgentStateStore 加载 agent_state
→ Agent 带着恢复的现场继续推理和调用工具
→ 本轮结束后把新状态写回同一槽位

一个措辞上的准确性提醒:这套接入方式验证的是"全新 Agent 实例 + 同一状态存储"和"全新 Store 实例 + 同一磁盘目录"两个层次。这和"跨进程恢复"不是一回事------如果要宣称进程重启后能恢复,需要两次独立进程运行读取同一文件的证据。写技术文章时这种层次差异不能含糊。

六、放回完整调用链:新 Agent 怎样接上"刚才那笔退款"

接口讲完了,放回真实调用链里看一遍。

第一幕:先产生可恢复的运行现场

arduino 复制代码
kefu-a / a-cust-1001
→ "查一下订单 202609001 的物流情况,客户催了"
→ 查到运单 ZW9527 在杭州转运中心滞留超过 72 小时
→ "这个客户的退款申请帮我提交一下"
→ 已发货订单触发 HITL 人工确认 → 批准
→ 退款申请受理:编号 1,金额 259 元,受理方式 CONFIRMED(人工确认后)

第二幕:丢掉旧 Agent,换全新实例

新 Agent 只收到一句"刚才那笔退款到哪一步了?"。它从恢复的 context 里看到第一幕的完整对话,把"那笔"解析成订单 202609001,然后调用 queryRefundTicket(orderId) 查到最新的退款记录。

对照组:换一个状态槽位

arduino 复制代码
kefu-b / b-cust-1001
→ 同样问"刚才那笔退款到哪一步了?"
→ 槽位里没有任何历史
→ "请您提供一下订单号"

把因果链钉死------不是"模型突然有了长期记忆":

ini 复制代码
相同槽位恢复历史
→ 历史帮助模型解析自然语言指代("那笔"= 202609001)
→ 稳定业务 ID 进入 Tool
→ Tool 查询最新业务事实

每一环都有分工,少了任何一环,开头的场景就不成立。

七、会不会只是模型猜对了?六项测试钉住边界

第六章的场景很顺,但先别急着信。kefu-a 接上了"刚才那笔退款",到底是槽位恢复在起作用,还是模型碰巧猜中了订单号?kefu-b 反过来要订单号,是真的被槽位隔离了,还是随口一句客气话?

靠演示回答不了这些问题------一旦把模型放进断言,验证就变成了抽卡。所以我绕开模型,在存储层(不依赖 LLM)直接对 AgentStateStore 写了六项测试,把关键行为钉在边界上:

测试 回答的问题
saveGetRoundTrip 写进去的状态能不能按 key 原样读回来?
sameUserDifferentSessionsIsolated 一个客服的多个会话会不会串线?
differentUsersSameSessionIdIsolated 不同客服碰巧用了相同 sessionId,会不会串线?
crossInstancePersistence 状态是否真的落盘,而不只活在当前 Store 对象里?
deleteRemovesSlot 会话结束或过期后能不能清干净?
listSessionIdsIsPerUser 会话列举是否严格按用户隔离?

2026-09-16 复核结果:6 项全部通过,failures=0errors=0

顺着"断言"这个话题,分享一个踩出来的坑。最早我给隔离用例写的断言是"回复里不能出现订单号 202609001"------结果误报。原因很微妙:工具描述里写着示例"订单号是 9 位纯数字,例如 202609001",模型会把描述里的示例复述出来,回复里出现这个字符串不代表它真的恢复了历史。

所以隔离测试要断言行为:新槽位的 Agent 无法解析指代、会向用户索要订单号。盯行为,不盯字符串。

状态功能至少要验证同槽恢复、跨槽隔离、真正落盘和可清理,不能只看一次连续对话。

到这里,"能恢复、会隔离、真落盘、可清理"都被钉住了。但还有一层窗户纸没捅破:恢复出来的历史,并不等于事实本身------这是下一章的事。

八、恢复出来的历史不是事实:AgentState 和业务数据库怎么连

上一章结尾留了半句话,这一章把它说透:恢复出来的历史,不能直接当事实用。

AgentStateStore 和业务数据库是两套不同的存储。AgentScope Java 不会自动替应用创建 sessionId → orderId 的外键,也不会自动判断哪条退款记录属于当前对话。先区分两套标识:

标识 定位什么 由谁使用
userId/sessionId 一份 Agent 会话存档 AgentStateStore
orderId/refundId 一条业务实体记录 Tool / Service / 业务数据库

订单助手里这两套标识是这样连起来的:

ini 复制代码
"刚才那笔退款"
→ 从 AgentState 恢复的历史中解析出 orderId=202609001
→ 调用 queryRefundTicket(orderId)
→ Tool 使用 orderId 查询业务数据库
→ 返回退款编号 1、259 元、受理方式 CONFIRMED

真正的桥梁不是框架的自动关联,而是两个东西:历史中留下的稳定业务标识 ,和能拿这个标识查询实时数据的 Tool

为什么不能直接相信历史里的退款状态?历史只能说明"上次说过什么"。假如财务已经把这笔退款从受理推进到打款完成,历史里那句"已受理"就是过时信息。所以助手里 queryRefundTicket 的工具描述专门写了一条硬约束:

回答退款进度时必须以本工具返回的最新状态为准,不要依据记忆中的历史状态回答。

职责应该这样分开:

  • AgentState 负责恢复上下文、识别"那笔"指谁;
  • 业务数据库负责回答这笔业务现在是什么状态;
  • Tool 负责拿稳定业务 ID 去查当前事实。

什么情况下需要显式映射? 当前单一场景可以从对话历史里解析 orderId。但出现下面任一情况,就应该在业务系统中显式维护 sessionId ↔ orderId/refundId 映射表:

  • 必须确定性定位,不能接受模型从自然语言里猜;
  • 需要审计"这次回答基于哪条业务记录";
  • 一个 Session 同时关联多个订单、退款或工单;
  • 需要从业务记录反查相关会话。

AgentState 保存"如何接着聊和接着做",业务数据库保存"事情现在究竟是什么状态"。两者通过稳定业务 ID 和 Tool 建立应用层联系。

九、真正接入业务前,还要做三个设计决定

框架把能力给你了,但这三个决定它替不了你。

决定一:sessionId 到底代表什么

示例里用的是 a-cust-1001,表达"客服 A 与客户 1001 的连续会话"。真实系统还要回答:同一客服再次接待同一客户,续旧会话还是新开会话?同一客户同时有多个订单,共用一个 Session 还是按工单拆开?30 天后还允许恢复吗?

这里坦白说:多订单、多会话并存的场景,我还没有完整验证过,所以"按客服 + 客户派生 sessionId"目前只是一个设计选择,不是被验证过的最佳方案。每种选择都有代价------按工单拆会话隔离干净但丢失客户维度上下文,按客户合并则上下文混杂。

决定二:AgentState 和业务数据的持久化等级是否匹配

我调试时遇到过一个很有价值的现象:会话状态已经落盘,但 Demo 业务库是遗失的。进程重启后,Agent 记得处理过退款,数据库里却没有对应记录------Agent 会拿着一个不存在的历史去问一个不记得这件事的系统。

这不是 AgentStateStore 的缺陷,而是应用两套持久化生命周期没对齐。要检查:AgentState 存多久?业务记录存多久?一边过期或丢失时,系统怎样报告不一致?

决定三:状态怎样过期、删除和保护

AgentState 里有对话、工具结果和业务标识,本质上是一份敏感数据副本。不能只设计"怎么存",还要设计:会话保留多久、谁可以列举和读取、什么时候调 delete、敏感字段进入状态前怎么裁剪或掩码(助手里物流工具的收件人信息就是在工具返回边界直接掩码的,这个思路同样适用于状态写入)、本地 JSON 是否只用于开发环境。

框架提供状态槽位和存取能力,业务系统仍要决定槽位语义、保留周期与数据边界。

十、框架不止一种实现:JsonFile 只是单机答案

读到这里最自然的追问是:示例用 JSON 文件,生产多实例部署怎么办?

AgentStateStore 在 2.0.2 里一共有八种实现:

实现 模块 适用场景
InMemoryAgentStateStore agentscope-core 单元测试、临时演示;进程退出即丢
JsonFileAgentStateStore agentscope-core 单机开发 / 示例项目;本文所用
RedisAgentStateStore(含 Jedis、Redisson 两个客户端变体) agentscope-extensions-redis 多实例部署首选;状态集中到 Redis
MysqlAgentStateStore agentscope-extensions-mysql 已有 MySQL 基础设施;要持久化和 SQL 查询
PostgresAgentStateStore agentscope-extensions-postgresql 同上,PostgreSQL 技术栈
OssAgentStateStore agentscope-extensions-oss 阿里云 OSS,归档式存储
CosAgentStateStore agentscope-extensions-cos 腾讯云 COS,归档式存储

为什么 JsonFile 扛不住分布式? 多副本部署时,每个实例只看到自己的本地磁盘:同一 userId/sessionId 的请求被负载均衡分到另一台机器,就找不到槽位。出路有两条:把状态槽位移到所有实例都能访问的共享存储(Redis / 数据库 / 对象存储);或者强制会话亲和(sticky session)------但扩容、重启、实例漂移都会让亲和性失效,只能算权宜之计。生产多实例的正确答案是第一条。

怎么选? 看五个维度:部署形态(单实例还是多副本,这是第一道分水岭)、可丢失性(测试可丢,生产不可丢)、延迟与吞吐(Redis 内存级,数据库和对象存储延迟更高)、已有基础设施(团队已经在运维什么)、合规与审计(会话含敏感信息时,数据库和对象存储更容易接权限与审计体系)。

还有一个容易想当然的点:我翻了 agentscope-extensions-redis 的源码,Redis 实现没有内置 TTL ------过期清理仍要应用自己调 delete 或设计 key 生命周期。换实现解决的是"多实例可见性",不自动解决第九章"决定三"的过期问题。

两条事实边界:本文只实际验证了 JsonFileAgentStateStore,其余实现清单来自 2.0.2 源码与 Maven Central(agentscope-extensions-redis:2.0.2 已发布),行为以框架自带测试为准;分布式并发写同一槽位的一致性,我没有验证过,不展开。也不存在"生产必须用 Redis"的说法------选型取决于你的部署形态和已有基础设施。

十一、最后,回答开头的三个问题

新 Agent 为什么能接上"刚才那笔退款"?

它用相同的 userId/sessionIdAgentStateStore 恢复了运行现场。不是新对象天然继承了旧对象的记忆,而是两个对象先后打开了同一个槽位。

为什么换成 kefu-b 就接不上?

kefu-b/b-cust-1001 是另一个槽位,里面没有 kefu-a 的历史。框架的分槽机制保证了这种隔离。

退款的受理状态从哪里来?

历史帮助 Agent 找回订单号 202609001;真正的状态由 queryRefundTicket 拿这个订单号重新查询业务数据库得到。历史给线索,数据库给事实。

如果只记住三句话:

AgentState 是运行现场,AgentStateStore 是存档系统。

userId/sessionId/key 决定状态保存在哪、下一次从哪恢复。

AgentState 负责找回上下文,业务数据库负责提供最新事实,中间靠稳定业务 ID 和 Tool 连接。

上一篇讨论 Tool 和 Service,是在划分模型入口与业务实现的边界。这一篇讨论 AgentStateStore,本质上是划分另一条边界:哪些信息属于 Agent 的运行现场,哪些信息必须继续留在业务系统里。

AgentScope Java 能替我们保存和恢复状态,但它不会替我们决定会话如何分槽,也不会替我们把 AgentState 和业务记录自动关联起来。这部分,仍然是应用设计。

相关推荐
用户1917291270831 小时前
多 Agent 并行不打架:worktree 隔离与反馈回流落地(附脚本)
人工智能
斯维赤1 小时前
Spring AI | Function Calling 是什么?
java·后端
jsl_jsl_jsl1 小时前
《Bun 后端怎么变桌面软件:Tauri 2 三进程架构与崩溃自愈》
人工智能
yunwei371 小时前
eBPF 教程:BPF 调度器入门
linux·后端·性能优化
lucas_AI1 小时前
第 10 讲 · PGO 实战:让程序的真实运行数据指导编译
人工智能
lucas_AI1 小时前
第 8 讲 · oeAware 与中断绑核:把手工调优自动化
人工智能
hyunbar7771 小时前
LangChain 实战:上下文工程长期记忆管理
人工智能
lucas_AI1 小时前
第 11 讲 · KAE 硬件加速:不改一行业务代码的性能提升
人工智能