Hermes Agent 的长会话架构:Prompt Cache、压缩与 SessionDB

前言:长会话的问题从来不只是 Token 超限:System Prompt 改动会打碎缓存,粗暴摘要会丢掉决策,多进程又可能同时写坏历史。本文把 Prompt 分层、上下文压缩、恢复指针和 SQLite Lease 连起来,看 Hermes 怎样维护一个可以长期工作的会话。


系列导航:这组文章按“总览 → 入口 → 运行时 → 扩展 → 状态 → 协作 → 安全”的顺序展开;第一次阅读可以从总览开始,带着具体问题也可以直接跳到专题。

  1. 01|Hermes Agent 架构总览:从 Agent Loop 到智能体平台
  2. 02|Hermes Desktop 架构:Electron 如何连接本地 Python Agent
  3. 03|Hermes Agent 运行时:AIAgent、模型协议与工具执行
  4. 04|Hermes Agent 扩展体系:Skill、Plugin 与 MCP
  5. 05|实战:同一个 Markdown 检查器的 Skill、Plugin 与 MCP 三种实现
  6. 06|Hermes Agent 的持续学习系统:Memory、Review、Skill 与 Curator
  7. 07|Hermes Agent 的长会话架构:Prompt Cache、压缩与 SessionDB
  8. 08|Hermes Agent 的多 Agent 与自动化:从 Goal 到 Kanban
  9. 09|Hermes Agent 安全架构:审批、沙箱、回滚与凭据隔离

Prompt 架构:稳定前缀是一条约束

读到 Prompt 这一层时,我才理解为什么代码里对“不要中途重建系统提示词”这么执着。对于长会话,供应商侧 Prompt Cache 能不能持续命中,会直接影响延迟和成本。于是 Hermes Agent 把系统提示词按顺序组织成三层:

flowchart TB
    Stable[Stable:身份 / 工具指导 / 模型与平台提示]
    Context[Context:调用方消息 / 项目规则文件]
    Volatile[Volatile:Skill 索引 / Memory / 用户档案 / 会话信息]
    Stable --> Context --> Volatile
    Volatile --> Frozen[冻结并持久化完整 System Prompt]
    Turn[本轮动态召回与 Plugin Context] --> UserAPI[只注入当前 User API Content]
    Frozen --> Request[模型请求]
    UserAPI --> Request

这里的 volatile 很容易误解。它表示内容来源容易变化,不表示每轮都去修改。系统提示词在新会话第一次构建后会被冻结并写入 SessionDB;恢复会话时尽量复用原始字节。

真正按轮变化的外部记忆召回和 pre_llm_call 插件上下文,不去污染系统提示词,而是注入当前用户消息的 API 版本。历史消息还可以保存当时真正发给模型的 api_content sidecar,让恢复后的请求前缀继续保持一致。

这会带来一个不那么直觉的行为:会话中途更新 MEMORY.md,不会在下一次普通请求前立刻改写 System Prompt。变更会在新会话或压缩触发的 Prompt 重建时进入新的快照;即时重建看起来灵活,却会打断前缀缓存,也会让同一会话的身份基础持续漂移。

上下文压缩:保留头尾,总结中段

Memory 解决跨会话知识,Compression 解决当前会话装不下。默认 ContextCompressor 实现 ContextEngine,但用户可以显式选择插件引擎;接口把 select_context()compress() 分开,前者只替换一次请求的消息视图,后者才改写会话的活跃上下文。

两道触发线:Gateway 安全网与 Agent 主压缩器

层次触发时机Token 依据职责
Gateway session hygiene消息进入 Agent 之前优先上轮真实用量,退化为粗估;固定 85% 安全线兜住隔夜增长、外部追加和遗漏压缩的会话
Agent ContextCompressorTurn preflight 与模型调用后的检查Provider usage 与请求估算日常上下文管理,阈值、尾部和失败策略可配置

配置里的基础 compression.threshold 默认是 0.50,但这不是所有模型的最终触发比例:小于 512K context 的模型有 raise-only 的 0.75 floor,model_thresholds 可以按模型最长子串覆盖,绝对 threshold_tokens 还能再设上限,auxiliary summarizer 的可用窗口也会影响可行阈值。因此判断“何时压缩”应该读取运行时计算值,而不是把 50% 写死。

默认压缩器的四个阶段

  1. 便宜地清理旧工具输出:受保护尾部之外、超过最小长度的旧 Tool Result 先换成短占位,减少摘要输入。
  2. 切出 Head / Middle / Tail:System Prompt 永远保留;首次压缩还保护配置数量的早期非 system 消息,后续重压缩会衰减这层保护,避免最早 Turn 永久固化;最近尾部保持原文,边界不能拆开 assistant tool call 与 tool result。
  3. 总结 Middle:Auxiliary LLM 按 Goal、Constraints、Progress、Decisions、Files、Next Steps 和 Critical Context 生成结构化 handoff;再次压缩时更新已有 summary,而不是从零开始。
  4. 重新组装并修复消息序列:加入 summary 和未改动 Tail,去掉孤儿 Tool Result,为缺失结果的 Tool Call 补 stub,并确保至少保留真实用户 Turn。
flowchart LR
    History[完整活跃消息] --> Prune[修剪旧 Tool Result]
    Prune --> Head[System + 受保护 Head]
    Prune --> Middle[可压缩 Middle]
    Prune --> Tail[近期原文 Tail]
    Middle --> Summary[辅助模型交接
Auxiliary LLM Handoff] Head --> Assemble[重新组装] Summary --> Assemble Tail --> Assemble Assemble --> Sanitize[修复 role 与 tool pair] Sanitize --> Commit[原子提交活跃视图]

