AgentScope 2.0 源码解析系列(Java 版)|第 3 篇:State 模块,Agent 靠什么记住这轮对话

面试现场

前两篇讲了数据怎么流动,这篇落到一个更实在的问题:Agent 拿什么「记住」这轮对话。面 Agent 岗,这几问挺常见:

  1. Agent 怎么管理多轮对话的上下文?
  2. 长对话把上下文窗口撑爆了,框架怎么处理?
  3. 进程重启、或者同一个会话在两台机器上跑,状态怎么不丢、不打架?

第一问很多人会答「用个 list 存历史消息」------对,但没答到点子上:这个 list 到底归谁管、能不能落库、怎么防并发写坏,才是框架要解决的。第二、第三问更考验你有没有真读过工程实现。

这一篇拆 core/state:AgentState 这个「状态袋」里装了什么、v2 为什么把 Memory 降级成兼容镜像、以及状态怎么持久化、怎么加乐观锁。读完这三问你能答得具体,文末有口语话术。

行号对着 agentscope-core/src/main/java/io/agentscope/core/state/(简写 state/)、core/memory/(简写 memory/)和 core/ReActAgent.java,我逐个核过。


一、它到底在解决什么问题

模型本身是无状态的。你这次发给它的每一轮,它并不记得上一轮------所谓「多轮对话」,全靠框架把历史消息重新拼给它。于是问题就来了:这些历史存在哪、以什么结构存、进程挂了能不能恢复、多个请求同时改同一个会话会不会写乱。

core/state 就是管这个的。它的中心是一个叫 AgentState 的对象,你可以把它想成一个「状态袋」:对话缓冲、滚动摘要、当前第几轮、权限/工具/任务几个子上下文,全装在这一个袋子里,随取随存。围绕这个袋子,还配了一整套持久化(存内存还是存文件)、版本与冲突控制、以及一个任务系统。

顺带把上篇预告的坑填了:v1 里那个 Memory 接口,到 v2 被降级成了兼容镜像。为什么、怎么降的,正是这篇的主线之一。


二、先看这个「状态袋」装了什么

一句话:一次回复所需的全部运行时状态,都挂在这一个对象上。你调试 Agent「它到底记不记得上文」,看的就是这个 context。


三、state 包里的东西不少,我分三组

  • 状态本体 :AgentState(423 行,核心)、State(一个空的标记接口)、VersionedState(带版本号的包装)。
  • 子上下文 :ToolContextState、TaskContextState、PlanModeContextState,加上 Task(todo 项的持久化记录)、SessionInfo、AdditionalWorkingDirectory 之类。
  • 存储与并发 :AgentStateStore(接口)、InMemoryAgentStateStore、JsonFileAgentStateStore、ConflictPolicy、ConcurrentSessionModificationException、LegacyStateLoader、ListHashUtil。

一个容易忽略的点:State 接口(state/State.java:30)体内容量就一个空壳,它只起「可持久化」的标记作用------实现了它的对象才允许被 AgentStateStore 存取。注释里推荐用 Java record,但 AgentState 自己是 final class 而非 record,因为它可变。


四、几个我觉得值得说的设计

Memory 被降级成「只写镜像」

开头那个「v2 为什么不用 Memory」的答案,源码里写得很直白。Memory 接口整个打了 @Deprecated(forRemoval = true, since = "2.0.0")(memory/Memory.java),注释说得很清楚:对话上下文现在归 AgentState.getContext() 管,这个接口只是为了兼容 1.0.x 用户代码留着。

具体怎么兼容的?看 StateBackedMemory(memory/StateBackedMemory.java)------它内部就攥着一个 AgentState,所有读写都转发到 state.contextMutable():

java 复制代码
// memory/StateBackedMemory.java(节选)
public void addMessage(Msg message) { state.contextMutable().add(message); }
public List<Msg> getMessages()      { return new ArrayList<>(state.contextMutable()); }

也就是说,老代码 agent.getMemory().getMessages() 照样能跑,但底下读的其实是同一份 AgentState.context。这么做的代价是留了一层适配器、多维护一个接口;好处是老用户升级 v2 不用改代码。我倾向于这是「单一真相源」的取舍------上下文只有一份,放在 AgentState 里,Memory 只是块遮羞布。

读时给拷贝,写时给活引用

AgentState 对 context 这个 list 给了两个口子:getContext()(state/AgentState.java:181)返回的是防御性拷贝,随便你怎么读都动不到本体;contextMutable()(:186)返回活的、可变的引用,专门给要往里写的地方用。同一个 ToolContextState 也是这个套路。

