跳到主要内容

微型压缩

一种摊销压缩成本的方式。

长对话最终会超出模型的上下文窗口,必然要有内容被丢弃或总结。Hermes 一直以来都是按一批来做这件事:当对话记录跨过阈值时,会话暂停,中间的一大块内容在单次调用中被总结,然后对话继续。这行得通,但整笔账会一次性到期 —— 一次可见的停顿,一个巨大的总结请求,在你恰好越线的那一刻发生。

微型压缩则以分期付款的方式支付同样的账单。每一轮完成后,Hermes 会将最早的一条尚未被吸收的交换折叠进一份持续更新的摘要中。工作量还是一样的工作量,只是它持续发生、一次一小块,而不是在会话中途一次性全部完成。

它并非没有代价,也不是什么万能药,而且它默认关闭 —— compression.micro_compact: true 才会开启。每一次都实打实地调用压缩模型,而且它在一轮结束时运行 —— 你的回答已经流式输出了,但这一轮在流程跑完之前不会关闭。每一次流程还会重写已经发送的历史,这会在每一轮都破坏服务商的 prompt 缓存前缀;在启用之前请先阅读 Prompt 缓存,因为在某些配置下这个代价超过了收益。

这个功能给你的是一个调优选项:你可以选择压缩成本如何分布,以及由哪个模型来承担。参见选择压缩模型,因为这个选择比这里其他任何事情都更重要。

代价是知识会比你习惯的更早到手。 由于压缩一直在运行,对话中较旧的部分会比批量压缩时更早变成摘要 —— 批量压缩会把所有内容原样保留,直到窗口真正被填满。会话早期细节变成二手信息的速度更快。你用一部分这样的保真度,换来了永远不会经历一次长时间停顿,以及一个持续更小、而非在阈值上下锯齿波动的上下文窗口。


它做什么​

在每一个正常结束的回合之后,finalize_turn 会要求上下文压缩器吸收一条交换:

  1. 找出尚未被总结的最早一条交换。
  2. 只把那条交换,加上当前的持续摘要,发送给辅助总结模型。
  3. 在对话记录中用一条携带更新后持续摘要的摘要标记替换掉那些消息。

每回合一条交换。无论对话变得多长,每回合的成本都保持有界。

一条交换是一个完整的智能体回合:一条助手消息连同它的工具结果及任何后续的助手迭代,直到下一条用户消息。在工具密集的工作中,绝大部分 token 就在那里 —— 一次文件读取或一条命令的输出,会让周围的文字相形见绌 —— 这就是为什么一次吸收一条交换值得一做。取整个回合(而非单个助手+工具组)还能保持对话记录的角色交替严格有效:摘要标记是一条助手角色消息,而整个回合两侧始终由用户消息界定。

你的消息永远不会被压缩​

一条交换刻意从助手消息开始。微型压缩会径直越过用户消息来到达那里,所以你输入的内容永远不会被总结 —— 你的提示在整个会话中始终原样保留,无论会话运行多久、压缩触发多少次。

这是整个设计中最有用的特性,值得明确说清其缘由。助手所产出的内容,很大程度上是对它所做事情的叙述:它读了这个文件,它跑了那条命令,它得到了这个结果。这类叙述在总结时损失极小 —— “它是这样做成的”压缩后和完整版几乎一样有信息量。你的指令则是另一类东西。它们是一切其他内容的派生来源,无法从后续的工作中重建。把“用已有的重试辅助函数,别新增一个”改写成摘要,恰恰就是一个智能体在六轮之后自信地做着你叫它别做的那件事的原因。

所以这种不对称是刻意的:压缩派生的材料,保留真相的来源。代价是中间部分能缩到多小有个下限,因为用户回合会累积且永不被吸收。实际上这个下限很低 —— 一条提示通常只占一份工具结果成本的一小部分 —— 但它确实是个真实的下限。如果你习惯性粘贴 10–20K token 的提示,那部分重量会按设计留在上下文中。

它从不触及的部分​

