3166 字
16 分钟
AgentScope 上手第二天:工具调用、记忆和压缩,我把源码翻了一遍

上手第二天。还是那套学法:边跑示例边读源码,文档当字典查。

今天啃了三块:工具调用、对话记忆、记忆压缩。


工具调用:给 Agent 装一双手#

第一天那个 Agent 只会聊天。你问它现在东京几点,它会瞎编——因为它没法真的去查系统时间,只能凭训练数据猜。

工具调用就是给它装一双手。你把一个普通的 Java 方法,方法上加个注解,它就变成 Agent 能调用的”工具”:

@Tool(name = "get_current_time", description = "Get the current time in a specific timezone")
public String getCurrentTime(
@ToolParam(name = "timezone", description = "Timezone name, e.g. 'Asia/Tokyo'")
String timezone) {
// ...真的去查这个时区的时间,返回字符串
}

这里最关键的字段是 description。它会原封不动发给模型。模型读了这段描述,才知道”这个工具能干什么、什么时候该用它”。描述写得越清楚,模型调用得越准。参数上的 @ToolParam 同理,是告诉模型”这个位置该填什么”。

然后把带工具的类塞进工具箱,工具箱再交给 Agent:

Toolkit toolkit = new Toolkit();
toolkit.registerTool(new SimpleTools()); // 扫描这个类里所有 @Tool 方法,全注册进来

到这,Agent 就从”只会说话”变成”能做事”了。


ReAct 循环:看清楚它脑子里在转什么#

光会调工具还不够,我想看清楚它内部到底怎么决策的。示例里挂了一个中间件 AgentTraceMiddleware,它会把每一步都打到控制台。我问了句”查询新加坡的当前时间”,日志长这样(我把它翻成人话):

PRE_CALL → 你的话进来了
PRE_REASONING → 第 1 次思考,把消息交给模型(messages=2:系统提示 + 你的问题)
POST_REASONING → 模型说"我不知道几点,我要调 get_current_time 这个工具"
PRE_ACTING → 准备真的去执行这个工具
POST_ACTING → 工具跑完,拿到结果(result_len=53, SUCCESS)
PRE_REASONING → 第 2 次思考(messages=4!多出来的两条是"我要调工具"的记录 + 工具返回的结果)
POST_REASONING → 这次模型有答案了,直接给文本:"新加坡当前时间是..."
POST_CALL → 最终答案返回给你

规律很清晰:REASONING(想)→ 如果想的结果是”调工具”就走 ACTING(做)→ 然后回到 REASONING 再想一轮(因为多了工具结果这条新信息)→ 直到某次模型不再调工具、直接给文本,循环结束。

这个”想→做→再想→…→回答”的往复,就是 ReAct(Reasoning + Acting)名字的由来。如果一个问题要连调两个工具,你会看到这组 REASONING → ACTING 重复两轮。

注意那个从 messages=2messages=4 的变化。工具结果不是凭空消失的,它被 append 进了对话历史,下一轮连同历史一起重新发给模型。这个细节,是理解后面记忆机制的钥匙。


记忆怎么存:换个 sessionId 就是失忆#

单次运行里,Agent 内部有个 list 一直在攒消息,所以多轮对话它记得。但程序一重启,这份记忆就没了。

要让它”重启还能接着聊”,加两样东西:一个 stateStore(存记忆的仓库),一个 sessionId(这份记忆的档案名)。

AgentStateStore stateStore = new JsonFileAgentStateStore(sessionPath);
ReActAgent agent = ReActAgent.builder()
.stateStore(stateStore) // 记忆存到哪
.defaultSessionId(sessionId) // 用哪个档案名
// ...
.build();

配好之后,框架自动干两件事:构建 Agent 时按 sessionId 去仓库里找有没有存过,有就加载;每次 call() 结束,自动把最新历史存回去。 你一行”保存/加载”的代码都不用写。

JsonFileAgentStateStore 就是把对话状态序列化成 JSON 存到磁盘,每个 sessionId 一个独立文件。我实测:第一次跟它说”我叫小明”,退出,用同一个 sessionId 再进去问”我叫什么”,它答得出来;换个 sessionId,它就失忆了。

