AI Agent 对话太长怎么办:三种压缩策略和一个自动兜底

这是 《写完一个 AI 编程助手之后,我才确定 prompt 工程不是重点》 系列的第七篇。前六篇讲了进程模型、权限、并发调度、记忆、Hook。这一篇讲一个所有 Agent 都会遇到但很少有人讲清楚的问题:上下文窗口快满了怎么办

代码:code-agent/src/core/context。ContextManager + 三个 Compressor 加起来不到 200 行。

Claude 有 200K token 的上下文窗口。听起来很大。

但一个真实的 Agent 对话长这样:

用户消息 (50 tokens)
  → 模型回复 + 3 个工具调用 (800 tokens)
  → 3 个工具结果 (每个 2000-5000 tokens)
  → 模型回复 + 2 个工具调用 (600 tokens)
  → 2 个工具结果 (每个 3000 tokens)
  → ...

一次"读三个文件然后改一个"的操作,轻松吃掉 15000–20000 tokens。十轮对话下来就是 150K–200K。

到了 80% 容量(160K),模型开始出问题——回复变慢、开始遗忘早期上下文、偶尔幻觉。到了 100%,API 直接报错:prompt_too_long

你不能让用户手动开新对话。Agent 的价值就在于连续工作


一、80% 阈值:在崩溃之前压缩

第一个设计决策:什么时候触发压缩?

// src/core/context/manager.ts
const COMPRESS_THRESHOLD = 0.8

shouldCompress(inputTokens: number): boolean {
  const limit = MODEL_CONTEXT_LIMITS[this.modelName] ?? 200_000
  return inputTokens > limit * COMPRESS_THRESHOLD
}

80% 不是随便选的:

  • 太早(60%):频繁压缩,每次都丢信息,模型反复"失忆"
  • 太晚(95%):压缩本身要调 LLM 做摘要,摘要请求也要 token。如果剩余空间不够发摘要请求,就死锁了
  • 80%:留 20% 的余量给压缩操作本身 + 下一轮对话的空间

API 返回的 inputTokens 是精确的——不需要自己数 token,用 API 告诉你的数字就行。


二、三种压缩策略,不是一种

第一版我只做了一种压缩:把所有历史丢给 LLM 做摘要。

跑了几天发现两个问题:

  1. 摘要太粗:一段话概括了 20 轮对话,关键细节(文件路径、错误信息、具体决策)全丢了
  2. 摘要太贵:把 150K token 的历史全发给 LLM 做摘要,这个摘要请求本身就要花 150K input token 的钱

所以我做了三种策略:

Auto(自动):保留最近 3 轮,摘要其余

// src/core/context/compressors/auto.ts
const KEEP_RECENT_ROUNDS = 3

const keepCount = KEEP_RECENT_ROUNDS * 2  // 每轮 = user + assistant
const toSummarize = messages.slice(0, messages.length - keepCount)
const toKeep = messages.slice(messages.length - keepCount)

const summary = await model.chat({
  messages: [
    ...toSummarize,
    { role: 'user', content: 'Summarize the above conversation concisely. Preserve: key decisions, files modified, errors resolved, context needed to continue.' }
  ],
  max_tokens: 1024
})

什么时候用:自动触发(到 80% 阈值时)。保留最近 3 轮的完整上下文(模型还能看到刚才在做什么),早期历史压缩成一段摘要。

代价:早期的具体文件路径、错误堆栈可能丢失。但最近 3 轮是完整的,当前任务不受影响。

Micro(微压缩):只压缩最老的 20%

// src/core/context/compressors/micro.ts
const MICRO_FRACTION = 0.2

const chunkSize = Math.max(2, Math.floor(messages.length * MICRO_FRACTION))
const toSummarize = messages.slice(0, chunkSize)
const toKeep = messages.slice(chunkSize)

什么时候用:上下文接近阈值但还不紧急时。只砍掉最老的 20%,损失最小。

代价:压缩幅度小,可能很快又触发。但每次丢的信息少,适合"慢慢压"的场景。

Manual(手动 /compact):全部压缩成结构化摘要

// src/core/context/compressors/manual.ts
const summary = await model.chat({
  messages: [
    ...messages,
    { role: 'user', content: `Create a structured context summary:
- Files created or modified
- Decisions made
- Errors encountered and resolved
- Current task
- Open questions or blockers` }
  ],
  max_tokens: 2048
})

return { messages: [], summary }  // 清空所有历史,只留摘要

什么时候用:用户主动输入 /compact。适合"我要换个方向了,之前的细节不重要了"的场景。

代价:所有历史细节丢失。但摘要是结构化的,关键信息保留率比 auto 高(因为 prompt 明确要求了保留哪些维度)。


三、压缩后的消息格式

压缩完之后,历史变成了:

[
  { role: 'user', content: '[Context compressed — auto]\nSummary: ...\nThe full conversation history above this point has been summarized. Continue the current task using this context.' },
  // ... 保留的最近 N 轮
]

这个格式有两个关键设计:

1. 明确告诉模型"历史被压缩了"

[Context compressed — auto] 这个标记让模型知道:前面的对话不是完整的,如果它发现信息缺失,不是幻觉,是真的被压缩掉了。

