前言:长会话的问题从来不只是 Token 超限:System Prompt 改动会打碎缓存,粗暴摘要会丢掉决策,多进程又可能同时写坏历史。本文把 Prompt 分层、上下文压缩、恢复指针和 SQLite Lease 连起来,看 Hermes 怎样维护一个可以长期工作的会话。
系列导航:这组文章按“总览 → 入口 → 运行时 → 扩展 → 状态 → 协作 → 安全”的顺序展开;第一次阅读可以从总览开始,带着具体问题也可以直接跳到专题。
- 01|Hermes Agent 架构总览:从 Agent Loop 到智能体平台
- 02|Hermes Desktop 架构:Electron 如何连接本地 Python Agent
- 03|Hermes Agent 运行时:AIAgent、模型协议与工具执行
- 04|Hermes Agent 扩展体系:Skill、Plugin 与 MCP
- 05|实战:同一个 Markdown 检查器的 Skill、Plugin 与 MCP 三种实现
- 06|Hermes Agent 的持续学习系统:Memory、Review、Skill 与 Curator
- 07|Hermes Agent 的长会话架构:Prompt Cache、压缩与 SessionDB
- 08|Hermes Agent 的多 Agent 与自动化:从 Goal 到 Kanban
- 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 ContextCompressor | Turn preflight 与模型调用后的检查 | Provider usage 与请求估算 | 日常上下文管理,阈值、尾部和失败策略可配置 |
配置里的基础 compression.threshold 默认是 0.50,但这不是所有模型的最终触发比例:小于 512K context 的模型有 raise-only 的 0.75 floor,model_thresholds 可以按模型最长子串覆盖,绝对 threshold_tokens 还能再设上限,auxiliary summarizer 的可用窗口也会影响可行阈值。因此判断“何时压缩”应该读取运行时计算值,而不是把 50% 写死。
默认压缩器的四个阶段
- 便宜地清理旧工具输出:受保护尾部之外、超过最小长度的旧 Tool Result 先换成短占位,减少摘要输入。
- 切出 Head / Middle / Tail:System Prompt 永远保留;首次压缩还保护配置数量的早期非 system 消息,后续重压缩会衰减这层保护,避免最早 Turn 永久固化;最近尾部保持原文,边界不能拆开 assistant tool call 与 tool result。
- 总结 Middle:Auxiliary LLM 按 Goal、Constraints、Progress、Decisions、Files、Next Steps 和 Critical Context 生成结构化 handoff;再次压缩时更新已有 summary,而不是从零开始。
- 重新组装并修复消息序列:加入 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 来维护所有权。
参考资料与源码入口
- 官方源码核验基线:59795c40
- Prompt Assembly
- Context Compression and Caching
- Session Storage
- ContextCompressor 源码
- Conversation Compression 源码
题图来源:《山、雪与日落》,创作者 jacobdeb,来自 Pixabay;依据 Pixabay Content License(Pixabay 内容许可)使用。