从零设计 Agent 上下文压缩:三级流水线与动态阈值,治好 context too long(附开源实现)

Agent 跑长任务时,上下文管理不做好会有三种死法:API 直接报 context too long、token 费用一路飞涨、模型在过长历史里"失忆"跑偏。这篇按步骤拆解我在开源 Agent 框架 milu 里落地的上下文压缩方案,照着做可以直接移植到你自己的 Agent 里。

目录

一、整体方案:三级压缩流水线

核心原则:上下文膨胀的大头是工具结果(一次文件读取就是几千上万字符),所以先用零成本的字符串操作处理工具结果,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 摘要,关键点有四个:

  1. 尾部保护:给尾部留 30% 窗口的 token 预算,按 3、2、1、0 条依次尝试保留最近消息。最后几条是当前任务状态,原样保留。
  2. 定向 prompt:明确要求保留五项内容,即当前目标、关键发现与决策、读过/改过的文件、剩余工作、用户约束,并要求带具体文件路径和变量名。泛泛的"请总结"会丢掉接续工作需要的状态。
  3. 失败熔断:摘要调用连续失败 3 次后停止尝试 L4,降级靠前两级硬扛,避免每轮白挂一次请求。
  4. 应急压缩 :万一发送时仍收到 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 也被长上下文折磨过,欢迎在评论区说说你的场景,我可以补充对应的处理思路。

相关推荐
love530love1 小时前
Anaconda Navigator 升级后图形界面启动失败故障修复实录
人工智能·windows·python·anaconda·navigator
bIo7lyA8v1 小时前
算法稳定性分析的参数敏感性建模研究的技术7
人工智能
爱睡懒觉的焦糖玛奇朵1 小时前
【视觉检测之人员奔跑检测算法开发思路】
人工智能·python·深度学习·算法·yolo·视觉检测
EDA365电子论坛1 小时前
AI 赋能 BOM 编制全流程,彻底解决型号 / 封装 / 精度 / 尾缀写错问题
大数据·人工智能
DogDaoDao1 小时前
【GitHub】深度解析 Open Notebook:开源 AI 笔记研究平台的完整指南
人工智能·ai·程序员·开源·github·ai编程·notebook
Swift社区1 小时前
AI + 鸿蒙游戏:下一代游戏架构正在形成吗?
人工智能·游戏·harmonyos
ai产品老杨1 小时前
架构师视界:基于 Docker 容器化与边缘计算的 AI 视频管理平台——打通 GB28181/RTSP 异构集群与源码交付实战
人工智能·docker·边缘计算
kuokay1 小时前
MLOps 与 AIOps 的核心概
人工智能·分布式·大模型·agent·llama
Jasonakeke1 小时前
CLion + OpenCV + Utf8 终极解决方案
人工智能·opencv·计算机视觉