这其实是在「防误改」和「能改」之间分开下注:绝大多数消费方只配拿到拷贝,少数明确的写入点(比如工具执行完把结果塞回上下文)才走 mutable 那个口子。我第一次看到这俩方法并存觉得多余,后来想明白------如果只给 getter,写入方每次都得 new 一个 list 再整体 set 回去,既啰嗦又容易在并发下丢更新。

摘要与「跑满轮次」的收尾

AgentState 里那个 summary 是自由格式字符串(getSummary/setSummary,:171/:175),配合 curIter 计数器和 ReActAgent 的 maxIters(ReActAgent.java:238)用。当推理轮次到达上限,reasoning(:2391)会触发一个收尾流程:发一个 ExceedMaxItersEvent(:3860),再单独调一次模型生成摘要(summaryStream,:3924),把这段总结当作本轮结果返回。

这里要诚实说一句:我在 core 里没找到 Python 版那种「按 token 阈值自动裁剪历史」的压缩器。Java 版的上下文控制,眼下更偏「跑满轮次就摘要收尾」这一条路径,summary 字段注释里也写了「更结构化的压缩留给后续迭代」。所以如果你面试时聊到「上下文压缩」,更准确的说法是:AgentScope Java 目前提供的是轮次上限 + 摘要收尾,而不是逐 token 的自动裁剪------别把 Python 版的机制安到 Java 版头上。

持久化 + 乐观锁,为的是「不打架」

状态袋不能只在内存里活着。AgentStateStore(state/AgentStateStore.java:61)是存储接口,按 (userId, sessionId, key) 三元组存取 State,给了两种实现:InMemoryAgentStateStore(进程内)和 JsonFileAgentStateStore(落 JSON 文件)。恢复会话、跨重启续跑,靠的就是它。

更有工程味的是并发控制。接口里有 supportsVersioning()、getVersioned()、saveIfVersion()(:134)------典型的乐观锁:先带着版本号读,写回时只有版本没变才提交,否则抛 ConcurrentSessionModificationException。ConflictPolicy 决定冲突时怎么办。这套东西,正是第三问「两台机器跑同一会话怎么不打架」的答案骨架:状态外置到 store + 版本比对,而不是把 Agent 状态锁死在某一台机器的内存里。


五、跟着我读源码

  1. state/State.java:先认清那个标记接口,十秒的事。
  2. state/AgentState.java:核心,重点看字段布局、getContext/contextMutable 这对组合。
  3. memory/Memory.java + memory/StateBackedMemory.java:对照着看「被弃用的接口」和「它如何转发到 AgentState」,v2 迁移的活教材。
  4. state/AgentStateStore.java + JsonFileAgentStateStore.java:持久化和乐观锁。
  5. 回到 ReActAgent.java:maxIters(:238)、reasoning(:2391)、summaryStream(:3924),看轮次上限怎么触发摘要。

六、状态在一次会话里怎么流转

用户连着发两条消息,大致这么走:

  1. 第一次 call:AgentState 的 context 追加这条用户消息,curIter 从 0 起,进入 reasoning↔acting 循环,每产出一点内容就把 Msg 写进 contextMutable()。
  2. 本轮结束,curIter 归零、replyId 更新;若配了 AgentStateStore,整个 AgentState 落库。
  3. 第二次 call 进来(哪怕换了台机器):先按 (userId, sessionId) 从 store 把 AgentState 读回来,context 里还躺着上一轮,模型于是「记得」前情。
  4. 若某次推理到了 maxIters 还没收敛:发 ExceedMaxItersEvent,调 summaryStream 生成摘要收尾,避免无限循环。

看懂这条线,就明白为什么上篇说「context 是模型推理的唯一信息来源」------所有状态最终都收敛到这个袋子里。


七、调试时我会看这几个点

  • 「Agent 忘了上一轮」:先确认有没有配 AgentStateStore、sessionId 对不对得上;纯内存 + 进程重启,状态自然没了。
  • 「同一会话行为错乱」:怀疑并发写。看是不是走了 contextMutable() 又在多处改,或版本冲突被 ConflictPolicy 吞了。
  • 「一直不收敛」:多半是 maxIters 设太大或工具反复失败,观察 curIter 和有没有触发 ExceedMaxItersEvent。