没有这个标记,模型会试图"回忆"不存在的上下文,产生幻觉。

2. 摘要放在 user 消息里,不是 system prompt 里

system prompt 是固定的(工具定义、角色设定)。压缩摘要是动态的、会被覆盖的。放在 user 消息里,下次再压缩时直接替换,不会污染 system prompt。


四、PTL 兜底:API 报错了再压一次

即使有 80% 阈值,还是可能翻车——比如一个工具返回了一个巨大的文件内容(50K tokens),一下子从 75% 跳到 100%。

这时候 API 会报 prompt_too_long(PTL)错误。

我的兜底策略:捕获 PTL,压缩一次,重试

// src/core/context/manager.ts
async ptlRetry<T>(messages: RawMessage[], fn: () => Promise<T>): Promise<T> {
  try {
    return await fn()
  } catch (err: any) {
    const msg = (err?.message ?? '').toLowerCase()
    const isPtl = PTL_PATTERNS.some(p => msg.includes(p))
    if (!isPtl) throw err

    // 压缩后重试一次
    const compressed = await this.compress(messages, 'auto')
    messages.splice(0, messages.length, ...compressed)
    return fn()
  }
}

PTL_PATTERNS 覆盖了不同 API 的错误格式:

const PTL_PATTERNS = [
  'prompt is too long',
  'prompt_too_long',
  'context_length_exceeded',
  'maximum context length'
]

只重试一次。如果压缩后还是 PTL,说明问题不在历史长度(可能是 system prompt 本身太大),继续重试没意义。

PTL 不是异常,是正常运行的一部分。任何长对话 Agent 都会遇到,区别是你有没有自动恢复。


五、Hook 集成:压缩前可以归档

压缩意味着丢信息。有些场景你想在丢之前做点什么——比如把完整历史写到磁盘、发到日志系统、或者更新记忆数据库。

所以压缩前后都有 hook:

// pre-compress: 压缩前,可以归档或修改消息
let effectiveMessages = messages
if (this.hooks) {
  const transformed = await this.hooks.transform('pre-compress', { messages }, hookEnv)
  effectiveMessages = transformed.messages
}

// 执行压缩
const result = await compressor.run(effectiveMessages, this.model, this.modelName)

// post-compress: 压缩后,通知
await this.hooks?.fire('post-compress', {
  AGENT_COMPRESS_ORIGINAL_COUNT: String(messages.length),
  AGENT_COMPRESS_RESULT_COUNT: String(compressed.length)
})

pre-compress 是 transform 型——hook 可以修改消息列表(比如把某些重要消息标记为"不可压缩")。post-compress 是 fire 型——纯通知,用于监控。


六、流式重试:指数退避 + Full Jitter

压缩解决的是"上下文太长"。但还有另一类错误:API 暂时不可用(限流、网络抖动、服务端过载)。

这种错误需要重试,但不能无脑重试——否则会加剧服务端压力。

// src/core/agent/loop.ts — runWithStream
if (error.recoverable && attempt < maxRetries) {
  const baseDelay = 1000
  const maxDelay = 60000
  const exponentialDelay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt))
  const delay = Math.random() * exponentialDelay  // Full Jitter

  await new Promise(resolve => setTimeout(resolve, delay))
  return this.runWithStream(request, messages, attempt + 1)
}

为什么用 Full Jitter 而不是 Equal Jitter?

Equal Jitter:delay = exponentialDelay / 2 + random(0, exponentialDelay / 2)
Full Jitter:delay = random(0, exponentialDelay)

Full Jitter 的最小延迟可以是 0,Equal Jitter 的最小延迟是 exponentialDelay / 2

当多个 Agent 实例同时被限流时,Full Jitter 让它们的重试时间分布更均匀——有的立刻重试成功,有的等很久。Equal Jitter 让所有实例都至少等一半时间,反而容易在同一时刻再次撞车。

AWS 的论文 Exponential Backoff And Jitter 有详细对比。结论是 Full Jitter 在高并发场景下完成时间最短。

最大重试 10 次,最大延迟 60 秒。10 次指数退避 = 最坏情况等 ~2 分钟。超过这个时间,问题大概率不是暂时的,继续等没意义。


所以呢

上下文管理是 Agent 开发里最容易被忽视的问题。大部分教程的 Agent 只跑 3-5 轮对话,永远不会触发上下文限制。但真实使用中,一个复杂任务轻松跑 20-30 轮。

我的设计总结:

层级 机制 触发条件
预防 80% 阈值自动压缩 inputTokens > limit × 0.8
选择 三种策略按场景选 auto(默认)/ micro(轻量)/ manual(用户主动)
兜底 PTL 捕获 + 压缩重试 API 报 prompt_too_long
恢复 指数退避 + Full Jitter API 暂时不可用

四层叠起来,Agent 可以无限对话下去——信息会逐渐丢失(这是物理限制),但永远不会崩溃。

上下文窗口不是"够大就行"的问题。200K token 也会满。你的 Agent 要么有压缩策略,要么在第 20 轮崩溃。没有第三种可能。

代码:code-agent/src/core/context。ContextManager + 三个 Compressor 加起来不到 200 行。


下一篇讲记忆系统——上下文压缩丢掉的信息,怎么用跨 session 的记忆找回来。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