Hermes 上下文压缩架构:长任务 Agent 不失忆的几个关键设计

拆解 Hermes 如何用触发器、边界算法和 handoff 摘要,让长任务 Agent 在压缩后继续可靠执行。

长任务 Agent 最容易坏在一个很不起眼的地方:它不是没能力继续推理,而是已经看不完整自己刚才做过什么。

一次真实的工具型会话里,系统提示、历史消息、工具调用、终端输出、文件读取结果、截图、代码片段都会在每一轮重新塞回模型。窗口没满时,模型已经开始丢约束、重复读文件、重新推导旧决策;窗口真正溢出时,请求会直接被 provider 拒掉。​Hermes 的上下文压缩机制​,就是为了解这个问题。

它处理的不是​长期记忆 ​。MEMORY.mdUSER.md、memory provider 负责跨会话事实;上下文压缩只管理当前会话的工作窗口。这个边界很重要,因为压缩天然有损,不能拿它替代真正的记忆系统。

图:上下文压缩只整理当前工作窗口,长期事实仍应进入 memory 层

ContextEngine:把上下文管理做成可替换引擎

Hermes 没把压缩逻辑写死在主循环里,而是抽象成 agent/context_engine.py 里的 ContextEngine。内置实现是 ContextCompressor,插件也可以接管整套上下文管理,比如换成无损上下文管理 LCM。

引擎选择由 config.yamlcontext.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.pybuild_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_compressor

if _compressor.last_prompt_tokens > 0:
    real_tokens = _compressor.last_prompt_tokens
elif _compressor.last_prompt_tokens == -1:
    real_tokens = 0
else:
    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)

完整逻辑大致如下:

复制代码
@staticmethod
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_length

    pct_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 重复文件读取去重,只保留最近全文 同一个文件被反复 read 时,旧副本没有必要继续占窗口
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 0
return 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: <context window 不小于主模型的模型>

摘要预算也不是固定值。它按被压缩内容量动态缩放:

复制代码
content_tokens = estimate_messages_tokens_rough(turns_to_summarize)
budget = int(content_tokens * _SUMMARY_RATIO)  # 0.20
return 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_tokens
    if tokens < self.threshold_tokens:
        return False
    if self._ineffective_compression_count >= 2:
        return False
    return 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: true
  threshold: 0.50
  target_ratio: 0.20
  protect_last_n: 20
  in_place: true
  abort_on_summary_failure: false

auxiliary:
  compression:
    model: <context window 不小于主模型的模型>

几条判断比参数本身更重要。

第一,辅助压缩模型窗口必须够大。这比换一个更便宜的摘要模型更重要。

第二,大上下文模型如果主要目标是省钱,可以适当降低 threshold;小窗口模型不用强行调,代码里已经有 85% 的退化保护。

第三,近期任务依赖工具输出和细粒度代码状态时,可以提高 target_ratioprotect_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 窗口限制与阈值抬升

推荐阅读

Agent Tool Interface 架构拆解:为什么好工具比强模型更决定成败

Hermes Skill Runtime 架构拆解:三层加载如何压住 Agent 上下文成本

Agent 评测系统架构:从指标分层到 GT/Judge 闭环的工程化落地

团队落地 Agent 工程化 Loop 的一些必看小技巧!

Agent 工程化新底座:用 CLI 契约层打通 HTTP 接口与业务能力