跳转到内容

上下文压缩与 Token 优化

上下文压缩与 Token 优化

学习目标

读完本章后,你将能够:

  • 理解上下文窗口溢出的处理策略:截断、压缩、摘要
  • 设计基于对话边界的智能截断算法
  • 实现本地和远程两种压缩模式
  • 评估不同策略对对话质量和成本的影响

前置知识

  • Token 化基础(Tokenization)
  • 大语言模型的上下文窗口限制

核心概念

1. 上下文窗口的限制

大语言模型有固定的上下文窗口(如 128K tokens),当对话历史超过这个限制时,Agent 需要采取措施:

上下文窗口溢出的影响

  • 模型无法看到早期的系统提示或关键上下文
  • 导致回复质量下降或行为偏离
  • 可能触发 API 错误(context_window 错误码)

三种核心策略

策略原理信息保留度实现复杂度
截断(Truncation)丢弃超出窗口的最早消息
压缩(Compaction)将历史摘要化,减少 token 数
远程压缩将压缩任务 offload 到服务端

2. 基于对话边界的截断

截断不是简单地按消息数量或 token 数切割,而应该基于对话边界(user turn boundary):

[系统提示] [用户1] [助手1] [工具结果1] [助手1续] [用户2] [助手2] ...
↑ 完整保留某个 user turn 之前的所有内容

截断策略

  • 保留前 N 个用户轮次:保留最近的 N 个完整的 user-assistant 回合
  • 保留最后 N 个 fork turns:在多分支对话中保留最近的分支点
  • 硬截断:当窗口即将溢出时,从最早的完整回合开始删除

为什么基于 user turn?

  • 保证每个保留的回合都是语义完整的(用户输入 + 模型回复 + 工具结果)
  • 避免在回合中间截断,导致模型看到不完整的工具调用序列

3. 本地压缩

本地压缩将完整历史发送给模型,请求生成摘要:

输入: 完整对话历史(N tokens)
输出: 摘要(M tokens,M << N)

压缩格式

  • 保留所有用户消息(不可丢失)
  • 将助手回复和工具结果替换为摘要
  • 摘要前添加前缀标记(如 SUMMARY:),便于模型识别

触发条件

  • 上下文窗口即将溢出
  • 用户手动触发
  • 达到预设的 token 阈值

4. 远程压缩

某些模型提供商(如 OpenAI)支持在服务端进行上下文压缩:

  • 客户端发送完整上下文
  • 服务端自动压缩并返回优化后的上下文
  • 客户端无需实现压缩逻辑

优势

  • 实现简单,客户端无压缩逻辑
  • 服务端可使用更优化的压缩算法
  • 压缩质量通常更高

劣势

  • 依赖特定提供商的能力
  • 增加了 API 调用成本
  • 透明度低,无法精细控制压缩策略

设计模式详解

消息历史管理

用户输入历史(与对话上下文不同)需要独立管理:

存储格式

  • JSONL(每行一个 JSON 对象):{"session_id": "...", "ts": 1234567890, "text": "..."}
  • 优点:追加写入高效、人类可读、便于日志工具处理

截断策略

  • 硬上限:配置 max_bytes,超过时触发裁剪
  • 软上限:裁剪至 0.8 * max_bytes,避免频繁裁剪
  • 裁剪方向:从文件头部(最旧记录)删除,保留尾部(最新记录)

并发安全

  • 使用 advisory file lock 保护读写
  • 单行写入使用单 write(2) 系统调用 + O_APPEND
  • 依赖 POSIX PIPE_BUF 保证原子性

Token 用量追踪

实时监控 token 用量是优化上下文的基础:

TokenCount {
prompt_tokens: u32, // 输入 tokens
completion_tokens: u32, // 输出 tokens
total_tokens: u32, // 累计 tokens
}

用途

  • 预测何时需要压缩
  • 成本估算和限额控制
  • 性能分析(哪些操作消耗最多 token)

问题与规避

压缩导致的信息丢失

