Agent 跑长任务时,上下文管理不做好会有三种死法:API 直接报 context too long、token 费用一路飞涨、模型在过长历史里"失忆"跑偏。这篇按步骤拆解我在开源 Agent 框架 milu 里落地的上下文压缩方案,照着做可以直接移植到你自己的 Agent 里。
目录
- 一、整体方案:三级压缩流水线
- 二、第一步:算清上下文用量
- 三、第二步:轮次分层处理工具结果
- [四、第三步:LLM 摘要与失败兜底](#四、第三步:LLM 摘要与失败兜底)
- 五、最容易踩的三个坑
- 六、参数速查表
一、整体方案:三级压缩流水线
核心原则:上下文膨胀的大头是工具结果(一次文件读取就是几千上万字符),所以先用零成本的字符串操作处理工具结果,LLM 摘要留作最后手段。每轮调用 LLM 前执行:
L1 条数裁剪 → [用量 ≥ 0.5?] 轮次分层工具压缩 → [用量 ≥ 0.7?] L4 LLM 摘要
| 层级 | 手段 | API 成本 | 触发条件 |
|---|---|---|---|
| L1 | 消息数超 300 时保留头 3 条 + 尾部,裁剪中间 | 0 | 条数超限 |
| 轮次分层 | 按轮次年龄截断/占位旧工具结果 | 0 | 用量 ≥ 50% |
| L4 | LLM 总结历史,保留尾部消息 | 1 次调用 | 用量 ≥ 70% |
大多数任务走不到 L4,前两级就够了。
二、第一步:算清上下文用量
触发条件全部基于「实际用量 / 模型最大窗口」。不要自己用 tokenizer 数(多厂商分词器不一致),直接用 API 返回的 prompt_tokens:
python
def _calc_usage_ratio(self, messages) -> float:
if self._last_prompt_tokens > 0: # 上次 API 调用的真实值
return self._last_prompt_tokens / self._max_context_window
# 首轮无数据时回退到字符估算(约 2 字符/token)
return (self._estimate_size(messages) // 2) / self._max_context_window
流式响应里每个带 usage 的 chunk 都更新 _last_prompt_tokens,下一轮压缩用的就是真实值。
分母同样重要:max_context_window 必须按模型名精确解析,不能按厂商写死一个常量(详见第五节坑 2),并留一个显式覆盖参数给内置表没收录的模型。
三、第二步:轮次分层处理工具结果
先分轮:一个 assistant 消息加其后的 tool/user 消息算一轮。再按每轮距最新轮的距离(age)分层:
| 轮次年龄 | 处理方式 |
|---|---|
| age ≤ recent_rounds(默认 5) | 原样保留 |
| recent_rounds < age ≤ old_round_threshold | 工具结果截断为前 N 字符 + 文件指针 |
| age > old_round_threshold | 整条替换为占位符 + 文件指针 |
两个阈值随窗口动态计算:
python
max_keepable_rounds = max_context_window // 1500 # 1 轮 ≈ 1500 tokens
old_round_threshold = max(5, min(max_keepable_rounds // 2, 30))
truncate_threshold = max(500, min(4000, max_context_window // 20))
# 8K 窗口 → 截断留 500 字符;32K → 1600;128K → 4000(封顶)
截断不是删除。会话历史以 append-only JSONL 落盘,被截断的消息里带一个指回日志的指针:
[工具结果已截断: file_read (call_abc123) → 前4000字符如下]
<前 4000 字符>
[完整内容 → /path/to/session/conversation.jsonl 第42行]
模型知道完整内容在哪,需要时可以重新调工具获取。实现时记得两个细节:已带 [工具结果已截断: 前缀的消息要跳过,避免重复套娃;用量达到 70% 时把 recent_rounds 动态降为 0,给即将到来的摘要腾空间。
四、第三步:LLM 摘要与失败兜底
用量过 70% 才调 LLM 摘要,关键点有四个:
- 尾部保护:给尾部留 30% 窗口的 token 预算,按 3、2、1、0 条依次尝试保留最近消息。最后几条是当前任务状态,原样保留。
- 定向 prompt:明确要求保留五项内容,即当前目标、关键发现与决策、读过/改过的文件、剩余工作、用户约束,并要求带具体文件路径和变量名。泛泛的"请总结"会丢掉接续工作需要的状态。
- 失败熔断:摘要调用连续失败 3 次后停止尝试 L4,降级靠前两级硬扛,避免每轮白挂一次请求。
- 应急压缩 :万一发送时仍收到
context too long,触发 reactive 压缩,保留最近 3 条消息、其余全部摘要,把会话从报错中恢复。
另外可以把压缩注册成一个 compact 工具暴露给模型,支持 focus 参数(如 "当前调试进度"),让模型在需要时主动整理历史。
五、最容易踩的三个坑
坑 1:压缩太勤快。 初版轮次压缩每轮无条件执行、只看轮次年龄,结果 1M 窗口的模型用量才 3% 时工具结果就被截光了。修复方式就是第一节的 0.5 触发线:用量不到一半,一个字符都不动。
坑 2:窗口常量按厂商写死。 qwen3.6-plus 实际窗口 1M 被当成 128K(压缩提前 8 倍),gpt-4o-mini 实际 128K 被记成 200K(有溢出风险)。窗口表必须按模型名维护,再加显式覆盖通道兜底。
坑 3:压缩/中断切断 tool_call 配对。 OpenAI 协议要求 assistant(tool_calls) 后紧跟对应 tool 结果。任务中断会留下"孤儿"tool_call,MiniMax 会报 400 错误 tool call result does not follow tool call (2013),且重发也无法恢复。解法:每次发送前做幂等的配对修复,缺失结果的 tool_call 补占位结果,孤儿 tool 结果直接丢弃。
六、参数速查表
| 参数 | 默认值 | 说明 |
|---|---|---|
enabled |
True | 自动压缩总开关 |
round_trigger_ratio |
0.5 | 轮次分层启动线(用量/窗口) |
trigger_ratio |
0.7 | LLM 摘要触发线 |
recent_rounds |
5 | 完整保留的最近轮数(逼近摘要线时降为 0) |
max_messages |
300 | 消息条数硬上限 |
回到开头说的三种死法:报错有 reactive 应急兜底,费用被前两级零成本压缩压住,"失忆"靠最近轮完整保留加定向摘要缓解。三级流水线对应的就是这三个症状。
完整实现已开源:github.com/stephonGAO/milu,压缩逻辑集中在 src/milu/agent/compactor.py(约 600 行),配套测试在 tests/test_compactor.py,可以直接对照源码移植。
如果你的 Agent 也被长上下文折磨过,欢迎在评论区说说你的场景,我可以补充对应的处理思路。