上下文压缩:让 Agent 在有限窗口里「记得住」

Hermes 如何用三层保护 + 结构化摘要,在 200K token 窗口里装下数小时的对话

通过 Hermes 探秘 Agent 工程 | 第 7 篇 · 查看全部

上一篇:沙箱与代码执行:让 Agent 安全跑代码的 RPC 架构


[系列文章导航]

#文章定位
1Agent Loop:Agent 的核心执行循环入口
2System Prompt:身份、上下文与策略的三层架构认知层
3工具系统:从注册到调度工具层
4工具调度系统:从注册到执行的完整生命周期调度层
5安全防护体系:当 Agent 拥有终端时如何防止「做出格」的事安全层
6沙箱与代码执行:让 Agent 安全跑代码的 RPC 架构执行层
7上下文压缩:让 Agent 在有限窗口里「记得住」记忆层
8记忆系统:跨会话持久化的工程实现记忆层
9技能系统:Agent 如何把经验变成可复用的程序化记忆记忆层
10Provider 抽象层:让 Hermes 同时驾驭 30+ 个 LLM 提供商模型层
11Gateway 网关:连接 20+ 平台的统一消息路由接入层
12多 Agent 协作:委托、调度与看板协作层

问题:上下文是"有价商品"

一个典型的 Agent 任务在第七步时,上下文里已经有十几条消息,每条消息都带着工具的输入和输出。对于一个 200K token 的窗口,大约 30-50 轮工具调用就会把窗口撑满。

Hermes 的解决方案是上下文压缩——不是简单丢弃旧消息,而是用一段结构化的摘要替换掉中间部分,保留关键信息、释放空间。

压缩的三个核心问题

  1. 什么时候压缩? 对话进行到什么程度才需要压缩?
  2. 压缩哪部分? 全部压缩还是只压缩一部分?
  3. 怎么压缩? 摘要应该包含什么内容?

Phase 1:要不要压缩

Hermes 在每次收到 API 响应后,通过 Provider 返回的 usage.prompt_tokens 来判断当前对话占用了多少 token。用 Provider 实际报告的数字,而不是自己估算——这意味着不会出现"预估没超但实际超了"的情况。

默认阈值为上下文窗口的 50%,最小不低于 64K token。 Hermes 的 ContextCompressor 在初始化时通过 _compute_threshold_tokens 计算阈值:

# 源码:agent/context_compressor.py
effective_window = context_length - (max_tokens or 0)
pct_value = int(effective_window * threshold_percent)
return max(pct_value, context_length)  # floored at window size

注意:对于 64K 的小模型,max(0.5 * 64000, 64000) == 64000,实际就是对话占满窗口时才压缩。

Phase 2:确定「切割边界」

保护头尾,只压中间

  • 头部:系统提示词 + 前几条用户消息
  • 尾部:最近约 50% token 预算的对话
  • 中间:最早的历史对话,可以压缩的部分

工具调用组不能切断

Hermes 有 boundary alignment 机制——每一对 tool_calls ↔ tool_results 要么整个保留、要么整个压缩,防止 Provider 返回 400。

最近一条用户消息不能被压

强制回路确保最新用户消息始终在尾部,防止被摘要后误执行。

Phase 3:生成结构化摘要

送进摘要模型之前,对中间区域做三层瘦身

  1. 工具结果替换为一行描述:每一条 tool result 超过 200 字符就用自动生成的短描述替代(如 [terminal] ran 'git log --oneline -20' -> exit 0, 20 lines output
  2. 内容截断(每消息上限 6000 字符):头 4000 + 尾 1500
  3. 图片清理:base64 图片被替换为文字占位符

thinking / reasoning_content 不设防

_serialize_for_summary 只读取 contenttool_calls 两个字段——思考链不在摘要范围内,直接丢弃。这是一个取舍:思考链可能对后续对话有价值,但占空间太大。

Hermes 的结构化摘要模板

## Active Task
[用户最近一条未读消息——逐字包含原话]

## Goal
[用户这次对话的总体目标]

## Constraints & Preferences
[用户偏好、编码风格、重要约束]

## Completed Actions
1. READ config.py:45 — found bug [tool: read_file]
2. PATCH config.py:45 — fixed comparison [tool: patch]
3. TEST pytest — 47/50 passed [tool: terminal]

## Active State
[当前正在做什么]

## Blocked
[还没解决的错误或障碍]

## Key Decisions
[重要技术决策及其原因]

## Resolved Questions
[已经被回答过的问题——防止重复回答]

## Pending User Asks
[还没完成的请求——参考性质,不要主动去做]

## Relevant Files
[文件路径列表]

## Remaining Work
[还有什么没做完——参考性质]

迭代更新

Hermes 的 ContextCompressor 保存了 _previous_summary 字段——每次压缩后把摘要"记住",下次压缩时作为迭代更新的基础。

组装与清理

压缩完成后:

  1. 工具对清理:检查孤立的 tool_call / tool_result,插入 stub 或删除
  2. 图片清理:老消息里的 base64 图片被替换为文本占位符
  3. 元数据注入:第一条系统提示词里追加压缩说明

两种压缩模式

旋转模式原地归档模式
session_id新的子 session_id保持原来的 session_id
旧对话结束原 session,创建新 session原 session 内归档
默认否(compression.in_place: true 开启)

并发压缩锁

Hermes 使用一个基于 SQLite state.db 的压缩锁来解决多线程并发压缩的问题:压缩前先获取锁,获取失败则放弃本次压缩。

摘要失败怎么办?

  1. 主模型回退:辅助模型不可用时退回用主模型
  2. 确定性摘要:所有 LLM 都失败时,直接从对话里提取用户说了什么、调用了哪些工具、最后几条消息
  3. 中止并保留:配置了 abort_on_summary_failure: true 时放弃压缩

什么时候触发压缩?

  • 手动触发:用户发送 /compress 命令
  • 自动触发:API 响应里的 prompt_tokens 超过阈值
  • 反复无效压缩保护:最近两次压缩各自只减少了不到 10% 的消息量,自动跳过

下一篇:记忆系统:跨会话持久化的工程实现 — Agent 如何跨会话持久化信息,让用户"上次聊了什么"不会忘