上下文压缩:让 Agent 在有限窗口里「记得住」

Hermes 如何用三层保护 + 结构化摘要,在 200K token 窗口里装下数小时的对话

系列:通过 Hermes 探秘 Agent 工程 | 第 4 篇 上一篇:System Prompt:身份、上下文与策略的三层架构


问题:上下文是"有价商品"

第二个系列文章里聊过,系统提示词是 Agent 的"宪法"——它告诉模型你是谁、能做什么、处于什么环境。但宪法只占上下文窗口的一部分,更大的部分是对话历史本身。

一个典型的 Agent 任务:

  1. 用户说:帮我写一个 Python 脚本来解析日志文件
  2. Agent 运行 write_file 生成脚本
  3. Agent 运行 terminal 执行脚本
  4. 脚本报错了
  5. Agent 运行 read_file 查看错误行
  6. Agent 运行 patch 修改脚本
  7. Agent 再次执行……

到第 7 步时,上下文里已经有十几条消息,每条消息都带着工具的输入和输出。如果日志文件有几 MB,一条 read_file 的 tool result 就能吃掉几千 token。

对于一个 200K token 的窗口,如果不做干预,大约 30-50 轮工具调用就会把窗口撑满。之后要么被 Provider 拒绝(400 错误),要么被迫截断历史——而截断意味着丢失上下文,Agent 的表现会急剧下降。

Hermes 的解决方案是上下文压缩——不是简单丢弃旧消息,而是用一段结构化的摘要替换掉中间部分,保留关键信息、释放空间。

压缩的三个核心问题

在动手之前,先想清楚三件事:

  1. 什么时候压缩? 对话进行到什么程度才需要压缩?
  2. 压缩哪部分? 全部压缩还是只压缩一部分?
  3. 怎么压缩? 摘要应该包含什么内容?

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 函数只读取两类字段:contenttool_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() 解决这个问题:

  1. 删除孤立的 tool_result(对应的 tool_call 没了)
  2. 为 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 把三部分拼成一个新的消息列表:

[头部消息(原样)]
[摘要消息]
[尾部消息(原样)]

然后做几件清理工作:

  1. 工具对清理:检查有没有"有 tool_call 但没有对应 tool_result"或反之的情况。如果有,要么插入一个占位 result,要么删除孤立的 result,确保消息列表永远合法。
  2. 图片清理:老消息里的 base64 图片(可能几 MB 一条)会被替换成文本占位符。只保留最近一张 user 消息里的图片。
  3. 元数据注入:第一条系统提示词里会追加一个压缩说明,让模型知道"早期的对话已经被压缩成摘要了"。
  4. 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 的处理方式:

  1. 主模型回退:如果辅助模型不可用,会退回到用主模型来生成摘要——毕竟主模型是经过验证的,虽然贵一点但至少能用。
  2. 确定性摘要:如果所有 LLM 都失败了(aux 和 main 都失败了),Hermes 会生成一个确定性摘要——不依赖 LLM,直接从对话里提取:用户说了什么、调用了哪些工具有哪些文件、最后几条消息是什么。虽然不如 LLM 写的丰富,但至少能防止信息完全丢失。
  3. 中止并保留:如果配置了 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 时,“上次我们都聊了什么”。