Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

上下文管理:长对话为什么不会失控

本章核心源码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

ContextCompressoragent/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_percent0.50使用达到 50% 上下文时触发压缩
protect_first_n3保护前 3 条消息(通常是 system + 第一轮对话)
protect_last_n20保护最后 20 条消息
summary_target_ratio0.20尾部保护预算占阈值的 20%

触发时机

压缩在两个地方触发:

  1. 预飞行run_agent.py:7000-7049):进入主循环前,估算 token 数,如超阈值立即压缩
  2. 响应后(主循环内):API 返回 context_length_exceededusage.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:

  1. 新 session 的 parent_session_id 指向旧 session(建立血缘链,详见第 10 章)
  2. 摘要作为新 session 的第一条消息
  3. 尾部保护的消息搬到新 session
  4. 新 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 错误导致的对话中断。

设计启示

上下文压缩展示了"渐进式降级"的设计哲学:

  1. 先做廉价操作:tool output pruning 不需要 LLM 调用,可能就够了
  2. 保护两端:Head(建立上下文)和 Tail(最近对话)比 Middle 更重要
  3. 摘要而非截断:LLM 生成的结构化摘要保留了关键决策和上下文
  4. 迭代更新:多次压缩不会丢失早期信息

设计哲学:情景记忆——首次印象和当前状态是神圣的

压缩算法的 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 引入了 ContextEngine ABC(agent/context_engine.py),将压缩层重构为可插拔架构,第三方引擎可通过 context.engine 配置项替换内置的 ContextCompressor