第七篇:上下文压缩 —— Agent 永续工作的秘密
2026/5/6 1:39:53 网站建设 项目流程

上下文压缩 —— Agent 永续工作的秘密

“上下文总会满,要有办法腾地方。”—— 这个 repo 中文文档里的一句原话。


200K 的上下文窗口也不够用

Claude 有 200K 上下文,GPT-4 有 128K,听起来很多对吧?

来算一笔账:

读一个 1000 行的 Python 文件 ≈ 4,000 tokens 读 10 个这样的文件 ≈ 40,000 tokens 跑 3 个测试(输出各 500 行) ≈ 12,000 tokens 改 5 个文件(diff 输出) ≈ 8,000 tokens 和 Agent 对话 20 轮 ≈ 15,000 tokens 工具调用的元数据 ≈ 5,000 tokens ───────────────────────────────────────── 总计 ≈ 84,000 tokens

还没干多少活,80K+ 就没了。

而且最关键的问题是:即使上下文窗口还有空间,模型的推理能力也会随着上下文长度下降。研究已经反复证明——Transformer 的注意力在长序列上会"稀释",模型会"忘记"早期内容。

所以 s06 的核心命题是:

与其给模型一个无限大的垃圾桶,不如教它定期倒垃圾。


三层压缩:从温和到激进

s06 的压缩策略分为三个层次,逐层加大力度:

每一轮对话: +------------------+ | Tool call result | ← 刚执行完一个工具 +------------------+ | v [Layer 1: micro_compact] ← 静默执行,每轮都跑 把超过 3 轮前的 tool_result 替换成 "[Previous: used {tool_name}]" | v [检查: tokens > 50000?] | | no yes | | v v 继续工作 [Layer 2: auto_compact] 保存完整对话到 .transcripts/ LLM 摘要整个对话 用摘要替换所有 messages | v [Layer 3: compact tool] 模型主动调用 compact 立刻触发摘要(和 auto 一样) Agent 手动控制

三层,从"轻量清理"到"归档重开"。


Layer 1: Micro Compact —— 悄悄的,每轮都做

KEEP_RECENT=3PRESERVE_RESULT_TOOLS={"read_file"}defmicro_compact(messages:list)->list:# 找出所有 tool_resulttool_results=[]formsg_idx,msginenumerate(messages):ifmsg["role"]=="user"andisinstance(msg.get("content"),list):forpart_idx,partinenumerate(msg["content"]):ifisinstance(part,dict)andpart.get("type")=="tool_result":tool_results.append((msg_idx,part_idx,part))# 如果总数不超过保留阈值,不动iflen(tool_results)<=KEEP_RECENT:returnmessages# 保留最近 3 条,其余的干掉to_clear=tool_results[:-KEEP_RECENT]for_,_,resultinto_clear:if...:# 跳过已精简的和 read_file 的结果continueresult["content"]=f"[Previous: used{tool_name}]"returnmessages

最微妙的设计是那行PRESERVE_RESULT_TOOLS = {"read_file"}

为什么保留 read_file 的结果?因为读文件是"参考材料"——模型读了一个文件的内容,后面写代码时可能还要引用。你把它压缩了,模型就得重新读一遍。

而 bash 命令的输出、写文件的结果——这些用完了就可以丢了。

这体现了一个深刻的原则:

压缩不是简单地删东西。压缩是要理解什么东西对模型还有用。


Layer 2: Auto Compact —— 超阈值时的核弹

当 token 估算超过 50,000,auto_compact 启动:

THRESHOLD=50000defauto_compact(messages:list)->list:# 1. 保存完整对话到磁盘transcript_path=TRANSCRIPT_DIR/f"transcript_{int(time.time())}.jsonl"withopen(transcript_path,"w")asf:formsginmessages:f.write(json.dumps(msg,default=str)+"\n")print(f"[transcript saved:{transcript_path}]")# 2. 让模型总结对话conversation_text=json.dumps(messages,default=str)[-80000:]response=client.messages.create(model=MODEL,messages=[{"role":"user","content":"Summarize this conversation for continuity. Include: ""1) What was accomplished, 2) Current state, 3) Key decisions made.""Be concise but preserve critical details.\n\n"+conversation_text}],max_tokens=2000,)summary=extract_text(response)# 3. 用摘要替换全部消息return[{"role":"user","content":f"[Conversation compressed. Transcript:{transcript_path}]\n\n{summary}"},]

这个方法有四个要点:

要点 1:存档到 .transcripts/

压缩前把完整对话写到.transcripts/transcript_1746000000.jsonl。这是保险——如果需要审计或复盘,数据还在。

要点 2:让模型自己总结自己

这里没有用外部的摘要算法。就是同一个模型,读自己的对话历史,然后写摘要。让他总结三个东西:

  1. 完成了什么
  2. 当前状态
  3. 关键决定

要点 3:一行摘要替换所有

压缩后,messages只剩下一个[{user: 摘要}]。上下文从 50K 骤降到几千。
但代价是——模型丢失了所有细节。这就是为什么要有存档。

要点 4:消息里带上存档路径