还有两个区域受到保护并保持原样:

  • 头部——系统提示词和开场的消息,因此会话最初的指令永远不会被转述。
  • 尾部——一个受 token 预算限制的近期消息窗口,因此所有即时相关的内容仍然完整保留。

微压缩只在两者之间的中间部分工作。

工作原理​

游标​

压缩器维护一个游标:即那条尚未被吸收的第一条消息的索引。每一次成功的处理都会将它推进到它刚刚总结完的那轮交流之后。

如果这个内存中的游标缺失或超出范围——例如全新进程、恢复的会话——它会通过扫描记录,找到最后一个总结标记,并从中断处之后恢复。记录本身就是事实来源,因此恢复会话不会重新总结已完成的工作。

滚动摘要​

不保留每轮交流的一堆独立摘要,而是有且只有一个持续运行的摘要,每轮新的交流都并入其中。总结器被要求合入新材料中的决策、需求、文件路径和待决问题,丢弃不再相关的细节,并保持现有结构。它还被明确指示用 [REDACTED] 替换遇到的任何凭证。

由于该摘要是累加的,因此记录中只保留最新的标记。较早的标记完全是多余的——当前摘要已经包含了它们所保存的一切——因此在被取代时它们会被丢弃。这件事比听起来更重要:把它们留在原处会堆叠几乎重复的相同文本,每段都带有自己的标题和结束标记脚手架,而且记录会随着每一轮增长而非缩小。

整理(Defrag)​

如果足够频繁地合入摘要,它就会变得臃肿——重复,而且比材料本身应有的规模更大。当运行摘要超过一个 token 阈值(默认 2000)时,下一次处理会整理:一次辅助调用会将运行摘要本身重新总结为一个新的压缩版本,记录中的摘要标记会原地重写。

整理从不触及记录的结构——不会被吸收或拼接任何消息,游标不会移动,用户轮次也不受影响。它只处理累积的摘要文本,从不处理会话消息,因此"你的消息永远不会被压缩"这一保证在它运行期间依然成立。

与会话数据库保持一致​

仅靠在内存中拼接还不够。Hermes 正常会话落盘是只追加的,所以原始行仍会被标记为活动,而恢复时会同时加载摘要和被它替换掉的消息——从而让会话直接超出上下文限制。

因此每一次处理还会调用 archive_and_compact,它会原子性地将这些活动行软归档,并插入压缩后的集合。之后这些消息被标记为已持久化,使得随后的只追加落盘会跳过它们。如果这个数据库步骤失败,它会被记录日志,会话继续运行;在下一批压缩清理之前,恢复会出现双重加载。

当总结器失败时​

总结调用可能失败——辅助模型不可达、超出配额,或者这轮交流本身以某种方式无法被总结。记录会保持原样,失败计数会递增。

如果同一轮交流连续失败三次,游标仍会被推进以越过它。没有这一机制,一次失败的交流会永无止境地在每一轮被重试。这些被跳过的消息会留在记录中,并被下一次批压缩拾取。

与批压缩的交互​

微压缩并不会取代批压缩——它推迟批压缩。基于阈值的压缩仍然存在,并且如果窗口仍然填满仍会触发,而且它们的摘要标记使用相同的格式,因此两者可以互操作。在实践中,微压缩会将记录保持在远低于阈值的水平,以至于批压缩路径触发得少得多。

配置​

微压缩默认关闭。需显式开启:

compression:
micro_compact: true # 默认:false
micro_compact_every_n_turns: 1 # 节奏——多久执行一次
micro_compact_defrag_threshold_tokens: 2000

如果 micro_compact 未设置或为 false,Hermes 的行为与以往完全一致:仅批压缩。压缩的其他方面保持不变。

micro_compact_every_n_turns 是开关之后最重要的旋钮,因为它决定了你为下述缓存中断付出的频率。设置为 1 时,每完成一轮就执行一次:最激进地回收,每轮一次断掉的前缀。设置为 5 时,你有五分之一的中断和五分之一的回收率,如果你的会话是长期存活的且你的服务商缓存折扣很深,这是正确的方向。低于 1 的值会被钳制为 1,而不会悄悄禁用该功能。计数器按轮推进,而非按已提交的处理推进,因此一轮没有可吸收内容时仍会展缓节奏,不会将其卡住。