问题:摘要无法保留所有细节,可能导致模型丢失关键信息(如特定的文件名、配置值)。

对策

  • 保留所有用户消息(用户消息通常包含关键指令)
  • 对工具结果进行选择性保留(如保留文件路径但截断内容)
  • 提供”关键标记”机制:用户可标记某些信息不可压缩

频繁压缩的性能开销

问题:每次压缩都需要额外的模型 API 调用,增加延迟和成本。

对策

  • 软上限机制:在硬上限的 80% 处触发裁剪,减少压缩频率
  • 批量压缩:累积多个回合后再统一压缩
  • 本地估算:使用轻量级 tokenizer 预估算 token 数,避免不必要的压缩

并发写入的历史损坏

问题:多个进程同时写入历史文件,可能导致 JSONL 格式损坏。

对策

  • 使用 O_APPEND 标志的原子追加
  • 写入前获取 advisory lock
  • 定期验证文件格式,发现损坏时重建

设计取舍

本地压缩 vs 远程压缩

维度本地压缩远程压缩
实现复杂度高(需实现压缩逻辑)低(依赖提供商)
可控性高(可自定义策略)低(黑盒)
成本仅额外 API 调用可能包含压缩服务费
质量依赖提示工程通常更高
可用性通用(所有模型)仅限支持的服务商

推荐策略:优先使用远程压缩(如果可用),回退到本地压缩。本地压缩应设计为可插拔,便于切换策略。

硬上限 vs 软上限

方案优势代价
硬上限简单直接频繁触发压缩/截断
软上限减少压缩频率实现稍复杂
动态上限自适应负载预测算法复杂

推荐策略:软上限(如 80% 硬上限)作为预警线,硬上限作为绝对防线。

可插拔 Compaction Provider

将压缩逻辑抽象为可插拔接口,允许自定义压缩策略:

接口设计

interface CompactionProvider {
async compact({ messages, instructions, identifierPolicy }): Promise<CompactedContext>;
}

使用场景

  • 专用压缩模型:使用更便宜或更快的模型进行摘要(如用轻量级模型压缩,用强模型对话)
  • 外部压缩服务:将压缩任务 offload 到专门优化的服务端
  • 领域特定压缩:法律、医疗等领域使用经过 fine-tune 的压缩策略

集成方式

  1. 插件注册 provider:api.registerCompactionProvider("my-provider", provider)
  2. 配置中指定:agents.defaults.compaction.provider: "my-provider"
  3. 运行时委托:当需要压缩时,调用注册的 provider 而非内置逻辑

回退策略

  • 若 provider 失败或返回空结果,自动回退到内置 LLM 摘要
  • 设置 provider 时强制 mode: "safeguard",确保保留最近回合上下文

与内置压缩的对比

维度内置压缩可插拔 Provider
可用性始终可用依赖插件安装和配置
质量依赖主模型可使用专用模型/服务
成本使用主模型可使用更便宜的模型
可控性固定策略完全自定义

记忆 Flush(压缩前保护)

在压缩执行前,自动触发一个静默 turn 提醒 Agent 将重要上下文保存到记忆文件:

目的:防止压缩导致的重要信息丢失 机制

  • 在 auto-compaction 触发前,先执行一次无用户可见输出的 Agent turn
  • Agent 使用 memory_write 等工具将关键事实写入 MEMORY.md 或 daily notes
  • 然后再执行压缩,摘要化对话历史

设计权衡

  • 优点:重要信息持久化到磁盘, survived 压缩
  • 代价:额外的模型调用和延迟;Agent 可能误判哪些信息”重要”

双策略压缩:Compaction + Prune

单一压缩策略无法应对所有场景。一个更健壮的做法是两种策略并行

策略触发条件行为信息保留
Compaction上下文溢出或达到阈值生成摘要,替换历史消息高(保留关键决策)
PruneCompaction 后仍然过大裁剪工具输出内容中(仅裁剪输出,保留调用记录)

