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 |
payload | JSONB,整份会话状态 |
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条消息:
| # | 角色 | 内容块(括号里是块的状态) | 哪一轮写进来的 |
|---|---|---|---|
| 1 | USER | text:“我买的那块Apple Watch……” | 第一轮 |
| 2 | ASSISTANT | thinking、text、tool_use load_skill(allowed) | 第一轮 |
| 3 | TOOL | tool_result load_skill(success),内容是手册正文 | 第一轮 |
| 4 | ASSISTANT | thinking、tool_use query_order,入参{"limit":5}(allowed) | 第一轮 |
| 5 | TOOL | tool_result query_order(success),内容是5笔订单 | 第一轮 |
| 6 | ASSISTANT | thinking、text、tool_use cancel_order,入参{"orderNo":"88235"}(asking) | 第一轮 |
| 7 | TOOL | tool_result cancel_order(denied) | 点“取消”那一轮 |
| 8 | ASSISTANT | thinking、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状态也不变。