micro_compact_defrag_threshold_tokens 决定运行摘要何时重新总结自身而不是无限增长——见整理。

它出厂时为选择加入而非默认开启,原因在于下一节描述的提示词缓存成本——这项成本是真实存在的,它并非普遍值得付出,它应该是你主动做出的决定而非被动继承的决定。

Prompt caching —— 你正在选择承担的成本​

在启用这一功能之前请先读这一节。这是反对它的最有力论据。

一个长期运行的会话每一轮都会复用缓存的 prompt 前缀,而缓存的输入 token 计费远低于未缓存的 token。这种折扣只有在prefix不发生改变时才存在。一次 micro-compaction pass 会重写已经发送过的历史,这会使从重写点开始的前缀失效 —— 因此开启了 micro-compaction 之后,你会每一轮都打破缓存,而不是每个 batch compaction 只打破一次。

这与主动 prune 刻意规避的是同一种成本。那条路径通过 compression.proactive_prune_min_reclaim_tokens(默认 4096)把自己挡在后面,正是为了让它的重写保持为配置注释所说的一整次“一次性的大幅中断,而不是每次工具迭代一次的小中断”。

Micro-compaction 没有等价的回收规模门槛 —— 一次 pass 无论那一个被吸收的 exchange 实际节省了多少,无论大小,都会提交本次改动。它有的是一个频率旋钮:micro_compact_every_n_turns。调高它会让中断更罕见、更具突发性,这达成的目的与 prune 的门槛相同,只是走的是另一条路径 —— 尽管它是通过少吸收一点来实现,而非等待一次更大的收益。如果你想要这里具备 prune 那样的精确语义,在 micro-compaction 上加一个回收阈值是最显而易见的后续改进,但目前尚不存在。

所以诚实的说法是:这是以一种成本换取另一种成本,而不是节省:

仅批量(默认)开启 micro-compaction
压缩停顿在阈值处一次长长的停顿分散到各轮中
上下文占用率逐渐上升到阈值保持低位且平稳
缓存前缀在压缩之间保持不变每一轮都被打破

哪边胜出取决于你自己的具体数字:你的服务商对缓存输入打了多少折扣、你的前缀有多大、你的会话运行多久、以及一次会话中途的停顿对你来说实际代价有多大。在一个缓存折扣深、前缀大的服务商上,每轮失效带来的代价很可能超过它消除的那次停顿。请测量你自己的会话 —— 见

Measuring it —— 而不是凭假设。

选择压缩模型​

Micro-compaction 使用 auxiliary.compression 模型:

auxiliary:
compression:
provider: openai-api
model: <your choice>
base_url: <endpoint>

这是最重要的一个旋钮,且没有普适正确的答案 —— it depends on your hardware and what you are willing to trade.

每次 pass 会把当前滚动摘要加上一个 exchange 发出去,所以 prompt 很小(几千 token),但这个调用会在每一轮结束时发生。有两个属性很重要:

  • 延迟占据主导。 因为一次 pass 每轮都会运行,它的实际耗时你会反复感受到。一个需要 30 秒的模型,会把每一轮都变成一轮加上 30 秒。
  • 推理模型不适合。 把一个 exchange 合并进摘要中是机械工作。一个 thinking 模型会为此花费推理 token,比同类规模的普通 instruct 模型慢得多,而输出质量没有任何提升。

以下是在某一个特定配置上测得的一些数据点 —— 视为对形态的示意,而非推荐:

模型观察到的情况
7B 4-bit instruct,本地(MLX,Apple Silicon)每 pass 约 31 秒;同时该机器还服务其他工作负载
大型 MoE 推理模型,远程 GPU仍明显更慢 —— 在摘要任务上花费 thinking token

规律是:一个小、快、非推理的 instruct 模型通常是正确的形态,而更大或“更聪明”的模型在这里往往是更差而不是更好。这对你意味着什么,取决于你要在什么之上运行它。

