Hermes Agent 自进化架构深度解析(八):上下文管理——全量发送下的压缩与边界
每条消息都发给 API,没有"服务端状态"。当聊天历史膨胀到十几万 token 时,Hermes 不是简单地丢掉旧消息——它用一个廉价模型把中间轮次总结成摘要,在保护头尾的同时,让上下文可持续地收敛。
一、问题:全量发送是唯一的游戏规则
Hermes 和 Claude Code、Cursor、GitHub Copilot Chat 一样,采用客户端维护完整会话历史的模式。每次 API 调用,不是只发当前用户消息——而是把 system prompt + 全部历史消息打包发送。
agent/conversation_loop.py 在每一轮循环中(约 line 800),把 agent.messages(完整会话历史)拷贝为 api_messages,然后走一整条处理链路:
agent.messages → api_messages → 孤儿对清理 → 空白规范化 → thinking-only 轮删除 → surrogate 移除 → build_api_kwargs() → OpenAI client.chat.completions.create()
agent/chat_completion_helpers.py 的 build_api_kwargs()(第 606 行)拿到 api_messages 后,按 provider 分支——Chat Completions、Anthropic Messages、Bedrock Converse、Codex Responses——各自构建请求体。消息列表始终是全量的。
这和 ChatGPT 网页版有本质区别。网页版是服务端维护状态——服务器记住对话历史,客户端只发最新消息。而 Hermes 选择了无状态协议上的有状态客户端——没有服务端存储,上下文必须每次全量携带。
这种设计带来了一个直接问题:对话越长,请求越大。超过模型的上下文窗口上限时,必须做点什么。
二、System Prompt:一次构建,全 Session 复用
在讨论压缩之前,先看 System Prompt 是怎么组织的——因为它决定了压缩时哪些内容被保护。
agent/system_prompt.py 的 build_system_prompt_parts()(第 113 行)把 system prompt 拆成三个 tier,用 \n\n 拼接:
stable — SOUL.md 身份 + 工具使用指导 + skills prompt + 环境提示 + 平台提示 context — 用户 system_message + 上下文文件(AGENTS.md / .cursorrules) volatile — memory 快照 + USER.md + 时间戳/模型/提供商信息行
关键设计决策:整个 session 只构建一次,缓存在 agent._cached_system_prompt 上。源码第 126-129 行明确写道:
Hermes never re-renders parts of this string mid-session — that's the only way to keep upstream prompt caches warm across turns.
这个设计服务于两个目标。第一,Cloudflare / Anthropic 等服务商的 prompt cache 按前缀匹配——system prompt 不变意味着每个后续请求的前缀都是相同的,缓存命中率接近 100%。第二,memory 快照的变化不会主动触发 system prompt 重建——只有 context compression 发生时才会 rebuild(因为 session 可能被旋转到子 session)。
三、上下文窗口解析:10 步链,不必手动配置
在压缩被触发之前,Hermes 需要知道模型有多少上下文可用。用户不需要在 config.yaml 里写 context_length: 128000——agent/model_metadata.py 的 get_model_context_length() 有一个 10 级优先级解析链:
0. config.yaml 显式覆盖 —— 用户知道的最好 1. 持久缓存 context_length_cache.yaml —— 之前探测过的 2. API 端点 /v1/models 实时查询 —— 权威来源 3. 本地服务器查询(Ollama 等) 4. Anthropic /v1/models API 5. Provider 专用查询(Copilot / Nous / Codex / GMI / Ollama) 6. OpenRouter 实时 API 元数据 7. 硬编码默认表(最长 key 优先子串匹配) 8. 本地服务器查询(最后手段) 9. 默认回退 256K
对于 deepseek-v4-pro,命中第 7 步——DEFAULT_CONTEXT_LENGTHS 字典(第 244 行)的 "deepseek-v4-pro": 1_000_000。最长 key 优先子串匹配确保 deepseek-v4-pro(17 字符)不会被 deepseek(8 字符)误匹配。首次命中后缓存到 context_length_cache.yaml,按 model@base_url 做 key——同一模型在不同 provider 下可以有不同的上下文长度。
四、Context Compression:三明治策略
agent/context_compressor.py 是上下文管理的核心,3045 行,是整个代码库中复杂度最高的单文件之一。
4.1 触发条件
ContextCompressor.__init__()(第 953 行)接受一个关键参数:
threshold_percent: float = 0.50 # 上下文窗口的 50%
对于 128K 窗口 → 压缩阈值约 64K token。对于 1M 窗口(如 deepseek-v4-pro)→ 约 500K。实际计算会扣除 max_tokens(输出预算),由 _compute_threshold_tokens()(第 912 行)完成。
should_compress()(第 1136 行)在 token 数超过阈值时返回 True——但在返回之前有三层防护:
-
失败冷却:summary LLM 调用失败后,600 秒内不重试。防止 API 429 / 持续失败循环烧钱。
-
防抖动:连续 2 次压缩各节省不到 10% → 跳过,提示用户
/new或/compress <topic>。防止每轮只压缩 1-2 条消息的无意义循环。 -
无压缩窗口:整个对话都在尾部预算内 →
compress_start >= compress_end,记录为无效压缩。下一轮should_compress()仍然返回 True 时会触发第 2 条防护。
4.2 三阶段算法
compress() 方法(第 2670 行)的核心逻辑:
Phase 1:剪枝旧工具结果(便宜,无 LLM 调用)
_prune_old_tool_results()(第 1179 行)遍历消息列表,把不在保护范围内的 tool role 消息内容替换为占位符:
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
保护范围由 protect_last_n(默认 20 条)和 tail_token_budget(默认约 20% * threshold)双重决定。文件读取、shell 执行等大输出占了绝大部分 token 消耗,这一步能清除大量冗余数据——而且不花任何 LLM 调用成本。
Phase 2:确定压缩边界
三条边界把消息列表分成三个区:
[0 ... compress_start) ← head,受保护(system prompt + 前 N 个交换) [compress_start ... compress_end) ← middle,送去总结 [compress_end ... end) ← tail,受保护(最后 ~20K token 的上下文)
_protect_head_size()(第 2272 行)保护 system prompt + 前 protect_first_n(默认 3)个用户-助手交换。_align_boundary_forward()(第 2245 行)确保边界落在完整的 tool turn 对之间——不在 tool_call 和 tool_result 中间切。
_find_tail_cut_by_tokens()(第 2546 行)从末尾向前扫描,累计 token 估算直到达到 tail_token_budget。保护的是最近的用户指令和工具执行上下文——这些对当前任务最相关。
Phase 3:LLM 总结中间轮次
_generate_summary()(第 1641 行)用辅助模型(配置在 auxiliary.compression 下)生成结构化摘要。如果辅助模型不可用,会回退到主模型;如果两者都失败,则使用静态回退——提取最近 N 轮的路径引用和关键工具调用,不依赖 LLM。
迭代更新是这里的关键设计。第一次压缩时,middle 的消息被总结为一个结构化的摘要。第二次压缩时,_previous_summary 非空——新要被总结的内容会附加上旧摘要,LLM 被指示在旧基础上更新而非从零开始。这防止了信息在多轮压缩中逐渐丢失。
4.3 摘要注入格式
压缩后,摘要以特殊前缀注入消息中(第 44-70 行):
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. ... Respond ONLY to the latest user message that appears AFTER this summary — that message is the single source of truth for what to do right now.
这个前缀经过了至少三次迭代(第 153-190 行的 _HISTORICAL_SUMMARY_PREFIXES 记录了每次变化的完整文本)。早期版本包含"resume exactly from Active Task"指令——导致模型在新话题上仍然执着完成旧任务。#11475 和 #14521 的 bug 报告显示弱模型会把 summary 中的 ## Active Task 引用当成新的用户输入。
当前版本明确声明:summary 只是背景参考,不是活跃指令;最新用户消息是唯一的真相来源;反向信号(stop / undo / never mind)必须立即终止 summary 中描述的任何在途工作。
摘要末尾还有一个边界标记(第 133-136 行):
--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---
这防止弱模型把 assistant-role 的 summary 当作自己的输出重复(#33256)。
4.4 Token 估算
Hermes 不使用模型特定的 tokenizer——那需要加载模型的 tokenizer 文件,对本地部署不友好。替代方案是字符估算:
_CHARS_PER_TOKEN = 4 # context_compressor.py 第 202 行 _IMAGE_TOKEN_ESTIMATE = 1600 # 匹配 Claude Code 的常量
_estimate_msg_budget_tokens()(第 301 行)不仅算 content 长度,还计算 tool_call 的完整 JSON 结构(id + type + function.name + 完整的 JSON envelope)。早期版本只算 function.arguments 字符串长度——导致一个 4 工具调用的 turn 被估算为 ~73 token(实际 ~1090),保护尾部超标,压缩失效(#28053)。
五、三条触发路径
上下文的压缩不在一个固定位置——它在三个时机被检查,确保不会在工具调用循环中越过上下文上限。
┌─ Turn Prologue(agent/turn_context.py:build_turn_context) │ 每轮开始前。用廉价字符估算判断是否可能超标。 │ 如果消息数少但单条消息极大(如一次读入了 50K 行日志), │ 也触发——这是 #27405 修复的边界情况。 │ ├─ Pre-API(agent/conversation_loop.py 约 line 990) │ 每次 API 调用前。工具调用循环中,一次大文件读取可能 │ 让 token 数暴涨——这个检查捕获了 turn prologue 之后 │ 新增的内容。每轮最多压缩 3 次(compression_attempts < 3)。 │ └─ Post-Response(agent/conversation_loop.py 约 line 2101) API 响应后通过 update_from_response() 更新实际 token 计数。 为下轮 should_compress() 提供精确数据。
三条路径都是防守性的——宁可多检查一次,也不让 API 返回 context length exceeded 错误。
六、API 请求的最后一道关口
即使压缩充分,发往 API 的消息列表还需要经过一系列清理——agent/conversation_loop.py(约 line 890-950)和 agent/chat_completion_helpers.py 的 build_api_kwargs():
-
孤儿 tool 对清理:
_sanitize_api_messages()—— 去掉没有对应 tool_result 的 tool_call,补上缺失的 tool_call_id stub。不依赖context_compressor是否运行——session 加载和手动消息操作都可能产生孤儿对。 -
thinking-only 轮次删除:
_drop_thinking_only_and_merge_users()—— Anthropic 的 thinking block 不能作为消息的最后一部分。删掉纯 thinking 的 assistant turn 并合并相邻的 user 消息。 -
空白规范化:每一条 message content 做
strip()—— 跨轮次确保位对位一致的前缀,提高 KV cache 命中率(llama.cpp / vLLM / Ollama 的 prefix matching 依赖严格相同的字节前缀)。 -
tool_call JSON 规范化:
sort_keys=True重整 arguments JSON —— 同样的工具调用参数,不管模型以什么顺序序列化 key,都变成一致的字符串。 -
surrogate 移除:
_sanitize_messages_surrogates()—— Ollama 上运行的 Kimi K2.5 / GLM-5 / Qwen 可能返回 U+D800-U+DFFF 范围的 lone surrogate,会导致 OpenAI SDK 内部的json.dumps()崩溃。 -
Anthropic prompt caching:
apply_anthropic_cache_control()—— 在 system 和最后 3 条消息上注入cache_control断点,将输入 token 成本降低约 75%。
七、Session 管理:压缩如何影响会话持久化
压缩不只是修改消息列表——它还涉及 session 在 SQLite(state.db)中的组织方式。agent/conversation_compression.py 处理这部分逻辑。
传统模式(compress_context() 第 435 行):压缩后创建新的 child session。旧 session 结束时设置 parent_session_id 指向新 session。/session 命令和 TUI 的 session 列表中,父子关系形成一条链。代价是 session ID 变化——任何持有旧 session_id 的外部引用都会失效。
In-place 模式(compression.in_place: true,#38763):压缩在同一 session_id 下原地重写消息。archive_and_compact() 把旧活跃行软归档(设置 active=0, compacted=1),然后插入压缩后的消息作为新的活跃行。session_id 保持稳定,消除了 session 旋转带来的整类 bug——日志引用、gateway 路由、外部监控都不需要感知压缩事件。
压缩过程有分布式锁保护。_CompressionLockLeaseRefresher(第 75 行)在 state.db 中获取一个带 TTL 的 lease,后台线程定时续约。防止两个并发进程(如 gateway 和 cron)同时对同一 session 执行压缩。
八、与 Claude Code 的设计对比
Claude Code 的上下文管理采取了更激进的策略。
Claude Code 的 /compact 命令由用户手动触发——没有自动压缩。它的系统提示词包含 additional_data 区段,把项目文件注入在每次请求的末尾。当上下文逼近窗口上限时,Claude Code 选择的策略是——由用户决定是否压缩。
Hermes 选择了一条不同的路:自动压缩。
这不是说 Hermes "更智能"。这是两种设计哲学的差异。Claude Code 的假设是用户知道自己想要什么——压缩什么、保留什么——并且愿意手动控制。Hermes 的假设是用户不应该被上下文管理打断——当窗口快满时,Agent 应该自己处理,而用户看到一个 🗜️ Compacting context 状态提示就够了。
具体到代码层面的差异:
| 维度 | Hermes | Claude Code |
|---|---|---|
| 压缩触发 | 自动(50% 阈值) | 手动(/compact) |
| 压缩模型 | 独立辅助模型(可配置) | 推测为同一模型 |
| 工具结果剪枝 | 有(免费 pre-pass) | 无独立阶段 |
| 迭代更新 | 旧摘要作为输入迭代更新 | 不明确 |
| 防抖/冷却 | 三层防护 | 用户手动控制节奏 |
| Session 旋转 | 支持(传统 + in-place) | 推测无 session 概念 |
两者都采用全量消息发送模式——这是客户端 AI Agent 的共性。差异在于谁来决定何时以及如何压缩上下文。
九、核心哲学
Hermes 上下文管理体现了一个贯穿始终的设计原则:上下文是稀缺资源,管理它的成本不应该由用户承担。
三层 system prompt 的缓存设计——让 token 开销只花一次。10 步上下文解析链——让用户不需要手动配置模型参数。50% 自动压缩阈值——让用户不需要数 token 和纠结何时该 /compact。防抖和冷却机制——让系统不会在边缘情况下陷入无限压缩循环。失败后的静态回退——让摘要不可用时仍然不丢上下文。
这些设计不是独立的 feature——它们是一个统一策略的不同侧面:让 Agent 自己管理自己的内存。这和 Claude Code 的"用户控制"哲学形成了一对有趣的对话——两者各有合理性,但 Hermes 明确地选择了自治这条路。
这也解释了为什么 context_compressor.py 是 3045 行而不是 300 行。自动压缩看起来像是一个简单的"把旧消息发给小模型总结"的功能——但要做好它,需要处理边界对齐、迭代更新、失败冷却、防抖动、占位符替换、摘要前缀演进、图片 token 估算、session 旋转锁……每一个边缘情况都对应一个 issue(#11475、#14521、#28053、#29559、#33256、#40803 等等),在源码中留下了详尽的 fix 注释。
这不是过度工程。这是边界驱动设计——每个检查点背后都有一个真实用户踩过的坑。
Hermes Agent 由 Nous Research 开发。本文基于 Hermes v0.18.0 源码分析。
全文完。
更多推荐



所有评论(0)