上下文管理:长对话为什么不会失控
本章核心源码:
agent/context_compressor.py(696 行)、run_agent.py(预飞行压缩 7000-7049 行,主循环内压缩触发)
定位:本章分析 Hermes 如何在长会话中维持可持续性——从 tool output pruning 到结构化摘要到 session splitting。 前置依赖:第 10 章(SessionDB)、第 11 章(Memory Provider)。适用场景:想理解 agent 如何处理长对话而不崩溃或丢失关键信息。
为什么长对话是个工程问题
LLM 的上下文窗口是有限的(128K-200K tokens)。一个包含 30 次工具调用的对话可能消耗 80K+ tokens——接近窗口上限。超限时 API 返回 context_length_exceeded 错误,对话被中断。
Hermes 的解法不是简单地截断旧消息,而是一个分层压缩策略:
graph TD
A["第 1 步:Tool Output Pruning<br/>廉价,无 LLM 调用"] --> B["第 2 步:Head/Tail 保护<br/>保护首尾消息"]
B --> C["第 3 步:Middle 摘要<br/>LLM 生成结构化摘要"]
C --> D["第 4 步:Session Splitting<br/>创建新 session,链接血缘"]
D --> E["迭代更新<br/>后续压缩更新已有摘要"]
ContextEngine:可插拔上下文引擎
ContextCompressor 现在继承自 ContextEngine ABC(agent/context_engine.py:32)。这个抽象基类将上下文管理层变成了一个可插拔的扩展点——第三方引擎(如 LCM、自定义摘要引擎)可以替换内置的压缩器,只需在配置中指定 context.engine。
ContextEngine ABC 定义了三个核心方法:
update_from_response():在每次模型响应后更新引擎的内部状态(如 token 使用量追踪)should_compress():判断当前上下文是否需要压缩compress():执行实际的压缩操作
此外,ABC 还定义了可选的生命周期钩子和工具 schema 接口,让自定义引擎可以向 agent 注入额外的工具(例如,一个基于 LCM 的引擎可能暴露 "recall_context" 工具,让模型主动从压缩历史中检索信息)。
内置的 ContextCompressor 完整实现了这个 ABC,行为与之前一致。这个重构的价值在于不破坏现有行为的前提下打开了扩展性——用户可以通过配置切换引擎,而编排层(AIAgent)只面向 ContextEngine 接口编程。
ContextCompressor
ContextCompressor(agent/context_compressor.py:53)封装了完整的压缩策略:
# agent/context_compressor.py:64-94(关键参数)
class ContextCompressor:
def __init__(self, model, threshold_percent=0.50, protect_first_n=3,
protect_last_n=20, summary_target_ratio=0.20, ...):
self.context_length = get_model_context_length(model, ...)
self.threshold_tokens = int(self.context_length * threshold_percent)
self.tail_token_budget = int(self.threshold_tokens * summary_target_ratio)
| 参数 | 默认值 | 含义 |
|---|---|---|
threshold_percent | 0.50 | 使用达到 50% 上下文时触发压缩 |
protect_first_n | 3 | 保护前 3 条消息(通常是 system + 第一轮对话) |
protect_last_n | 20 | 保护最后 20 条消息 |
summary_target_ratio | 0.20 | 尾部保护预算占阈值的 20% |
触发时机
压缩在两个地方触发:
- 预飞行(
run_agent.py:7000-7049):进入主循环前,估算 token 数,如超阈值立即压缩 - 响应后(主循环内):API 返回
context_length_exceeded或usage.prompt_tokens超阈值时压缩
第 1 步:Tool Output Pruning
# agent/context_compressor.py:155-190(简化)
def _prune_old_tool_results(self, messages, protect_tail_count):
"""Replace old tool result contents with a short placeholder."""
pruned = 0
for i in range(len(messages) - protect_tail_count):
msg = messages[i]
if msg.get("role") == "tool" and len(msg.get("content", "")) > 200:
msg["content"] = "[Tool result pruned — original too large]"
pruned += 1
return messages, pruned
第一步最廉价:不调用 LLM,只是将老的 tool 结果(超过 200 字符)替换为占位符。工具结果通常是大块 JSON(文件内容、搜索结果),压缩比很高。
第 2 步:Head/Tail 保护
消息列表: [sys, u1, a1, u2, a2, tool1, u3, a3, ..., u20, a20]
|← head 保护 →| |← tail 保护 →|
(前 3 条) (最后 20 条)
|← middle 区域 →|
↓ 这部分被摘要
Head 保护前 3 条消息(system prompt + 第一轮对话——建立上下文的关键)。Tail 保护最后 20 条消息(最近的对话上下文)。Middle 区域是压缩目标。
第 3 步:结构化摘要
Middle 区域的消息被发送给 LLM 生成结构化摘要:
# agent/context_compressor.py:253(摘要模板关键字段)
# Goal: 对话的主要目标
# Progress: 完成了什么
# Key Decisions: 做出的重要决策
# Modified Files: 修改过的文件及原因
# Next Steps: 下一步计划
摘要使用辅助模型(可配置,通常选择便宜快速的模型),不消耗主模型的 token 预算。
迭代更新
如果之前已经有过一次压缩,_previous_summary 存储了上一次的摘要。新一次压缩不是从零开始,而是更新已有摘要:
# agent/context_compressor.py:122
self._previous_summary: Optional[str] = None
这让多次压缩后的摘要仍然保持连贯——不会丢失早期的重要信息。
第 4 步:Session Splitting
压缩完成后,Hermes 创建一个新 session:
- 新 session 的
parent_session_id指向旧 session(建立血缘链,详见第 10 章) - 摘要作为新 session 的第一条消息
- 尾部保护的消息搬到新 session
- 新 session 获得新的 IterationBudget
用户感知不到 session 切换——对话无缝继续。但在 SessionDB 中,一次长对话实际上被记录为一条 session 链。
Memory Provider 的 on_pre_compress 钩子
压缩发生前,MemoryManager 会通知外部 memory provider:
# agent/memory_provider.py:163
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Extract information from messages before they are compressed away."""
return ""
Provider 可以在消息被压缩丢弃前提取有价值的信息——比如 Honcho 可以从即将被压缩的对话中提取用户偏好变更,确保信息不会在压缩中丢失。
压缩对 Prompt Cache 的影响
压缩触发 session splitting 后,system prompt 需要重建(因为新 session 的 system prompt 需要包含摘要)。这意味着 Anthropic 的 prompt cache 会 miss 一次。
但这是可接受的代价——压缩本身就意味着对话已经很长(消耗了 50%+ 上下文窗口),此时一次 cache miss 的成本远小于 context_length_exceeded 错误导致的对话中断。
设计启示
上下文压缩展示了"渐进式降级"的设计哲学:
- 先做廉价操作:tool output pruning 不需要 LLM 调用,可能就够了
- 保护两端:Head(建立上下文)和 Tail(最近对话)比 Middle 更重要
- 摘要而非截断:LLM 生成的结构化摘要保留了关键决策和上下文
- 迭代更新:多次压缩不会丢失早期信息
设计哲学:情景记忆——首次印象和当前状态是神圣的
压缩算法的 head/tail 保护揭示了一种特定的记忆理论:前几轮交互建立了上下文和意图(protect_first_n=3,硬编码),最近的交互包含当前活跃的工作状态(protect_last_n 由 token 预算动态决定),中间的一切都是可替换的。这与人类的情景记忆一致——我们记住开头和结尾,不记得中间。中间部分可以被摘要,因为它的目的是到达当前状态,而不是成为当前状态。
这些策略让 Hermes 可以维持超过 100 轮的长对话而不崩溃,也不丢失关键上下文。
设计赌注回扣:上下文压缩服务于 Personal Long-Term(通过 on_pre_compress 钩子在压缩前保存记忆,确保长对话中的重要信息不丢失)和 Run Anywhere(压缩后的 session splitting 让 SQLite 写入保持小事务,减少 WAL 锁竞争)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 Context compression 相关逻辑在 v0.3.0 之前就已经出现;之后 v0.4.0-v0.7.0 之间逐步补上了结构化摘要、session splitting、多轮预压缩和 provider 侧
on_pre_compress钩子。v0.8.0 引入了ContextEngineABC(agent/context_engine.py),将压缩层重构为可插拔架构,第三方引擎可通过context.engine配置项替换内置的ContextCompressor。