面试现场
前两篇讲了数据怎么流动,这篇落到一个更实在的问题:Agent 拿什么「记住」这轮对话。面 Agent 岗,这几问挺常见:
- Agent 怎么管理多轮对话的上下文?
- 长对话把上下文窗口撑爆了,框架怎么处理?
- 进程重启、或者同一个会话在两台机器上跑,状态怎么不丢、不打架?
第一问很多人会答「用个 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 状态锁死在某一台机器的内存里。
五、跟着我读源码
state/State.java:先认清那个标记接口,十秒的事。state/AgentState.java:核心,重点看字段布局、getContext/contextMutable这对组合。memory/Memory.java+memory/StateBackedMemory.java:对照着看「被弃用的接口」和「它如何转发到 AgentState」,v2 迁移的活教材。state/AgentStateStore.java+JsonFileAgentStateStore.java:持久化和乐观锁。- 回到
ReActAgent.java:maxIters(:238)、reasoning(:2391)、summaryStream(:3924),看轮次上限怎么触发摘要。
六、状态在一次会话里怎么流转
用户连着发两条消息,大致这么走:
- 第一次
call:AgentState 的context追加这条用户消息,curIter从 0 起,进入 reasoning↔acting 循环,每产出一点内容就把Msg写进contextMutable()。 - 本轮结束,
curIter归零、replyId更新;若配了AgentStateStore,整个 AgentState 落库。 - 第二次
call进来(哪怕换了台机器):先按(userId, sessionId)从 store 把 AgentState 读回来,context里还躺着上一轮,模型于是「记得」前情。 - 若某次推理到了
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 |