Skip to main content

Agent的会话状态存在哪

下一轮靠什么接上上一轮​

上一篇停在确认卡上:用户说要取消那块Apple Watch,Agent查到订单88235,准备调cancel_order时弹出了确认卡。这一篇从用户在卡上点了“取消”之后讲起。

下面的数据来自2026-09-28在本机服务上跑的一个会话,还是同一句话:

我买的那块Apple Watch还没发货的话,帮我取消吧,不想要了。

这是T10订单变更。Agent先调load_skill加载订单变更手册,再调query_order查最近5笔订单,找到88235(Apple Watch Series 12配一条表带,实付3378.00,已支付待发货),然后调cancel_order,页面弹出确认卡。

接着在卡上点“取消”。前端发给/agent/v1/chat/confirm的请求体只有三个字段:

{"conversationId": "2104420977583996928", "messageId": "2104420992494747648", "approved": false}

模型会根据用户的拒绝结果和已有上下文继续生成回复,具体措辞每次可能不同。这次实测中,回复的开头是:

这次取消没有执行——你在确认卡片上点了「取消」,所以我停下来了,订单88235目前还是已支付待发货状态。

这次回复后面还列了真要取消的话会一起发生的事:两件商品一起取消、库存回补、优惠券退回、实付3378.00原路退款。

这条回复里的信息有两个来处。订单号、商品、金额和状态,来自上一轮query_order的返回;库存回补、券退回、原路退款,来自上一轮加载的手册第3步:“取消前把这单会一起发生的事说清楚:库存回补、用掉的券退回、已支付的原路退款”。

点“取消”时,前端只告诉后端用户拒绝了哪条消息里的操作,没有把订单信息和手册正文再发一遍。用户继续提问时,前端也只发送这次的问题和会话ID,不会把之前的聊天内容一起发过去。

那上一轮调过哪些工具、工具返回了什么、模型当时怎么想的,存在哪里?下一轮又是怎么交回给模型的?

本篇把这份数据叫会话状态:框架为一个会话保存的工作数据,核心是消息列表,用来为后续调用准备上下文。它和上一篇讲的消息记录不是一回事。消息记录按块存在t_agent_message,给页面回放用;会话状态存在另一张表t_agent_state。下面讲后者放在哪、存了什么、什么时候读写,以及两者为什么分开存。

1. 每轮只把这一句交给框架​

先看一轮请求进来,后端交给框架的是什么。下面是AgentChatServiceImpl里发新问题和确认后继续执行的两个方法,只留交给launchStream()的那几行。本篇代码块里的行注释,有的是源码原有的,有的是为讲解补的,后面不再逐段区分:

private void startRun(String question, String userId, String conversationId, String taskId,
SseEmitter emitter, Runnable releaseGate) {
// 省略建会话、存用户消息
// 交给框架的只有这一句问题
launchStream(new UserMessage(question), activeAgent, RunScope.builder()
// 省略 RunScope 字段
.build());
}

private void startConfirmRun(String userId, String conversationId, String messageId, boolean approved,
String taskId, SseEmitter emitter, Runnable releaseGate) {
// 省略卡片校验、取出待确认的调用
// 空正文消息仅携带确认/拒绝结果,框架不会把它并进对话上下文
Msg resumeMsg = UserMessage.builder()
.metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS, confirmResults))
.build();
launchStream(resumeMsg, activeAgent, RunScope.builder()
// 省略 RunScope 字段
.build());
}

发新问题时,交给框架的是一条只装着问题文本的UserMessage。确认那条路给得更少,是一条没有正文的消息,只在metadata里带着确认结果,开篇点“取消”走的就是这一条。两条路里都没有上一轮的任何内容。

所以之前干过什么,只能由框架自己去取。HTTP请求一问一答、互不相干,ReAct循环却要带着前面每一圈的调用和结果往下走,中间缺的就是会话状态。

2. RAG的历史从消息表里拼,为什么够用​

Ragent的RAG模式在处理下一次提问时,主要需要知道用户之前问过什么、系统回答过什么。这些内容已经保存在消息表里,直接取最近几轮就能组织成历史。JdbcConversationMemoryStore.loadHistory()做的就是这件事:读取最近的用户问题和助手回答,跳过空正文,再去掉回答里的引用标记。

Agent还需要保留执行过程。开篇用户拒绝取消订单后,模型要用到上一轮查到的订单、加载的手册,以及这次工具调用被拒绝的结果。只取用户问题和最终回答,无法补齐这些信息。

这些信息也可以补进消息表,但还要考虑:用户要看的历史,和Agent下一轮要用的上下文,后续会发生不同的变化。 对话变长后,Ragent会裁剪上下文中的部分工具结果,或者把早期消息压缩成摘要。用户翻看历史时,仍然应该看到当时保存的对话和工具记录,不能因为模型的上下文被压缩,页面上的历史也跟着变成摘要。