Prune 策略

  • 从最近的消息往前扫描,累积工具输出的 token 数
  • 保留最近 2 轮的完整工具输出(PRUNE_PROTECT = 40K tokens)
  • 超出保护阈值后,裁剪更早期的工具输出(保留调用标题和元数据,删除内容)
  • 保护规则:某些工具的输出永不被裁剪(如 skill 工具,因为技能指令是关键上下文)

Tail Turns 保留策略

压缩时不应该把所有历史都摘要化。最近几轮对话通常需要完整保留,因为 Agent 正在处理这些内容。

Tail 预算计算

budget = min(8000, max(2000, usable_tokens * 0.25))

即保留最近对话的 token 预算为可用窗口的 25%,上限 8K,下限 2K。

Tail 选择算法

  1. 识别所有 user turn(不包含已压缩的 turn)
  2. 从最近开始往前累加,直到超过 budget
  3. 如果最后一个 turn 太大,尝试从 turn 内部切分(保留 turn 的后半部分)
  4. 最终结果:head(可摘要的历史)+ tail(完整保留的最近对话)

增量更新摘要

每次压缩时,不重新生成完整摘要,而是基于上一轮摘要增量更新:

prompt = "Update the anchored summary below using the conversation history above.
Preserve still-true details, remove stale details, and merge in the new facts.
<previous-summary>
{上一轮摘要}
</previous-summary>"

优势

  • 节省 token:只需发送新对话 + 旧摘要,而非全部历史
  • 信息累积:摘要随对话增长而越来越完整
  • 减少漂移:增量更新比重新生成更稳定,不易丢失已保存的事实

自动续跑(Auto-Continue)

压缩完成后,Agent 的上下文被重置,但用户的原始意图可能尚未完成。自动续跑机制在压缩后自动生成提示,让 Agent 继续工作:

"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."

触发条件

  • 仅对自动触发的压缩(auto-compaction)启用
  • 手动触发的压缩不自动续跑,等待用户下一步指令
  • 插件可以通过 Hook(experimental.compaction.autocontinue)控制是否启用

回放机制:如果压缩前的上下文包含用户最近的消息但已被压缩掉,系统会将该消息回放为新的用户消息,确保 Agent 不会丢失用户的最新指令。

溢出降级(Overflow Fallback)

当压缩本身也无法将上下文缩减到模型限制内时(如附件图片太大、对话历史极长),系统进入溢出降级模式:

  • 剥离所有媒体附件(图片、文件)
  • 压缩工具输出到最小(TOOL_OUTPUT_MAX_CHARS = 2000)
  • 如果仍然溢出,标记为 error 并告知用户 “Conversation history too large to compact”
  • 建议用户减少附件大小或数量,或开启新的 Session

基于图结构的上下文管理

传统的上下文管理是线性列表(消息数组),但当上下文来源多样化(文件、工具输出、子 Agent 结果、记忆)时,线性结构无法表达依赖关系和增量更新。一个更高级的方案是图结构上下文表示

核心组件

组件职责
ContextGraph用 DAG 表示上下文,每个节点代表一块上下文内容(系统提示、工具输出、文件内容等),节点之间有依赖边
触发器(Trigger)每个节点声明何时需要被重新处理(内容变化、token 阈值、定时等)
处理器(Processor)对节点执行具体操作:蒸馏(去除冗余)、掩码(隐藏敏感信息)、压缩(摘要化)、裁剪(截断长输出)
ContextWorkingBuffer双缓冲机制:维护”纯净图”(原始状态)和”当前图”(处理后状态),处理器结果通过事件总线驱动更新
PipelineOrchestrator多处理器并行/串行调度,按依赖顺序执行处理器
ContextEventBus处理器完成后发布结果事件,ContextWorkingBuffer 验证不变量后应用更新

关键设计模式

  • Pull Master 模式:ContextManager 是”拉取主节点”,主动告诉 Orchestrator 该处理什么,而非被动等待事件
  • 节点缓存(Redundant Render Cache):基于节点 ID 哈希值缓存渲染结果,避免相同节点的重复处理
  • 历史硬化(History Hardening):确保渲染出的 API History 格式一致(角色正确、工具调用/响应配对等)
  • 不变量检查器(Invariant Checker):在应用处理器结果前验证图状态一致性

