写在前面
上一篇讲父 Agent 怎么派子 Agent 干活,怎么并行打工,但是任务复杂后,会碰到一个逃不脱的问题
那就是 Agent-loop 如果一直跑下去,messages 会越来越长,最终超出上下文的窗口,Loop被迫终止
messages 不能无限塞,也不能随便删。
随便删,早期的用户目标、工具结果、中间变量都没了;不删,每一轮 API 都要带着越来越长的历史继续跑,token 成本会上去,模型也更容易漏看前面的信息。
所以这篇重点讲 Claude Code 里的 compact 机制,也就是 Agent 的怎么做会话记忆压缩
然后这个因为涉及到的内容很多,复现实验拆成了3个部分,分别对应解决下面几个问题:
- 实验 06: 怎么实现对会话进行压缩?— 先跑通最基础的压缩链路
- 实验 08:压缩完后,怎么让任务继续跑? — 实现 Loop-Continuatio
- 实验 07:多次压缩后,会不会越忘越多? — 实现不重复摘要已经摘要过的摘要(有点绕口,哈哈哈哈)
这三个实验是递进关系, 06 (能compact)→ 08(compact后能继续进入loop) → 07(处理多次compact 的内容)
如果这篇对你有帮助,欢迎一键三连~
阅读指引
Compact 机制是什么,为什么需要它? 👉 一
实验 06:摘要链路怎么跑通? 👉 二
实验 08:压缩后怎么继续执行? 👉 三
实验 07:多次压缩怎么防信息衰减? 👉 四
与 Claude Code 设计的区别? 👉 五
踩坑记录 👉 六

一、为什么 Agent 需要"压缩记忆"?
Agent Loop 的运作方式,前几篇说过:用户给任务,Agent 自己想一下、调工具、看结果、再继续,这样循环。
但这里面就有个隐患,messages一直在积累:每一轮调 API,都要把所有历史消息带上。
第 1 轮带几条,第 5 轮带十几条,第 10 轮带二三十条……任务跑久了,context 就像一条没有出口的流水线,只进不出。
任务跑得越久,历史越长,这会带来2个问题。
第一,成本会上去。每轮都带完整历史,prompt tokens 会越来越多,每轮 API 调用越来越贵;
第二,模型不一定能稳定用好所有上下文。长上下文能装下更多 token,不代表模型会均匀、准确地使用每个 token。之前看到 Chroma 关于 Context Rot 的文章时,也是这个类似的问题
这次复现 compact,算是把它落到了消息列表和日志里。
那对于这种问题,最直接的处理方式是截断:超过长度就删掉前面的消息。
但这对 Agent 太简单粗暴了
前面可能有用户最初的目标、已经算过的变量、工具执行结果、下一步计划,删掉以后,Agent 真的看不到了。
Claude Code 的做法是 Compact(压缩):在上下文快满时,fork 一个 child agent,把前面的历史压成一份结构化摘要,然后用摘要替换原来的历史
主 Agent 拿着摘要继续干,信息被提炼了,但没有真的消失
这就是Compact的核心作用及设计思路
可以把它简单理解成:把之前和Agent的一长串聊天记录,总结聊天重点,重写成一份“当前工作状态”。
这个的设计机制很简单,怎么写摘要就是Compact的fork agent 的prompt怎么设计
这次不展开讲,感兴趣的可以自己看CC的内容,这次把Compact涉及到的实验做设计复现~

