7. 上下文压缩
前面六篇文章给 Agent 装了一身装备:Loop 让它循环执行,Memory 让它记住历史,Plan 让它拆分任务,Rules/Skills/MCP 扩展了能力边界,SubAgent 和 Multi-Agent 让它能组队协作。
然后你会发现一个问题:能力越强,负重越大。
Agent 跑长任务的时候,messages 数组只增不减。每读一个文件、每跑一条命令、每调一次 SubAgent,都在往里面塞东西。塞着塞着,上下文窗口满了——Agent 没崩,但喘不上气了。这叫上下文窒息。
本地部署的小模型更惨。窗口本来就小,跑不了几轮就挂。大模型也只是晚死一会儿——窗口再大,消息无限涨,迟早撞墙。
Messages 一定会爆
Agent 的核心循环决定了 messages 只增不减:
第 1 轮: [system, user] → 2 条
第 2 轮: [system, user, assistant, tool] → 4 条
第 3 轮: [..., assistant, tool] → 6 条
...
第 20 轮: [..., 40 条,还在涨]每轮循环都在往上堆。只要 LLM 调了工具,这一轮就至少追加两条:assistant(tool_call)和 tool(返回结果)。一轮调三个工具,就是六条。20 轮下来,上百条很正常。
直觉告诉我们:靠大窗口硬扛。 128K 不够换 200K,200K 不够换 1M。这是迷信。
窗口再大,也不代表你想塞满。每轮请求都把整个 messages 数组发给 API——数组越长,请求越慢,token 费用线性涨。还有一个更隐蔽的问题:Lost in the Middle。LLM 对长文本的开头和结尾记得最清楚,中间的内容注意力急剧衰减。messages 前半段的旧对话,LLM 早就看不清了,但你还是每一轮都传过去,白白烧 token。
最后的结果都一样:context_length_exceeded。任务做了一半,Agent 死了。
为什么压缩是唯一体面的出路
几条路可以走:
方案 A:换更大的模型。 治标不治本。128K 不够换 200K,200K 不够换 1M。窗口大了,费用和延迟同比例膨胀。而且再大的窗口也终有一爆——你只是把 deadline 往后推了。
方案 B:限制循环次数。 因噎废食。maxIterations=10,简单任务够,复杂任务呢?一个正经的代码重构可能需要读十几个文件、跑好几次测试、改七八处代码。10 轮根本不够。
方案 C:直接截断。 过于粗暴。删掉数组前半段,你不知道删了什么——可能是一条关键报错、一个重要路径、一段被纠正后的上下文。闭着眼睛剪电线,剪到哪根都可能出事。
方案 D:上下文压缩。 不是删,是提炼。把前 30 轮冗长的工具调用过程压成一段几百字的摘要。摘要保留关键信息:读了哪些文件、改了什么、出了什么错、进展到哪。但丢弃每次 tool_call 的完整 JSON 和工具返回的原始输出。旧历史浓缩了,现场原封不动。
记住要点,忘掉细节。但现场要留——最近几轮消息原封不动,那是 Agent 正在处理的东西。
搬家式压缩
messages 就像一个住了很久的房间,东西越堆越多。压缩就是搬家——不是全扔进垃圾袋,而是分类处理。
假设当前的 messages 长这样:
[system] [user] [assistant] [tool] [assistant] [tool] [assistant] [tool]
[assistant] [tool] [assistant] [tool] [assistant] [tool] [assistant] [tool]
[assistant] [tool] [assistant] [tool] [assistant] [text: "找到了问题..."]压缩的目标:
[system] [摘要: "前 8 轮做了什么"] [assistant] [tool] [assistant] [tool]
[assistant] [text: "找到了问题..."]四件事,一件都不能乱。
证件和贵重物品,贴身揣着。 System Prompt 定义了 Agent 的身份、规则和工具列表。它不是”东西”,是 Agent 自己。永远不进箱。
成套的易碎餐具,整箱搬。 Assistant 的 tool_call 和它对应的 tool 返回是一套。拆开装箱,到了新地方拼不回去——LLM 看到孤立的 tool_call 没有对应返回,要么报错,要么编一个补上。装箱时必须整套一起走。
代码里的 while 循环就是在做这件事:从装箱线往前扫,踩到碎碗(role: "tool"),继续往前找到配套的盘子(assistant tool_call),整套端走。
用不着的东西,装箱贴标签。 System 之后、装箱线之前的全部消息,装进一个纸箱。纸箱上贴一张标签——用一个不带工具的 LLM 调用,把几十轮的流水账提炼成一段摘要。箱子里的细节不跟人走了,标签告诉你箱子里有什么。
正在用的东西,不打包。 装箱线之后的消息留在原地。这是 Agent 的当前现场——正翻的文件、刚拿到的报错、想到一半的思路。下一秒就要用,不装箱。
代码实现
const COMPACT_THRESHOLD = 30; // messages 超过 30 条触发压缩
const KEEP_RECENT = 6; // 保留最近 6 条不压缩
async function compactMessages(messages: ChatMessage[]): Promise<ChatMessage[]> {
if (messages.length <= COMPACT_THRESHOLD) return messages;
// System Prompt 永远不动
const systemMsg = messages[0];
// 从后往前找装箱线:保留最近 KEEP_RECENT 条
let cutIndex = messages.length - KEEP_RECENT;
// 安全回溯:装箱线不能踩在孤立的 tool 消息上(碎碗不能单独装箱)
while (cutIndex > 1 && messages[cutIndex].role === "tool") {
cutIndex--;
}
// 装箱线之前的旧历史,送去做摘要
const oldHistory = messages.slice(1, cutIndex);
// 贴标签:LLM 提炼摘要
const summary = await summarize(oldHistory);
// 重组
const compacted: ChatMessage[] = [
systemMsg,
{
role: "user",
content: `[上下文摘要]\n以下是之前对话的要点,原始细节已省略:\n\n${summary}\n\n---\n继续处理剩余任务。`,
},
...messages.slice(cutIndex),
];
return compacted;
}
async function summarize(messages: ChatMessage[]): Promise<string> {
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
const resp = await client.chat.completions.create({
model: "gpt-4o-mini", // 摘要用便宜模型
messages: [
{
role: "system",
content: `Summarize the conversation between an AI agent and its tools.
Focus on:
- What tasks were attempted
- What file paths were involved
- What errors occurred and how they were resolved
- Current progress
Be concise. Omit exact command outputs and full code.`,
},
{
role: "user",
content: JSON.stringify(messages, null, 2),
},
],
temperature: 0.1,
});
return resp.choices[0]?.message?.content ?? "";
}summarize 里有几个点值得单独说:
摘要用便宜模型。 压缩不需要强大推理能力,甚至不需要长上下文——gpt-4o-mini 就够。压缩 50 条消息可能花 5000 token,但之后每一轮都省了传这 50 条的成本,几轮就跑回本。
不带任何工具。 摘要调用的 LLM 拿不到工具列表。跟 Plan-as-Tool、SubAgent 里的递归防护是同一个原则——摘要只做一件事,就是提炼文本。
temperature 拉低。 摘要要准确稳定,不需要创意。
嵌入主循环
压缩函数嵌入 Agent Loop 只需要一行:
async function agentLoop(messages: ChatMessage[], maxIterations = 50) {
for (let i = 0; i < maxIterations; i++) {
// 一行拦截
messages = await compactMessages(messages);
const msg = await callLLM(messages);
messages.push(msg);
if (!msg?.tool_calls?.length) break;
for (const toolCall of msg.tool_calls) {
const { name, arguments: rawArgs } = toolCall.function;
const output = await toolsMap[name](JSON.parse(rawArgs || "{}"));
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}
}
return messages;
}Agent 感知不到压缩。它看到的消息数组永远是 System + 摘要 + 现场。旧历史变成了摘要里的一段话,但对话始终是”连续”的——没有”压缩事件”,没有中断提示。旧细节进了纸箱,但箱子上的标签告诉了 Agent 里面有什么。
锯齿波:无限续航的代价
压缩让 Agent 理论上能无限跑下去。messages 数量的走势变成一条锯齿波:
轮次 →
条数 ↑
│ ╱╲ ╱╲
30 │ ╱ ╲ ╱ ╲
│ ╱ ╲ ╱ ╲
10 │ ╱ ╲╱ ╲
│ ╱ ╲
└──────────────────────→
压缩点 压缩点涨到阈值 → 压回去 → 再涨 → 再压。Agent 不会被 messages 撑死,但每次压缩都在丢东西。
你让 Agent 批量重命名 20 个文件。压缩后摘要写的是”已完成 20 个文件的重命名”。后续如果第 7 个文件名改错了,Agent 不记得原始文件名是什么——它只知道”批量重命名完成”。
这不是 bug。这是取舍。
Agent 真需要那个细节的时候,它会用工具重新获取——再 ls 一次,再 read 一遍文件。这是一种人类行为代偿:你不会记得两周前每封邮件的全文,但你知道去哪搜。Agent 有工具,就像你有搜索栏——丢了的东西随时能找回来。
和生产方案的差距
这套 compactMessages 只有一百行。Claude Code 这类生产级 Agent 的压缩系统要复杂得多:
触发条件。 我们数的是消息条数。生产上用精确的 token 计数——一条消息可能是 10 token 也可能是 10000 token,条数不等于实际占用。
压缩粒度。 我们一刀切——旧历史全压成一段摘要。生产上通常分层:最旧的深度压缩(一句带过),中段保留结构化摘要(每个文件、每个报错单独一条),最近的不压缩。
保留策略。 我们固定保留最近 N 条。生产上可以按重要性权重保留——包含报错的消息优先留,关键路径相关的不删,纯工具输出可以优先扔。这需要给每条消息打标签,不是简单地按位置切。
但核心逻辑是一样的:记住要点,忘掉细节,保留现场。
这一篇在系列里的位置
前面六篇都在做一件事:给 Agent 加能力。Loop、Memory、Plan、Rules/Skills/MCP、SubAgent、Multi-Agent——每一篇让 Agent 能干更多。
这一篇做的是另一件事:让 Agent 不会因为能干太多而死掉。
压缩就是教它抓大放小——把笔记本里的流水账撕掉,只留几行要点和正在写的那一页。
下一篇:8. 安全和权限控制