到这为止都好理解。真正让我卡住的是下一个问题。


卡住我的问题:新对话怎么找到旧消息?#

我盯着这个存储看了半天,冒出一个疑问:

我只看到”对话消息被存起来”,以及”下次把旧消息列表展示出来”。但新的一轮对话,到底是怎么找到之前那些旧消息的?是全部都读回来,还是像 skill 那样——存的时候带一段描述,让大模型自己判断该不该调用某段旧记忆?

我当时是真觉得后者更”高级”、更像那么回事。毕竟 skill 就是”一堆带描述的能力,模型自己挑要不要用”。记忆按理也可以这么搞:给每段旧对话打个摘要标签,模型觉得相关才捞回来。

带着这个猜想去翻源码,结果被打脸了。答案是:没有那回事。这一层就是”全部读、全部塞回去”。

分两块看。

加载阶段——纯 key-value 查找,跟大模型半点关系没有。

JsonFileAgentStateStore.get(userId, sessionId, "agent_state", ...) 干的事就是:按 (userId, sessionId) 拼出一个文件路径,文件在就把整个 JSON 反序列化成 AgentState 对象,不在就返回空。纯 I/O。没有任何”判断哪部分该加载”的逻辑——要么整份加载,要么啥都没有。

使用阶段——每次请求把整个历史列表原样塞给模型。

关键在 ReActAgentreasoning() 方法里,每一轮思考都是这么起手的:

hookDispatcher.firePreReasoning(state.contextMutable(), systemMsg, model.getModelName());
// ^^^^^^^^^^^^^^^^^^^^^^^ 这就是那份完整的历史消息 List<Msg>

state.contextMutable() 就是完整的历史。每一轮 PRE_REASONING,框架都把这整个列表原封不动当对话历史发出去。这也解释了前面那个 messages=2 → 4:工具调完,新消息 append 进这个 list,下一轮连着一起再发一遍。

跟 skill 完全是两回事。skill 是”把一堆描述给模型,模型自己选要不要调”;这里是没得选,历史消息无脑全量重发。


全量重发的代价,逼出了压缩#

全量重发有个显而易见的问题:聊得越久,列表越长,每次请求发的 token 越多,迟早撑爆模型的上下文窗口。

这就是”记忆压缩”(Compaction)存在的理由。我一开始以为压缩会是那种聪明的”选择性检索”,翻完源码发现——它不是。它的思路朴素得多:列表长到一定程度,就把旧的那截摘要压掉,只留最近几条 + 一段摘要文本。 本质还是全量发送,只是发之前先”瘦身”过。

CompactionMiddleware 挂在每次思考之前,够长了就触发。触发之后 ConversationCompactor 干这么几件事:

  1. 找安全切分点——粗略按”保留最近 N 条”算个切点,但绝不能把”模型发起的工具调用”和”对应的工具结果”从中间切开,否则模型会看到一个调了工具却没结果的诡异历史。
  2. 抽长期记忆(可选,默认开)——真丢弃旧消息前,先把里面的关键事实抽出来,写进 memory/2026-07-01.md 这种按日期命名的文件兜底。
  3. 原始消息离线存档(可选,默认开)——把旧消息一字不改写进一个 JSONL 文件,路径记进摘要里。原始记录没消失,只是不再每次都发给模型了。
  4. 一次 LLM 摘要——把旧消息塞进一个结构化 prompt,让模型输出四段式摘要(这次会话想干嘛 / 关键上下文 / 涉及的文件 / 还没做完的事)。

最后 [一大堆旧消息] 变成 [一条摘要] + [最近几条],列表瞬间变短,但语义上”没丢东西”——细节在磁盘文件里,事实在 memory 日志里,脉络在摘要里。

写到这我以为记忆这块讲完了。结果第二天回头一想,觉得不对劲,又把自己绕回去了。


我把结论说早了:其实是两层记忆,不是一层#