令牌蒸馏(Token Distillation)

在图结构上下文中,令牌蒸馏是一种高级压缩技术:将节点内容通过轻量级模型或规则转换为更紧凑的表示,同时保留关键信息。

蒸馏级别

级别保留内容压缩率
原始完整内容1x
轻量去除空白和重复行~0.8x
中等关键行 + 结构摘要~0.3x
重度纯摘要~0.1x

蒸馏级别根据当前 token 预算动态选择:token 充裕时保留原始内容,紧张时使用重度蒸馏。

结构化文本中的 URL 缩短与恢复

当上下文中包含大量 URL(如浏览历史、搜索结果)时,长 URL 的 query 参数会消耗大量 token。一种有效的策略是自动截断 + 哈希标记 + 双向恢复

原始 URL: https://example.com/search?q=very+long+query+string...&page=1&sort=desc&filter=active
缩短后: https://example.com/search?q=very+long+quer...a1b2c3d

算法

  1. 用正则匹配 URL,分离 base(域名 + 路径)和 after_path(query + fragment)
  2. 如果 after_path 超过限制(如 25 字符),截断并附加 MD5 哈希的前 7 位
  3. 维护 shortened → original 的映射表
  4. LLM 回复时,递归遍历 Pydantic 模型中的所有字符串字段,将缩短的 URL 恢复为原始 URL

关键设计

  • 缩短必须是对称可逆的,否则 LLM 返回的缩短 URL 无法使用
  • 只在缩短后实际变短时才使用(避免短 URL 被无意义地加长)
  • 递归处理嵌套 Pydantic 模型、字典、列表/元组中的所有字符串

为什么不直接删除 query 参数:保留截断部分给 LLM 提供了语义线索(如 q=very+long+quer 暗示这是一个搜索查询),而哈希保证了精确恢复。

DOM 感知的上下文截断

在浏览器自动化场景中,DOM 树的文本表示是上下文中最大的部分。简单的字符截断会破坏 DOM 结构:

策略

  • 设置 max_clickable_elements_length(如 40000 字符)
  • DOM 文本表示生成时逐字符累积,超过限制时截断
  • 截断后附加 (truncated to N characters) 标记
  • 配合页面统计(links、interactive_elements、iframes 数量),即使被截断,LLM 仍然知道页面全貌

陷阱:截断可能刚好切断一个重要的交互元素。更优策略是按元素边界截断(而非字符边界),保证每个列出的元素是完整的。

显式 Prompt Caching 标记

支持 Prompt Caching 的模型(如 Anthropic Claude)需要在消息级别标记哪些内容可缓存:

# 系统提示标记为可缓存(每次请求都相同)
SystemMessage(content=prompt, cache=True)
# 用户消息中的截图标记为可缓存(同一页面的历史截图可能重复)
UserMessage(content=[text, image], cache=True)

缓存边界设计

  • 将变化最少的内容放在前面(系统提示 → 工具定义 → 历史),变化最多的放在最后(当前浏览器状态 → 步骤信息)
  • 这样即使当前状态变化,前面的缓存前缀仍然命中
  • 步骤信息(step counter、日期)放在消息末尾,因为它每步都变

Anthropic 4.5 模型的特殊要求:Claude Opus 4.5 / Haiku 4.5 需要 prompt 达到 4096+ token 才能触发缓存命中。对于这些模型,使用更长的系统提示模板以确保超过缓存阈值。


OpenHands 补充:6 种 Condenser 策略

OpenHands 通过 config.template.toml 提供了 6 种 Condenser 类型,覆盖从”不做任何处理”到”LLM 动态注意力评分”的全谱系。

策略对比表

