微压缩

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

长对话最终会超出模型的上下文窗口,必须丢弃或摘要掉某些东西。Hermes 一直是一批次做完:当转录越过阈值,会话停住,一大块中间内容在单次调用中被摘要,对话恢复。这可行,但整笔账一次性付清——一次可见的停顿、一次大摘要请求,就发生在你恰好越线的那一刻。

微压缩把同一笔账分期付。每轮完成后,Hermes 把最老的一个尚未吸收的对话对折叠进一条运行摘要。活是同一摊活;只是连续地、一次一小片地做,而不是在会话中间一次性全做。

它不是免费的,也不是灵丹妙药,而且默认关闭——compression.micro_compact: true 开启它。每一遍都是一次对压缩模型的真实调用,跑在轮次末尾——你的回答已经流式输出完,但轮次要到这遍结束才关闭。每一遍还会重写已发送的历史,每轮都打破 provider 提示词缓存前缀;在开启之前请读提示词缓存——你正在选择付出的代价,因为对某些配置而言那成本超过收益。

这个特性给你的是一个调谐选项:你选择压缩成本如何分布、由哪个模型付它。见选择压缩模型,因为那个选择比这里其他一切都重要。

权衡是:知识比你习惯的更早变成二手。 因为压缩一直在跑,对话较早的部分比批次压缩下更早变成摘要——批次压缩则让一切逐字保留,直到窗口真的填满。会话早前的细节更快变成转述。你用一部分保真度,换永不遭遇一次长停顿,换一个始终更小、而不是锯齿状冲到阈值再回落的上下文窗口。


它做什么

每一轮正常结束后,finalize_turn 请上下文压缩器吸收一个对话对:

  1. 找到最老的、尚未被摘要的对话对。
  2. 只把这一对加当前运行摘要,发给辅助摘要模型。
  3. 用一个携带更新后运行摘要的单一摘要标记替换转录中的那些消息。

一轮一个对话对。无论对话多长,每轮成本保持有界。

一个对话对是一轮完整 agent 轮次:一条助手消息连同其工具结果与后续助手迭代,直到下一条用户消息。在工具密集的工作中,token 的大头就住在那里——一次文件读取或一条命令输出远超周围的叙述——这正是为什么一次吸收一个对话对值得做。取整轮(而不是单个助手+工具组)还让转录的角色交替严格有效:摘要标记是一条助手角色消息,而整轮总是两侧以用户消息为界。

你的消息绝不被压缩

一个对话对刻意从助手消息开始。微压缩径直走过用户消息到达那里,因此你输入的内容绝不被摘要——你的提示词在整个会话中保持逐字,无论它跑多久、压缩触发多少次。

这是整个设计最有用的属性,值得说清为什么。助手产出的大体上是一份它做了什么的账目:读了这个文件、跑了那条命令、得到这个结果。那种叙述在摘要后损失极小——"它是这么做的"压缩后几乎和全文一样有信息量。你的指令是另一回事。它们是其他一切从中派生的意图,无法从后续工作中重建。把"用既有的重试 helper,别新加一个"转述进摘要,正是一个 agent 在六轮后自信地做了你叫它别做的事的原因。

因此这种不对称是有意的:压缩派生材料,保留真相源。代价是中间能缩到多小有了下限,因为用户轮次不断累积且从不被吸收。实践中这个下限很低——一条提示词通常只是单次工具结果成本的零头——但它是真实下限。如果你常贴 10–20K token 的提示词,那份重量按设计留在上下文中。

它绝不触碰什么

另有两个区域受保护并保持逐字:

  • 头部——系统提示词与开头消息,因此会话的创始指令绝不被转述。
  • 尾部——按 token 预算的最近消息窗口,因此一切当前相关的内容仍完整在那里。

微压缩只在这两者之间的中间地带工作。

它如何工作

游标

压缩器维护一个游标:第一个尚未吸收的消息的索引。每次成功的遍程把它推过刚摘要的那个对话对。

若内存中这个游标缺失或越界——新进程、恢复的会话——它通过扫描转录找最后一个摘要标记并从其后恢复来重建。转录本身是真相源,因此恢复会话不会重新摘要已完成的工作。

滚动摘要

不是堆一堆逐对话对的摘要,而是恰好有一条运行摘要,每个新对话对折叠进它。摘要器被要求折入新材料的决策、需求、文件路径与未决问题,丢弃不再相关的细节,并保留既有结构。它还被显式指示把遇到的任何凭据替换为 [REDACTED]。

因为那摘要是累积的,转录中只保留最新标记。更早的标记严格冗余——当前摘要已含它们装的一切——因此它们在被取代时被丢弃。这比听起来重要:把它们留在原处会堆叠近乎重复的同文副本,各自带自己的标题与结束标记脚手架,转录每轮都在增长而非缩小。