我当时是这么想的:既然全量重发是”活跃对话”里的机制,那压缩掉的旧消息虽然写进了 memory/*.md 和离线归档,但这些文件写进去之后就没人管了吧? 顶多算个人工翻查用的日志。

我把这个疑问原样抛出去:AgentScope 的记忆存储,真的完全就是全量加载吗?我新开一个对话,会自己跑去读之前的历史对话吗?我总觉得不太合理——我了解的 GAM 论文,是那种”带目录的书”,先查小索引定位,再去捞具体内容的全部关键内容。是不是我把概念记混了?

结果一查源码,发现是我自己把话说得太满了。agentscope-harness 模块里,明晃晃地放着这几个工具类:MemorySearchToolSessionSearchToolMemoryGetToolMemorySaveToolMemorySearchTool 上的工具描述写得很直白:

@Tool(
name = "memory_search",
readOnly = true,
description =
"Search through long-term memory files (MEMORY.md and memory/*.md) for"
+ " relevant information. Use before answering questions about prior"
+ " work, decisions, dates, people, preferences, or todos.")

翻译一下:“回答关于过往工作/决定/日期/人物/偏好的问题之前,先搜一下。” 这就是我一开始设想的”skill 那种描述,模型自己判断该不该调用”——只不过它不作用在”活跃对话”这一层,而是专门服务于长期记忆这一层。回头再看那次工具调用的 trace 日志,里面有一条 tool_call: name=memory_save——原来那时候它已经在往这一层写东西了,只是我当时没往这上面想。

所以准确的说法应该拆成两层:

第一层:活跃对话的工作上下文(我之前讲的那套)

  • sessionId 当钥匙精确开一个抽屉,不会主动跨会话乱翻——换个 sessionId 就是干净的新对话,这点我之前讲得没错。
  • 同一个 sessionId 内,全量加载、全量重发、超长就压缩摘要——这套也没讲错。

第二层:长期记忆(我漏掉的部分)

  • MEMORY.md:策展过的精炼事实,每次都常驻注入,跟 Claude Code 的 CLAUDE.md是一个思路。
  • memory/*.md 日志 + 会话归档:海量原始细节,平时不进上下文,靠模型自己调 memory_search / session_search 按需捞回来。

两层是接起来的:压缩发生时,“抽长期记忆”那一步会把关键事实写进第二层的 memory/*.md,“离线存档”把原始对话整段写进归档。旧对话从第一层的工作上下文里被压缩掉,细节没有真的丢,是沉到了第二层,需要的时候能靠搜索捞回来。

对照一下会更清楚:

维度AgentScope(HarnessAgent)Claude CodeGAM(我另一个项目参考的论文)
活跃会话工作上下文全量重发,超长压缩摘要当前窗口 + /compact 压缩Working Memory
常驻长期记忆MEMORY.md 每次注入CLAUDE.md 每次注入——
按需检索memory_search 关键词搜Read/Grep 按需读文件向量 + BM25 混合检索,还带反思迭代
检索精细度低,纯关键词匹配中,agentic 读文件高,先查索引定位再捞原文,不够就换角度再查(最多几轮)

原来这三个东西是同一个骨架的三种精细度,不是三套互不相干的设计。AgentScope 的 memory_search 相当于 GAM 的简配版——都是”先有个轻量的东西能定位,再去捞完整内容”,只是 AgentScope 目前只做到关键词匹配,没有向量、没有反思循环。GAM 那套”书+目录”的直觉我并没有记混,只是我一开始只看到了 AgentScope 的第一层,就急着下结论说”这里没有这套东西”。


所以这次学习最后的结论要改一版:AgentScope 的对话记忆不是单一的”全量加载 + 全量重发”,而是活跃对话全量重发(用压缩控制长度)+ 长期记忆按需检索(用关键词工具捞取),两层各司其职。全量重发那层负责”当前在聊的这件事”,按需检索那层才负责”很久以前聊过的事”。

我原来的猜测方向是对的,只是问错了层——不是”活跃对话该不该按需检索”,而是”框架有没有另开一层来做按需检索”。答案是有。这算是我这两天学习里,最值得记一笔的一次纠偏。

AgentScope 上手第二天:工具调用、记忆和压缩,我把源码翻了一遍
https://liuhuanblog.top/posts/ai/agentscope-tool-memory-compaction/
作者
liuhuan
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0