几乎所有 Agent 框架的 demo 都跑得很漂亮,因为 demo 只聊十轮。真把 Agent 扔进一个跑一小时的编码任务,第一个撞墙的就是上下文:一条文件读取的工具结果就是几千上万字符,几十轮下来窗口必满。
我在写 milu(一个开源的多用户 Agent 框架,主打国产大模型支持)的时候,把上下文压缩当成一个核心子系统来做,从首次引入到现在改了好几版,踩的坑比写的代码还有意思。这篇把设计思路、关键取舍和踩坑记录都摊开讲,完整实现在仓库的 src/milu/agent/compactor.py,不到 700 行,可以对着读。
为什么"让 LLM 总结一下历史"是下策
最容易想到的方案是:上下文快满了,调一次 LLM 把历史总结成一段话。很多框架也确实只做了这一步。但它有三个问题:
- 摘要是有损且不可逆的。摘要丢掉的细节(某个变量名、某条报错原文)再也找不回来,而你不知道哪条细节会在十轮之后变得关键。
- 多一次 LLM 调用,意味着多一截延迟、多一笔钱。长任务里压缩可能反复发生,这笔账不小。
- 最关键的:上下文的大头根本不是对话,是工具结果。一次
file_read几千字符,一次 shell 命令输出上万字符,而真正的对话文本占比很小。把对话和工具结果混在一起总结,是用最贵的手段处理最不值钱的内容。
所以 milu 的思路是反过来的:先用零成本的手段处理工具结果,LLM 摘要留作最后手段。
总体设计:三级流水线
每轮 LLM 调用前,压缩器按这个顺序过一遍历史(compactor.py 开头的注释就是这张图):
css
L1 snip → [用量 ≥ 0.5?] 轮次分层工具压缩 → [用量 ≥ 0.7?] L4 LLM 摘要
| 层级 | 手段 | API 成本 | 触发条件 |
|---|---|---|---|
| L1 snip | 消息条数超 300 时裁剪中间消息 | 0 | 条数超限(与窗口无关) |
| 轮次分层 | 按轮次年龄分层处理工具结果 | 0 | 用量 ≥ 50% 窗口 |
| L4 摘要 | LLM 总结历史,保留尾部 | 1 次调用 | 用量 ≥ 70% 窗口 |
大多数任务走不到 L4。前两级都是纯字符串操作,不花一次 API 调用就把上下文摁住了。
用量怎么算:别自己数 token
压缩的触发条件全部挂在「实际用量 / 模型窗口」这个比例上。用量的来源有讲究:自己用 tokenizer 数不现实(九个厂商的分词器各不相同),所以 milu 直接用上一次 API 调用返回的 prompt_tokens:
python
def _calc_usage_ratio(self, messages: list[Message]) -> float:
if self._last_prompt_tokens > 0:
return self._last_prompt_tokens / self._max_context_window
# 首次调用无 token 数据,回退到字符估算(约 2 字符/token)
estimated_tokens = self._estimate_size(messages) // 2
return estimated_tokens / self._max_context_window
流式响应的 usage chunk 一到就更新,下一轮压缩用的就是真实值。只有首轮没有数据时才退回粗糙的字符估算。
分母 max_context_window 也踩过坑。早期版本里窗口是按厂商写死的常量,结果 qwen3.6-plus 实际窗口 1M 却被当成 128K,gpt-4o-mini 实际 128K 却被记成 200K。前者导致压缩比理论值提前了 8 倍,后者直接有溢出风险。0.1.1 版本改成了按模型名精确解析,再加一个 Agent(context_window=N) 的显式覆盖通道,给内置表没收录的模型兜底。
轮次分层:工具结果才是该动刀的地方
这是整套设计里我觉得最值钱的一层。先把消息按「轮」分组:一个 assistant 消息加上它后面的 tool/user 消息算一轮。然后按每轮距离最新轮的距离(age)分层处理工具结果:
| 轮次年龄 | 处理方式 |
|---|---|
| age ≤ 5(最近轮) | 原样保留 |
| 5 < age ≤ old_round_threshold | 长工具结果截断,保留前 N 字符 + 文件指针 |
| age > old_round_threshold | 整条替换为占位符 + 文件指针 |
直觉是:最近几轮是 Agent 的工作记忆(正在改的文件、刚出的报错),必须完整;越老的轮次,工具结果细节越不重要,但"做过什么"这个事实还得在。
两个阈值不是拍脑袋定的,跟着窗口大小走:
python
# 占位符轮次:窗口能装下的轮数的一半(下限 5,上限 30)
max_keepable_rounds = self._max_context_window // 1500 # 1 轮 ≈ 1500 tokens
self._old_round_threshold = max(5, min(max_keepable_rounds // 2, 30))
# 截断字符数:随窗口缩放。8K → 500,32K → 1600,128K → 4000(上限)
self._truncate_threshold = max(500, min(4000, self._max_context_window // 20))
8K 的小模型,超过 5 轮就上占位符、截断只留 500 字符;128K 的模型可以宽裕到 30 轮、4000 字符。同一套代码自动适配。
还有一个动态收紧的细节:当用量达到 70% 这条 L4 摘要线时,recent_rounds 直接降为 0,只留最新一轮完整,给马上要做的摘要腾地方。
截断不等于删除:文件指针
被截断的工具结果长这样:
css
[工具结果已截断: file_read (call_abc123) → 前4000字符如下]
<前 4000 字符内容>
[完整内容 → /path/to/session/conversation.jsonl 第42行]
milu 的会话是 append-only 的 JSONL 日志,原始工具结果一个字都没丢,全在盘上。压缩只是决定"让模型看见多少",指针告诉模型完整内容在哪一行。模型如果真的需要,可以重新调工具或者提示用户去查日志。我把这理解成内存分级:上下文是内存,会话日志是磁盘,指针就是换页表。
顺带一提,已经截断过的消息会带上 [工具结果已截断: 前缀,下次压缩扫到时识别前缀直接跳过,避免对截断结果再截断、套娃出一堆嵌套标记。轮次老化之后,截断消息会被升级成占位符,进一步省空间。
踩坑实录:压缩器自己制造的 bug
坑一:压缩太勤快本身就是 bug。 初版的轮次分层是每轮无条件跑的,只看轮次年龄。逻辑上没毛病,直到接了大窗口模型:MiniMax 的 1M 窗口,按一轮约 1500 token 算,跑二十轮用量还不到 3%,工具结果却已经被截得七零八落。窗口明明还空着,信息却没了。0.1.1 加了 round_trigger_ratio = 0.5:用量不到一半,一个字符都不动。这个坑给我的教训是,压缩策略必须感知"还剩多少",而不是只看"已经多老"。
坑二:压缩会切断协议约束。 OpenAI 协议要求 assistant(tool_calls) 后面必须紧跟对应的 tool 结果。但任务中断(超时、达到调用上限)会留下没有结果的"孤儿" tool_call,用户输入"继续"之后,MiniMax 直接报 400:tool call result does not follow tool call (2013),而且每次重发都失败,会话等于报废。
milu 的解法是在每次发送前跑一个幂等的配对修复:缺结果的 tool_call 补一条占位结果,找不到前置调用的孤儿 tool 结果直接丢弃。合法序列原样通过。修复函数不改会话日志,只修发送视图,所以旧的已损坏会话也能直接复活。
L4 摘要:真到了那一步
用量过 70%,零成本手段已经救不回来了,才轮到 LLM 摘要。这一层也有几个细节:
尾部保护:摘要前先给尾部留 30% 窗口的预算,按 3、2、1、0 条依次尝试保留最近的消息。最后几条往往是当前任务状态,原样保留比进摘要可靠。
摘要 prompt 是定向的,不是"请总结"三个字:
markdown
Summarize this agent conversation so work can continue.
Preserve:
1. Current goal and task
2. Key findings and decisions
3. Files read or changed
4. Remaining work
5. User constraints and preferences
Be compact but concrete. Include specific file paths, variable names, ...
要求保留目标、决策、文件清单、剩余工作和用户约束,这五项是 Agent 接着干活的最小状态。
失败熔断:摘要毕竟要调 LLM,会失败。连续失败 3 次后熔断,不再尝试 L4,降级靠前两级硬扛,避免每轮都白白挂一次摘要请求。
兜底的兜底:万一估算偏低、发送时真的收到了 "context too long",还有一个 reactive_compact:保留最近 3 条消息,其余全部摘要,把会话从报错里捞回来。另外还有一个注册给 LLM 的 compact 元工具,模型可以带着 focus 参数主动整理历史(比如 focus="当前调试进度")。
压缩之后:快照落盘,重启不重压
每次压缩后的消息列表会作为快照写进会话 JSONL(type: "compaction" 事件)。下次加载会话时,从最后一个快照恢复,再追加快照之后的新消息。好处是重启后不需要重新压缩一遍,长会话的加载是 O(快照后增量) 而不是 O(全部历史)。
总结一下取舍
这套设计参考了 Claude Code 的 compaction 思路(compactor.py 的 docstring 里老实写着),但分层策略和动态阈值是按 milu 自己的场景(九个厂商、窗口从 8K 到 1M)长出来的。它不完美:占位符确实丢信息,只能靠文件指针和会话日志兜底;字符估算也粗糙,只敢用在首轮。但工程上我对它满意的点就一个:大多数任务从头到尾没有为压缩多花一次 API 调用。
完整代码在 github.com/stephonGAO/...,压缩相关就三个文件:compactor.py、history.py、session.py,测试在 tests/test_compactor.py,欢迎来提 issue。
你们的 Agent 是怎么处理长上下文的?滑动窗口、向量召回还是硬截断?评论区聊聊,我很好奇有没有更野的路子。