乐于分享
好东西不私藏

ClaudeCode源码学习笔记(三):Agent Loop 干着干着忘了怎么办?Compact 机制拆解与复现

ClaudeCode源码学习笔记(三):Agent Loop 干着干着忘了怎么办?Compact 机制拆解与复现

写在前面

上一篇讲父 Agent 怎么派子 Agent 干活,怎么并行打工,但是任务复杂后,会碰到一个逃不脱的问题

那就是 Agent-loop 如果一直跑下去,messages 会越来越长,最终超出上下文的窗口,Loop被迫终止

messages 不能无限塞,也不能随便删。

随便删,早期的用户目标、工具结果、中间变量都没了;不删,每一轮 API 都要带着越来越长的历史继续跑,token 成本会上去,模型也更容易漏看前面的信息。

所以这篇重点讲 Claude Code 里的 compact 机制,也就是 Agent 的怎么做会话记忆压缩

然后这个因为涉及到的内容很多,复现实验拆成了3个部分,分别对应解决下面几个问题:

  1. 实验 06:  怎么实现对会话进行压缩?— 先跑通最基础的压缩链路
  2. 实验 08:压缩完后,怎么让任务继续跑? — 实现 Loop-Continuatio
  3. 实验 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 拿摘要继续

  1. Agent loop 跑出一些历史
  2. 触发 compact
  3. fork child 生成 
  4. 用摘要构建新的消息列表
  5. 做一次“截断路径 vs 摘要路径”的回忆验证

2.1 token 窗口不能乱设

最开始我把 context_window 设得很小,想让它快点触发。

结果第 1 轮就 Compact 了,因为工具定义本身就占 token

calculateget_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 日志参数是:

markdown
context_window=200threshold=0.75compact_trigger_tokens=150keep_recent=4

第 1 轮跑了两个工具:计算求和获取时间

这一轮 API 返回的 prompt_tokens=377,超过阈值 150,于是下一轮前判触发 compact

compact child 生成的摘要里,记录了几类信息:

  1. 用户原始任务:一共 5 件事
  2. 已完成:第 1 步 100+200=300,第 3 步查询时间
  3. 未完成:第 2、4、5 步
  4. 数据记录:当前年份 2026

日志里的 child token 统计:

markdown
child prompt=491child completion=472child total=963

这说明 compact child 能把历史整理成可读的结构化状态。

2.3 06 的局限

实验 06 做完,可以验证的是:压缩链路能跑通,摘要质量也过关,child 能正确记录 Agent 已完成哪些步骤、结果是什么、下一步要干什么。

但有一个局限:压缩完了,任务就停了

当时的实现里,Compact 触发完,摘要生成好,Agent 就把摘要输出给用户了,没有继续干后面的活。这是实验 08 要解决的问题

不过,因为其实任务不复杂,我保留了最近 4 条消息,早期信息还没完全离开近场窗口,所以截断路径也能答出来

所以 06 没跑出一个特别漂亮的“截断失忆、摘要记住”的对照。


三、实验 08:从基础 Loop 到能 Compact 的 Loop

还记得前几篇的Agent - Loop 长什么样子吗?

markdown
while 没有最终答案:    调 API    如果没有 tool_calls -> 输出答案,结束    如果有 tool_calls  -> 执行工具,追加结果,继续

这个结构让 Agent 能跑多轮,但 context 只增不减,任务跑久了,prompt\_tokens 不停涨,最终超出模型上限或者成本失控

所以,实验 08 在 loop 顶部加了一层状态检查:

markdown
while 轮次未超限:    1. 查状态(新加的),检查是否需要 compact    2. 调 API    3. 处理响应,tool_calls / final answer

如果第 1 步发现上下文超过阈值,就触发 compact:

markdown
触发 compact  ↓fork child 生成 summary  ↓messages = [boundary, summary]  // boudary后面会说  ↓transition = "compact"  ↓continue 回 loop 顶部

这里的重点是 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 的最新日志参数:

markdown
context_window=1200threshold=0.75compact_trigger_tokens=900

任务是 7 步计算和总结。

markdown
第 1 轮 prompt_tokens=400第 2 轮 prompt_tokens=717第 3 轮 prompt_tokens=969 -> 超过阈值 900,触发 compactcompact 后消息数: 2compact 后估算 token: 159第 4 轮 transition=compact第 4 轮 prompt_tokens=712最终输出完整 7 步总结

第 3 轮 compact 摘要记录了步骤 1-6 的数据:

text
第1步: 100+200 = 300第2步: 300*3 = 900第3步: 当前时间 13:35:50,小时数 13第4步: 13*5 = 65第5步: 50+70 = 120第6步: 900+120 = 1020

第 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 步,并且加了实验控制:

text
每一轮最多调用 2 个工具所有数学计算都必须调用 calculate查询时间必须调用 get_current_time

参数:

markdown
context_window=1000threshold=0.75compact_trigger_tokens=750MAX_COMPACTS=2

第 1 次 compact:

markdown
第 2 轮 prompt_tokens=1280 -> 超过阈值 750[boundary] 未找到 boundary markerboundary 切片: 全部 7 条 -> 切片 7 条child 消息数: 8

第 1 次 compact 后,messages 保留了三类内容:

text
[boundary_1, original_task_spec, summary_1]

这个 original_task_spec 是后面补上的

第 2 次 compact:

text
第 3 轮 prompt_tokens=831 -> 超过阈值 750[boundary] 找到 boundary marker at index 0boundary 切片: 全部 6 条 -> 切片 5 条child 消息数: 6

第二次 summary 只记录了新增事实:

text
步骤4结果 = 5步骤6结果 = 1020

没有把步骤 1、2、3、5 的旧摘要重新写一遍。

这正是 07 要验证的点:第二次 compact 只摘要增量。

4.3 07 最后跑完了 14 步

compact 触发两次后,后面继续执行:

text
第7步: 1020 / 2 = 510第8步: 查询时间 2026-06-24 01:23:54第9步: 5 + 510 = 515第10步: 11 * 12 = 132第11步: 515 + 132 = 647第12步: 647 - 33 = 614第13步: 614 / 3 = 204.666...

最终第 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 当成了“块分隔符”。

脑子里的结构是:

markdown
[boundary_1, summary_1]  第一块[新消息...]             块外

按这个理解,fork child 只看块外的新消息,summary_1 在块内,所以 child 看不到它。

这个理解是错的,boundary marker 实际是位置指针。

它标记一个位置,函数返回这个位置之后的所有消息:

markdown
index 0: boundary_1index 1: original_task_specindex 2: summary_1index 3: 新消息_1index 4: 新消息_2

get_messages_after_compact_boundary() 返回的是 index 1 之后的内容。

所以 fork child 能看到旧 summary。

只是 compact 指令会告诉它:旧  只能当状态参考,不能重新摘要。新增数据只写 boundary 后新对话产生的事实。

坑二:Anthropic 和 OpenAI 的 tool call 格式有差别

读 Claude Code 源码时,我一开始很容易拿它和自己的实验代码硬对。

但两边的 tool call 格式不同:

Claude Code 里的 forkSubagent.tscompact.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 干活时不跑偏、不失忆、不失控

如果对你有帮助,欢迎一键三连~下篇见