如果 pass 感觉太慢,你的选项按效果大致排序是:换一个更快或更小的压缩模型;给它一个争用更少的主机;或者关掉 micro-compaction,回到批量压缩。

如何度量​

Micro-compaction 主要不是 token 节省或时间节省的优化,仅用节省的 token 来评判它会低估它。它真正带给你的是两点:

  1. 长停顿被摊平了。 同样的摘要工作仍会发生,但作为每轮之后的小增量,而不是会话中途的一次停顿。
  2. 你的上下文用得更久。 因为中间部分被持续回收,占用率保持低位,而不是锯齿状升到阈值。一次会话能跑得远得多 —— 常常是无限期 —— 才会需要一次硬压缩。

所以真正重要的数字是占用率:窗口被维持在有多满,作为压缩阈值的百分比。一个稳稳保持在 40% 左右的会话是有的余量继续跑的;一个爬到 90% 的会话则快要停顿了。第二个数字是实际触发了多少次批量压缩 —— ideally none.

一个会话完全可能在纸面上什么都没节省,却在这两项计数上都是明显的赢家。

每次 pass 会发出一条不含内容的 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 探测,而遥测绝不能成为阻塞某一轮的东西。

当一次 pass 使 transcript 缩小时,tokens_delta 为负。tokens_saved_total 和 passes_total 会在整个会话中累加,从而可以从最后一行总结出整轮运行。payload 中不出现任何 transcript 内容 —— 只有计数。

要把日志变成答案:

python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...]

默认使用 $HERMES_HOME/logs/agent.log。它会报告 pass 次数、结果混合、净节省 token、被吸收 exchange 的平均大小以及 pass 时长。

正常工作时看起来是什么样​

一个真实会话——历时 3.5 小时的全项目代码审查,约 75K token 的记录,400K 窗口,压缩阈值为 320K:

轮次消息数tokens增量占用率时长
140 -> 3927,479 -> 27,778+2998.7%2.2s
261 -> 5948,676 -> 48,128-54815.0%4.5s
370 -> 6758,309 -> 55,915-2,39417.5%9.1s
484 -> 8075,251 -> 69,818-5,43321.8%36.2s
584 -> 8074,659 -> 70,264-4,39522.0%31.2s

从中可以读出三件事。

占用率趋于平缓。 它攀升到约 22% 后便停下了。最后两轮完全一致(84 -> 80 条消息);两者之间对话新增了 4,841 token,而微压缩回收了 4,395。这就是平衡态:窗口保持稳定,而不是一路逼近阈值。

没有触发批量压缩。 整个会话过程中,那次长暂停从未发生。

只有超过尾部预算后回收量才逐步上升。 最初的几轮几乎什么都没回收,因为在尾部预算之下(此处为 64,000 token,占窗口的 16%),几乎整个记录都是受保护的尾部,可触碰的内容非常少。会话早期完全没有任何一轮压缩也属正常。

还有代价,直言不讳地说:各轮耗时 2 到 37 秒,中位数约 31 秒,运行在一个同时还在服务其他工作的小型本地模型上。在整个三个半小时里,大约分摊了两分钟的摘要开销。相比对一个 75K token 的中段做一次批量压缩,这仍然是更划算的取舍,但 37 秒的增量不是可以忽略的舍入误差。参见 选择压缩模型。

诚实解读这些数字​

会话中的第一轮通常消耗 token 而非节省。 插入摘要标记会带来固定约 400 token 的脚手架——压缩前言、历史标题、结束标记——而在第一轮,这些开销只对应一个被吸收的交换。第一轮出现 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

消息数只有小幅变动——这符合预期。效果体现在 token 数上:吸收一个工具密集的交换可以掉下数百 token,而消息数只变动一两条。

失败行为​

微压缩自始至终是尽力而为的。finalize_turn 中的调用被包裹起来,任何异常都会被记录并吞掉——失败会原样返回对话,该轮照常完成。它可以降级,但不应该能让一个会话中断。