上下文压缩与 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 的压缩策略
集成方式:
- 插件注册 provider:
api.registerCompactionProvider("my-provider", provider) - 配置中指定:
agents.defaults.compaction.provider: "my-provider" - 运行时委托:当需要压缩时,调用注册的 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 | 上下文溢出或达到阈值 | 生成摘要,替换历史消息 | 高(保留关键决策) |
| Prune | Compaction 后仍然过大 | 裁剪工具输出内容 | 中(仅裁剪输出,保留调用记录) |
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 选择算法:
- 识别所有 user turn(不包含已压缩的 turn)
- 从最近开始往前累加,直到超过 budget
- 如果最后一个 turn 太大,尝试从 turn 内部切分(保留 turn 的后半部分)
- 最终结果: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算法:
- 用正则匹配 URL,分离 base(域名 + 路径)和 after_path(query + fragment)
- 如果 after_path 超过限制(如 25 字符),截断并附加 MD5 哈希的前 7 位
- 维护
shortened → original的映射表 - 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 个事件 | 高 | 无 | 任务导向、不需要长期记忆 |
| llm | LLM 摘要化历史 | 高 | 有(额外 API 调用) | 复杂任务需要语义摘要 |
| amortized | 智能遗忘,保留重要事件 | 高 | 无 | 长期对话、需要选择性记忆 |
| llm_attention | LLM 评分决定保留哪些上下文 | 最高 | 有(每次评分调用 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 的截断点是消息级别的,如果工具调用(请求+响应)跨越截断点,模型会收到不完整的工具调用序列。应对方法是将工具调用消息作为原子单元处理。
参考来源
- OpenAI Tokenizer
- Hierarchical Neural Story Generation, Fan et al., 2018
- 源码验证: OpenHands
config.template.toml[Condenser] 配置段 — 6 种 Condenser 策略定义
案例补充:CodeWhale 前缀缓存感知的压缩策略
本补充基于 CodeWhale 源码分析,首次覆盖于 2026-06-01。
CodeWhale 的压缩(compaction)设计展示了前缀缓存经济学在实践中的应用:
关键设计:
- 纯 Token 触发(v0.8.11 移除了消息计数触发器)——长消息小会话不再误触发压缩
- 80% 上下文窗口阈值——V4 1M 窗口在 800K tokens 时触发(60% 时会建议手动
/compact) - 500K 自动压缩地板——低于此值
should_compact直接返回 false,无论enabled或token_threshold- 经济学原因:低 token 数时前缀缓存健康,压缩重写缓存的成本(miss 价格 × 全量重填)远超收益(少量预算回收)
- 高于地板时压缩仍可能净收益——缓存已受压、前缀已漂移、回收预算更重要
- 手动
/compact绕过地板——用户显式请求不受自动策略约束 - 模型未知模型的优雅降级——未知模型的默认阈值从 50K 提升到 102.4K(旧版 128K 窗口的 80%),而非在 5% 窗口时就触发