微压缩
一种摊薄压缩成本的方式。
长对话最终会超出模型的上下文窗口,必须丢弃或摘要掉某些东西。Hermes 一直是一批次做完:当转录越过阈值,会话停住,一大块中间内容在单次调用中被摘要,对话恢复。这可行,但整笔账一次性付清——一次可见的停顿、一次大摘要请求,就发生在你恰好越线的那一刻。
微压缩把同一笔账分期付。每轮完成后,Hermes 把最老的一个尚未吸收的对话对折叠进一条运行摘要。活是同一摊活;只是连续地、一次一小片地做,而不是在会话中间一次性全做。
它不是免费的,也不是灵丹妙药,而且默认关闭——compression.micro_compact: true 开启它。每一遍都是一次对压缩模型的真实调用,跑在轮次末尾——你的回答已经流式输出完,但轮次要到这遍结束才关闭。每一遍还会重写已发送的历史,每轮都打破 provider 提示词缓存前缀;在开启之前请读提示词缓存——你正在选择付出的代价,因为对某些配置而言那成本超过收益。
这个特性给你的是一个调谐选项:你选择压缩成本如何分布、由哪个模型付它。见选择压缩模型,因为那个选择比这里其他一切都重要。
权衡是:知识比你习惯的更早变成二手。 因为压缩一直在跑,对话较早的部分比批次压缩下更早变成摘要——批次压缩则让一切逐字保留,直到窗口真的填满。会话早前的细节更快变成转述。你用一部分保真度,换永不遭遇一次长停顿,换一个始终更小、而不是锯齿状冲到阈值再回落的上下文窗口。
它做什么
每一轮正常结束后,finalize_turn 请上下文压缩器吸收一个对话对:
- 找到最老的、尚未被摘要的对话对。
- 只把这一对加当前运行摘要,发给辅助摘要模型。
- 用一个携带更新后运行摘要的单一摘要标记替换转录中的那些消息。
一轮一个对话对。无论对话多长,每轮成本保持有界。
一个对话对是一轮完整 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 评判它会低估它。它实际买到的两样是:
- 长停顿被摊薄。 同样的摘要工作发生,但作为轮次后的小增量,而不是会话中间一次停顿。
- 你的上下文撑更久。 因为中间被持续回收,占用保持低位而非锯齿冲到阈值。一个会话能跑远得多——常常无限——才需要一次硬压缩。
因此要紧的数字是占用率:窗口被保持多满,占压缩阈值的百分比。一个稳定在 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:
| 遍程 | 消息 | token | delta | 占用 | 耗时 |
|---|---|---|---|---|---|
| 1 | 40 -> 39 | 27,479 -> 27,778 | +299 | 8.7% | 2.2s |
| 2 | 61 -> 59 | 48,676 -> 48,128 | -548 | 15.0% | 4.5s |
| 3 | 70 -> 67 | 58,309 -> 55,915 | -2,394 | 17.5% | 9.1s |
| 4 | 84 -> 80 | 75,251 -> 69,818 | -5,433 | 21.8% | 36.2s |
| 5 | 84 -> 80 | 74,659 -> 70,264 | -4,395 | 22.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 中的调用被包裹,任何异常都被记录并吞掉——失败让对话保持不变,轮次正常完成。它可以降级,但不应能弄坏一个会话。