微量压缩功能
一种分摊压缩成本的方法。
长时间的对话最终会超出模型的上下文窗口限制,此时不得不丢弃部分内容或对其进行总结。Hermes 以往都是通过一次性处理来应对这一问题:一旦对话记录超过阈值,会立即终止当前会话,对中间的大部分内容进行一次性总结,之后再继续对话。虽然这种方法可行,但所有费用会一次性产生——无论在何时超出限制,都会出现一次明显的暂停,并触发一次大规模的总结请求。
而微量压缩功能则允许分阶段支付这些费用。在每轮对话结束后,Hermes 会将最旧且尚未被纳入总结的那段对话内容合并到当前的总结中。实际处理的工作量并无变化,只是不再在会话进行到中间时一次性完成,而是持续地、逐段地进行。
需要注意的是,该功能并非免费,也无法解决所有问题,而且默认是关闭状态——需通过设置 compression.micro_compact: true 才能启用。每次执行压缩操作实际上都是对压缩模型的一次独立调用,它会在每轮对话结束后运行——此时你的回复已经发送完毕,但直到该操作完成,本轮对话才会结束。此外,每次压缩操作都会重写已发送的历史记录,从而导致每次对话都会重新生成提供方的提示词缓存前缀;在启用此功能之前,请先阅读提示词缓存机制,因为在某些使用场景下,其带来的成本可能高于收益。
该功能为您提供了调整选项:您可以自主决定压缩成本如何分配,以及由哪个模型来承担这些成本。详情请参阅选择压缩模型,因为这一选择比其他任何因素都更为重要。
**相应的权衡是,信息呈现的时间会比您习惯的稍早一些。**由于压缩过程始终处于运行状态,对话中较早的内容会比在批量压缩模式下更快被汇总——这意味着在上下文窗口实际填满之前,所有内容都会以原文形式保留。会话早期的细节也会更快变成间接引用的内容。您需要牺牲部分内容的精确度,以此避免出现长时间的响应延迟,并确保上下文窗口始终保持在较小规模,而不会因接近阈值而反复波动。
其功能原理
在每个正常结束的对话轮次之后,finalize_turn函数会要求上下文压缩器处理一次信息交换:
- 找出尚未被汇总的最旧的那次信息交换。
- 将该次交换内容连同当前的运行中摘要一起,发送给辅助摘要模型。
- 用一个包含更新后摘要的标记,替换对话记录中的这些消息。
即每个对话轮次处理一次信息交换。无论对话持续多长,每轮次的成本都保持可控。
一次交互指的是一个完整的智能体轮次:即一条助手消息及其工具执行结果,再加上后续的所有助手回复,直至下一条用户消息出现为止。在需要大量使用工具的任务中,大部分token都集中在这些内容上——比如文件读取内容或命令的输出,其长度往往远超周围的文字描述——正因如此,才有必要一次处理一个交互单元。采用整个轮次作为处理单位(而非仅处理助手消息与工具响应的组合),还能确保对话记录中的角色切换始终符合规范:总结标记属于助手角色的消息,而一个完整的轮次始终以两侧的用户消息作为边界。
您的消息绝不会被压缩
一次交互是刻意从助手消息开始的。微压缩功能会直接跳过用户消息来达到这一起点,因此您输入的内容绝不会被汇总——无论会话持续多久,或者压缩功能触发多少次,您的提示语都会保持原样不变。
这是整个设计中最具实用价值的特性,因此有必要明确说明其原因。助手生成的输出大多只是对其操作过程的描述:它读取了该文件、执行了某条命令、得到了这样的结果。这类叙述在总结时几乎不会丢失信息——“它是以这种方式完成的”这种简写形式所传递的信息量,与完整表述相差无几。而用户的指令则属于另一类内容——它们是所有其他内容的出发点,无法从后续的执行结果中重新推导出来。如果将“使用现有的重试辅助工具,不要新增一个”这样的指令总结成简短语句,那么六轮对话之后,助手很可能会擅自去做你明确要求它避免的事情。
因此,这种不对称设计是有意为之:压缩衍生出的内容,但保留信息的源头。其代价在于中间部分的长度存在下限,因为用户的对话会不断累积且不会被吸收。实际上这一下限很低——一个提示语的长度通常仅相当于单个工具输出结果的一小部分——但它确实存在。如果你经常输入1万到2万token的提示语,根据设计,这些内容将始终保留在上下文中。
那些绝不会被修改的部分
还有两个区域受到保护,会原封不动地保留下来:
- 开头部分——系统提示语以及开场消息,因此会话最初的指令绝不会被改写。
- 结尾部分——按token数量限制的最新消息窗口,这样所有即时相关的内容都能完整保留。
微压缩功能仅适用于这两个区域之间的中间部分。
工作原理
光标机制
压缩器会维护一个光标,即尚未被处理的第一条消息的索引。每次成功处理完一条消息后,该光标就会向前移动,指向下一条消息。
如果内存中的光标丢失或超出范围——例如启动了新进程或恢复会话时——系统会通过扫描转录记录中的上一个总结标记,从该位置继续处理。由于转录记录本身就是真实数据的来源,因此恢复会话不会重复处理已完成的内容。
滚动式总结
系统不会为每条消息单独保存总结,而是仅维护一个正在生成的总结,所有新消息的内容都会被合并到这个总结中。系统会要求总结工具将新消息中的决策、需求、文件路径及未解决的问题纳入总结,同时删除不再相关的细节,并保留原有的结构。此外,系统还会明确指示将遇到的任何敏感信息替换为 [REDACTED]。
由于该总结是累积式的,转录记录中仅保留最新的标记。之前的标记完全属于冗余信息——当前总结已包含它们所代表的所有内容——因此一旦被新标记取代就会被删除。这一点的重要性超乎想象:若保留这些旧标记,会导致相同文本出现近乎重复的副本,每个副本都有独立的标题和结束标记结构,从而导致转录记录规模持续扩大而非缩小。
碎片整理功能
若频繁将内容合并到摘要中,摘要会变得臃肿——内容重复且篇幅远超实际需要。当运行中的摘要字符数超过阈值(默认为2000)时,系统会执行拆分处理:通过一次辅助调用,将当前摘要重新总结为一个简洁的新版本,并同步更新转录文本中的摘要标记。
拆分处理不会改变转录文本的结构——既不会合并或拼接任何消息,光标位置也不会移动,用户的对话轮次同样保持不变。该操作仅处理累积的摘要文本,而不涉及对话消息,因此“您的消息永远不会被压缩”这一保证依然成立。
与会话数据库保持同步
仅依靠内存中的拼接操作是不够的。Hermes常规的会话刷新机制为只写模式,因此原始记录仍会被标记为活跃状态,而恢复时会同时加载摘要及其所替代的消息——这会导致会话内容迅速超出上下文限制。
为此,每次处理流程还会调用archive_and_compact函数,该函数能以原子方式将活跃记录标记为已归档,并插入压缩后的版本。随后这些消息会被标记为已持久化,这样后续的只写刷新操作就会跳过它们。如果该数据库操作失败,系统会记录错误但会话仍可继续运行;此时恢复操作会重复加载数据,直到下一次批量压缩清理完毕。
当摘要生成器出现故障时
摘要生成过程可能会失败——可能是辅助模型无法访问、超出配额限制,或是该对话内容本身根本无法被总结。此时,对话记录将保持原样,且该次失败会被计入统计。
如果同一个对话连续三次失败,系统仍会继续处理后续内容。若没有这一机制,每次轮次都会无限重试那些出错的对话。被跳过的消息会保留在对话记录中,随后会在下一次批量压缩时被处理。
与批量压缩的协同工作
微压缩并不会取代批量压缩,而是将其延迟执行。基于阈值的压缩功能依然存在,一旦时间窗口被填满就会触发;且其摘要标记格式与批量压缩相同,因此两者可以相互配合使用。实际上,微压缩能将对话记录的长度控制在阈值以下,从而大幅减少批量压缩的触发频率。
配置选项
微压缩默认处于关闭状态。如需启用该功能,需手动开启:
compression:
micro_compact: true # default: false
micro_compact_every_n_turns: 1 # cadence — how often a pass runs
micro_compact_defrag_threshold_tokens: 2000
当 micro_compact 的值为未设置或 false 时,Hermes 的行为与以往完全一致:仅支持批量压缩,其他压缩相关功能均保持不变。
在开关控制之外,最重要的参数便是 micro_compact_every_n_turns,因为它决定了触发下述缓存中断的频率。当该值为 1 时,每次轮次结束后都会进行一次压缩操作:回收效率最高,但每个轮次会产生一个被破坏的前缀。若设置为 5,则压缩频率和回收率均降低为原来的五分之一,这适用于会话时长较长且提供方缓存折扣较大的场景。小于 1 的数值会被强制限制为 1,而不会直接禁用该功能。计数器是按轮次递增的,而非按实际完成的压缩操作次数,因此即使某轮次没有需要处理的数据,计数器仍会继续前进,不会停滞。
micro_compact_defrag_threshold_tokens 则决定了何时对滚动摘要进行重新汇总,而非让其无限增长——详情请参阅 Defrag 部分。
由于下一节将介绍的提示词缓存成本问题,该功能采用可选启用模式而非默认开启——这一成本是真实存在的,并非在所有情况下都值得承担,因此应由用户自行决策,而非被动接受。
提示词缓存——你选择启用时需承担的成本
在启用此功能之前,请先阅读这部分内容。这是反对该功能的最有力论据。
在长时间对话中,系统会在每一轮重复使用缓存的提示前缀,而缓存过的输入标记的计费费用仅为未缓存标记的一小部分。只要该前缀保持不变,这种优惠就会持续有效。微压缩功能会重写已发送的历史记录,从而使得从重写点开始的前缀失效——因此开启微压缩后,缓存会在每一轮被清空,而非仅在批量压缩时才清除一次。
这正是主动清理功能刻意避免的成本。该功能通过 compression.proactive_prune_min_reclaim_tokens(默认值为4096)来控制其运行条件,正如配置注释中所说的,这样做的目的是让重写操作形成“一次大的历史断点,而非每次工具调用都产生微小的断点”。
微压缩功能没有类似的回收大小控制参数——无论一次处理能节省多少内容,它都会将其全部纳入缓存。它所具备的是一个频率调节选项 micro_compact_every_n_turns。提高该数值会让缓存清空变得更少见、更呈阶段性,这与主动清理功能的控制目标最终是一致的,只是前者是通过减少缓存量来实现,而非等待更大的节省空间。如果您希望微压缩也能具备与主动清理相同的功能逻辑,设置回收阈值显然是可行的方向,但目前这一功能尚未实现。
因此,更准确的表述是这属于一种成本之间的权衡,而非真正的节省。
| 仅批量处理(默认) | 微级压缩模式 | |
|---|---|---|
| 压缩停滞 | 在阈值处出现一次长时间停滞 | 分散在多轮处理中 |
| 上下文占用率 | 随接近阈值而呈锯齿状上升 | 保持较低且平稳 |
| 缓存前缀 | 各次压缩之间保持完整 | 每轮处理后都会被破坏 |
哪种模式更优取决于您的具体使用情况:您的服务提供商对已缓存输入的折扣幅度、缓存前缀的大小、会话的持续时间,以及会话中途的停滞所带来的实际损失。在那些对缓存输入给予大幅折扣且缓存前缀较大的服务提供商环境下,每轮处理后的缓存失效所造成的损失,很可能会超过该操作消除的停滞时间。建议通过实际测试您的会话——参见测量方法——而非凭推测做出判断。
选择压缩模型
微级压缩模式使用 auxiliary.compression 模型:
auxiliary:
compression:
provider: openai-api
model: <your choice>
base_url: <endpoint>
这是最重要的参数之一,且并不存在放之四海而皆准的答案——它取决于您的硬件条件以及您愿意做出的权衡。
每次处理都会生成运行摘要并附加一次信息交换,因此提示词长度较短(仅几千个标记),但该操作会在每个轮次结束时执行。此处有两个关键因素需要考虑:
-
延迟影响最为显著。由于每次轮次都会进行一次处理,其实际耗时会反复累积。如果模型本身需要30秒才能完成推理,那么每个轮次的总耗时就会变成“1轮时间 + 30秒”。
-
推理型模型并不适用。将一次信息交换整合为摘要属于机械性工作。推理型模型会耗费大量标记用于处理这项任务,其速度会比规模相近的普通指令型模型慢得多,且对输出质量并无提升。
以下是在某特定配置下测得的一些数据——请将其视为情况特点的示例,而非具体建议:
| 模型 | 测量结果 |
|---|---|
| 7B 4位精度指令型模型,本地运行(MLX,苹果芯片) | 每次处理约31秒;该设备同时还需承担其他任务 |
| 大型MoE推理型模型,远程GPU运行 | 效率更低——在摘要生成任务上仍需消耗大量推理标记 |
总体而言,小型、快速且无需推理的指令型模型通常是最佳选择;而更大或“更智能”的模型往往反而效果更差。具体该选哪种模型,则取决于您的硬件条件。
如果单次处理速度过慢,可尝试以下方法,其效果从好到差大致如下:选择更快或体积更小的压缩模型;为模型分配竞争较少的主机;或者关闭微压缩功能,转而使用批量压缩。
测量方式
微压缩并非主要为了节省令牌或时间,仅以节省的令牌数量来评判会低估其实际价值。它真正带来的好处有两点:
- 延长了处理间隔时间。虽然仍需进行相同的摘要生成工作,但会在每次对话轮次后分小步完成,而不会在会话中途出现长时间停滞。
- 延长了上下文有效时长。由于中间空间会持续被回收,内存占用率始终保持在较低水平,不会骤然升至阈值。这样一来,会话可以持续更长时间——往往甚至无限期地运行,才需要执行强制压缩。
因此,关键指标是内存占用率:即窗口空间的填充程度,以压缩阈值的百分比表示。若占用率稳定在40%左右,说明仍有足够空间继续处理;而若已升至90%,则即将出现停滞。另一个重要指标是实际执行的批量压缩次数——理想情况下应为零。
有时,尽管从表面数据上看似乎没有节省任何资源,但在上述两个指标上依然能取得显著优势。
每次处理都会生成一条不含内容的JSON行,其格式与批量压缩的监控数据一致:
micro compaction telemetry: {"event":"micro_compaction","outcome":"absorbed",
"tokens_before":12739,"tokens_after":12060,"tokens_delta":-679,
"occupancy_pct":38.4,"threshold_tokens":34816,"context_limit":40960,
"exchange_tokens":868,"rolling_summary_tokens":31,"passes_total":1,
"tokens_saved_total":679,"duration_ms":14,...}
occupancy_pct 表示压缩阈值(即剩余空间)中所占的 tokens_after 比例。当模型的输入窗口尚未确定时,该值为 null:此时遥测系统仅读取缓存中的数值,因为确定输入窗口可能会触发同步的 /models 查询,而遥测数据绝不能成为阻碍处理流程的因素。
若某次处理使转录内容变短,则 tokens_delta 为负值。tokens_saved_total 和 passes_total 会在整个会话过程中持续累计,因此只需查看最后一行即可了解整个运行过程的汇总情况。日志数据中不包含任何转录内容,仅有计数信息。
将日志转换为答案的步骤:
python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...]
默认路径为 $HERMES_HOME/logs/agent.log。该日志会记录审核通过次数、结果分布、节省的净令牌数、平均吸收-交换规模以及每次审核的持续时间。
运行时的示例数据
以下为一个完整的代码审查会话示例:会话时长3.5小时,文本总量约75K令牌,窗口大小为400K,压缩阈值设为320K:
| 审核轮次 | 消息数量变化 | 令牌数量变化 | 变化量 | 占用率 | 持续时间 |
|---|---|---|---|---|---|
| 1 | 40 -> 39 | 27,479 -> 27,778 | +299 | 8.7% | 2.2秒 |
| 2 | 61 -> 59 | 48,676 -> 48,128 | -548 | 15.0% | 4.5秒 |
| 3 | 70 -> 67 | 58,309 -> 55,915 | -2,394 | 17.5% | 9.1秒 |
| 4 | 84 -> 80 | 75,251 -> 69,818 | -5,433 | 21.8% | 36.2秒 |
| 5 | 84 -> 80 | 74,659 -> 70,264 | -4,395 | 22.0% | 31.2秒 |
从这些数据中可得出三点结论。
占用率趋于平稳。 占用率上升至约22%后便不再变化。最后两轮审核的消息数量相同(均为84 -> 80),此期间对话内容新增了4,841个令牌,而微压缩功能又回收了4,395个令牌。这说明系统已达到平衡状态:窗口大小不再持续增长,也不再向阈值靠拢。
未触发批量压缩。 在整个会话过程中,从未出现长时间暂停的情况。
回收量仅在超出尾部预算后才开始增加。 在前几轮审核中,系统几乎无法回收任何令牌,因为当占用率低于尾部预算值(此处为64,000个令牌,即窗口大小的16%)时,几乎所有对话内容都属于受保护的尾部数据,几乎没有可压缩的空间。因此,在初期会话中确实可能不会进行任何审核操作。
成本方面则相当明确:在同时处理其他任务的小型本地模型上,首次处理耗时2至37秒,中位数约为31秒。三小时半的时间里仅完成了大约两分钟的摘要生成工作。相比之下,对75K令牌规模的中间数据执行单次批量压缩仍然是更优选择,但37秒的额外耗时绝非微不足道的小误差。详情请参阅选择压缩模型。
客观看待各项数值
**一次会话中的首次处理通常会消耗令牌,而非节省令牌。**插入摘要标记需要约400个令牌的固定开销——包括压缩前置信息、历史标题以及结束标记——而在首次处理时,这些开销会从单次数据交换中扣除。若首次处理的tokens_delta: +330,这并非故障表现。
从第二次处理开始,标记会被替换而非新增,因此之前的开销已预先支付,每次数据交换几乎都能实现真正的节省。通常在第二次或第三次处理时即可达到成本平衡点。正因如此,会话整体的表现比单次处理结果更为重要:应依据整个会话的进展来评估该功能,而非仅看某一次处理。
当然,那些更易于人类理解的表述方式也依然存在:
Micro-compaction: 37 -> 36 messages
Micro-compaction defrag: rolling summary re-summarized (1843 chars)
Micro-compaction: skipping exchange at cursor 12 after 3 consecutive failures
消息数量的变化幅度通常很小,这是正常现象。真正能体现其影响的是令牌数量:处理一个包含大量工具调用的请求时,令牌数量可能会减少数百个,而消息数量仅会变化一两个。
失败处理机制
微压缩功能始终以尽力而为的方式运行。finalize_turn 函数中的调用被封装在异常处理逻辑中,所有异常都会被记录并默默吞掉——即便发生故障,对话也会保持原样,当前轮次仍会正常结束。虽然该功能的表现可能会下降,但不应导致会话中断。