本节摘要:长会话必然膨胀到窗口边缘,压缩是内循环的代谢系统。Hermes 把压缩做成可替换引擎:
agent/context_engine.py定义ContextEngineABC(配置context.engine选择,默认"compressor"),内置实现是agent/context_compressor.py的 ContextCompressor(8415 行)——辅助模型摘要中段、保护头尾(系统提示隐式保护+protect_first_n=3头部消息+按 token 预算的尾部窗口,阈值默认 75% 窗口)。在此之上,micro-compaction(docs/micro-compaction.md,默认关闭)把整批压缩的账单分期付款:每个完成的轮次之后吸收**恰好一个"交换"(exchange,一条 assistant 消息及其工具结果直到下一条 user 消息)**进滚动摘要,游标推进、用户消息永不压缩。它诚实到把最大代价写在最前面——每轮重写已发送历史,每轮击穿一次缓存前缀,因此micro_compact_every_n_turns是频率旋钮,缓存折扣深的 provider 上可能得不偿失。另有prune_tool_results_only确定性工具输出剪枝(4096 token 回收门槛,不调 LLM)与 native_compaction(OpenAI 服务端压缩)。token 估算用estimate_tokens_rough:ASCII 4 字符/token,CJK 码点近似 1 字 1 token。压缩与缓存的交互贯穿始终:压缩点就是缓存断点,何时压缩=何时付费。
内容来源:原项目源码
agent/context_engine.py(ABC)、agent/context_compressor.py(阈值/微压缩/交换查找)、agent/model_metadata.py(token 估算)、agent/native_compaction.py、docs/micro-compaction.md、website/docs/developer-guide/context-compression-and-caching.md。
⚠️ 注意:micro-compaction 默认关闭(
compression.micro_compact: true显式开启),官方理由直言不讳:"Each pass also rewrites already-sent history, which breaks the provider prompt-cache prefix every turn; read Prompt caching before enabling it, because for some setups that cost exceeds the benefit"。同一处的失败重试上限 3 次,超过则跳过该交换防死循环。
阅读完本节,你应当能够:
_micro_compact 的节奏门/游标/defrag/失败跳过/数据库同步。agent/context_engine.py:1-22 的 docstring 定界:"A context engine controls how conversation context is managed when approaching the model's token limit. The built-in ContextCompressor is the default implementation. Third-party engines (e.g. LCM) can replace it via the plugin system"。生命周期六步:实例化注册→on_session_start→每次 API 响应后 update_from_response(喂 usage 数据)→每轮检查 should_compress→真则调 compress→会话真边界 on_session_end。类属性即策略参数(120-125 行):
120 threshold_percent: float = 0.75 121 protect_first_n: int = 3 122 protect_last_n: int = 6
compress(messages, current_tokens, focus_topic, force, memory_context) 是主入口,返回合法 OpenAI 消息序列——实现自由("summarize, build a DAG, or do anything else")。ABC 还区分两个正交动词:compress 是"上下文太长→变短";select_context(可选钩子)是"这一轮属于别的上下文→换一组消息",后者在缓存标记与请求清洗之前运行——默认 no-op 时请求字节不变、缓存无扰;引擎若替换列表,"changes its own cache prefix by definition; that is the engine's concern"(第 6-01 节的铁律在扩展点的又一次重申)。另有 prune_tool_results_only 确定性剪枝:低门槛、独立触发、不调 LLM,内置 4096 token 的最小回收量("one big episodic break instead of a tiny break every tool iteration"——又是缓存意识)。
ContextCompressor 的经典策略(模块 docstring 自述"protecting head and tail context"):触发阈值为窗口的 threshold_percent(默认 75%,小上下文模型有地板值),压缩目标按 summary_target_ratio 缩;头保护 _protect_head_size(5970 行):
5975 """``protect_first_n`` is defined as *additional* messages protected 5976 beyond the system prompt. The system prompt (if present at index 0) 5977 is always implicitly protected — it's load-bearing context that 5978 must never be summarised away. ... 5986 The ``protect_first_n`` portion DECAYS after the first compression 5990 Examples (first compaction): 5991 protect_first_n=0 → system prompt only 5992 protect_first_n=3 → system + first 3 non-system messages 5993 After the first compaction: system prompt only.
protect_first_n 首压后衰减(#11996:不让早期用户轮在反复压缩中化石化)。尾部按 token 预算而非固定条数(_find_tail_cut_by_tokens),且边界对齐到工具组边缘不腰斩(_align_boundary_backward:边界落进工具结果组中间时回退到组首)。摘要前先做廉价预pass:旧工具输出剪枝,再喂辅助模型;摘要模板带 Resolved/Pending 问题追踪、凭据替换 [REDACTED]、历史化标题(避免"Next Steps"被读成活动指令)。压缩不是无脑等阈值:摘要调用失败有分类冷却(_SUMMARY_PERMANENT_QUOTA_MARKERS/_SUMMARY_MISSING_CREDENTIAL_MARKERS 区分配额耗尽与缺 key),should_compress_info 能给出"为何这轮没压"的人类可读理由(#62625 静默溢出警告);小上下文模型(<512K)有阈值地板,防止窗口太小的模型把触发点压得过低;resolve_model_threshold 还支持按模型名子串配置每模型阈值覆盖。用户也可手动 /compress <focus> 触发并给主题焦点(focus_topic 透传给引擎)。
docs/micro-compaction.md 的第一句即定位:"A way to amortize the cost of compression"——批量压缩"one visible pause, one big summarization request"在跨过阈值的那一刻整单到期;微压缩每轮只吸收一个交换。交换的定义(_find_one_exchange,6528 行 docstring):
6530 """An exchange is one full agent turn: the first assistant message after 6531 *start* plus everything through the end of that turn — tool results 6534 ``user`` message. ... 6537 The full-turn shape is an alternation-safety requirement ... the splice 6540 two consecutive assistant turns, which strict providers reject and 6543 repair_message_sequence would then mangle. 6545 User messages are deliberately NOT part of an exchange. ... 6560 what the assistant emits is largely an account of what it did, which 6561 survives summarising, while the user's own words are the instructions 6562 everything else is derived from and are the one thing that cannot be 6563 reconstructed from context.
三条深层理由:整轮(而非单个 assistant+tools 组)吸收保住角色交替合法;用户消息永不摘要是全设计最有用的性质——assistant 的叙述是"做了什么"的账本,摘要无损;用户的话是一切派生工作的源头,改写它正是"六轮之后 agent 自信地做了你禁止的事"的成因。保护区域共三处:头部(系统提示+开局消息)、尾部(token 预算窗)、全部 user 轮——微压缩只在头尾之间的中段作业。
context_compressor.py:6791 的 _micro_compact 由 finalize_turn 调用,流程(节选):
6808 # Cadence gate. A pass rewrites already-sent history, so it costs one 6809 # prompt-cache break; `every_n_turns` is how an operator trades reclaim 6810 # frequency against that cost. 6812 every_n = max(1, int(self._micro_compact_every_n_turns or 1)) ... 6854 # Check for defrag trigger: the rolling summary itself has grown baggy. 6855 # Defrag rewrites the summary text and the existing marker in place — 6856 # no splice, no cursor movement, no user turns touched ... 6872 updated_summary = self._micro_summarize_one(exchange_text) 6873 if updated_summary is None: ... 6894 if self._micro_compact_consecutive_failures >= _MICRO_COMPACT_MAX_CONSECUTIVE_FAILURES: # 3 次同位失败→游标跳过该交换,留给批量压缩 ... 6922 self._micro_compact_rolling_summary = updated_summary 6923 self._micro_compact_cursor = exchange_end 6925 result = self._splice_micro_compact_result( 6926 messages, exchange_start, exchange_end, supersede=_cumulative, 6929 self._micro_compact_cursor = self._cursor_after_splice(result, exchange_start + 1) 6930 self._sync_micro_compact_to_db(result)
要点逐条:节奏门按"调用次数"而非"成功次数"计数,空转轮也推进节奏防卡死;滚动摘要只有一份,新交换折入旧摘要,旧标记被取代即删(不删会每轮堆近重复副本);defrag 在摘要超 2000 token 时触发,只重摘要摘要本身——不接拼、不动游标、不碰用户轮;失败处理区分"同位连续失败"(3 次跳过)与换位重计;游标必须从拼后的列表重新推导(_cursor_after_splice 的注释记录了一个真 bug:沿用旧索引会让游标插进后续交换的工具组中间,"roughly half the work silently never happened");数据库同步调用 archive_and_compact 原子软归档旧行+插入压缩集——纯内存拼接遇上 append-only 会话落盘会在 resume 时双重加载,该步失败则记日志继续(下次批量压缩兜底清理)。恢复路径:进程重启后游标丢失,扫描转写找最后一个摘要标记续跑(_rolling_summary_from_marker 同时从标记里水合滚动摘要——否则重启后第一趟会从空摘要重建、顶掉承载全部历史的旧标记),"The transcript itself is the source of truth"。标记格式与批量压缩共用(MICRO_COMPACT_MARKER_KEY 区分滚动标记),两套机制互操作:微压缩把占用率压得够低,阈值触发的批量压缩自然很少再开火。
微压缩的诚实账本(docs 原文表格):
| 批量 only(默认) | micro-compaction 开 | |
|---|---|---|
| 压缩停顿 | 阈值处一次长停 | 分摊到各轮 |
| 上下文占用 | 锯齿冲到阈值 | 低且平 |
| 缓存前缀 | 压缩之间完好 | 每轮击穿 |
三个旋钮:micro_compact(开关,默认 false)、micro_compact_every_n_turns(频率,1=最激进也最破缓存,5=五分之一的破坏与五分之一的回收,小于 1 钳到 1)、micro_compact_defrag_threshold_tokens(2000)。压缩模型选 auxiliary.compression:延迟主导、**推理模型不合适**("A thinking model will spend reasoning tokens ... substantially slower, for no benefit");官方实测 7B 4-bit 本地模型每趟约 31 秒。度量看占用率而非省 token:一个 3.5 小时会话实例里占用率爬到 22% 后持平(最后两趟回收 4395 token ≈ 新增 4841 token 的均衡),全程零次批量压缩;第一趟通常反而**多花**约 300-400 token(摘要标记的脚手费),第二趟起标记是替换而非新增、逼近纯节省——"judge the feature on a session's trajectory, not on one turn"。遥测每趟一行无内容 JSON(occupancy_pct/tokens_delta/passes_total...),scripts/micro_compaction_report.py 可从日志汇总整场数据。token 估算本身(estimate_tokens_rough,model_metadata.py:3502)是 CJK 感知的:ASCII 走 O(1) 快路 (len+3)//4,非 ASCII 再数 CJK 码点按 1 字 1 token、其余 4 字符 1 token——"CJK/Hangul/Kana text is much denser than English under common LLM tokenizers"。
💡 循环要点:压缩是内循环里唯一合法违反缓存铁律的操作——因为不压缩的替代项是会话死亡。整个第 6 章因此是一场受控的违规管理:批量压缩把违规定点在阈值处(一次 episodic break);工具剪枝给违规设最低回收门槛(4096);微压缩把违规摊成每轮小额、并交出频率旋钮让用户按自家缓存折扣定价。头部系统提示永远不进摘要,意味着第 6-01 节的 stable 前缀在最长会话里也保持可缓存——两节合起来才是完整的上下文工程:先排好序,再学会扔,扔的时候别把命根子扔了。
context.engine 配置切换,第三方引擎可插件替换。protect_first_n=3(首压后衰减)+token 预算尾窗、工具组边界对齐、摘要前廉价剪枝。archive_and_compact 同步数据库、重启扫标记恢复游标。至此器官层三章(工具/技能/记忆/上下文)齐备。下一章转向生态层:单进程 34 平台网关——telegram/discord/slack 之外,微信/飞书/钉钉/QQ 机器人/企微/元宝如何接入同一个 Hermes。