摘要:同一次 call 里,压缩会把 9 条工作历史换成 1 条摘要加 3 条尾部,模型还记得“夜莺”,却复述不出原话;记忆则在不碰历史的情况下,把事实写进 daily、合并进 MEMORY.md。我用三个实验拆开这两条流水线:默认值、切点保护、三笔额外 LLM 调用,以及 160K 兜底和缓存这两笔账。
共约 8191 字 · 阅读约 20 分钟
1、开场:没让它调工具,它还是把话写进了磁盘
本以为禁掉工具就等于禁掉写盘。E2 第一步我把指令砍到只剩一句"不要调用任何工具",返回确实是 0 次工具调用;call 结束,memory/2026-10-30.md 里多了一段 ## Memory Flush — 2026-10-30T12:33:04Z,两条事实躺在里面。E1/E2/E3 是本课三个实验;0003/0007/0009 是前几课编号,后面不再解释。
另一头更别扭。9 条变 4 条的那套压缩,挪到真机上跑到第 4 轮:它还记得暗号是"夜莺";我追问第一轮原话,它承认已经不在上下文里了,只剩摘要。
一个没动手却记了,一个记得结论却丢了原话。到底谁在写盘,谁在丢话?我一开始都往 bug 上想,翻完两边的日志,两个结果都指向同一件事,方向却相反。
口径先钉住:AgentScope Java 2.0.3,模型 deepseek-flash。E2 跑在 2026-10-30 晚上,E3 跑在 10-31 早上,写这篇是 11-01。下面所有数字都出自这个组合;换版本、换模型,我不保证。
我原来的两个预期都错了。第一个错在"工具是模型唯一的手脚";第二个错在"压缩是加法"。这两条错得挺典型,值得从头拆。
2、先把预算摆上桌:三道闸,只有两道改历史
三个实验分工先交代:E1 在算法层用脚本摘要模型,E2 用真实 DeepSeek 跑记忆四步,E3 用真实 HarnessAgent 跑压缩。
2.1 一次请求的预算怎么分
0003 那次常规轮,input 6137 token;0009 的默认装配,23 个工具。把这两笔和系统提示、输出预留摆到一起,一次请求的输入大概长这样:系统提示和指令固定,23 个工具 schema 也固定,输出预留 2.0.3 默认 20000,剩下那段工作历史是可伸缩区。
固定不等于免费。23 个工具的 schema 每次模型请求都要发(一次 call 里多轮推理就多份);AGENTS.md 每次 call 开头读一遍,再随系统提示发出去。这两块是你为“能力”付的固定税;它们不随对话变,但实打实占 token。
固定材料不能动,输出预留也不能动——请求还没发,就得先给回复扣出位置,跟 HTTP 响应体的大小预算一个道理。唯一能动的是工作历史。还有一段随 call 变的注入材料:MEMORY.md,上限 8000 token;它既不算固定材料,也不属于工作历史。
所以上下文管理不是"仓库满了再租一间",是"这趟车只能装这么多,先扔哪个箱子"。
图 10-1 把三道闸画在一起:
三道闸都从可伸缩区里取东西,取法完全不同。
2.2 三道闸各挂在哪
看最后一列:只有前两道改历史,第三道连碰都不碰。
表里第三道跨了两个钩子:注入在 onSystemPrompt(call 开头),flush 和维护在 onAgent(call 结束后、后台)。你在一次请求里看到的 MEMORY.md,是上一次 call 结束后留下的版本。
onReasoning 是每一次模型请求前,不是每一次 call 前——一次 call 里工具轮多,第二、第三轮推理之间就可能压一次,你会在一次运行里看到历史突然变短。你要是写过 HandlerInterceptor,这个位置不用解释,就是每个"请求"进 Controller 前都过一遍,只是这里的请求是模型调用。
onAgent在 call 成功结束之后,像 Spring 的afterCompletion:主流程走完了才轮到它,它的开销不在主调用里,是另一笔模型请求。
2.3 为什么把"卸货"单列
装不下有两种形状。一种是整段历史太长,另一种是单条工具结果太大——一条舱单查询返回三十万字符,摘要救不了它:摘要会把它变成一句"查过舱单",细节全丢。
卸货的处理是搬走,不是删掉。单条 tool_result 文本超过 80K 字符,头尾各留 2K 预览,中间换成 large_tool_results/<hash>.txt 的路径;read_file、memory_search 这类工具本身不参与,免得把"查文件"的结果再搬一次,排除名单一共七个。
为什么阈值是字符不是 token:字符在本地就能数,不用分词器;阈值粗一点没关系,因为搬走是保守动作,搬错了还能
read_file回来。
删之前先搬走。后面 E2 第四步模型能靠 read_file 把事实从 daily 捞回来,用的是同一个思路——磁盘上还有原件。本课没构造大工具结果样本,80K/2K 是读字节码与速查表的口径。
3、压缩:把工作历史整体换掉
3.1 触发:50 条,或一条 token 线
2.0.3 的默认值,javap 出来的,不是文档抄的:
两个 0 的意思不一样:
triggerTokens=0是"用动态值",triggerMessages=0才是"关掉条数触发"。我第一遍看混了,以为两个 0 都是关。
两个条件是"先到先触发",不是每轮都压。50 触发、20 保留,默认一次压掉三十条上下;这是给长会话留的余量,不是给短问答准备的。
我们这台机器上有一条具体的链:deepseek-flash 不在 2.0.3 的 ModelContextWindows 表里,getContextWindowSize() 返回 0,动态 token 触发落到 160000 兜底;keepTokens 的动态值也算不出来,退回按条数保留 20 条。
只背"50 条、20 条"不够,得顺着"模型窗口 → 减 reserved → 读不到就兜底 → keep 退化"这条链解释,排障时才知道阈值为什么落在那儿。
3.2 七步:最后两步才是关键
一次压缩不是"加一条摘要",是七个动作:
// ConversationCompactor 的动作序(2.0.3,伪代码)
truncateArgs?; // 可选,默认关:只截旧 ToolUseBlock 的参数字符串
pruneToolResults(); // 默认开:保护最近 40K token,至少 20K 可压,预览留 2K 字符
if (msgs < triggerMessages && tokens < triggerTokens) return; // 先到先触发
int cutoff = findSafeCutoffPoint(msgs, keepMessages, keepTokens);
flushPrefix(msgs.subList(0, cutoff)); // 压前抽事实,best-effort,失败不阻断
String summary = model.summarize(prefix); // 四段:SESSION INTENT / SUMMARY / ARTIFACTS / NEXT STEPS
List<Msg> compacted = summaryMsg(summary) + msgs.subList(cutoff, msgs.size());
state.contextMutable().clear();
state.contextMutable().addAll(compacted);
前五步都在准备,第六步花一笔模型调用,第七步才是切换。第一步和第二步都不改消息条数;真正决定"压多少"的是第三步和第四步。
pruneToolResults 和后面的摘要不是一回事:它只把旧工具结果的正文压成头尾加省略号,消息条数不变,也不调模型;真正让历史变短的是第七步的替换。
注意最后两行不是 append:clear() 之后 addAll,原来的中间轮次从账本里消失了。压前 flush 默认开,会把将被压掉的前缀先抽到 daily;offloadBeforeCompact 默认也开,会把前缀写进 session transcript。一个是记忆,一个是证据——E1 为了隔离变量,两个都关了。
做过 LSM 的同学会眼熟:合并重写之后,旧段不再单独存在了。区别是这里合并的是自然语言,丢掉的措辞没法从新段里反推。
所以"压缩一下、原文还在"这个理解,在 2.0.3 上不成立。原文只可能在 session log 或压之前的账本快照里;工作历史里只剩摘要和尾部。
3.3 切点保护:7 为什么变成 6
E1 的输入 9 条,keepMessages=2,朴素切点是 9 − 2 = 7。索引 7 是一条 TOOL 消息。keepTokens>0 时切点是从尾部往前累加 token 算的;E1 的 keepTokens=0,所以按条数。
照切会留下一个没有来源的 tool_result——就像数据库里留了一条没有主表的外键。第 1 课焊的"ToolUse ↔ ToolResult 必须配对",在这里变成压缩器的硬约束:收集这段 TOOL 的 result id,向前找到含对应 tool_use 的 ASSISTANT 消息,切点移到 6。
结果是尾部 3 条,不是 2 条。日志里
cutoff=6, keeping=3就是这件事。宁可多留一条,也不拆散一对。
这个规则写在 cutoff.reason 里,E1 的 JSON 直接给了原话:切在索引 7 会让 tool_result 失去配对的 tool_use,所以前移到 6。
3.4 摘要的身份
压完的第 0 条不是 system,是一条 role=USER、name=__compaction_summary__ 的消息,正文以 Here is a summary of the conversation to date: 开头,后面四段:SESSION INTENT、SUMMARY、ARTIFACTS、NEXT STEPS。
四段各有分工。按我的理解,INTENT 和 SUMMARY 保结论,ARTIFACTS 保文件,NEXT STEPS 保没做完的动作;压完最怕丢的是最后一段——模型下一轮不知道自己该干什么。
为什么是 USER:它得作为"对话内容"继续参与推理。塞进 system 会抬高优先级、污染规则层。起名是为了下次压缩能认出旧摘要,把它合并进新摘要,不至于层层套娃。
3.5 两种坏天气
摘要调用失败,2.0.3 退化成 (Summary unavailable),带错误信息,仍然照压;它没有"摘要不比原会话小就保留"的 size 检查。模型真溢出,HarnessAgent 捕获 context_length_exceeded,forceCompactAndRetry 在同一次执行里重试一次,压不动就抛给调用方。
这两条我没构造失败样本,是读字节码得出的结论,跟实测分开摆。
3.6 E1:9 条变 4 条的现场
E1 是算法层,不走真实模型:摘要模型是脚本罐头,只回一段固定文本,为的是把变量关掉。配置 triggerMessages=6 / keepMessages=2 / keepTokens=0。
页面点一下,9 条变 4 条。日志一行:
Compaction triggered: total=9 msgs / 305 tokens, cutoff=6, keeping=3 msgs
305 token 是估算,不是账单;9 条货运对话都短,所以这个数小。我第一眼还以为是哪条没算进去。
读法:先看日志,再看 after 数组。日志给切点,after 给顺序——第 0 条摘要,后面依次是 tool_use、tool_result、assistant;正好是被前移留下的那一对加最后一句。被压掉的是前 6 条,包括查船期那次往返。
10-31 我用当前构建复跑了一次,JSON 跟 10-30 那份一样。E2 和 E3 没再跑第三遍。
4、记忆:三笔模型调用,两层文件
4.1 三笔额外调用:记忆两笔,压缩一笔
官方原文就是"three independent LLM calls",不是这节课编的。第三笔是压缩流程的账,列在这里只因为都是额外模型请求;它不写盘,也不该算进记忆的成本。
三笔都是额外模型请求,默认共用主模型;MemoryConfig 和 CompactionConfig 都能单独换便宜模型,但频率是另一回事。
三笔里只有第三笔改当前上下文,前两笔都落磁盘。排障时先分清是哪一笔,再去看对应的文件。
E2 的三次主对话,额外四笔:flush 3、consolidation 1。同一日志窗口里 E1 另记一笔压缩摘要(total=9),那是压缩实验的账,不算进 E2。记忆不是纯文件 IO,它要花钱。
4.2 两层文件与一个水位
2.0.3 本地跑出来的目录大致长这样(archive/ 是 90 天规则,本次没触发):
workspace/
└─ lab-user/ ← 本地模式按 userId 开命名空间(文档画在根目录)
├─ MEMORY.md ← L2:整篇重写、≤4000 token、每 call 注入
└─ memory/
├─ 2026-10-30.md ← L1:只追加;Flush 段 / Memory Save 段
├─ archive/ ← 超过 90 天的日记账
└─ .consolidation_state ← 水位(ISO Instant,30 字节)
L1 便宜,写得全,但越来越长、重复越来越多。L2 要短、要可读、要去重,所以必须有一个昂贵的"整篇重写"步骤。L2 写入上限 4000 token(consolidationMaxTokens);它被注入时另有读取上限 8000 token(maxContextTokens)——两个数不是同一个参数。
水位是两层之间的消费位点:consolidation 只读水位之后被改过的日记账;没有新条目就直接跳过,连模型都不调——日志里那句 No fresh daily entries since 1970-01-01T00:00:00Z — skipping consolidation 就是它。写完 MEMORY.md 才推进水位,写失败水位不动,下一轮重跑同一批——不漏条目,但要多付一笔合并调用。水位存 ISO Instant 而不是行号,因为台账按天分文件;时间戳跨文件可比,行号不行。
这套结构和 WAL 加定期压实很像:L1 只追加,保证不丢;L2 定期重写,保证可读;水位决定下一轮从哪继续。差别是这里的日志是自然语言,压实要靠模型。
daily 超过 90 天移进 archive;sessionRetentionDays=180 这个字段还在,我没验它实际管什么,先不算进结论。
路径这里有个漂移:文档示意图画在工作区根目录,2.0.3 本地模式实际在 workspace/<userId>/,实验里就是 lab-user/。按文档去根目录找 MEMORY.md,会找不到。
4.3 读路径:注入是垫底,检索才是主力
每次 call 开头,WorkspaceContextMiddleware 读一遍 MEMORY.md,按 maxContextTokens(默认 8000)截断,作为 HARNESS_CONTEXT 的 USER 侧参考材料注入。system 侧只有 Memory Recall / Persistence 的使用指导。
我这边看到的是"明明注入了,它还是先搜一遍"。Recall 指导让它回答过去工作的问题前先 memory_search,注入只是垫底。
截断不是摘要,后面的条目这一轮就是看不见。注入只覆盖 L2,且可能落后一次合并;要问"今天刚发生的事",只剩检索这条路。
E2 第三步新会话问同一事实,两次窗口分别 3 次和 0 次搜索,Source 都指向 MEMORY.md;完整读数放 §4.5。
Source 带行号,说明它是被读进来的材料,不是指令。指令不会给你行号。
4.4 写路径:自动 flush 与主动 save
自动 flush 是中间件:每次成功 call 结束,后台把这段对话交给模型,抽出值得记的事实追加到当日台账;没东西可记就回 NO_REPLY。它不碰 MEMORY.md。NO_REPLY 是个约定:没东西可记就不写,不然每天的台账会被"无事发生"撑满。
主动 save 是模型的手脚:调用 memory_save,同时写 MEMORY.md(追加)和当日台账。disableMemoryTools 关掉的是这只手,不影响 flush。
E2 第一步就是证据:0 次工具调用,daily 照样多出
## Memory Flush。两条路径,一条都不需要模型"决定"。
一次普通问答 = 主调用 + 一笔 flush;如果赶上首次后台维护,再加一笔合并。这笔账 §7.3 再算。
4.5 E2 四步:不让调工具也能写盘
E2 跑了两次:18:26 与 20:32。下面以 20:32 为主,18:26 只作对照;两个窗口的读数不同,不能混成同一次。
第一步,输入那句"不要调用任何工具,只用一句话回答并确认:我们公司出口固定走上海港,客户代号 ALPHA"。0 次工具调用,daily 140 B,出现 ## Memory Flush — 2026-10-30T12:33:04Z,MEMORY.md 不存在。
日志同时有一行 No fresh daily entries since 1970-01-01T00:00:00Z — skipping consolidation:首次后台维护抢在 flush 落盘之前跑完,读到空台账就跳过了。这不算失败;两层之间还欠一次合并。flush 和首次维护都是 call 结束后排队的后台任务,谁先跑没有保证;这也解释了 ① 之后 MEMORY.md 为什么还没生成。反向样本(flush 先落盘、维护顺手合并出一份)我只在逻辑上推过,没抓到。
18:26 那次它的回答是"尚未写入长期记忆";20:32 那次改口成"对话结束时记忆会自动提取保存"。两次磁盘上都多了一段 Flush。模型说的不算,中间件写的算。
页面能看到这些,是因为 /chat 在 call 返回后等了 MemoryBackgroundTasks.awaitQuiescence(60s);不等的话,你可能读到半成品。
第二步,手动合并。MEMORY.md 从无到 158 B,整篇重写;daily 140 B 原样;水位 30 B;耗时 1148 ms。正文是 ## 业务与物流 加两条事实:出口港口、客户代号。合并读的是旧 MEMORY.md 加水位之后的 daily,输出整篇重写;它不是把 daily 的内容 cat 到文件末尾。
注意 562 B 那个数是 18:26 窗口的,不是这一次。两个窗口的合并产物不同,不能混成同一次实验。
第三步,新会话追问。3 次搜索、Source 指向 MEMORY.md,答对。18:26 那次 0 次工具也答对。同一条事实,两条读路径都走得通。
第四步,删掉 MEMORY.md 再问。9331 ms、10 次工具:memory_search 两次,read_file 两次,list_files 三次,session_search 两次,最后 memory_save。MEMORY.md 被重建到 127 B,daily 涨到 317 B,新增一段 ## Memory Save,水位不变。它能重建,是因为 daily 里有 Flush 段;L1 只追加的价值就在这儿——L2 可以丢,L1 不能。
18:26 那次是 5 次工具、4909 ms,Source 指向 memory/2026-10-30.md#3-4,没有重建 L2。同一张卡跑两次,次数不一样。
判读看证据链:Source 指向哪个文件、用了哪几个工具、磁盘终态是什么。次数不是结论。
4.6 记忆不是账本
删掉 L2 不是永久失忆:L1 还在,检索能找回,模型还可能顺手把 L2 写回去。删只是删了一个视图。
另一头,flush 会脑补。18:26 那次它写出这样一句:
2026-02-13 之前这些信息仅在对话中确认,尚未写入长期记忆……
这个日期在对话里从未出现。consolidation 把它原样并进了 562 B 版的 MEMORY.md,还多了一节"备注(记录来源)"。
这不算 bug。记忆本来就是模型对对话的抽取,不是账本的复制。它适合提效,不适合当审计事实源。要审计,回 AgentState、session 日志、原始单证。
顺便把另一个错位点掉:把"长期记忆"当成"把聊天记录存起来",存聊天记录的是 AgentState 账本;但账本也不是一整份原文,压缩会把它整体换掉,完整原文在 session log。MEMORY.md 是模型抽出来的几条事实,写入者都不是同一个。
5、真机压缩:trigger 写 5,第 3 轮就压了
5.1 配置:5 条 / 留 2 条
先回到压缩线。E3 跑的是真 HarnessAgent,不是算法层:
labAgent = HarnessAgent.builder()
.name("compaction-lab")
.model(agentConfig.resolveModel())
.workspace(WORKSPACE)
.stateStore(new JsonFileAgentStateStore(STATE))
.compaction(CompactionConfig.builder()
.triggerMessages(5)
.triggerTokens(0) // 0 = 动态;这里靠条数
.keepTokens(0) // 0 = 按条数保留
.keepMessages(2)
.flushBeforeCompact(false)
.offloadBeforeCompact(false)
.build())
.disableMemoryHooks() // 隔离变量:只看压缩
.disableMemoryTools()
.sysPrompt("你是压缩实验的对话助手。回答保持简洁,不要调用任何工具。")
.build();
关键不是这几行,是三个隔离:关 memory hooks、关 memory tools、flushBeforeCompact=false。不关的话压之前还会多一笔 flush,变量就混了。E2 的 chat 端点反着来,.disableCompaction(),把压缩关掉,只看记忆;两个实验各关一头,才能把两条流水线分开看。
脚本 4 轮必测:记暗号"夜莺"只回复收到;问暗号是什么;把暗号倒过来念;问第一轮原话还在不在。第 5 轮补一次,再答一次暗号。
5.2 时间线:我押第 4 轮,实测第 3 轮
我押第 4 轮。理由很蠢:trigger=5,我数的是"第 5 条消息差不多第 5 轮",又想着 keep=2 要留两条,就押了第 4 轮。
实测第 3 轮。原因是本轮的用户消息在 onReasoning 之前就已经入账:
第 2 轮结束账本 4 条,第 3 轮 user 进来变 5 条,正好命中。压缩瞬间 context 是摘要加 2 条尾部,3 条;模型回复再加 1 条,接口看到 4 条。那 3 条是摘要、A2、U3;U3 是本轮刚进来的 user,不是上一轮的回复。
阈值数的是消息条数,不是轮数。普通问答一轮两条,5 条自然落在第 3 轮;如果一轮带三个工具往返,条数涨得快,按挂载点推,同一轮里压两次也有可能,这个样本我没造。
第 3、4、5 轮各压一次。三次日志都是 total=5 msgs / cutoff=3 / keeping=2,Compacted to 3 messages before reasoning。
页面上"调用前 4、调用后 4",我盯着看了半分钟,以为没压。4 是调用前账本;压发生在 user 入账之后、模型请求之前,两次读数之间内容已经换过一轮,所以条数对上了。
5.3 记忆保真:记得夜莺,丢了原话
第 4 轮问"第一轮定的暗号是什么?第一轮原话还在吗?"它答:
第一轮定的暗号是夜莺。但第一轮的原话已经不在上下文里了:我现在能看到的只是这份会话摘要……所以结论可靠,措辞无法还原。
第 3 轮它把“夜莺”倒过来念成“莺夜”;第 5 轮再问暗号,它仍答“夜莺”,还补了一句按第一轮的规矩倒过来是“莺夜”。结论活得好好的。从回答看,摘要保住了“夜莺”和“倒过来念”这条规矩,没保住第一轮的原句;短结论能活,措辞活不下来。
摘要长度 698 → 874 → 168 字符,第三次反而短了。我猜是压完工作集变小,摘要跟着收;这条没单独验。终态 4 条、624 token 估算,624 也是估算,真值看那一轮的 usage,我没截。
压的是工作历史,不是完整历史。session log 里第一轮的原话还在,只是模型这一轮看不见了。
5.4 节奏:为什么第 3 轮之后每轮都压
压完 keep+1=3 条,回复 +1=4,下一轮 user +1=5,又命中。所以 trigger=5 / keep=2 的稳态是从第 3 轮起每轮都压,不是每 5 轮一次。
普通问答的间隔:ceil((trigger − keep − 1) / 2)。想要 5 轮一次,得 trigger=13 / keep=2,第一次在第 7 轮命中——这条是算的,我没跑。拿 trigger=6 / keep=2 代进去,间隔 2 轮;trigger=5 / keep=2 代进去是 1 轮,也就是每轮。
带工具的一轮是 4–6 条消息,节奏完全不同。生产上按 token 预算设,别按"轮"设。
6、能看见,才敢动手:仪表与手动压缩
6.1 仪表:估算加上一轮真值
主面板顶部加了一条只读仪表,端点完整路径是 GET /workbench/sessions/{threadId}/context:
@GetMapping("/{threadId}/context")
public ResponseEntity<ContextMeter> context(@PathVariable String threadId) {
// 空会话分支与遍历略;关键两行:
int tokens = TokenCounterUtil.calculateToken(context); // 与 Harness 压缩同一个估算器
return ResponseEntity.ok(new ContextMeter(threadId, context.size(), visible,
tokens, hasSummary, "none", "主链路 ReActAgent 未启用压缩"));
}
前端接 CUSTOM token_usage,显示"上次请求 input / cached / 本轮累计"。
实测读数:10 条、458 token 估算、上次 input 1.2K、cached 1.0K、累计 2.3K。发这一轮之前是 6 条、253 token。
458 是估算,1.2K 才是上一轮的真值。估算器是启发式的字符加消息开销,不是 provider 的分词器;我自己排障时先看这个数,账单看 usage。主链路要兼容多个模型,估算器只能统一;误差靠"上一轮真值"兜底,所以两个数一起显示。
估算值的用处是趋势:同一会话里从 253 涨到 458,说明这一轮多出来的主要是工具往返;绝对值不准,但涨跌能看。
主链路是 ReActAgent,没开压缩,所以仪表不显示阈值;真正的阈值读数在 E3 卡片里。仪表只读,不会触发任何模型调用。
cached 1.0K 不是"上下文被压了",这笔账 §8.4 算。
6.2 手动压缩的 Rails,与 2.0.3 的现实
手动压缩是有损操作,按钮不是 UI 问题,是状态机问题。我把要加的东西归成三道:
- 状态安全:只在空闲时压,且没有未决 interrupt。压缩改的是同一个 context 列表,推理正在读它,并发改会写出半截;之前那个没回的中断,恢复时要接着原来的上下文,先把历史压了,恢复点就对不上了。
- 可撤销:确认框加前置快照。有损操作得让人知道原文会不在工作历史里,压完出问题能回去。
- 可追溯:审计日志。谁、什么时候、切点在哪、摘要多长,出事故要能查。
三道里最容易被跳过的是第二道。快照看起来浪费,等真压丢一条关键结论,你就知道那一份 JSON 值钱。
2.0.3 没有公开的 compactNow。HarnessAgent 只暴露 getCompactionHook;CompactionMiddleware 只有 onReasoning 和构造器。手动压缩和自动压缩用的是同一个 compactor,区别是谁触发、什么时候触发、有没有 Rails。
要用,只能应用层自己拼 ConversationCompactor 加 AgentState,或者等新版本。最小实现见 HarnessMemoryLabController.compactionDemo()(E1 端点)。主链路是 ReActAgent,暂时只能在 lab 里做——这也是课程实验和生产按钮的边界。
想自己跑:
- 页面:
http://127.0.0.1:8080/lab.html#exp-0010-e1;实验跑在我本地 workbench(JDK21),工程不随文发布。E1 不需要 key,E2/E3 需要 DeepSeek key。 - E1:点"执行一次压缩",预期 9 → 4、切点 6。
- E2:先点重置,再按 ①;预期 0 次工具、daily 多出
## Memory Flush;MEMORY.md 可能不存在,只要 0 工具 + daily 有 Flush 就算通过。 - E3:先点重置或刷新换新会话;4 轮必测,第 5 轮补测;预期第 3 轮出现
__compaction_summary__,第 4 轮答"夜莺"并承认原话不在。 - 失败分支:服务没起先看 8080 和日志;401 看 key;daily 没 Flush 先看
NO_REPLY;E3 答偏也是结果,先看摘要里有没有夜莺。 - 单次读数:上面的字节/字符/毫秒是单次运行,模型换一次可能不同;结构判据不变。PowerShell 的
curl是别名,用curl.exe或Invoke-RestMethod。
7、三个容易搞错的点:契约、注入与节流
7.1 中间件改不改历史:契约 vs 能力
第 7、9 课我写过三句不准确的话,这里逐条修正。0007 说"中间件不能改写历史"。那句话是契约:钩子给你输入和事件流,没有"把历史改成这样"的入参。
但中间件拿得到 Agent 和 RuntimeContext,能力上是能改的:
AgentState state = runtimeContext.resolveAgentState(ctx);
List<Msg> history = state.contextMutable();
history.clear();
history.addAll(compacted);
eviction 和 compaction 就是这么干的。契约不给你 API,不等于能力不存在。0007 的说法也不算错:它描述的是给普通中间件的契约;框架自己的中间件是"内部人",走的是另一条门。
排障含义:我自己写中间件时踩过的坑是乱动账本;框架自己动的时候,我得知道它动了。看到历史突然变短,先想压缩,别先怀疑模型。
7.2 MEMORY.md 注入哪:参考材料不是 system
0009 写"记忆每轮注入 system",写粗了。官方原文是:
… loaded once per Agent call and supplied as HARNESS_CONTEXT reference material, not System.
实际是 HARNESS_CONTEXT 的 USER 侧参考材料,system 里只有 Recall / Persistence 的使用指导(AgentScope memory 文档)。
注入了还先搜;可变事实不能升格成指令。规则和材料分开:AGENTS.md 是规则,MEMORY.md 是材料。
还有一个工程推断:MEMORY.md 内容每次合并都变,那一段前缀就要重建;放 USER 侧也一样,不是放进 system 才有这个问题。
7.3 flush 是不是每轮:ALWAYS 的代价
0009 说"压缩、记忆提炼、后台维护都被节流闸门管着"。三件事的节奏不一样:
- flush:默认
ALWAYS,每次成功 call 都跑。 - consolidation:首次立即,之后 30 分钟,有新台账才跑。
- compaction:过阈值才跑。
所以"都被节流管着"这句话,只对 consolidation 成立;flush 是每 call 一笔,压缩是过阈值才来。
18:26 那次的日志:callMs=2627、flushWaitMs=1962。回答本身 2.6 秒,flush 又等了 2.0 秒——这是端点等了后台静默,生产里你不一定等,但钱照花。
省法三条:flushTrigger(throttled(10min)) 限频,never() 只留压前那笔,disableMemoryHooks() 全停(连维护一起)。只关"每次 call 都跑"的那笔,改 flushTrigger,别动 disableMemoryHooks。同理,disableCompaction 关掉的不只是阈值压缩,还有溢出兜底那一次重试。
8、别家也有,账也要算对:整机、零件与两笔账
先看别家怎么做,再算两笔最容易读错的账:DeepSeek 的 160K,和缓存。
8.1 产品层:压缩加文件记忆已成套
先纠一个容易写错的判断:这套组合不是 AgentScope 独有。
Claude Code 的 auto memory 是默认开的,目录里一个 MEMORY.md 索引加每个主题一个文件;压缩之后 CLAUDE.md 和 auto memory 都从磁盘重新注入(memory 文档)。PreCompact 钩子还能拿到 trigger 和 custom_instructions:手动 /compact 时可以带指令,自动压缩时是 null。
OpenAI Agents SDK 把压缩做成会话层动作:OpenAIResponsesCompactionSession 可以每轮压,压缩会清空并重写会话历史(sessions 文档)。这页只讲会话历史;Sessions 本身不管跨会话事实抽取,别据此推断 OpenAI 没有别的长期记忆产品。SessionSettings(limit=N) 还能只取最近 N 条,相当于会话层的滑窗。
这几家我没跑,只读官方文档;AgentScope 的数字才是本机实测。
三家有跨会话存法(Claude Code / LangGraph Store / Deep Agents);OpenAI 这条只到会话层。四家都把压缩和记忆分开处理,差别在注入位置和检索工具:Claude Code 压缩后重注入,Deep Agents 可进 system prompt,AgentScope 放 USER 侧参考材料。写成"独家能力"会翻车。
8.2 Java 库层:给的是零件
Spring AI 的 MessageWindowChatMemory 默认窗口 20 条,超了淘汰旧的,但永远保留 SystemMessage;它按条计数、按轮对齐,不会切半轮,但没有摘要。它自己的文档还补了一句:ChatMemory 不是存完整聊天历史的最佳选择(chat-memory 文档)。这两点决定了它只能当 L1——它不沉淀事实。
LangChain4j 只有 MessageWindowChatMemory 和 TokenWindowChatMemory 两种滑窗,官方自陈"currently offers only 'memory', not 'history'",要完整历史自己存(chat-memory 文档)。这条流水线里它只给了两个滑窗实现;要摘要、要沉淀、要检索,都得自己接。
两边都没有"摘要写回 + 事实沉淀 + 检索"这条流水线。差别在默认装多厚、落在 Java/Spring 哪一层。
8.3 DeepSeek 的 160K:框架兜底,不是厂商限制
§3.1 那条链(裸名→窗口 0→160K)在厂商口径里还有一层:官方把 deepseek-flash 标到 1M 上下文、384K 最大输出(定价页)。2.0.3 登记的却是两个已经标成 legacy 的 deepseek-v4-flash / v4-pro,新裸名反而没进表——表既不全,也过期。所以这是框架保守,不是厂商限制;我排障时先问"框架登记了这个模型名吗",不急着说"DeepSeek 只有 160K"。
文档也跑在制品前面:2.0.3 输出预留固定 20000,当前文档改成窗口的 1/8、封顶 4096;未知窗口兜底,2.0.3 是 160000,文档写 64000;摘要验收门,2.0.3 没有,文档说"完整且确实更小才替换"。排障以 2.0.3 为准,升级复验另开一课。
按普通问答的体量推,先撞上的是 50 条这条线——每条几十到几百 token,攒到 160K 要很久;真正让 token 线提前的是大工具结果。E3 用 5 条就能压,也是条数先到。
8.4 缓存不是压缩:两条正交的账
DeepSeek 的上下文缓存是前缀级、按完整前缀单元命中、best-effort,命中价约为未命中的 1/50;prompt_tokens 等于命中加未命中。
它复用计算,不减少你发出去的 token 条数。压缩减少的是 token 条数,还改变内容。两条账正交。
仪表上 cached 1.0K 说明上一轮前缀基本命中,省的是单价;它不代表上下文被压过。反过来,压缩之后前缀变了,缓存那一段大概率要重建——省了 token 条数,可能多付单价。这笔取舍要按会话长度算,我只在文档层面推过,没有实测账单。
9、货代落点:三类信息,三种归宿
9.1 三类信息
只有一条判据:这条信息在下一个会话里还成立吗?
9.2 判据:下一个会话还成立吗
我实际分的时候,先看影响期。E2 那两条事实(只走上海港、客户代号 ALPHA)是跨会话口径,落在第一档;"某航次跳港"这种,如果只影响今天装船就进 daily,如果这条航线接下来两周都绕行,它就已经跨到长期规则。判据看影响期,不是信息本身。
"这票货的临时备注"是最容易进错档的一类:它看起来像客户偏好,但只对当前这票货成立,下一票就不成立了。它留在对话里,让压缩带走,别写进 MEMORY.md。
别把所有聊天都当记忆。那只是把上下文爆炸换成了记忆库爆炸。daily 需要合并,也是同一个道理:今天的运营事实过几天就不成立,留在 L1 让它自然衰减,不用人去删。
9.3 审计边界:记忆不是事实源
记忆是模型抽取,会省略、改写、脑补,18:26 那个假日期就是样本。
订舱号、箱号、金额这些,以原始单据为准;要对账,回 AgentState、session 日志、原始单据。记忆里的版本只能当线索。
这条我不是从文档抄的,是自己跑出来的。
10、收口:带走三句,临走三问
回到开头那两个结果:写盘的是 flush;丢原话的是压缩。两个问题各归各的机制。
- 压缩改这一次的工作历史:请求前把
AgentState.context换成摘要加尾部,有损,原文不在里面(E3 第 4 轮:原话已不在)。 - 记忆搬跨会话的事实:call 结束写 daily,合并进 MEMORY.md,不改历史,下次注入或被检索(E2 ①:0 工具调用仍写盘)。
- 先算这笔钱,再决定开不开:flush 默认每次成功 call 都跑,consolidation 首次立即、之后 30 分钟一趟,compaction summary 过阈值才来。
临走三问:
- 你的场景里,哪条信息该进 MEMORY.md,哪条只该进 daily,剩下哪些应该什么都不进?
- 你的模型在 2.0.3 的窗口表里被读成多少,压缩阈值会落到哪一条线上?
- 如果明天就加"手动压缩"按钮,三道 Rails 你先加哪一道,为什么?
三道都答对了,这一课才算真的带走。
第 10 课结课,学习进度 9/57(0006 暂挂)。
下一篇 0011 Workspace:这些记忆文件住的工作区到底长什么样,AGENTS.md 与它什么关系。
如果这篇文章对你有帮助,欢迎转发给需要的同事和朋友。