scripts and commands

长会话中的增量式微压缩: Hermes Agent 实现拆解

2026/08/01 15:26

分类:agent

长会话中的增量式微压缩:Hermes Agent 实现拆解

本文以 NousResearch 的 hermes-agent 仓库中一项已合并的改动为分析对象,梳理它要解决的问题、关键设计决策、验证方式与代价。分析对象:PR #75345

2026-08-01 · 基于 PR 正文、14 个 commit 及设计文档 docs/micro-compaction.md

2026 年 7 月 31 日,NousResearch 的 hermes-agent 仓库合并了一项编号为 #75345 的改动,标题是 feat(agent): per-turn micro-compaction to amortize context compression——按轮次进行的微压缩,用于摊薄上下文压缩的代价。该 PR 基于 lxman 此前提交的 #74522 重构而来,以 14 个 commit 进入主干,并附带一份独立的设计文档。我通读了该 PR 的正文、全部 14 个 commit 的说明与设计文档,下面按自己的理解,把它的思路逐层拆开。

一、要解决的问题:上下文将满时的单次长停顿

凡事都有上限。对一个 Agent 而言,这个上限就是它的上下文窗口。

一次持续数小时的任务中,对话与工具输出不断累积,逼近窗口上限。传统做法是在上下文接近上限时暂停执行,将中间一大段历史交给辅助模型重新总结,使占用回落到较低水位,再继续任务。

这种批量压缩(batch compaction)是有效的,但代价集中:暂停期间,模型需要读取并总结数万 token 的历史,任务随之出现一次明显的长停顿。会话越长,积累越多,停顿越长。假设上下文窗口最多容纳 10 万 token,一次长任务的占用可能这样增长:

2 万 → 4 万 → 6 万 → 8 万 → 逼近上限

快满时,系统才暂停下来,把中间很大一段历史交给另一个模型总结:

8 万 token 的历史 → 压缩成一份摘要 → 重新降到 3 万 token

用曲线来描述,上下文占用呈锯齿形——逐步爬升,骤降,再爬升。压缩的总工作量是必须付出的,问题在于它被积压到一次完成。

二、方案:把一次停顿摊薄为逐轮整理

PR #75345 的出发点是一个朴素的观察:压缩的总工作量未必能减少,但发生的时间分布可以改变。与其等窗口将满时一次处理全部,不如在每一轮对话结束后,只处理最老、尚未吸收的一轮交换(exchange)——一条 assistant 消息连同它的工具调用结果——将其折叠进一份不断累积的滚动摘要。

每一次整理只涉及一轮交换,通常只有几千 token。成本被摊销到各轮之间,任务中途不再出现集中的长停顿。实现上,这一动作挂在每轮结束的 finalize_turn 阶段:回答已经流式输出给用户,但这一轮要等压缩完成才算真正收尾。

例如,一轮 Agent 工作可能包含这样的记录:

助手:我先查看配置文件
工具:返回 500 行文件内容
助手:发现问题在重试逻辑
工具:执行测试,返回大量日志
助手:已修复并验证测试通过

这些内容可能有几千甚至上万 token。微压缩之后,它们被折叠成类似这样的一句话:

已检查 config.py,发现重试次数未读取用户配置;
修改 retry.py 使用现有 retry helper;
测试 test_retry.py 全部通过。

下一轮结束后,它再把更老的下一段工作合进来:

已有摘要:已修复重试配置问题,相关测试通过。

本轮需要吸收的旧记录:读取 deployment.yaml;发现超时值硬编码;修改并运行测试。

新的摘要:已修复重试配置问题,相关测试通过;部署超时已改为读取 deployment.yaml,测试通过。

注意,始终只保留最新的一份累计摘要,而不是堆很多份小摘要。

三、关键设计决策

这个机制的骨架并不复杂,真正值得拆解的是围绕它的一系列边界设计。它们决定了这个功能在真实会话中是否可靠。

1. 用户消息永不压缩

在可压缩的边界定义上,PR 做了一个明确的取舍:被折叠的只有 assistant 消息与工具输出,用户消息永远保留原文。实现上,_find_one_exchange 的遍历会跳过用户消息,直接从 assistant 消息开始。