丢弃一个标记让它两侧的用户轮次相邻,于是它们为模型合并成一条用户消息。这条合并行被标 display_metadata.model_only:原文在显示历史中保留为已压缩行,每个显示投影(恢复、分页历史、提示词时间线)都跳过这次合并,因此恢复的会话把每条输入恰好显示一次。

整理(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 时你得到五分之一的打破与五分之一的回收速率,若你的会话长寿命、provider 缓存折扣深,这是正确方向。低于 1 的值被钳到 1,而不是静默禁用该特性。计数器按轮次推进,而非按已提交的遍程,因此一轮无可吸收内容仍推进节奏,不会卡死它。

micro_compact_defrag_threshold_tokens 是滚动摘要何时被重新摘要而非永远增长——见整理。

它以 opt-in 而非默认发货,是因为下一节描述的提示词缓存成本——那成本是真实的、并非普遍值得付,它应是你做出的决定,而非你继承来的。

提示词缓存——你正在选择付出的代价

开启该特性前读这段。这是反对它最强的论据。

一个长寿命对话每轮复用一个缓存的提示词前缀,缓存输入 token 按未缓存的一小部分计费。那折扣只在前缀不变时存活。一遍微压缩重写已发送的历史,从前缀点起使其失效——因此开了微压缩,你每轮打破缓存,而不是每次批次压缩一次。

这正是主动修剪刻意避开的同一成本。那条路径把自己门在 compression.proactive_prune_min_reclaim_tokens(默认 4096)之后,正是为了让它的重写如配置注释所说,是"一次大的 episodic 打破,而不是每次工具迭代的小打破"。

微压缩没有等价的回收量门——一遍提交它恰好吸收的那个对话对省下的,大小不论。它有的是一个频率旋钮 micro_compact_every_n_turns。调高它让打破更稀有、更 episodic,这与修剪的门殊途同归,只是靠吸收更少而非等待更大收益到达。若你想在这里要修剪的确切语义,一个微压缩回收阈值是显然的后续,目前还不存在。

因此诚实的表述是用一种成本换另一种,而非节省:

仅批次(默认)微压缩开启
压缩停顿阈值处一次长停顿摊到各轮
上下文占用锯齿冲到阈值保持低位平坦
缓存前缀压缩之间完整每轮打破

哪一方占优取决于你自己的数字:你的 provider 对缓存输入折扣多少、你的前缀多大、你的会话跑多久、一次会话中停顿实际花你多少。在一个缓存折扣深、前缀大的 provider 上,逐轮失效可能比它消除的停顿更贵。量你自己的会话——见度量它——而不是想当然。

选择压缩模型

微压缩用 auxiliary.compression 模型:

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

这是唯一最重要的旋钮,没有普遍正确答案——它取决于你的硬件与你愿意交换什么。

每遍发送运行摘要加一个对话对,因此提示词小(几千 token),但调用发生在每一轮、轮次末尾。两个属性要紧:

  • 延迟主导。 因为每轮跑一遍,它的挂钟成本被反复感受到。一个要 30 秒的模型把每一轮变成一轮加 30 秒。
  • 推理模型不合适。 把一个对话对折叠进摘要是机械活。一个思考模型会在它上面花推理 token,比同尺寸的普通 instruct 模型显著更慢,对输出毫无益处。

一些实测点,来自某一个特定配置——当作形状的示意,而非推荐:

模型观测
7B 4-bit instruct,本地(MLX,Apple Silicon)每遍约 31s;该机器还在服务其他工作
大型 MoE 推理模型,远程 GPU明显更慢——在摘要任务上花思考 token

规律是:一个小、快、非推理的 instruct 模型通常是正确形状,更大或"更聪明"的模型在这里往往更糟而非更好。这对你落在哪取决于你有什么硬件跑它。

若遍程感觉太慢,按效果大致排序的选项是:选更快或更小的压缩模型;给它一个更少争用的宿主机;或关掉微压缩回到批次压缩。

度量它

微压缩主要不是 token 节省或时间节省优化,按省下的 token 评判它会低估它。它实际买到的两样是:

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

因此要紧的数字是占用率:窗口被保持多满,占压缩阈值的百分比。一个稳定在 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 跨会话累积,因此整次运行可从最后一行总结。payload 中无转录内容——只有计数。

把日志变成答案:

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

默认 $HERMES_HOME/logs/agent.log。它报告遍程、结果分布、净省 token、平均吸收对话对大小与遍程耗时。

它正常工作时的样子

一个真实会话——3.5 小时整项目代码评审,约 75K token 转录,400K 窗口,压缩阈值 320K:

遍程消息tokendelta占用耗时
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 而非省 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 中的调用被包裹,任何异常都被记录并吞掉——失败让对话保持不变,轮次正常完成。它可以降级,但不应能弄坏一个会话。