上下文压缩 ------ 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 ------ 悄悄的,每轮都做
python
KEEP_RECENT = 3
PRESERVE_RESULT_TOOLS = {"read_file"}
def micro_compact(messages: list) -> list:
# 找出所有 tool_result
tool_results = []
for msg_idx, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part_idx, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((msg_idx, part_idx, part))
# 如果总数不超过保留阈值,不动
if len(tool_results) <= KEEP_RECENT:
return messages
# 保留最近 3 条,其余的干掉
to_clear = tool_results[:-KEEP_RECENT]
for _, _, result in to_clear:
if ...: # 跳过已精简的和 read_file 的结果
continue
result["content"] = f"[Previous: used {tool_name}]"
return messages
最微妙的设计是那行 PRESERVE_RESULT_TOOLS = {"read_file"}。
为什么保留 read_file 的结果? 因为读文件是"参考材料"------模型读了一个文件的内容,后面写代码时可能还要引用。你把它压缩了,模型就得重新读一遍。
而 bash 命令的输出、写文件的结果------这些用完了就可以丢了。
这体现了一个深刻的原则:
压缩不是简单地删东西。压缩是要理解什么东西对模型还有用。
Layer 2: Auto Compact ------ 超阈值时的核弹
当 token 估算超过 50,000,auto_compact 启动:
python
THRESHOLD = 50000
def auto_compact(messages: list) -> list:
# 1. 保存完整对话到磁盘
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
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:让模型自己总结自己
这里没有用外部的摘要算法。就是同一个模型,读自己的对话历史,然后写摘要。让他总结三个东西:
- 完成了什么
- 当前状态
- 关键决定
要点 3:一行摘要替换所有
压缩后,messages 只剩下一个 [{user: 摘要}]。上下文从 50K 骤降到几千。
但代价是------模型丢失了所有细节。这就是为什么要有存档。
要点 4:消息里带上存档路径
[Conversation compressed. Transcript: .transcripts/transcript_1746000000.jsonl]
完成了数据库接口的抽取。当前正在实现 Repository 类。
决定使用 SQLAlchemy 作为 ORM,放弃手写 SQL。
模型知道刚才压缩了,也知道档案在哪。必要的时候,它可以读档案来恢复细节。
Layer 3: Compact Tool ------ 模型主动说"我内存不够了"
第三层是手动触发的。模型自己决定什么时候需要压缩:
python
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 检测到后立刻执行:
python
if block.name == "compact":
manual_compact = True
# ... 其他工具 ...
if manual_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:
python
def make_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>",
}
在压缩后的消息列表最前面插入这个块:
python
if len(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 用几个机制来缓解:
- 完整存档 ------
.transcripts/目录保存了原始的 JSONL,任何时候都可以回溯 - 三层渐进 ------ 从最轻量的 micro_compact 开始,不给模型造成扰动
- 手动控制 ------ compact 工具让模型自己决定何时压缩(如果它知道的话)
但说实话,这些都只是缓解,不是完美解决。信息丢失是压缩的固有代价。这个 repo 没有假装它能完美压缩------它只是承认"上下文总会满,要有办法腾地方"。
这种诚实让人舒服。
Token 估算的"土办法"
最后看一个有趣的细节------s06 怎么估算 token 数的?
python
def estimate_tokens(messages: list) -> int:
"""Rough token count: ~4 chars per token."""
return len(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 边推理边等结果