Legacy Tail 与 Lean Tail

模式保留策略优点代价
legacy(默认)按 threshold × target_ratio 保护较大的原文尾部,并保证最少消息数最近工具细节保留多,摘要调用少大窗口模型压缩后仍可能很重
lean尾部压到 10K~25K 区间;summary 增加分块 digest、标识符索引、真实用户原话和 session_search 恢复指针更小的活跃上下文,长会话可恢复性更强压缩边界多几次 summarizer 调用

Lean 模式最值得注意的不是“摘要更短”,而是把可验证标识符和恢复路线机械化:路径、SHA、PR 号、错误字符串不只依赖 LLM 改写,丢到活跃窗口之外的内容还可以通过同一 session id 的 session_search 找回来。

压缩怎样与 Memory、Prompt Cache 和 SessionDB 协作

sequenceDiagram
    participant C as 压缩协调器
Compression Coordinator participant M as 记忆管理器
MemoryManager participant E as 上下文引擎
Context Engine participant P as 提示词构建器
Prompt Builder participant D as 会话数据库
SessionDB C->>M: 压缩前钩子
on_pre_compress(messages) M-->>C: 可选 Provider insights C->>E: 压缩快照与记忆上下文
compress(snapshot, memory_context) E-->>C: 头部 + 摘要 + 尾部
Head + Summary + Tail C->>P: 使提示词失效并重建
invalidate / rebuild Prompt P->>P: 重新读取内置 Memory C->>D: archive_and_compact 原子提交 D-->>C: 同 session id 新活跃视图 C->>M: 会话切换与提取
on_session_switch / extraction

压缩前,外部 Provider 可以通过 on_pre_compress() 把不该被摘要丢掉的 insight 送入 summary prompt。压缩后,系统提示词缓存会失效并重建,内置 Memory 从磁盘刷新;如果没有外部 Provider 且新的 Memory blocks 已经和旧 Prompt 字节一致,代码会保留原 Prompt 以尽量延续 KV cache。

默认 compression.in_place: true 时,SessionDB 使用 archive_and_compact() 原子地把旧行标成 inactive/compacted,再插入新的活跃 Head + Summary + Tail;session id、标题、cwd、Goal 和路由都不变。关闭 in-place 才使用旧的 parent/child session rotation。

失败时是丢历史、降级,还是暂停

当前源码并不是“摘要失败就静默清空中段”这么简单。Auxiliary model 不可用时会尝试回退到主模型;网络断开、鉴权和不可恢复配额错误会直接 abort,保持消息不变。其他失败默认插入带本地恢复锚点的 deterministic fallback,再进入 cooldown;如果把 compression.abort_on_summary_failure 设为 true,则所有摘要失败都保留原会话并等待手动 /compress/new

另外两种 Runtime 有自己的压缩所有权:Codex app-server 的真实 thread 不在 Hermes message list 里,所以走 thread/compact/start;符合条件的 Responses 路径可选 Provider-side compaction,但本地压缩器仍保留为 fallback。Context Engine 插件也可以替换摘要策略,代价是它同时接管 compaction policy 与自己的 Prompt Cache 行为。

SessionDB 与并发边界

hermes_state.py 是另一个体积很大的模块。SessionDB 默认使用 SQLite WAL 和 FTS5,读路径有受限连接池,写冲突采用应用层退避;系统提示词按哈希存储,避免同一份长 Prompt 在数据库里重复占空间。WAL 并非无条件开启:NFS、SMB、部分 FUSE/ZFS 或存在已知 SQLite WAL 风险时会安全退回 rollback-journal DELETE 模式,也绝不会在其他进程仍持有 WAL 数据库时现场强制降级。

更关键的是 turn lease。CLI、消息网关和其他进程可能同时打开同一会话,如果它们都按“读历史 → 跑几分钟工具 → 写结果”的方式工作,普通数据库事务根本不能锁那么久。Hermes Agent 在应用层给每轮执行建立租约,长轮次定期续租;失去租约时主动中断,防止两个进程把对话历史交叉覆盖。

flowchart LR
    P1[进程 A 请求同一 Session] --> Lease{获取 Turn Lease}
    P2[进程 B 请求同一 Session] --> Lease
    Lease -->|A 获得| Run[A 读取历史并运行]
    Lease -->|B 等待| Wait[B 等待最新提交]
    Run --> Refresh[长任务定期续租]
    Refresh --> Commit[原子写入消息]
    Commit --> Release[释放租约]
    Release --> Wait
    Wait --> Reload[B 重新加载最新历史]

我觉得可以把一次 Agent Turn 看成“不能真的用数据库锁包住的长事务”。它可能经历多次模型调用、并行工具、人工审批和上下文压缩,只能靠应用层 lease、turn id、session id、中断和 Finalizer 来维护所有权。

参考资料与源码入口

题图来源:《山、雪与日落》,创作者 jacobdeb,来自 Pixabay;依据 Pixabay Content License(Pixabay 内容许可)使用。

下一篇:Hermes Agent 的多 Agent 与自动化:从 Goal 到 Kanban

Jenkins + CICD流水线构建指南 REK2 搭建6节点K8S教程(四):K8S可视化管理工具 实战:同一个 Markdown 检查器的 Skill、Plugin 与 MCP 三种实现
View Comments