【AgentScope 2.0】9-会话持久化:一场对话怎么拆成 session 和 block 存下来
发布时间:2026/9/14 23:10:37 锦皓数字建站

源码地址后端地址 前端地址第 08 集结尾预告了AgentStateStoreSPI 与DbAgentStateStore。这一篇把它讲完Agent 的会话——每一轮推理的完整上下文——是怎么落到 MySQL 里的。框架负责「什么时候存、存什么结构」平台负责「存到哪、怎么存、压缩时怎么办」。核心就一件事一场对话拆成两半一半增量写一半整体覆盖。为什么需要它Agent 是状态机不是无状态接口一个 Agent 处理多轮对话时每一轮的推理都依赖完整的上下文对话历史用户问过什么、自己答过什么、中间调过哪些工具、HITL 审批状态、PlanMode 计划状态、任务子上下文……这些合起来构成 Agent 的「短期记忆」。不持久化页面一刷新 Agent 就失忆会话没法续聊出问题也没法审计。但「上下文」本身是两类性质完全不同的数据数据例子特征context对话消息用户消息、助手回复、工具调用与结果大、持续增长、增量追加非 context 状态summary / permissionContext / planModeContext / tasksContext / toolContext小、数量固定、整体覆盖如果混在一张表里整存整取每轮都要序列化/反序列化整个会话压缩时还要重写全部——又慢又脆。DbAgentStateStore的答案是一拆两存对话消息走ac_agent_block增量 append其余状态走ac_agent_session.extra_state_json整体覆盖。框架 SPI 与两张表AgentStateStore是框架的 SPIio.agentscope.core.state包DbAgentStateStore第 55 行是平台实现。类头注释把契约说得很清楚第 36 行框架在每次agent.call()完成后自动调save()每次call()开始自动调get()——存储的时机由框架决定平台只实现存取语义。两张表都是 V1 MVP 就有的-- ac_agent_session会话级状态一行一个会话thread_idVARCHAR(64)NOTNULLuser_idVARCHAR(64)NOTNULLagent_codeVARCHAR(64)NOTNULLextra_state_jsonLONGTEXTNULL-- V9 加AgentState 非 context 字段 JSONUNIQUEKEYuk_thread(thread_id)-- ac_agent_block消息级增量一行一条消息thread_idVARCHAR(64)NOTNULLagent_codeVARCHAR(64)NOTNULLseqINTNOTNULLroleVARCHAR(16)NOTNULLcontentTEXTNOTNULLblock_typeVARCHAR(32)NOTNULLarchivedINTNOTNULLDEFAULT0-- V26 加0活跃1已压缩归档UNIQUEKEYuk_thread_seq(thread_id,agent_code,seq)uk_thread_seq是幂等保证——同一个会话同一序号只能有一行。V26 迁移补齐了archived列压缩归档标记并把列名与实体对齐deleted→is_deleted、create_time→gmt_create加了idx_archived索引。注意 V26 的注释「生产 V1 DDL 缺失的列」——表先建、实体后演进靠迁移追平这是从第 02 集就强调的 Flyway 纪律。save压缩检测与双路径写入save()第 75-94 行先做两层过滤只处理agent_state这个 key、只处理AgentState类型其余跳过——框架可能用别的 key 存别的状态平台不掺和。真正的逻辑在doSave()第 101-148 行一段值得逐行读的代码privatevoiddoSave(StringsessionId,AgentStatestate){AcAgentSessionsessfindSession(sessionId);if(sessnull){log.warn(save: session not found, skip threadId{} ...,sessionId);return;// subagent 会话框架没预建 session 行跳过}ListMsgmsgsstate.getContext();StringagentCodesess.getAgentCode();longexistingActiveCountcountActiveBlocks(sessionId,agentCode);// V2.24: 压缩信号 context 条数 DB 活跃 block 数booleancompactedmsgs!nullmsgs.size()existingActiveCount;if(compacted){longseqmaxSeq(sessionId,agentCode);archiveAllActiveBlocks(sessionId,agentCode);// 旧行 archived1for(Msgm:msgs){saveBlock(sessionId,agentCode,seq,m);// 压缩产物全量重写seq 续排}metrics.truncateCounter().increment();metrics.archiveWriteCounter().increment(existingActiveCount);}elseif(msgs!null!msgs.isEmpty()){if(msgs.size()existingActiveCount){// 增量 appendlongseqmaxSeq(sessionId,agentCode);for(inti(int)existingActiveCount;imsgs.size();i){saveBlock(sessionId,agentCode,seq,msgs.get(i));}}}// 非 context 字段 - extra_state_json整体覆盖ExtraStateBlobblobExtraStateBlob.from(state);sess.setExtraStateJson(JsonUtils.getJsonCodec().toJson(blob));sessionService.updateById(sess);}四个设计点一个比一个值得记。压缩检测第 113-117 行是 V2.24 修出来的。compacted msgs.size() existingActiveCount——context 条数比 DB 里活跃 block 数少了说明这一轮发生过压缩。为什么不用更「直观」的检测注释把事故讲得很清楚core 压缩会置AgentState.summary但harness 的 CompactionMiddleware 只做ctx.clear() addAll([__compaction_summary__ 消息 保留尾部])summary 字段恒为——旧检测「要求 summary 非空」对 harness 永不成立压缩后整轮消息被静默丢弃页面刷新就是「对话记录缺失」。按条数缩水判定两套压缩机制core / harness都覆盖。这个「检测信号选错导致功能静默失效」的坑避坑节展开。压缩路径第 119-131 行是归档 重写。旧 block 不删archiveAllActiveBlocks()第 331-338 行一条SET archived1 WHERE archived0幂等归档——历史不丢只是不参与get()还原。压缩产物含__compaction_summary__摘要消息作为 NORMAL block 全量重写get()还原时摘要照常进 contextAgent 下一轮还能看到历史摘要——压缩不是失忆是「把细节压成摘要再继续」。seq 一律续排第 124 行、第 136 行。压缩重写和增量 append 都从maxSeq()取当前最大值再 1。为什么不能从 1 起排maxSeq()的实现注释第 357 行一句话点破uk_thread_seq不含 archived归档行仍然占着 seq。压缩后活跃行是高位 seq比如 6,7归档行是 1…5——重写若从 1 起排直接撞唯一键。这是 V2.7 修过两次的老 bug避坑节讲。非 context 字段走ExtraStateBlob第 144-147 行。summary/replyId/curIter/shutdownInterrupted 四个子上下文permission/tool/tasks/plan序列化成一个 JSON blob整体覆盖写进extra_state_json——量小、固定、不需要增量覆盖写最合适。这 8 个字段正是框架AgentState里除 context 之外的全部状态跨 call 恢复时一个不丢。get从两半拼回完整状态get()第 152-204 行是 save 的逆过程ListAcAgentBlockblocksblockService.lambdaQuery().eq(AcAgentBlock::getThreadId,sessionId).eq(AcAgentBlock::getAgentCode,sess.getAgentCode()).eq(AcAgentBlock::getArchived,0)// 只读活跃行.orderByAsc(AcAgentBlock::getSeq).list();...AgentState.BuilderbAgentState.builder().sessionId(sessionId).userId(userId).context(toMsgList(blocks));if(sess.getExtraStateJson()!null!sess.getExtraStateJson().isBlank()){ExtraStateBlobblob...fromJson...;// 逐字段还原summary / replyId / curIter / shutdownInterrupted / 4 个子上下文}else{b.replyId(UUID.randomUUID().toString().replace(-,));// 兜底老会话无 extra_state}三个降级设计值得记archived0过滤 seq升序压缩后只读活跃行消息顺序天然正确。extra_state_json 解析失败只 warn 不炸第 192-194 行context loaded but sub-contexts lost——对话不丢只是 HITL/计划等子上下文丢失会话还能继续。存储是可用性的底座解析异常不该让整个get()失败。老会话兜底 replyId第 196 行V9 之前的会话行没有 extra_stateget 时补一个随机 replyId避免空指针。getList()第 206-228 行是另一条读取路径——框架用memory_messageskey 取记忆相关消息实现里只挑USER/ASSISTANT角色的活跃 block过滤掉工具消息这个细节第 11 集记忆系统会用到。会话行的管理归属校验与事务doSave的前提是 session 行存在——谁建的AguiChatController.upsertSession()第 417-443 行每次聊天请求进来先确保会话行就位transactionTemplate.executeWithoutResult(tx-{AcAgentSessionsess...byThreadId...;if(sessnull){sessnewAcAgentSession();// 新建threadId agentCode userId titlesessionService.save(sess);}elseif(!empId.equals(sess.getUserId())){thrownewResponseStatusException(HttpStatus.FORBIDDEN,Session owned by another user: threadId);// R4 归属校验}elseif(!sess.getAgentCode().equals(agentCode)){sess.setAgentCode(agentCode);// 换过 Agent 的会话刷新归属sessionService.updateById(sess);}});三个点查写两步包进事务注释 feature-gaps #5中途失败不留脏行threadId 归属校验security R4已存在的会话属于别人直接 403——threadId 由客户端持有不构成秘密403 语义准确防会话劫持会话标题用首条用户消息截断 50 字第 449-454 行空白不设列表接口再从首条 USER block 兜底派生。存储层还有个安全细节listSessionIds()第 255-264 行按userId过滤——租户隔离是存储接口自带的不是调用方自觉。findAskingToolCalls()第 274-310 行是 HITL 的旁路探测第 13 集展开归属不符时静默返回空列表而不是抛异常——「探测是只读旁路不阻断 chat」。避坑压缩后刷新丢失——uk_thread_seq 撞唯一键设计文档给这一集标的 就是这个事故它其实是两个 bug 叠在一起。事故一V2.7重写从 1 起排二次压缩必炸。压缩把 1…5 归档重写压缩产物时如果按自然序从 seq1 排起——归档行还占着 1…5uk_thread_seq直接撞唯一键save()抛异常。修复seq 从maxSeq()含归档行续排。这个 bug 的教训是唯一键包含的列任何「重新编号」的操作都是地雷——归档行不能删历史要留所以序号只能单调递增永不回收。事故二V2.24压缩检测信号选错功能静默失效。harness 压缩中间件只清空 context 再塞回摘要消息AgentState.summary恒为空串——旧检测summary 非空才认作压缩对 harness 永不成立。结果压缩真实发生了但存储层没识别出来把「压缩后剩的几条摘要消息」当成普通增量去 append从existingActiveCount之后续排……再往下就是整轮历史静默丢失页面刷新「对话记录缺失」。修复不看 summary看条数缩水msgs.size() existingActiveCount。这类事故的共性值得记一条方法论检测一个「事件是否发生」要用事件必然留下的可观察痕迹而不是用另一个机制「应该会」设置的字段。harness 压缩不设 summary 是既定行为指望它等于把检测建在别人不承诺的契约上。动手15 分钟看会话持久化第一步建立会话并查库。发一条消息带X-Thread-Id: demo-1然后查两张表curl-s-XPOSThttp://localhost:8080/agui/chat?agentCodedata_analyst\-HAuthorization: Bearer token-HX-Emp-Id: demo\-HX-SSO-Token: token-HX-Thread-Id: demo-1-HContent-Type: application/json\-d{messages:[{role:user,content:你好}]}# 会话行 消息行SELECT thread_id, user_id, agent_code, title FROM ac_agent_session WHEREthread_iddemo-1;SELECT seq, role, block_type, archived, LEFT(content,40)FROM ac_agent_block WHEREthread_iddemo-1ORDER BYseq;你会看到 session 一行title 来自首条消息、blocks 若干行seq 从 1 开始、archived0、含工具调用消息。uk_thread_seq的幂等语义同一 seq 重复插入会失败——试试手动INSERT一条 seq1 的同会话行会撞唯一键。第二步看历史接口。会话列表和消息读取都是只读活跃行curl-shttp://localhost:8080/agui/sessions-HAuthorization: Bearer tokencurl-shttp://localhost:8080/agui/sessions/demo-1/messages-HAuthorization: Bearer token第三步需真 Key 已配压缩观察归档与 seq 续排。把压缩触发阈值调小CompactionConfig的 triggerMessages第 10 集讲连发几条消息触发压缩再查库旧行archived1新行 seq 从高位续排比如 6,7,8——压缩后再压缩一次也不炸这正是 V2.7 bug 修复后的样子。再看指标curl-slocalhost:8080/actuator/prometheus|grepblock.# block_truncate_count_total 压缩事件次数# block_archive_write_count_total 归档行数# block_persist_fail_total 持久化失败撞唯一键会记在这里没真 Key 时前两步已完整验证「一拆两存」和幂等语义。小结记住三件事一拆两存context对话消息走ac_agent_block增量 appenduk_thread_seq幂等非 context 状态summary 4 个子上下文等 8 个字段走ac_agent_session.extra_state_json整体覆盖。框架AgentStateStoreSPI 决定「何时存」平台决定「存哪、怎么存」。seq 一律续排压缩归档 全量重写、增量 append都从maxSeq()含归档行继续排。uk_thread_seq不含 archived归档行仍占位——从 1 重排就是撞唯一键V2.7 bug。压缩检测按条数缩水V2.24harness 压缩不设 summary旧检测永不成立导致压缩后整轮消息静默丢弃。归档是软标记archived1压缩产物含__compaction_summary__重写为 NORMAL——压缩不是失忆是「细节压成摘要再继续」。下一篇进入上下文工程——CompactionConfig的 triggerMessages/keepMessages 怎么决定何时压缩ToolResultEvictionConfig的 80K 阈值怎么把大工具结果卸载出上下文以及__compaction_summary__怎么渲染成 system 消息。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。