二、实验 06:先把摘要链路跑通
实验 06 先验证最小链路:能不能跑通——触发压缩、fork child 生成摘要、主 Agent 拿摘要继续
Agent loop 跑出一些历史 触发 compact fork child 生成 用摘要构建新的消息列表 做一次“截断路径 vs 摘要路径”的回忆验证
2.1 token 窗口不能乱设
最开始我把 context_window 设得很小,想让它快点触发。
结果第 1 轮就 Compact 了,因为工具定义本身就占 token
calculate、get_current_time 这些 function calling 的 JSON schema,每次 API 调用都要带上。工具定义和消息内容无关,但它会算进 prompt tokens。
从日志里看到一个明显差异:
estimate_tokens(messages) ≈ 52API 返回 prompt_tokens = 377
估算的偏差值很大,这意味着不能用估算值estimate_tokens() 来判断要不要触发压缩,更靠谱的是用上一轮 API 返回的 response.usage.prompt_tokens,它才是当前请求实际花掉的 prompt tokens。
估算只能作为兜底,在第一轮没有精确值时用~
2.2 06 跑出来了什么
最新一次 06 日志参数是:
第 1 轮跑了两个工具:计算求和获取时间
这一轮 API 返回的 prompt_tokens=377,超过阈值 150,于是下一轮前判触发 compact
compact child 生成的摘要里,记录了几类信息:
用户原始任务:一共 5 件事 已完成:第 1 步 100+200=300,第 3 步查询时间未完成:第 2、4、5 步 数据记录:当前年份 2026
日志里的 child token 统计:
这说明 compact child 能把历史整理成可读的结构化状态。
2.3 06 的局限
实验 06 做完,可以验证的是:压缩链路能跑通,摘要质量也过关,child 能正确记录 Agent 已完成哪些步骤、结果是什么、下一步要干什么。
但有一个局限:压缩完了,任务就停了。
当时的实现里,Compact 触发完,摘要生成好,Agent 就把摘要输出给用户了,没有继续干后面的活。这是实验 08 要解决的问题
不过,因为其实任务不复杂,我保留了最近 4 条消息,早期信息还没完全离开近场窗口,所以截断路径也能答出来
所以 06 没跑出一个特别漂亮的“截断失忆、摘要记住”的对照。
三、实验 08:从基础 Loop 到能 Compact 的 Loop
还记得前几篇的Agent - Loop 长什么样子吗?
这个结构让 Agent 能跑多轮,但 context 只增不减,任务跑久了,prompt\_tokens 不停涨,最终超出模型上限或者成本失控
所以,实验 08 在 loop 顶部加了一层状态检查:
如果第 1 步发现上下文超过阈值,就触发 compact:
这里的重点是 continue,Compact 不是结束动作,它是 loop 中的一次状态重写。
3.1 状态检查,检查什么?
每轮开始时,先看 context 有没有超过阈值。超了就触发 compact——fork child 生成摘要,替换 messages,然后用 continue 回到循环顶部,对用户透明。
任务不中断,主 Agent 读摘要接着跑。
3.2 脏值问题
实验 08 里最容易出错的地方,是 current_context_tokens。
compact 过程不调用主模型 API,它只是替换了 messages。
所以 compact 结束后,变量里的 current_context_tokens 还是 compact 前那一轮的旧值。比如第 3 轮触发 compact 前,API 返回 prompt_tokens=969。compact 后消息已经变短了,但变量仍然是 969。
如果下一轮顶部继续用这个旧值判断:
969 > 阈值 900它会立刻再次触发 compact。
再 compact 一次,变量还是旧值,还可能继续触发。
所以这里加了一个 transition 状态:
# compact 完成时打标记transition = 'compact'# 下一轮顶部if transition == 'compact':# current_context_tokens 是脏值,只用 estimate_tokens 做判断estimated = estimate_tokens(messages)if estimated >= 阈值:叫停(...) # compact 后还超阈值,摘要没有缩减 context,叫停else:# 正常轮次,用精确值if current_context_tokens >= 阈值:触发 compact
下一轮顶部如果看到 transition == "compact"
就知道 current_context_tokens 是脏值
这时只用 estimate_tokens(messages) 判断 compact 后的新 messages 是否真的还很大,
CC 里对应的是 query.ts 里的 State.transition 状态机,处理逻辑一致,复现让Agent 参照写就行
3.3 叫停机制
Compact 有次数上限(CC是设置了3次,我们实验设 2 次)
如果次数到了,任务还没完成,就停下来,把当前状态和卡点输出给用户,不允许无限压缩下去。
3.2 日志验证
跑了一个 7 步有依赖关系的任务(步骤 1 的结果给步骤 2 用,以此类推)
08 的最新日志参数:
任务是 7 步计算和总结。
第 3 轮 compact 摘要记录了步骤 1-6 的数据:
第 4 轮主 Agent 从摘要里恢复进度,输出了完整总结。
到这里,08 就完成了它的核心作用,Compact后进行完成 loop continuation
四、实验 07:第二次 compact 不能把旧摘要再摘要
08 只触发了一次 compact。
但真实长任务里,可能会触发多次。
这时会出现一个新问题:第二次 compact 的时候,第一次生成的 summary 怎么办?
如果第二次把 summary_1 再总结一遍,就会变成“摘要的摘要”。每压一次,信息可能更糊一点。
实验 07,就是解决这个问题
4.1 CC的解法,加入boundary marker
每次 compact 完成后,在 messages 里插一个标记(role\="system",内容含 "[compact boundary]")
下次 compact 触发时,先从后往前扫 messages,找到最后一个 boundary marker,只摘 marker 之后的新内容,marker 之前的旧 summary 不动。
实现很简单,每次 compact 后,messages 里会插入一个 system 消息:
def get_messages_after_boundary(messages: list) -> list:for i in range(len(messages) - 1, -1, -1):msg = messages[i]if msg.get("role") == "system" and "[compact boundary]" in msg.get("content", ""):return messages[i + 1:] # marker 之后的全部消息return messages # 没有 marker → 第一次 compact,返回全部
下次 compact 时,从后往前扫 messages,找到最后一个 boundary marker,只处理它后面的消息,
第一次 compact 没有 boundary,返回全部 messages
第二次 compact 找到 boundary,只返回 boundary 后面的切片。
4.2 日志验证
07 的任务涉及的更长,一共 14 步,并且加了实验控制:
参数:
第 1 次 compact:
第 1 次 compact 后,messages 保留了三类内容:
这个 original_task_spec 是后面补上的
第 2 次 compact:
第二次 summary 只记录了新增事实:
没有把步骤 1、2、3、5 的旧摘要重新写一遍。
这正是 07 要验证的点:第二次 compact 只摘要增量。
4.3 07 最后跑完了 14 步
compact 触发两次后,后面继续执行:
最终第 9 轮输出完整 14 步总结,中间压缩两次,后面还能继续完成。
五、和 Claude Code 源码怎么对应
实验用的是 Python + OpenAI 兼容接口,Claude Code 源码用的是 Anthropic 接口。消息格式不一样,所以有些小调整,但是大的是一致的。

