长任务型 Agent 的失效点往往隐藏在一个不易察觉的细节上:它并非丧失了推理能力,而是已经无法完整追溯自己之前执行过的操作。
在一次真实的工具型会话中,系统指令、对话历史、工具调用记录、终端输出、文件读取内容、屏幕截图以及代码片段,都会在每一轮会话中被重新塞入模型。即便上下文窗口尚未占满,模型就已经开始出现遗漏约束、重复读取文件、重新推导旧决策等问题;而当窗口真正溢出时,请求会直接被 provider 拒绝。Hermes 的上下文压缩机制,正是专门为解决这一瓶颈而设计的。
它处理的并非长期记忆。MEMORY.md、USER.md 以及 memory provider 负责跨会话的事实存储;上下文压缩仅管理当前会话的活跃工作窗口。这一边界划分至关重要,因为压缩本质上是有损的,不能将其视为真正的记忆系统来替代。
图:上下文压缩只整理当前工作窗口,长期事实仍应进入 memory 层
ContextEngine:将上下文管理设计为可替换的引擎策略
Hermes 并未将压缩逻辑硬编码在主循环中,而是将其抽象为 agent/context_engine.py 中的 ContextEngine。内置实现为 ContextCompressor,同时允许插件接管整套上下文管理,例如替换为无损上下文管理方案 LCM。
引擎的选择由 config.yaml 中的 context.engine 参数控制。插件不会自动启用,必须显式配置;若未找到匹配的插件,系统将回退至内置的 ContextCompressor。
图:上下文管理引擎的配置选择与回退路径
这一层设计将压缩从“单一功能”提升为“策略接口”。主循环只需关心何时向引擎询问、何时获取新的消息列表,而无需了解具体实现是 LLM 摘要、无损索引,还是其他上下文管理方案。
双层压缩:Gateway 兜底,Agent 主压缩协同工作
Hermes 拥有两道压缩防线,它们的定位和目标各不相同。
第一道防线位于 gateway,属于会话健康检查。它在 Agent 开始处理消息之前运行,阈值固定为 85%。这道防线并非日常压缩的主力,主要应对隔夜会话、群聊积压、外部通道消息狂灌等异常情况。
第二道防线位于 Agent 内部,即 ContextCompressor。它在工具循环中运行,默认阈值为 50%,优先使用 provider 返回的真实 prompt_tokens。这是日常上下文管理的核心机制。
图:Gateway 兜底与 Agent 主压缩的分工
| 维度 | Gateway 会话卫生 | Agent 压缩器 |
|---|---|---|
| 触发阈值 | 固定 85% | 默认 50%,可通过 compression.threshold 配置 |
| 运行位置 | Agent 处理前 | Agent 工具循环内部 |
| token 来源 | 上轮真实 token,缺失时用字符估算 | provider 返回的真实 prompt_tokens 优先 |
| 主要目标 | 防止超大会话直接打挂请求 | 常规上下文管理 |
| 额外保护 | hygiene_hard_message_limit | |
| 默认 5000 条 | 防抖、边界对齐、会话锁、摘要失败降级 |
阈值的错开并非随意设定。如果 gateway 也按 50% 触发,长会话将在多轮中提前被压缩,导致成本增加且信息损耗加大。它只应接管那些 Agent 压缩器未能及时处理的异常会话。
三个触发器:预检、响应后与错误恢复
ContextCompressor 并非等到 API 报错才采取行动。在一次 turn 内,它拥有三个触发入口。
图:预检、响应后与错误恢复三类压缩入口
Preflight:请求发出前的低成本拦截机制
预检压缩位于 agent/turn_context.py 的 build_turn_context 中。它首先执行一个低成本的判断:检查消息数量是否已超过保护头尾的安全范围,或者字符粗估是否已经很大。只有通过这一门控,才会执行更昂贵的 token 粗估。
这里有两个容易被忽略的细节。
第一,粗估必须将 tool schemas 纳入计算。工具数量增多时,schema 本身可能占用 20K 到 30K token;仅估算 messages 会低估请求体的大小。
第二,Hermes 会使用 should_defer_preflight_to_real_usage() 来抵抗 schema-heavy 请求的噪声。如果上一次压缩后的真实 token 已经证明请求能够容纳,就不应被同一批 schema 的粗估反复触发。
Post-response:利用真实 prompt token 做出日常决策
响应后压缩位于 agent/conversation_loop.py 中。它在模型响应返回、工具结果追加后执行,是最常见的压缩路径。
核心逻辑可以简化为以下形式:
_compressor = agent.context_compressorif _compressor.last_prompt_tokens > 0:real_tokens = _compressor.last_prompt_tokenselif _compressor.last_prompt_tokens == -1:real_tokens = 0else:real_tokens = estimate_request_tokens_rough(messages, tools=...)if agent.compression_enabled and _compressor.should_compress(real_tokens):messages, active_system_prompt = agent._compress_context(...)
它仅关注 prompt_tokens,不将 completion_tokens 纳入触发条件。原因很实际:推理模型的 reasoning token 可能很大,如果 completion 也参与判断,会导致会话在尚未真正挤占输入窗口时就过早压缩。
last_prompt_tokens == -1 是一个哨兵值。压缩刚结束时,系统尚未获取下一轮 provider 的真实 usage 数据,此时将 token 视为 0,避免刚压缩完就被 schema 粗估拉回压缩循环。
Error recovery:窗口真正溢出时执行的强制抢救
当 provider 返回 413、context overflow 错误,或 Anthropic 长上下文层的 429 时,Hermes 会进入错误恢复压缩。它会降级 context 设置并强制压缩重试,最多尝试 max_compression_attempts=3 次。
这条路径并非主流程,而是一道保险丝。真正健康的会话应依赖预检和响应后压缩来解决,大部分情况下不应走到 provider 报错这一步。
阈值计算:并非窗口乘以百分比
许多上下文压缩实现会直接使用 context_length × threshold。Hermes 并未采用这种方式。
它先从窗口里扣除 max_tokens,因为输出空间也占用 provider 提供的总窗口。输入预算应为:
effective_window = context_length - (max_tokens or 0)
完整逻辑大致如下:
def _compute_threshold_tokens(context_length: int,threshold_percent: float,max_tokens: int | None = None,) -> int:effective_window = context_length - (max_tokens or 0)if effective_window <= 0:effective_window = context_lengthpct_value = int(effective_window * threshold_percent)floored = max(pct_value, MINIMUM_CONTEXT_LENGTH)if effective_window > 0 and floored >= effective_window:return max(1,min(int(effective_window * ContextCompressor._MIN_CTX_TRIGGER_RATIO),effective_window - 1,),)return floored
这段代码同时解决了几个问题。
第一,为输出预留空间。如果自定义 provider 将 max_tokens 配置到 65536,输入预算会明显减少,不扣除就容易导致溢出。
第二,大窗口模型不应过早压缩。MINIMUM_CONTEXT_LENGTH=64K 确保大上下文模型不会因为 50% 的阈值就频繁触发压缩。
ContextCompressor:先剪枝,再摘要,最后重组
ContextCompressor.compress() 的目标并非简单截断历史消息,而是将会话改造为三段结构:
保护头 + 结构化摘要 + 原样保留的尾部消息
压缩过程分为四步。
图:先剪枝、再摘要、最后重组消息窗口
旧工具结果先做无损降噪处理
第一阶段不调用模型,仅执行工程剪枝。保护尾之外的长工具输出会被压缩成信息化的单行描述,而非丢弃为空占位。
[terminal] ran `npm test` -> exit 0, 47 lines output[read_file] read config.py from line 1 (3,400 chars)
这一步包含三遍扫描:
| Pass | 处理内容 | 为何需要 |
|---|---|---|
| Pass 1 | 重复文件读取去重,仅保留最近的完整内容 | 同一文件被反复读取时,旧副本无需继续占用窗口 |
| Pass 2 | 长工具结果压缩为单行,截图剥离 base64 | 防止旧终端输出和旧截图永久拖累每轮请求 |
| Pass 3 | 截断超大 tool_call 参数,但保持 JSON 合法 | 避免损坏的 JSON 毒化后续 provider 请求 |
这个阶段看似朴素,实际效果显著。许多上下文膨胀源自工具结果,而非用户的实际对话量。先通过确定性规则降噪,可以减轻后续 LLM 摘要的压力。
图:旧工具输出先被结构化压缩,降低后续摘要负担
边界算法:压缩不能切坏消息结构
压缩边界是这套机制中最具工程挑战的部分。它既要尽量压缩,又不能破坏 tool call 组的完整性,不能将最新用户任务卷入摘要,也不能让早期头部无限增长。
保护头仅在第一次压缩时保护首轮任务框架
protect_first_n 默认保护最初几条非系统消息,确保首次任务设定在第一次压缩后存活。但这份保护会衰减:
if self.compression_count >= 1 or self._previous_summary:return 0return self.protect_first_n
原因很直接:第一次压缩后,早期任务框架已经进入 handoff 摘要。如果后续每次仍保护前几条旧消息,它们会变成“不朽消息”,在每个子会话中反复复制,导致头部无界增长。
系统提示不参与此衰减。它由 _protect_head_size() 单独保护,始终保留。
尾部优先按 token 预算保护
尾部保护并非简单保留最后 N 条消息,而是优先按 token 预算从尾部往前切分:
图:按 token 预算保护活跃任务尾部
这里的关键并非“保留最后 20 条”这种固定规则,而是让最近活跃任务在 token 意义上尽量完整。尾部只会增大,不会缩小:最近一条 user 消息必须位于尾部,最近一条有文字内容的 assistant 回复也必须在尾部。
tool_call 配对要么一起压缩,要么一起保留
OpenAI 消息格式要求 assistant tool_call 后面紧跟匹配的 tool 结果。如果压缩切边界时切进工具组中间,后续请求会报错。
Hermes 用两道防线处理这个问题。
第一道是边界对齐。当边界落在 tool results 组中时,_align_boundary_backward() 会向前找到父 assistant 消息,让 assistant + tool_results 整组进入同一侧。
第二道是重组后的清理。_sanitize_tool_pairs() 会扫描仍存活的 tool call id 和 tool result id:
图:孤儿 tool result 删除与缺失结果补桩
对于缺少 result 的 tool call,会补充一个类似 [Result from earlier conversation...] 的桩结果。它并不试图还原历史内容,仅保证 provider 消息结构合法。
结构化摘要:为续作提供的 handoff,而非新指令
摘要阶段由 _generate_summary() 执行,使用辅助模型 call_llm(task="compression")。它并非让模型写一段“简短总结”,而是强制填充一份 handoff 模板。
典型字段包括:
| 字段 | 保存内容 | 作用 |
|---|---|---|
Historical Task Snapshot | 最近未完成用户输入的逐字原话 | 防止旧任务在压缩后变形 |
Goal | 总体目标 | 保留会话主线 |
Constraints & Preferences | 约束、偏好、编码风格 | 减少压缩后违反硬性要求 |
Completed Actions | 已完成动作、目标、结果、工具 | 避免重复执行已完成工作 |
Active State | 工作目录、分支、改动文件、测试状态、进程 | 支撑继续接手 |
Blocked | 阻塞点和精确报错信息 | 防止重新踩入同一个坑 |
Key Decisions | 技术决策及其原因 | 保留设计判断 |
Resolved Questions | 已回答的问题 | 防止重复解释 |
Relevant Files | 读取过、修改过、创建过的文件 | 续作时定位上下文 |
Critical Context | 精确配置值、报错信息、关键事实 | 抢救容易丢失的细节 |
摘要前缀中有一条重要约束:这是一段来自旧 context window 的参考材料,并非当前用户指令。模型仅应响应摘要之后的新用户消息。
摘要尾部也有明确的边界标记:
--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---
这并非形式主义。压缩摘要中常包含用户旧请求的逐字引用,弱模型可能将其视为新的待执行命令。Hermes 通过前缀、尾标记、时间锚定和过去时改写,将“历史事实”与“当前指令”明确区分开来。
图:handoff 摘要提供历史参考,但不应变成新的指令
多次压缩依赖 previous summary 迭代更新
长会话可能经历多次压缩。Hermes 并非每次只总结当前中段,而是将上一版摘要作为 PREVIOUS SUMMARY 一并交给辅助模型,使其进行更新而非重写。
图:previous summary 在多次压缩中迭代更新
这样,旧项目状态会从 in progress 移至 completed,新阻塞会被加入,过时内容会被删除。在 resume 场景下,压缩器还会从当前消息中寻找最近的 handoff 摘要,恢复 _previous_summary。
跨会话泄漏也做了防护。如果内存中存在 _previous_summary,但当前消息列表找不到对应的 handoff,说明它可能来自已结束的会话,压缩器会丢弃这份摘要。
摘要模型窗口:压缩质量最常见的潜在陷阱
摘要模型的 context window 必须不小于主模型。原因很简单:被压缩的中段会一次性发送给辅助模型。
如果辅助模型窗口比主模型小,摘要调用可能直接引发 context-length 错误。更麻烦的是,在默认降级路径下,压缩器可能使用 fallback 摘要继续推进,导致中段细节彻底丢失。
Hermes 通过 check_compression_model_feasibility 在会话开始或首次压缩时探测辅助模型的可行性。实际配置时,最稳妥的方式仍是显式选择窗口足够大的压缩模型:
auxiliary:compression:model:
摘要预算也并非固定值。它根据被压缩内容的量动态缩放:
content_tokens = estimate_messages_tokens_rough(turns_to_summarize)budget = int(content_tokens * _SUMMARY_RATIO)# 0.20return max(_MIN_SUMMARY_TOKENS, min(budget, self.max_summary_tokens))
默认下限为 2000 token,上限为 min(context_length × 0.05, 12000)。这为摘要留出了充足空间,但不会让摘要本身膨胀成新的上下文负担。
会话存储:轮转与原地压缩的取舍
压缩成功后,Hermes 还需要处理 SQLite 中的会话存储。这里有两种模式,由 compression.in_place 控制。
图:原地压缩与会话轮转的落库差异
| 模式 | 行为 | 优点 | 风险 |
|---|---|---|---|
| 原地压缩 | 在同一 session_id 下软归档旧 turn,插入压缩后的 active turn | 会话身份稳定,轮转边界问题较少 | 需要存储层正确支持归档和检索 |
| 会话轮转 | 结束旧 session,创建 parent 指向旧会话的新 session | 旧 transcript 保留,便于搜索 | fork、回滚、goal 迁移、日志上下文同步都更复杂 |
配置注释中已倾向于推荐 in_place: true。在长任务场景下,同一 session id 持续存在,心智负担和边界 bug 都更少。
并发安全通过 state.db 的 session 级压缩锁实现。当两个 Agent 实例共享同一 session 时,只有一个能真正执行压缩;另一个拿不到锁就原样返回,让获胜者完成。锁子系统异常时采用 fail-open 策略,避免会话卡死。
Prompt caching:压缩会中断部分缓存,但不应中断全部
压缩和 prompt caching 是两套机制,但它们会相互影响。
正常多轮情况下,system prompt 稳定,前缀缓存命中率高。压缩发生后,中段被重写,压缩区后面的缓存自然失效;但 system prompt 缓存仍然应该存活,尾部三条消息也会在一两轮内重新形成滚动缓存。
图:压缩后 system 缓存保留,滚动缓存重建
失败处理:宁可中止,也不要静默丢失上下文
压缩失败不能只当作普通异常处理。Hermes 将摘要失败分为几类:
| 失败类型 | 处理方式 | 原因 |
|---|---|---|
| 未配置 provider | 长 cooldown 后返回失败 | 继续重试只会浪费时间 |
| 401 / 403 | 标记认证失败并中止压缩 | 凭证问题不会靠重试自愈 |
| 网络中断 | 标记网络失败并中止压缩 | 保留原会话,等待网络恢复再压缩 |
| 允许降级的普通失败 | 插入确定性 fallback 摘要 | 至少保留连续性锚点 |
abort_on_summary_failure=true | 直接中止 | 用户显式选择保守策略 |
中止时会设置 _last_compress_aborted=True,上层可以向用户提示“会话已冻结,修复后再 /compress 或开启新会话”。这比悄悄丢失中段要安全得多。
防抖机制也至关重要。如果连续两次压缩节省不到 10%,should_compress() 会直接返回 False:
def should_compress(self, prompt_tokens=None) -> bool:tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokensif tokens < self.threshold_tokens:return Falseif self._ineffective_compression_count >= 2:return Falsereturn True
有些会话确实没有多少可压缩空间,例如尾部全是近期大工具输出。继续压缩只会空转,防抖机制能将这种循环停下来。
图片也有专门的恢复路径。当 provider 因图片太大拒绝时,try_shrink_image_parts_in_messages() 会将 base64 图片重新编码到限制以内,再重试请求。旧图片在常规压缩中也会被 _strip_historical_media() 替换为文字占位,避免多 MB 媒体长期留在窗口里。
Hermes 与 Claude Code:同一模式,不同的控制面
Hermes 和 Claude Code 都遵循“保护头、摘要中段、保留尾”的大方向,但控制面差异显著。
| 维度 | Hermes Agent | Claude Code |
|---|---|---|
| 实现位置 | 客户端 ContextCompressor | 服务端 Compaction API 加客户端配合 |
| 可配置性 | 阈值、尾部比例、保护消息数、辅助模型、原地压缩均可配置 | 用户可调空间较少 |
| 触发阈值 | Agent 默认 50%,gateway 85% | 200K 模型上约 150K input tokens |
| 工具输出处理 | 在上下文内剪枝、去重、截断 | microcompaction 将工具输出落盘,窗口里保留引用 |
| 摘要模型 | 可独立配置辅助模型 | 主要由服务端 compaction 处理 |
| 失败防护 | 防抖、并发锁、摘要失败分级、fallback | 更多由服务端策略接管 |
| 续作恢复 | handoff 摘要 + session_search + memory provider | 摘要 + 重读最近文件 + todo 恢复 |
两者共同的弱点也很明确:压缩擅长保留叙事连续性,但不擅长确保每个精确约束都存活下来。第 2 轮提到的偏好、第 8 轮确认的配置值、第 12 轮临时添加的禁用项,都可能在摘要时被模型认为“不够重要”。
这就是为什么 memory 层不可或缺。需要跨压缩、跨会话存活的事实,应在压缩发生前进入 memory provider,而非寄希望于摘要模型每次都能做出正确判断。
配置建议:先保证不丢失信息,再谈节省成本
一个保守但实用的配置可以从这里开始:
compression:enabled: truethreshold: 0.50target_ratio: 0.20protect_last_n: 20in_place: trueabort_on_summary_failure: falseauxiliary:compression:model:
几条判断比参数本身更为重要。
第一,辅助压缩模型的窗口必须足够大。这比更换一个更便宜的摘要模型更为关键。
第二,如果大上下文模型的主要目标是节省成本,可以适当降低 threshold;小窗口模型则无需强行调整,代码中已有 85% 的退化保护。
第三,当近期任务依赖工具输出和细粒度代码状态时,可以提高 target_ratio 或 protect_last_n。压缩得狠可以节省 token,但更容易丢失活跃任务细节。
第四,建议开启 in_place。除非你明确依赖旧的会话轮转语义,否则原地压缩更符合长会话的使用直觉。
第五,看到压缩告警时不要忽略。Session compressed N times 说明会话质量可能开始下降;Compression skipped 说明窗口里缺少可压缩空间;Context compression aborted 往往是辅助模型、凭证或网络出了问题。
源码索引
| 文件 | 关注点 |
|---|---|
agent/context_engine.py | ContextEngine |
| 抽象与生命周期 | |
agent/context_compressor.py | 压缩主算法、边界对齐、摘要生成、防抖 |
agent/conversation_compression.py | 会话压缩落库、原地压缩、会话轮转、模型可行性探测 |
agent/turn_context.py | 预检压缩入口 |
agent/conversation_loop.py | 响应后压缩与错误恢复压缩 |
agent/prompt_caching.py | Anthropic system_and_3 缓存策略 |
gateway/run.py | Gateway 会话卫生安全网 |
agent/model_metadata.py | token 粗估,包含 tool schemas |
agent/auxiliary_client.py | Codex gpt-5.5 窗口限制与阈值抬升 |

