上下文压缩:让 Agent 在有限窗口里「记得住」
Hermes 如何用三层保护 + 结构化摘要,在 200K token 窗口里装下数小时的对话
系列:通过 Hermes 探秘 Agent 工程 | 第 4 篇 上一篇:System Prompt:身份、上下文与策略的三层架构
问题:上下文是"有价商品"
第二个系列文章里聊过,系统提示词是 Agent 的"宪法"——它告诉模型你是谁、能做什么、处于什么环境。但宪法只占上下文窗口的一部分,更大的部分是对话历史本身。
一个典型的 Agent 任务:
- 用户说:帮我写一个 Python 脚本来解析日志文件
- Agent 运行
write_file生成脚本 - Agent 运行
terminal执行脚本 - 脚本报错了
- Agent 运行
read_file查看错误行 - Agent 运行
patch修改脚本 - Agent 再次执行……
到第 7 步时,上下文里已经有十几条消息,每条消息都带着工具的输入和输出。如果日志文件有几 MB,一条 read_file 的 tool result 就能吃掉几千 token。
对于一个 200K token 的窗口,如果不做干预,大约 30-50 轮工具调用就会把窗口撑满。之后要么被 Provider 拒绝(400 错误),要么被迫截断历史——而截断意味着丢失上下文,Agent 的表现会急剧下降。
Hermes 的解决方案是上下文压缩——不是简单丢弃旧消息,而是用一段结构化的摘要替换掉中间部分,保留关键信息、释放空间。
压缩的三个核心问题
在动手之前,先想清楚三件事:
- 什么时候压缩? 对话进行到什么程度才需要压缩?
- 压缩哪部分? 全部压缩还是只压缩一部分?
- 怎么压缩? 摘要应该包含什么内容?
Hermes 的实现逻辑清晰,每一层都有明确的工程判断。
Phase 1:要不要压缩
Hermes 不会每轮对话都检查是否需要压缩(太浪费 token),而是在每次收到 API 响应后,通过 Provider 返回的 usage.prompt_tokens 来判断当前对话占用了多少 token。
如果这个数字低于阈值(默认 50% 的上下文窗口),什么都不做。一旦超过阈值,就触发压缩。
——这里有个精妙的地方:Hermes 用 Provider 实际报告的数字,而不是自己估算。这意味着即使你的估算模型不准(比如图片 token 计算有偏差),最终也是按 Provider 实际计费来判断的,不会出现"预估没超但实际超了"的情况。
Phase 2:确定「切割边界」
压缩的核心原则是:保护头尾,只压中间。
- 头部:系统提示词 + 前几条用户消息。这部分包含任务最初的上下文,不能压。
- 尾部:最近 ~20K token 的对话。这部分是刚刚发生的事,Agent 需要它来继续工作。
- 中间:最早的历史对话,已经被尾部"盖过去"了——这是可以压缩的部分。
但切边界不是简单的"从头数 N 条消息"。Hermes 有几个特殊处理:
工具调用组不能切断
Agent 的对话里,一条 assistant tool_calls 消息后面一定跟着几条 tool result 消息(每个工具调用一条结果)。如果在中间切断——tool_calls 被压掉了,tool result 还留着——Provider 会收到一个"没有对应 tool_call 的 tool_result",返回 400 错误。
所以压缩切边界的时候,必须保证每一对 tool_calls ↔ tool_results 要么整个保留、要么整个压缩。Hermes 把这个叫做 boundary alignment。
最近一条用户消息不能被压进去
如果压缩算法不小心把用户最新的一条消息切到了"中间区域",这段对话就会被写进摘要。摘要的开头有一行明确指令:"只响应摘要之后的消息,不要响应摘要里的请求"——但用户最新的一条消息和摘要里的消息可能混在一起,模型可能误以为这条新消息也需要"响应",导致 Agent 卡在历史任务上出不来。
所以 Hermes 有一个强制回路:无论 token 预算怎么算,最近一条用户消息必须在尾部区域。如果算出来的切割点会把它压在中间,就把切割点往前拉,直到这条消息落在尾部。
同理,最近一条 assistant 可见回复(用户最后看到的那个回复)也不能被压进去——否则用户在 WebUI 里会突然看到一条"Context Compact"的占位符,而看不到刚刚的回复。
Phase 3:生成结构化摘要
切割点确定后,中间区域会被提取出来,送进一个辅助 LLM 模型生成摘要。
但在送进摘要模型之前,Hermes 对中间区域的工具输出做了三层瘦身。这三层发生在摘要模型调用之前,是免费的(不需要额外 LLM 调用)。
第一层:工具结果替换为一行描述
对中间区域里每一条 tool result(tool role 的消息),如果内容超过 200 字符,就用一段自动生成的描述替代:
[terminal] ran `git log --oneline -20` -> exit 0, 20 lines output
[read_file] read /project/src/main.py from line 1 (15,200 chars)
[search_files] content search for 'compress' in agent/ -> 12 matches
[web_extract] https://example.com/docs (8,400 chars)
不是手动模板——Hermes 根据每种工具的类型自动选择合适的折痕格式。每种工具都有一个摘要模板:terminal 显示 exit code + 行数,read_file 显示路径 + 长度,show_command 显示匹配数量……
第二层:内容截断(每消息上限 6000 字符)
经过第一层替换后,剩下的长文本(比如用户消息、assistant 的文本内容)在送到摘要模型时会被截断到 6000 字符。具体是头 4000 + 尾 1500,中间用 ...[truncated]... 替代。
这意味着:一条 50,000 字符的日志文件,先被折痕替代(如果它是 tool result),之后作为中间区域的内容又被截断到 6000 字符——只有两端留下来了。
第三层:图片清理
中间区域里的 base64 图片消息会被替换成文字占位符(如 [Attached image — stripped after compression]),只保留最新的一张 user 消息里的图片。
这一层专门处理 agent 使用 computer_use 时产生的大体积截图——不卸载就会每次请求都携带几 MB 的数据。
三层瘦身让我能得到什么?
一个典型的例子:假设中间区域有 20 条工具调用结果,平均每次 5000 字符,总计 100,000 字符。
- 第一层后:每条工具结果被 1-2 行描述替代,总计 ~80 字符 → 缩减 99.9%
- 第二层后:如果有超长 assistant 思考内容(超过 6000 字符),被截断
- 第三层后:如果有 base64 图片,被替换
所以摘要模型实际看到的可能是 2000-3000 字符的"精炼版历史",而不是 100K+ 的原始对话。
Hermes 摘要里丢失了什么?thinking 不设防
_serialize_for_summary 函数只读取两类字段:content 和 tool_calls。这意味着:
- ✅ 用户消息(完整度视截断情况)
- ✅ 工具调用名称和参数
- ✅ 工具结果(折痕替代后的短描述)
- ❌ thinking / reasonig_content —— 直接不在摘要范围内
也就是说:你之前对话中模型的思考链(reasoning content)不会被写入摘要,它直接就"消失"了。
这是一个取舍:思考链可能很长(几千甚至上万 token),但对后续对话的价值相对有限。Hermes 选择不携带 thinking 内容跨摘要——如果模型在下次对话中需要这些信息,它可以通过工具重新读取文件来获取。
工具对完整性的强制保护
摘要生成完成后,压缩的消息列表里可能出现一种"非法状态":
- 一条
assistant消息里有tool_call,但对应的tool_result在摘要中被截断了 - 一条
tool消息里有tool_call_id,但对应的assistant(tool_calls)被压缩到了摘要里
这种情况会导致 Provider 返回 400(“没有匹配的 tool_call” 或 “tool_result 找不到对应的 request”)。
Hermes 通过 _sanitize_tool_pairs() 解决这个问题:
- 删除孤立的
tool_result(对应的 tool_call 没了) - 为 orphan
tool_call插入 stub result:[Result from earlier conversation — see context summary above]
确保每一条发送给 Provider 的消息列表永远是格式正确的。
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
[还有什么没做完——参考性质]
这个模板不是随便写的——它背后的逻辑是:让模型在下次看到这段摘要时,能立刻理解"之前发生了什么、当前应该做什么",而不会把旧任务当成新任务重新执行。
为了防止模型把摘要里的旧请求当成新任务,摘要开头有一行关键的前缀指令:
[CONTEXT COMPACTION — REFERENCE ONLY] ... 不要响应摘要里的任何请求,只响应摘要之后出现的最新消息。
摘要末尾还有一行 end marker,防止模型把摘要里的 “Active Task” 部分当成用户新消息读进去。
时间锚定:防止"过期任务复活"
摘要里有一个被很多 Agent 忽略的设计:temporal anchoring。
如果摘要里写着 “email John about the proposal”,模型可能会认为这是一个待办事项,重新执行一遍。但如果摘要里写着 “Sent the proposal email to John on 2026-07-02”,模型就知道这件事已经做过了。
Hermes 在摘要模板里明确告诉.summary 模型:如果动作已经完成,用过去时 + 具体日期来写。这样就不会把历史任务重新激活。
迭代更新:多次压缩不会丢失信息
第一次压缩后,摘要替代了中间区域。第二次压缩时,需要把"第一次摘要 + 新的中间对话" 一起再压缩成一个更短的摘要。
Hermes 的 ContextCompressor 类保存了 _previous_summary 字段——每次生成的摘要被"记住",下次压缩时作为迭代更新的基础。摘要模型的 prompt 会变成:“这是上次压缩的摘要,这是新发生的对话,请更新摘要”。
这确保了在 50 轮甚至 100 轮对话后,早期的关键信息(用户最初的目标、重要决策、文件路径)不会随着一次又一次的压缩而消失。
Phase 4:组装与清理
压缩完成后,Hermes 把三部分拼成一个新的消息列表:
[头部消息(原样)]
[摘要消息]
[尾部消息(原样)]
然后做几件清理工作:
- 工具对清理:检查有没有"有 tool_call 但没有对应 tool_result"或反之的情况。如果有,要么插入一个占位 result,要么删除孤立的 result,确保消息列表永远合法。
- 图片清理:老消息里的 base64 图片(可能几 MB 一条)会被替换成文本占位符。只保留最近一张 user 消息里的图片。
- 元数据注入:第一条系统提示词里会追加一个压缩说明,让模型知道"早期的对话已经被压缩成摘要了"。
- session 分离:压缩完成后,旧的对话历史会被归档(或结束),新的压缩后历史会启用一个新的 session_id(旋转模式)或在原 session 下更新(原地归档模式)。
两种压缩模式:旋转 vs 原地归档
Hermes 实现了两种压缩后处理模式:
| 旋转模式(legacy) | 原地归档模式(in-place) | |
|---|---|---|
| session_id | 新的子 session_id | 保持原来的 session_id |
| 旧对话 | 结束原 session,创建新 session | 原 session 内归档(active=0),替换为新消息列表 |
| 搜索 | 原对话仍在数据库里,可搜索恢复 | 仍在数据库里,可搜索恢复 |
| 优点 | 干净、每段独立 | 只有一个 session_id,不产生孤儿对话 |
| 缺点 | session_id 频繁变化 | 实现更复杂 |
默认使用旋转模式,但在配置 compression.in_place: true 时切换到原地归档模式。原地归档模式下,整个对话保持同一个 session_id,不会因为压缩而产生"父 session / 子 session / 孙 session"的层级关系。
并发压缩锁
一个 session 可能同时有多个 agent 实例在运行(比如主对话线程和 background_review 线程)。如果两者同时检测到 token 超过阈值并各自触发压缩,两个线程都会生成摘要、各自旋转 session_id——最终会产生两个并行的"子 session",其中一个会成为孤儿。
Hermes 使用一个基于 SQLite state.db 的压缩锁来解决这个问题:压缩前先获取锁,获取失败则放弃本次压缩,等另一边的压缩完成后再试。
摘要失败怎么办?
辅助模型可能失败(网络超时、限流、模型不存在)。Hermes 的处理方式:
- 主模型回退:如果辅助模型不可用,会退回到用主模型来生成摘要——毕竟主模型是经过验证的,虽然贵一点但至少能用。
- 确定性摘要:如果所有 LLM 都失败了(aux 和 main 都失败了),Hermes 会生成一个确定性摘要——不依赖 LLM,直接从对话里提取:用户说了什么、调用了哪些工具有哪些文件、最后几条消息是什么。虽然不如 LLM 写的丰富,但至少能防止信息完全丢失。
- 中止并保留:如果配置了
abort_on_summary_failure: true,或者错误类型是认证失败(401/403),Hermes 会放弃压缩,保持对话原样——因为在这种情况下创建子 session 会让对话进入一个无法继续的状态,不如不生。
什么时候触发压缩?
手动触发:用户发送 /compress 命令时。
自动触发:API 响应里的 prompt_tokens 超过 threshold_tokens(默认上下文窗口的 50%,最小不低于 64K token)。
有一个"反复无效压缩"的保护:如果最近两次压缩各自只减少了不到 10% 的消息量,会自动跳过压缩——这说明对话里有一条巨大的消息(比如一次 terminal 输出几十万字符),压缩算法压不动它,再压缩也是浪费。
工程启示
1. 分层保护是 Agent 的标准配置
重要的不是"压缩率",是"保护什么"。头部(初始上下文)、尾部(最近对话)、工具对(消息合法性)——这三个边界必须守住了,再谈压缩。
2. 结构化摘要比自由摘要效果好得多
给摘要模型一个模板(Active Task、Goal、Completed Actions…),比让它自由发挥写一段总结要稳定得多。模板确保每次压缩后关键信息都在固定的位置,下游的模型不需要"理解"摘要的格式——它只需要定位 “## Active Task” 这一行从哪开始。
3. 抽象"边边界对齐"能力
在 Agent 工程中,很少有"单纯的数据处理"——你要考虑 tool_call / tool_result 的配对规则、system message 的特殊性、图片消息的大小代价。这些边界情况不是 bug,是 Agent 特有的业务逻辑。
4. 并发安全是底线
多 Agent 共享 session 时,“两个线程同时做同一件事"是常见的故障模式。压缩锁不只是"防止出错”——它保证了即使有 background_review 在跑,主对话线程的压缩也能正确工作。
总结
上下文压缩不是简单的"旧消息塞进摘要"。它是一整套工程方案:分层保护 + 边界对齐 + 结构化摘要 + 迭代更新 + 图片清理 + 并发锁 + 降级策略。
Hermes 的设计让一个 200K token 的窗口可以支撑数十甚至上百轮对话。关键在于:压缩是有损的,所以必须让这个"损"发生在对话中已经"过期"的部分,而不能损到最近的消息和最初的系统提示。
下一篇,我们将进入 Hermes 的记忆系统——Agent 如何跨会话持久化信息、如何让用户在下次打开 Hermes 时,“上次我们都聊了什么”。