策略原理Token 节省额外成本适用场景
noop保留完整历史短对话、大窗口模型
observation_masking保留事件结构,掩码旧观察需要保持事件完整性但内容可压缩
recent仅保留最近 N 个事件任务导向、不需要长期记忆
llmLLM 摘要化历史有(额外 API 调用)复杂任务需要语义摘要
amortized智能遗忘,保留重要事件长期对话、需要选择性记忆
llm_attentionLLM 评分决定保留哪些上下文最高有(每次评分调用 LLM)高度复杂的长对话

关键配置参数

  • keep_first:始终保留的初始事件数(通常是任务描述)
  • max_size / max_events:触发压缩的事件数阈值
  • attention_window:observation_masking 中不被掩码的最近事件数
  • llm_config:llm/llm_attention 使用的 LLM 配置引用

默认 Condenser 开关

enable_default_condenser = true 时,未配置 Condenser 的情况下自动使用 LLMSummarizingCondenserConfig;设为 false 则使用 NoOpCondenserConfig(无压缩)。

陷阱

  • LLM 类型 Condenser 在长对话中会产生额外 token 成本(每次压缩都是一次完整的 LLM 调用)
  • attention_window 设置过小会导致有效上下文不足;设置过大则压缩效果不明显
  • Amortized Forgetting 的事件重要性评分是启发式的,可能遗忘看似不相关但实际关键的上下文

AutoGen 补充:四种 ChatCompletionContext 策略

来源:AutoGen(microsoft/autogen)python/packages/autogen-core/src/autogen_core/model_context/,commit 027ecf0

AutoGen 在 Core 层定义了四种 ChatCompletionContext 策略,分别对应不同的上下文管理需求:

策略类名原理Token 控制
无限制UnboundedChatCompletionContext累积所有消息无,超出窗口会报 API 错误
缓冲区BufferedChatCompletionContext保留最近 N 条消息按消息数量控制
首尾保留HeadAndTailChatCompletionContext保留系统提示 + 最近消息,丢弃中间按消息数量控制
Token 限制TokenLimitedChatCompletionContext按 Token 计数,从最旧开始截断按 Token 计数控制

HeadAndTail 的设计灵感:系统提示(头部)包含关键指令不可丢弃,最近消息(尾部)包含当前对话状态不可丢弃,中间部分可以安全摘要或丢弃。这与上文所述的 Head/Tail 预算计算策略一致。

选择建议

  • 短对话测试:Unbounded(最简单)
  • 工具密集型任务:TokenLimited(精确控制,防止超出窗口)
  • 长时间对话:HeadAndTail(保留指令和最近上下文)
  • 对话历史不重要的场景:Buffered(只关心最近几轮)

陷阱TokenLimitedChatCompletionContext 的截断点是消息级别的,如果工具调用(请求+响应)跨越截断点,模型会收到不完整的工具调用序列。应对方法是将工具调用消息作为原子单元处理。


参考来源

案例补充:CodeWhale 前缀缓存感知的压缩策略

本补充基于 CodeWhale 源码分析,首次覆盖于 2026-06-01。

CodeWhale 的压缩(compaction)设计展示了前缀缓存经济学在实践中的应用:

关键设计

  1. 纯 Token 触发(v0.8.11 移除了消息计数触发器)——长消息小会话不再误触发压缩
  2. 80% 上下文窗口阈值——V4 1M 窗口在 800K tokens 时触发(60% 时会建议手动 /compact
  3. 500K 自动压缩地板——低于此值 should_compact 直接返回 false,无论 enabledtoken_threshold
    • 经济学原因:低 token 数时前缀缓存健康,压缩重写缓存的成本(miss 价格 × 全量重填)远超收益(少量预算回收)
    • 高于地板时压缩仍可能净收益——缓存已受压、前缀已漂移、回收预算更重要
  4. 手动 /compact 绕过地板——用户显式请求不受自动策略约束
  5. 模型未知模型的优雅降级——未知模型的默认阈值从 50K 提升到 102.4K(旧版 128K 窗口的 80%),而非在 5% 窗口时就触发