因此,Ragent分别保存消息记录和框架状态。消息记录保留页面展示的内容;框架状态保存当前运行所需的数据,其中的上下文可以独立裁剪和压缩。比如一段早期对话已经被压成摘要,下一轮加载的是这份摘要,用户在历史页面里仍能展开查看原来的消息。

这也与AgentScope的接入方式一致:框架通过AgentStateStore读写整份状态,Ragent实现这个接口,就能沿用框架的状态结构。当前选择分开存,是为了让这两份数据各自维护,职责更清晰。

状态放在哪里:进程内存还是数据库​

框架的ReActAgent不自己决定状态存在哪,它通过AgentStateStore接口存取,接入方给什么实现就存到哪。

1. 只放在进程内存里​

框架自带InMemoryAgentStateStore,状态放在进程内存里。本地跑demo、单机调试,它完全够用。

放到生产环境,有两种情况它保证不了:

  • 进程重启,内存里的状态跟着没了。
  • 多节点部署时,同一个会话的下一轮可能落到另一台机器,那台机器的内存里没有这份状态。

2. 存进t_agent_state​

Ragent实现了PgAgentStateStore,把状态存进PostgreSQL的t_agent_state表。下面是它的存和取:

@Override
public void save(String userId, String sessionId, String key, State value) {
// 整份状态用框架的编码器转成 JSON,按主键覆盖写入一行
agentStateMapper.upsert(userId, sessionId, key, JsonUtils.getJsonCodec().toJson(value));
}

@Override
public <T extends State> Optional<T> get(String userId, String sessionId, String key, Class<T> type) {
String payload = queryPayload(userId, sessionId, key);
// 这个会话还没存过,返回空,由框架决定接下来怎么办
if (StrUtil.isBlank(payload)) {
return Optional.empty();
}
// 用同一个编码器还原成框架要的类型
return Optional.ofNullable(JsonUtils.getJsonCodec().fromJson(payload, type));
}

payload保存的是AgentScope把会话状态转换成的JSON。Ragent的存储层只负责整份保存和读取,JSON与状态对象之间的转换由框架提供的JsonUtils.getJsonCodec()完成。

存进数据库以后,进程重启、下一轮落到另一个节点,读到的都是同一份状态。人设或工具目录变化时,Ragent会重建Agent实例,状态也不受影响,ReActAgentProvider的类注释写着“会话状态在PgAgentStateStore中按次加载,重建不丢历史”。

3. 现有实现与官方PG存储有什么区别​

AgentScope Java 2.0.2的官方扩展agentscope-extensions-postgresql已经提供了PostgresAgentStateStore。和它相比,Ragent现有实现的价值主要在事务接入上。

官方实现自行获取连接、提交和回滚,要参与项目现有的Spring事务,还需要适配。Ragent通过MyBatis存取状态,删除一个会话时,会话记录、消息记录和Agent状态可以一起提交或回滚,避免只删掉其中一部分。批量删除时,同样由外层事务保证整批操作一起提交或回滚。

表结构上,Ragent把user_id、session_id、state_key分列保存,状态放在payload的JSONB列里,按用户和会话定位、用SQL展开消息都比较方便;官方实现把用户和会话拼成一个存储标识,状态JSON放在TEXT列里。

需要自己维护的只是数据库存取这一层,状态对象、编解码和缓存仍然用框架的。没有共享业务事务这类要求的项目,直接用官方实现也可以。

2.0.3又增加了通用的JDBC实现;本篇按项目当前使用的2.0.2解读,暂不升级。

t_agent_state里存了什么​

1. 一个会话一行,payload是整份AgentState​

t_agent_state一共6列:

列存什么
user_id用户id
session_id会话id,就是Ragent的conversationId
state_key框架固定传agent_state
payloadJSONB,整份会话状态
create_time / update_time创建和最后写入时间

主键是前三列,所以一个会话就是一行。开篇那次对话在表里就是session_id = 2104420977583996928的那一行。

写入用的是AgentStateMapper.upsert()里的这条SQL:

INSERT INTO t_agent_state (user_id, session_id, state_key, payload, create_time, update_time)
VALUES (#{userId}, #{sessionId}, #{stateKey}, #{payload}::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
-- 这个会话已经有一行,就把 payload 整个换掉
ON CONFLICT (user_id, session_id, state_key)
DO UPDATE SET payload = EXCLUDED.payload, update_time = CURRENT_TIMESTAMP

这里每次替换的是整份状态,而不是只追加本轮的新消息;上下文中保留的内容越多,每次需要读取、解析和写回的数据也越多。

payload是框架AgentState对象序列化出来的JSON,顶层11个字段。下表列出了各字段的用途,以及这次实测中用户点击“取消”后保存的值。

字段在框架里管什么开篇这个会话
session_id / user_id这份状态属于谁会话id、admin的用户id
context交给模型的消息列表8条消息
summary框架自带的摘要字段空串
reply_id当前版本没有用它区分每次回复第一轮和第二轮的值相同
cur_iter状态对象中的迭代计数字段;2.0.2的执行流程未更新它两轮都是默认值0
shutdown_interrupted上次是不是被进程优雅停机打断false
permission_context框架的权限模式和allow / ask / deny规则默认模式,规则全空
tool_context读文件缓存配置、已激活的工具分组、子Agent登记默认值
tasks_context任务列表空
plan_mode_context计划模式未开启

大头是context,先看它。

2. context里是给模型的消息列表​

开篇这个会话点完“取消”之后,context里有8条消息:

#角色内容块(括号里是块的状态)哪一轮写进来的
1USERtext:“我买的那块Apple Watch……”第一轮
2ASSISTANTthinking、text、tool_use load_skill(allowed)第一轮
3TOOLtool_result load_skill(success),内容是手册正文第一轮
4ASSISTANTthinking、tool_use query_order,入参{"limit":5}(allowed)第一轮
5TOOLtool_result query_order(success),内容是5笔订单第一轮
6ASSISTANTthinking、text、tool_use cancel_order,入参{"orderNo":"88235"}(asking)第一轮
7TOOLtool_result cancel_order(denied)点“取消”那一轮
8ASSISTANTthinking、text点“取消”那一轮

这8条按模型接口的消息格式排:模型想一段、说一句、发起一次调用,合在一条ASSISTANT里;工具返回的结果单独一条TOOL。

拿第5条看一眼,它是query_order的返回。下面是从库里取出的原样JSON,删掉了usage、metadata两个字段,text只留开头:

{
"id": "b16e63c2-911b-4233-95cd-8b423eab5079",
"name": "RagentAI",
"role": "TOOL",
"content": [
{
"id": "call_00_Pn5XSbxzGg78PDQZTSmS2548",
"name": "query_order",
"type": "tool_result",
"state": "success",
"output": [
{
"text": "【最近 5 笔订单】\n\n1. 订单 88240 | 已支付待发货 | ……\n4. 订单 88235 | 已支付待发货 | 实付 3378.00 | 下单 2026-09-13 14:56\n 商品: Apple Watch Series 12 深空灰色 42 毫米 铝金属 GPS ×1、……",
"type": "text"
}
]
}
],
"timestamp": "2026-09-28 12:00:35.842"
}

查询订单时,上下文里会留下两条消息:一条记录模型调用了query_order、传了什么参数,里面的调用块叫tool_use;另一条记录查到了哪些订单,里面的结果块叫tool_result。

两条消息通过调用ID对应起来:发起调用时,tool_use里有一个id,返回结果时,tool_result带上相同的id。上面JSON中content里面以call_开头的id就是调用ID;最外层的id则标识整条消息,作用不同。

取消订单也按这个方式记录。模型提出调用cancel_order时,框架先保存这次调用,状态是asking,表示需要用户确认。用户点击“取消”后,框架不会执行取消订单,而是为这次调用补上一条结果,状态是denied,表示用户拒绝了操作。这条拒绝结果带着同一个调用ID,框架据此知道这次调用已经处理完,不再等待确认或执行它。

因此,库里原来的调用记录仍是asking,后面却已经有了一条denied结果。判断这次操作是否还在等待,不能只看调用记录,还要看它有没有对应的结果。

用户点击“取消”后,后端先加载这个会话已保存的上下文,再补上拒绝结果,让模型继续回复。之前查到的订单、加载的手册都还在上下文里,所以前端只需提交会话ID、消息ID和拒绝决定,不需要再发送订单信息和手册正文。模型便能结合这些记录,说明订单没有被取消,并提到库存、优惠券和退款等信息。

框架默认用Permission denied by user记录用户拒绝。这句话表达了“用户没有授权”,但没有明确告诉模型:用户在确认卡片上主动取消了操作,工具没有执行,接下来应该停止。直接把它交给模型,模型可能把Permission denied理解成权限不足,向用户解释成“没有权限取消订单”,甚至建议解决权限问题后重试。而这里需要告诉用户的是:你选择了不执行,订单保持不变。

因此,Ragent在调用模型前,通过AgentConfirmDenialMiddleware把这条结果的文字替换为:

用户在确认卡片上点了取消,这次操作没有执行。这不是权限不足,也不是系统故障。请如实告诉用户操作已取消;等用户确认后再重新发起。

这段话补全了用户的选择、操作的结果和下一步该怎么做,模型再据此生成回复。替换只发生在发给模型的消息副本里,t_agent_state中仍保留原来的Permission denied by user,调用ID和denied状态也不变。

解锁付费内容,👉戳​