上下文压缩:让 Agent 在有限窗口里「记得住」
Hermes 如何用三层保护 + 结构化摘要,在 200K token 窗口里装下数小时的对话
通过 Hermes 探秘 Agent 工程 | 第 7 篇 · 查看全部
[系列文章导航]
| # | 文章 | 定位 |
|---|---|---|
| 1 | Agent Loop:Agent 的核心执行循环 | 入口 |
| 2 | System Prompt:身份、上下文与策略的三层架构 | 认知层 |
| 3 | 工具系统:从注册到调度 | 工具层 |
| 4 | 工具调度系统:从注册到执行的完整生命周期 | 调度层 |
| 5 | 安全防护体系:当 Agent 拥有终端时如何防止「做出格」的事 | 安全层 |
| 6 | 沙箱与代码执行:让 Agent 安全跑代码的 RPC 架构 | 执行层 |
| 7 | 上下文压缩:让 Agent 在有限窗口里「记得住」 | 记忆层 |
| 8 | 记忆系统:跨会话持久化的工程实现 | 记忆层 |
| 9 | 技能系统:Agent 如何把经验变成可复用的程序化记忆 | 记忆层 |
| 10 | Provider 抽象层:让 Hermes 同时驾驭 30+ 个 LLM 提供商 | 模型层 |
| 11 | Gateway 网关:连接 20+ 平台的统一消息路由 | 接入层 |
| 12 | 多 Agent 协作:委托、调度与看板 | 协作层 |
问题:上下文是"有价商品"
一个典型的 Agent 任务在第七步时,上下文里已经有十几条消息,每条消息都带着工具的输入和输出。对于一个 200K token 的窗口,大约 30-50 轮工具调用就会把窗口撑满。
Hermes 的解决方案是上下文压缩——不是简单丢弃旧消息,而是用一段结构化的摘要替换掉中间部分,保留关键信息、释放空间。
压缩的三个核心问题
- 什么时候压缩? 对话进行到什么程度才需要压缩?
- 压缩哪部分? 全部压缩还是只压缩一部分?
- 怎么压缩? 摘要应该包含什么内容?
Phase 1:要不要压缩
Hermes 在每次收到 API 响应后,通过 Provider 返回的 usage.prompt_tokens 来判断当前对话占用了多少 token。用 Provider 实际报告的数字,而不是自己估算——这意味着不会出现"预估没超但实际超了"的情况。
默认阈值为上下文窗口的 50%,最小不低于 64K token。 Hermes 的 ContextCompressor 在初始化时通过 _compute_threshold_tokens 计算阈值:
# 源码:agent/context_compressor.py
effective_window = context_length - (max_tokens or 0)
pct_value = int(effective_window * threshold_percent)
return max(pct_value, context_length) # floored at window size
注意:对于 64K 的小模型,max(0.5 * 64000, 64000) == 64000,实际就是对话占满窗口时才压缩。
Phase 2:确定「切割边界」
保护头尾,只压中间。
- 头部:系统提示词 + 前几条用户消息
- 尾部:最近约 50% token 预算的对话
- 中间:最早的历史对话,可以压缩的部分
工具调用组不能切断
Hermes 有 boundary alignment 机制——每一对 tool_calls ↔ tool_results 要么整个保留、要么整个压缩,防止 Provider 返回 400。
最近一条用户消息不能被压
强制回路确保最新用户消息始终在尾部,防止被摘要后误执行。
Phase 3:生成结构化摘要
送进摘要模型之前,对中间区域做三层瘦身:
- 工具结果替换为一行描述:每一条 tool result 超过 200 字符就用自动生成的短描述替代(如
[terminal] ran 'git log --oneline -20' -> exit 0, 20 lines output) - 内容截断(每消息上限 6000 字符):头 4000 + 尾 1500
- 图片清理:base64 图片被替换为文字占位符
thinking / reasoning_content 不设防
_serialize_for_summary 只读取 content 和 tool_calls 两个字段——思考链不在摘要范围内,直接丢弃。这是一个取舍:思考链可能对后续对话有价值,但占空间太大。
Hermes 的结构化摘要模板
## Active Task
[用户最近一条未读消息——逐字包含原话]
## Goal
[用户这次对话的总体目标]
## Constraints & Preferences
[用户偏好、编码风格、重要约束]
## Completed Actions
1. READ config.py:45 — found bug [tool: read_file]
2. PATCH config.py:45 — fixed comparison [tool: patch]
3. TEST pytest — 47/50 passed [tool: terminal]
## Active State
[当前正在做什么]
## Blocked
[还没解决的错误或障碍]
## Key Decisions
[重要技术决策及其原因]
## Resolved Questions
[已经被回答过的问题——防止重复回答]
## Pending User Asks
[还没完成的请求——参考性质,不要主动去做]
## Relevant Files
[文件路径列表]
## Remaining Work
[还有什么没做完——参考性质]
迭代更新
Hermes 的 ContextCompressor 保存了 _previous_summary 字段——每次压缩后把摘要"记住",下次压缩时作为迭代更新的基础。
组装与清理
压缩完成后:
- 工具对清理:检查孤立的
tool_call/tool_result,插入 stub 或删除 - 图片清理:老消息里的 base64 图片被替换为文本占位符
- 元数据注入:第一条系统提示词里追加压缩说明
两种压缩模式
| 旋转模式 | 原地归档模式 | |
|---|---|---|
| session_id | 新的子 session_id | 保持原来的 session_id |
| 旧对话 | 结束原 session,创建新 session | 原 session 内归档 |
| 默认 | 是 | 否(compression.in_place: true 开启) |
并发压缩锁
Hermes 使用一个基于 SQLite state.db 的压缩锁来解决多线程并发压缩的问题:压缩前先获取锁,获取失败则放弃本次压缩。
摘要失败怎么办?
- 主模型回退:辅助模型不可用时退回用主模型
- 确定性摘要:所有 LLM 都失败时,直接从对话里提取用户说了什么、调用了哪些工具、最后几条消息
- 中止并保留:配置了
abort_on_summary_failure: true时放弃压缩
什么时候触发压缩?
- 手动触发:用户发送
/compress命令 - 自动触发:API 响应里的
prompt_tokens超过阈值 - 反复无效压缩保护:最近两次压缩各自只减少了不到 10% 的消息量,自动跳过
下一篇:记忆系统:跨会话持久化的工程实现 — Agent 如何跨会话持久化信息,让用户"上次聊了什么"不会忘