[Conversation compressed. Transcript: .transcripts/transcript_1746000000.jsonl] 完成了数据库接口的抽取。当前正在实现 Repository 类。 决定使用 SQLAlchemy 作为 ORM,放弃手写 SQL。

模型知道刚才压缩了,也知道档案在哪。必要的时候,它可以读档案来恢复细节。


Layer 3: Compact Tool —— 模型主动说"我内存不够了"

第三层是手动触发的。模型自己决定什么时候需要压缩:

TOOLS=[...{"name":"compact","description":"Trigger manual conversation compression.","input_schema":{"type":"object","properties":{"focus":{"type":"string","description":"What to preserve in the summary"}}}},]

当一个模型说"我觉得聊天变得越来越慢了,可能是 token 太多了"——开个玩笑,模型不会这么说。但在进行了一大段探索性工作后,模型可能会意识到自己写了很多中间代码在上下文里,而这些东西已经不需要了。

此时模型可以调compact,Harness 检测到后立刻执行:

ifblock.name=="compact":manual_compact=True# ... 其他工具 ...ifmanual_compact:print("[manual compact]")messages[:]=auto_compact(messages)

注意messages[:] = ...这个切片赋值——它直接替换了外层变量的全部内容。这是 Python 里"修改引用所指的全部内容"的标准做法。

三层压缩一起工作时的生命周期:

Round 1-10: Tool results 越来越多 每轮 micro_compact 把 3 轮前的旧结果替换成占位符 Round 11: Token 估算 > 50K → auto_compact 触发 存档 + 摘要 + 替换 messages Round 12: 从摘要继续工作 模型如果需要细节,可以读存档 Round 30: Token 又 > 50K → 再次 auto_compact ... Round 50: 模型主动调 compact → 手动压缩 ...

理论上,这个过程可以无限重复。这就是"无限会话"的秘密。


Identity Re-injection:压缩后的自我认知

s06 没有处理这个问题,但 s11 补上了——上下文压缩后,模型可能会忘记自己是谁。

你是一个叫 “coder”、role 是 “backend” 的 Agent,你在团队 “my-team” 里。压缩后:

[Conversation compressed. Transcript: ...] 完成了后端 API 开发。当前正在等待 review。

但如果这里缺少了身份信息,模型醒来后可能一脸茫然:“我是谁?我在哪?我在干嘛?”

s11 的解法是identity re-injection

defmake_identity_block(name:str,role:str,team_name:str)->dict:return{"role":"user","content":f"<identity>You are '{name}', role:{role}, team:{team_name}. Continue your work.</identity>",}

在压缩后的消息列表最前面插入这个块:

iflen(messages)<=3:messages.insert(0,make_identity_block(name,role,team_name))messages.insert(1,{"role":"assistant","content":f"I am{name}. Continuing."})

这就像是你午睡醒来后看了一眼自己的工作牌:"哦对,我是张三,我在做后端开发。"然后继续干活。


和 Subagent 的配合

压缩和子代理是天生一对:

  • Subagent负责把"一次性探索"隔离到独立上下文里,用完即焚 → 减少主上下文的膨胀速度
  • Compression负责在主上下文不可避免增长时,安全地"瘦身" → 延长会话寿命

一个类比:

Subagent = 用完一次性餐具就扔掉 Compress = 定期洗碗

两者都不做 → 厨房堆满了脏盘子 → 没法做饭了。


压缩的安全考虑

压缩过程中有一个潜在风险:模型总结时可能丢失关键信息。

s06 用几个机制来缓解:

  1. 完整存档——.transcripts/目录保存了原始的 JSONL,任何时候都可以回溯
  2. 三层渐进—— 从最轻量的 micro_compact 开始,不给模型造成扰动
  3. 手动控制—— compact 工具让模型自己决定何时压缩(如果它知道的话)

但说实话,这些都只是缓解,不是完美解决。信息丢失是压缩的固有代价。这个 repo 没有假装它能完美压缩——它只是承认"上下文总会满,要有办法腾地方"。

这种诚实让人舒服。


Token 估算的"土办法"

最后看一个有趣的细节——s06 怎么估算 token 数的?

defestimate_tokens(messages:list)->int:"""Rough token count: ~4 chars per token."""returnlen(str(messages))//4

不是用 tiktoken,不是用 tokenizer API,就是粗暴地str(messages)的长度除以 4

这个"土办法"其实很好:

  • 4 chars ≈ 1 token 是对英文 tokenizer 的合理近似
  • 不需要额外依赖
  • 不需要网络请求
  • 够用就行(阈值是 50K,差个几千不致命)

Harness Engineering 的一个原则:不要过度设计。一个大致差不多的估算就够触发压缩了。精确到个位数的 token 计数没有意义。


下集预告

s01 到 s06 组成了 Agent 的基础设施:循环、工具、规划、子代理、技能、压缩。这基本上就是一个最小可用 Agent 的全部了。

但作者没有停在这里。s07 开始,进入了"企业级"功能:持久化任务系统、后台线程、Agent 团队、状态机协议、自主行动、worktree 隔离。

下一篇,我们看 s08 的后台任务系统——让 Agent 可以"边思考边干活"

下一篇:后台任务 —— Agent 边推理边等结果

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询