这一决策背后是一条不变量(仓库内部编号 #64650):**用户消息是意图的来源,其余内容都是从意图推导出的执行过程。**执行过程可以被概括、重建,意图不能——"必须复用现有 retry helper,不要再创建一个新的重试模块"一旦被改写成"用户希望完善重试机制",关键约束就丢失了,Agent 可能在数轮之后违背原始要求而不被察觉。PR 中有一条专门的 commit 修正了与此相关的文档错误,并补充了测试,确保这一属性是刻意的,而非偶然。

2. 头部与尾部的保护区

并非所有历史都可压缩。上下文被划分为三段:

[头部:系统提示词与开场信息]
[中部:较旧的对话与工具记录 · 可压缩]
[尾部:最近发生的对话 · 近期保护]

微压缩只作用于中部。头部关系到系统行为稳定,尾部关系到当前任务正在使用的细节,两者均受保护。刚结束的对话不会立刻被压缩,它先停留在尾部保护区,随着会话增长逐渐变旧、移出保护区后,才进入可压缩范围。

3. 书签:游标与摘要 marker

系统内部维护一个游标(cursor),指向第一条尚未被吸收的消息。整个会话的压缩进度可以这样看:

已压缩区域

书签

尚未压缩区域

最近保护区

每成功整理一轮,书签就往后移一格。游标本身不持久化,恢复时通过扫描会话记录中的最后一条摘要 marker 重新定位;滚动摘要的内容也从这个 marker 中恢复,保证会话恢复后继续合并,而非覆盖已有历史。

摘要以 marker 消息的形式写入会话记录,携带压缩摘要的元数据,与批量压缩的 marker 共用同一套机制——恢复会话、会话交接、/compress 命令都能把它当作普通摘要处理。同时,只有最新的一份滚动摘要被保留,更早的 marker 会被取代合并,避免摘要副本在会话记录中堆积,否则记录会随每个轮次增长,而不是收缩。

4. 与会话数据库保持同步

会话记录是追加写入数据库的。如果只在内存中替换消息,而数据库中的原始行仍然有效,恢复会话时会同时加载摘要与摘要之前的原始内容,反而把上下文撑爆。因此每次微压缩都会同步调用归档逻辑(archive_and_compact),将旧消息软归档,使新的摘要版本成为唯一有效记录。

5. 碎片整理:摘要本身的收缩

滚动摘要不断吸收新内容,自身也会变得臃肿、重复、结构混乱。当摘要超过阈值(默认约 2000 token),下一轮不再吸收新交换,而是先对摘要本身做一次重新总结:

越来越臃肿的滚动摘要

重新总结摘要本身

更短、更干净的滚动摘要

这次整理只处理摘要文本,不碰用户消息,也不改变对话结构。

这一行为是刻意与"用户消息永不压缩"的不变量对齐的——PR 在评审中曾发现一个实现缺陷:碎片整理把整个剩余中部(包括用户消息)序列化后整体替换,一次操作销毁了 10 条用户消息中的 8 条,与该功能的核心承诺直接冲突。修复后,碎片整理只重总结摘要文本,并在原位置改写 marker 内容。

6. 失败容错

辅助模型并非总能成功总结某一轮交换。PR 的处理是:对无法处理的交换进行有限次重试,仍失败则跳过,避免单条异常记录卡死后续所有轮次的整理。此外,入口处有双重校验(功能开关为真、处理函数可调用、返回非空),防止启用不兼容的压缩器实现时,空结果误删消息。

四、它如何验证自己

设计可以论证,效果必须测量。整份 PR 最见功力之处,正是它的验证方式——作者没有停留在设计论证上,而是给微压缩加了一套逐轮 token 遥测:每次整理输出一条结构化日志,记录整理前后的 token 数、吸收的交换大小、滚动摘要大小、耗时与累计值,并附带一个聚合脚本,用于从日志还原整次会话的整理过程。这套测量立刻带来了两个从设计层面无法推出的发现。

其一,第一轮整理通常是"亏本"的。摘要 marker 自带约 400 token 的固定结构开销,在第一轮只吸收单条交换时,这部分开销可能超过回收量。从第二轮起,marker 被改写而非新增,开销已经支付,之后每一轮都接近纯回收。盈亏平衡通常出现在第二或第三轮——单看第一轮的数据,会得出"这个功能让情况更糟"的错误结论。

其二,上下文占用会趋于平稳。实测一次会话中,占用率爬升至约 22% 后不再上升:两轮整理之间新增约 4841 token,整理回收约 4395 token,达到动态平衡。整次会话没有触发任何一次批量压缩。这正是该功能想达到的状态——窗口保持低水位,长会话得以持续运行。

测量还修正了文档。文档最初称整理发生在"响应之后的闲置时刻",作者用一次真实的 3.5 小时会话数据纠正了这一点:整理是一次真实的模型调用,发生在轮次末尾,实测耗时 2 至 37 秒,中位数约 31 秒(本地小模型)。回答虽然已经输出,轮次并未真正结束。这一事实直接影响了辅助模型的选择——整理是机械性工作,不适合慢速推理模型。

五、代价:每轮破坏一次缓存

凡事都有代价。这是软件工程不可撼动的底层法则。

那么问题就是——代价在哪里。

微压缩的代价不在总工作量——同样的总结工作一次都没少,只是被摊开了。真正的代价在缓存:主流模型服务商普遍提供 prompt cache,输入前缀与上一次请求相同则命中,价格显著降低;正常长会话中,前缀基本稳定,缓存持续命中。

而微压缩每次整理,都会改写已经发送过的历史:

上一轮:用户 A → 助手 A → 用户 B → 助手 B
这一轮:用户 A → 滚动摘要 → 用户 B → 助手 B

前缀从改写位置开始全部失效,缓存需要重新计算。

如果每轮都整理,就等于每轮都破坏一次缓存。对于 prompt cache 折扣很高的服务商,省下的上下文空间可能抵不过丢失的缓存折扣。PR 的说明文档将这一成本写得直白,并提供了频率开关:

compression:
  micro_compact: true
  micro_compact_every_n_turns: 5

设为 1,回收最积极,缓存破坏也最频繁;设为 5,回收速度降为五分之一,缓存破坏频率同样降为五分之一。

此外,PR 还隔离了批量压缩与微压缩的 marker:微压缩只能取代微压缩产生的 marker,不能覆盖批量压缩的摘要,否则两套机制交替运行时可能造成历史丢失。

六、判断

把整条实现放在一起看,它不是单纯的摘要功能,而是一套长会话的内存管理策略。最贴切的类比是垃圾回收:批量压缩是积累到阈值后的一次 "Stop the world" 式回收,全场暂停;微压缩是增量式回收,每次处理一点,避免长停顿。

假设传统方式需要一次性总结 5 万 token,可能造成一次很长的停顿。微压缩把它拆成很多次:

第 1 轮:整理 2000 token
第 2 轮:整理 1500 token
第 3 轮:整理 3000 token
……

总工作量未必更少,只是把成本分散了。在作者的估算中,结果是上下文占用不再不断逼近上限,而可能逐渐达到一种平衡:每轮新增约 4000 token,每轮又回收约 3500~4500 token,上下文长期保持在一个相对平稳的水位,不容易触发传统的大规模压缩。(PR 实测的平衡数值见第四节。)

它以"每若干轮一次的小规模总结调用 + 缓存失效",换取了"上下文将满时的一次长停顿"。

因此它适合工具输出密集、持续数小时、更看重占用平稳的会话;不适合短会话、prompt cache 折扣极高、或对早期执行细节要求苛刻的场景。PR 已合并,但默认关闭——它被定位为一种可选的调优手段,而非可以无脑开启的优化。


参考

  1. PR #75345 —— feat(agent): per-turn micro-compaction to amortize context compression(已合并,2026-07-31):https://github.com/nousresearch/hermes-agent/pull/75345

  2. 原始 PR #74522(lxman 提交,被 #75345 重构沿用):https://github.com/nousresearch/hermes-agent/pull/74522

  3. 设计文档 docs/micro-compaction.md(随 PR 合并):https://github.com/nousresearch/hermes-agent/blob/main/docs/micro-compaction.md

说明:文中的机制描述、配置项与测量数据均引自 PR 正文、commit 说明及设计文档;"垃圾回收类比"与适用场景判断为本文作者的分析,与 PR 原文相区分。