八、要扩展,该动哪

  • 想换存储(比如落数据库):实现 AgentStateStore,注意要不要支持版本(supportsVersioning/saveIfVersion)。
  • 想在每轮往里塞点上下文(比如待办提醒):优先用 middleware 往 contextMutable() 写,而不是改 AgentState 结构。
  • 别在 getContext() 返回的拷贝上改完就以为生效了------那是防御性拷贝,改它不影响本体,要写得走 contextMutable()。

九、我会抄的几个做法

  • 把「一次回复所需的全部状态」收进一个可序列化对象,恢复、迁移、调试都只看一处。
  • 读给拷贝、写给活引用,用一对方法而非一个,把「防误改」和「能改」分开。
  • 老接口不硬删,降级成转发到新真相源的适配器,给升级留出缓冲。
  • 状态外置 + 乐观锁,把「多实例同一会话」从难题降成版本比对。

十、这三问,我一般会这么说

下面是口语版,自己顺一遍改成你的说法。

Agent 怎么管多轮上下文。

「框架里有个 AgentState,相当于一个状态袋,对话历史就是它里面的一个 List<Msg>,叫 context。模型本身没记忆,每轮都是框架把这个 list 重新拼给它。除了 context,袋子里还装着滚动摘要、当前第几轮、权限和工具这些子上下文。有意思的是它对 context 给了两个口子:读的时候返回拷贝防止你乱改,真要写的时候走一个 mutable 方法拿活引用。而且 v2 把以前的 Memory 接口整个废弃了,只留个适配器转发到这份 context,本质就是想让上下文只有一个真相源。」

长对话撑爆上下文怎么办。

「得说清楚版本。我在 AgentScope Java 的 core 里没看到 Python 那种按 token 阈值自动裁剪的压缩器。Java 版目前主要是靠一个轮次上限 maxIters:推理到达上限就发一个 ExceedMaxIters 事件,再单独调一次模型生成一段摘要收尾,避免它无限转下去。AgentState 里那个 summary 字段就是放这个摘要的,注释也写了更结构化的压缩留给以后。所以准确讲,它现在是『跑满轮次就摘要收尾』,不是逐 token 自动裁剪。」

进程重启或多实例并发,状态怎么不丢不打架。

「状态是外置的。有个 AgentStateStore 接口,按 userId、sessionId、key 三元组把 AgentState 存起来,内置内存和 JSON 文件两种实现,所以重启后能读回来。并发这块它带了乐观锁:读的时候带版本号,写回时版本没变才提交,否则抛一个 ConcurrentSessionModificationException,再用 ConflictPolicy 决定怎么处理。这样同一个会话就算在两台机器上碰,也是靠版本比对兜,而不是把状态锁死在某台机器内存里。」


附 · State 速查

关键点 事实 位置
状态袋 可变,implements State state/AgentState.java:59
对话缓冲 getContext 拷贝 / contextMutable 活引用 state/AgentState.java:181、:186
摘要/轮次 summary + curIter state/AgentState.java:171、:204
标记接口 空壳,仅表「可持久化」 state/State.java:30
Memory 弃用 @Deprecated(forRemoval),转发到 context memory/Memory.java、memory/StateBackedMemory.java
持久化 按 (user, session, key) 存取 state/AgentStateStore.java:61
乐观锁 saveIfVersion + 冲突异常 state/AgentStateStore.java:134、ConcurrentSessionModificationException
轮次收尾 maxIters → ExceedMaxIters → summaryStream ReActAgent.java:238、:3860、:3924
任务记录 pending→in_progress→completed,带 blocks/blockedBy state/Task.java

相关推荐
沙蒿同学1 小时前
一个人做完一套企业级系统,赚到了第一个 1000 块
vue.js·后端·go
机器之心1 小时前
突发:Claude自主发现未知生物系统,或能编辑基因
前端·人工智能·后端
用户8356290780511 小时前
使用 Python 在 Excel 中添加和管理图片
后端·python
码事漫谈1 小时前
AI巨头集体喊“慢”,但谁都不敢先踩刹车
后端
leavesleo1 小时前
KV Cache 压到 4bit 之后:SGLang 在 Blackwell 上的 1.78 倍上下文和 +78% 解码
面试
用户6802659051191 小时前
企业电脑统一管理怎么做?2026企业终端统一管理方法与工具推荐
javascript·后端·面试
青Cheng序员石头1 小时前
失控之前 | AI 安全到底在保护什么?
后端·安全·aigc
二月龙1 小时前
为什么很多大模型 Demo 看着很强,上线业务就翻车
后端
XuCoder1 小时前
Spring Boot 报端口被占用,netstat 却查不到进程,谁在抢?
后端