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. 安全和权限控制