我的实验里为了简化,compact child 没传 tools,所以它不可能生成 tool calls。
Claude Code 里更完整:它会把工具定义也带上,用来保持 cache prefix 一致,但通过 createCompactCanUseTool 拒绝工具执行。
这也是源码里的工程味比较重的地方:不是只靠 prompt 告诉模型“别调用工具”,代码层也会拦。
七、踩坑记录
坑一:Agent把 boundary marker 理解反了

一开始设计 07 时,Agent 把 boundary marker 当成了“块分隔符”。
脑子里的结构是:
按这个理解,fork child 只看块外的新消息,summary_1 在块内,所以 child 看不到它。
这个理解是错的,boundary marker 实际是位置指针。
它标记一个位置,函数返回这个位置之后的所有消息:
get_messages_after_compact_boundary() 返回的是 index 1 之后的内容。
所以 fork child 能看到旧 summary。
只是 compact 指令会告诉它:旧 只能当状态参考,不能重新摘要。新增数据只写 boundary 后新对话产生的事实。
坑二:Anthropic 和 OpenAI 的 tool call 格式有差别
读 Claude Code 源码时,我一开始很容易拿它和自己的实验代码硬对。
但两边的 tool call 格式不同:

Claude Code 里的 forkSubagent.ts、compact.ts 用的是 Anthropic 消息结构。
我的实验用的是火山方舟的 OpenAI 兼容接口,所以全程用 OpenAI 格式。
两套格式都能表达“模型要调工具、程序回填结果”,但不能混着写。混着写,下一轮 API 很容易 400。
写在最后
这三个实验复现下来,Compact 机制本身不复杂:超阈值 → fork child 压缩 → 替换历史 → 继续跑。
自己手搓实验,就会发现细节不少:compact 触发时机要在轮次顶部;compact 完成后 token 值是脏的,下一轮要用 transition 保护;多次 compact 要防止摘要嵌套摘要
CC 里 transition 状态、boundary marker、叫停机制,都是这个逻辑的体现。
这里面最让我有收获的,是几个代码层保护:
transition防止 compact 后用脏 token 值反复触发 boundary marker 防止旧摘要被重复摘要 [original task spec]保住用户最初给的完整任务 compact 次数上限防止无限压缩
这些点都指向同一个经验:越不能出错的规则,越要靠状态变量和代码结构兜住,不能只靠模型自己记住
可能是 query.ts 的主循环,里面还有工具权限、interrupt、用户中断处理这些东西,核心还是在解决:怎么让 Agent 干活时不跑偏、不失忆、不失控
如果对你有帮助,欢迎一键三连~下篇见
夜雨聆风