会话管理与上下文压缩
本教程共 25 篇 · 第 8 篇 · 更新于 2026-07-26 · 约 19 分钟阅读
8. 会话管理与上下文压缩
本节目标:搞懂 Hermes Agent 的会话是怎么存储、恢复、压缩和检索的。从 SQLite + WAL 的持久化设计,到会话生命周期与谱系分裂,到 Context References 的 @ 语法注入,再到上下文压缩的三层递进算法和 session_search 跨会话检索。学完你能解释「为什么退出后对话还在」「长对话怎么不撞上下文上限」「怎么从历史会话里找东西」。
会话是什么:从一条消息到一段历史
session(会话)是一次完整的对话。它有开始时间、结束时间、唯一 ID。对话里的所有 messages 都属于这个 session。
- CLI 模式下,从你启动
hermes到退出是一个 session - Gateway 模式下,同一个聊天窗口里的连续对话是一个 session
Hermes Agent 自动把每个会话存下来,不管是 CLI、Telegram、Discord、Slack 还是其他 30+ 平台的消息,都进同一个存储。这让你能恢复对话、跨会话搜索、完整管理历史。
SQLite + WAL:为什么选这个组合
会话存储的底层是 SQLite 数据库,位置在 ~/.hermes/state.db。选 SQLite 不是因为「比文件高级」,而是因为它同时解决了三个问题:
- 读到之前的对话历史:进程退出后还能读回来
- 并发写入不冲突:Gateway 模式下多个平台消息同时到达,写同一个数据库文件不锁死
- 全文搜索:事后能搜索历史会话
WAL 模式解决读写阻塞
SQLite 默认模式下,有人在写数据库时,其他人连读都不行:
Telegram 适配器正在写消息 -> 整个数据库被锁住
↓
Discord 适配器想读历史消息 -> 等着,读都不行
CLI 单人用无所谓,但 Gateway 模式下多个平台同时收发消息,这就卡住了。
开了 WAL(Write-Ahead Logging)模式后,写操作先写到一个临时日志文件,不动主数据库。读操作继续读主数据库文件,不受影响:
Telegram 适配器正在写消息 -> 写到 WAL 日志文件(不动主数据库)
↓
Discord 适配器想读历史消息 -> 照读,读的是主数据库
日志会在合适时机自动合并回主数据库。
默认模式:
写 ──阻塞──> 读
写 ──阻塞──> 写
WAL 模式:
写 ──不阻塞──> 读 ← 关键改进
写 ──仍阻塞──> 写 ← 这个没变
NoteWAL 模式下写和写之间还是要排队的。Hermes 的做法是把 SQLite 超时设短(1 秒),然后在应用层做随机退避重试。随机间隔自然打散了竞争的写入者,避免高并发时的队列效应。
FTS5 全文搜索
FTS5 是 SQLite 的全文搜索扩展(Full-Text Search 5)。它让你能在大量历史消息中快速搜索关键词,不需要遍历所有行做 LIKE '%keyword%'。每次插入 message 时,SQLite trigger 自动更新 FTS 索引。
数据库存什么
state.db 存这些:
- Session ID、来源平台、用户 ID
- 会话标题(唯一、人类可读的名字)
- 模型名和配置
- System prompt 快照
- 完整消息历史(role、content、tool_calls、tool 结果)
- Token 计数(输入/输出)
- 时间戳(started_at、ended_at)
- 父会话 ID(压缩触发的会话分裂用)
会话生命周期:创建、续接、分裂、归档
agent 启动
|
v
新 session? ──是──> 建一条 session 记录
|
否(继续旧 session)
|
v
从 SQLite 读出历史 messages
|
v
传给 AIAgent.run_conversation()
|
v
每一轮对话结束后,新 messages 写入 SQLite
|
v
agent 退出
|
v
下次启动时,从 SQLite 读回来,对话还在
增量写入
每轮对话后,Hermes 只写新增的 messages,不是全量重写。对话越长,这个差别越明显—全量重写会越来越慢。
System Prompt 缓存到 session 表
Gateway 每条消息可能创建新的 AIAgent 实例。如果每次都重新组装 system prompt,中间可能因为 MEMORY.md 被改了导致 prompt 变化,Anthropic 的 prompt cache 就失效了。
所以 Hermes 把第一次组装好的 system prompt 存到 session 表里。后续实例直接读缓存,保证 prompt 不变。只有上下文压缩事件才会清除缓存并重建。
会话来源标记
每个 session 有一个 source 字段,标记消息来自哪个入口:
| 来源 | 说明 |
|---|---|
cli | 交互式 CLI |
telegram / discord / slack | 各消息平台 |
whatsapp / signal / matrix | 即时通讯 |
feishu / dingtalk / wecom / weixin | 国内平台 |
email / sms | 邮件短信 |
cron / batch | 定时任务和批处理 |
webhook / api-server / acp | API 接入 |
这让你能按平台过滤会话:「只看 Telegram 的对话」。
会话恢复:—continue 与 —resume
继续上个会话
# 恢复最近的 CLI 会话
hermes --continue
hermes -c
# 或用 chat 子命令
hermes chat --continue
hermes chat -c
这会从 SQLite 查最近的 cli 会话,加载完整对话历史。
按名字恢复
给会话起了标题后,可以按名字恢复:
# 按名字恢复
hermes -c "my project"
# 如果有谱系变体(my project、my project #2、my project #3),
# 自动恢复最近的一个
hermes -c "my project" # -> 恢复 "my project #3"
按 ID 恢复
# 按 session ID 恢复
hermes --resume 20250305_091523_a1b2c3d4
hermes -r 20250305_091523_a1b2c3d4
# 按标题恢复
hermes --resume "refactoring auth"
TipSession ID 格式是
YYYYMMDD_HHMMSS_<hex>—CLI/TUI 会话用 6 位 hex 后缀(如20250305_091523_a1b2c3),网关会话用 8 位后缀(如20250305_091523_a1b2c3d4)。按 ID(完整或唯一前缀)或按标题恢复都行,-c和-r都支持。
恢复时的对话回顾
恢复会话时,Hermes 会在输入提示符前显示一个紧凑的上轮对话回顾面板:
- 显示用户消息(金色
●)和助手回复(绿色◆) - 截断长消息(用户消息 300 字符,助手回复 200 字符/3 行)
- 折叠工具调用为计数+工具名(如
[3 tool calls: terminal, web_search]) - 隐藏系统消息、工具结果、内部推理
- 上限最近 10 轮交流,带「… N earlier messages …」提示
- 用暗色样式跟活跃对话区分
要禁用回顾、保留极简的一行行为:
display:
resume_display: minimal # 默认: full
会话命名与谱系
自动标题
Hermes 在首次交流后自动生成简短描述性标题(3-7 个词)。这跑在后台线程,用快速辅助模型,不加延迟。自动标题只在每个会话触发一次,手动设过标题就跳过。
手动设标题
在任何会话里用 /title 斜杠命令:
/title my research project
标题立即生效。如果会话还没在数据库创建(比如你第一条消息前就敲 /title),会排队等会话开始后应用。
命令行也能改名:
hermes sessions rename 20250305_091523_a1b2c3d4 "refactoring auth module"
标题规则
- 唯一:不能两个会话同名
- 最多 100 字符:保持列表输出整洁
- 净化:控制字符、零宽字符、RTL 覆盖自动剥离
- Unicode 正常:emoji、CJK、带重音字符都行
压缩触发的谱系
当会话上下文被压缩(手动 /compress 或自动),Hermes 创建一个新的续接会话。如果原会话有标题,新会话自动获得编号标题:
"my project" -> "my project #2" -> "my project #3"
按名字恢复时(hermes -c "my project"),自动选谱系里最近的会话。
Note压缩后的新会话通过
parent_session_id指向旧会话。旧历史不删除,只是归档。这形成一条链:session_001 -> session_002 -> session_003,可追溯。
Context References:@ 语法注入
Context References(上下文引用)让你在消息里用 @ 加引用,把文件、目录、git diff、URL 的内容直接注入消息。Hermes 内联展开引用,把内容追加在 --- Attached Context --- 段下面。
支持的引用
| 语法 | 说明 |
|---|---|
@file:path/to/file.py | 注入文件内容 |
@file:path/to/file.py:10-25 | 注入指定行范围(1 索引,闭区间) |
@folder:path/to/dir | 注入目录树列表+文件元数据 |
@diff | 注入 git diff(未暂存的工作树改动) |
@staged | 注入 git diff --staged(已暂存改动) |
@git:5 | 注入最近 N 次提交带补丁(最多 10) |
@url:https://example.com | 抓取并注入网页内容 |
用法示例
Review @file:src/main.py and suggest improvements
What changed? @diff
Compare @file:old_config.yaml and @file:new_config.yaml
What's in @folder:src/components?
Summarize this article @url:https://arxiv.org/abs/2301.00001
一条消息里可以多个引用:
Check @file:main.py, and also @file:test.py.
行范围
@file: 支持精确行范围:
@file:src/main.py:42 # 单行 42
@file:src/main.py:10-25 # 10 到 25 行(闭区间)
行号 1 索引。无效范围静默忽略(返回整文件)。
Tip大文件用行范围只注入相关部分,避免撑爆上下文。比如
@file:main.py:100-200只注入 100-200 行。
大小限制
引用有边界,防止压垮模型上下文窗口:
| 阈值 | 值 | 行为 |
|---|---|---|
| 软限制 | 上下文长度的 25% | 追加警告,展开继续 |
| 硬限制 | 上下文长度的 50% | 拒绝展开,原消息不变返回 |
| 目录条目 | 最多 200 文件 | 超出替换成 - ... |
| Git 提交 | 最多 10 | @git:N 钳制到 [1, 10] |
敏感路径拦截
这些路径永远从 @file: 拦截,防止凭据泄露:
- SSH 密钥和配置:
~/.ssh/id_rsa、~/.ssh/config等 - Shell 配置:
~/.bashrc、~/.zshrc、~/.profile等 - 凭据文件:
~/.netrc、~/.pgpass、~/.npmrc、~/.pypirc - Hermes 环境:
$HERMES_HOME/.env
这些目录整体拦截(里面任何文件):~/.ssh/、~/.aws/、~/.gnupg/、~/.kube/、$HERMES_HOME/skills/.hub/
WarningContext References 主要是 CLI 特性。在消息平台(Telegram、Discord 等)上,
@语法不被网关展开—消息原样传递。智能体自己仍可通过read_file、search_files、web_extract工具引用文件。
与上下文压缩的交互
对话上下文被压缩时,展开的引用内容会被纳入压缩摘要。这意味着:
@file:注入的大文件内容会占用上下文- 对话后续被压缩时,文件内容会被摘要(不是逐字保留)
- 大文件考虑用行范围(
@file:main.py:100-200)只注入相关部分
上下文压缩:三层递进
上下文不是越多越好,而是要把「仍然有用的部分」留在活跃工作面里。
智能体会读大文件、跑长命令、多轮工具调用—上下文很快膨胀。没有压缩机制会出三个问题:1) 模型注意力被旧结果淹没;2) API 请求越来越重越来越贵;3) 撞上上下文上限任务中断。
压缩要解决的是:怎样在不丢掉工作连续性的前提下,把活跃上下文重新腾出空间。
三层递进算法
第 1 层:旧工具输出先裁剪
-> 不需要 LLM,纯字符串替换
-> 把很久以前的工具结果换成占位提示
第 2 层:保护头尾,只压中间
-> 头部(任务定义)不动
-> 尾部(最近工作)不动
-> 只压中间那些已经「用过了」的轮次
第 3 层:用 LLM 把中间部分摘要化
-> 调一个便宜的辅助模型
-> 生成结构化摘要替代原文
画成流程:
messages(100 条,150K tokens)
|
+-- 第 1 层:旧 tool 结果 -> "[Old tool output cleared]"
| (不需要 LLM,先减掉一批 token)
|
+-- 还是太长?
|
+-- 第 2 层:找边界
| 头部:前 N 条(不动)
| 尾部:最近 ~20K tokens(不动)
| 中间:要被压缩的部分
|
+-- 第 3 层:中间部分 -> 辅助 LLM -> 结构化摘要
|
v
新 messages = [头部] + [摘要] + [尾部]
三层是递进的:第 1 层最便宜(不花钱),第 2 层是边界计算,第 3 层才真正调 LLM。
压缩后要保住什么
压缩不是「把历史缩短」这么简单。真正重要的是:让模型还能继续接着干活。
一份合格的摘要至少要保住:
- 当前任务的目标是什么
- 已经完成了哪些关键动作
- 做过哪些重要决定
- 改过或重点查看过哪些文件
- 下一步应该做什么
Hermes 用结构化摘要模板确保这些不丢:
## Goal
...
## Progress
...
## Key Decisions
...
## Files Modified
...
## Next Steps
...
不是自由文本,而是有格式。这让模型更容易从摘要中提取关键信息。
Note摘要消息的
role用assistant而不是user:userrole 会被模型理解为「用户的新一轮发言」,触发重新规划,反而让智能体重复已做过的工作。assistantrole 配合明确的[CONTEXT COMPACTION - system-generated]前缀,是更稳的方案。
关键设计:边界对齐避免孤儿 tool_call
assistant.tool_calls 和它的 tool 响应必须配对—只留下前者、把后者压进摘要,下一次 API 请求就会被拒。
find_boundaries 在两端都会把候选切点「吸附」到下一个非 tool 消息上:
def _align_to_assistant_boundary(messages, index):
while index < len(messages) and messages[index].get("role") == "tool":
index += 1
return index
碰到 tool 就往后走,直到找到 assistant 边界。这样切下来的中段始终是若干完整的 assistant <-> tool 配对,怎么压都不会出孤儿。比「压完再扫一遍清孤儿」更彻底:从源头就保证消息流合法。
压缩失败早退
极端情况下头部本身就吃掉大部分预算(比如用户首条消息塞了一整篇文档),compress 找不到可压的中段,再多压几轮也只是空转。compress 比较前后 token 估算,如果没降到原值的 90% 以下,抛 CompressionStuckError:
class CompressionStuckError(RuntimeError):
"""Compress couldn't meaningfully shrink the message list."""
# in compress():
after = estimate_tokens(new_messages)
if after >= int(before * COMPRESSION_MIN_SHRINK):
raise CompressionStuckError(before, after)
主循环捕获后立即退出当轮,告诉用户「会话已无法压缩,请新开 session」,而不是一直循环到 MAX_ITERATIONS。
Preflight 压缩
run_conversation() 进入主循环之前就检查 token 数。如果已经超了(比如用户从大窗口模型切到小窗口模型),在第一次 API 调用之前就压缩。不等到 API 报错再处理—主动防御比被动恢复好。
压缩配置详解
所有压缩设置在 config.yaml(没有环境变量):
compression:
enabled: true # 开关
progress_notices: false # 是否向聊天平台推送压缩进度通知
threshold: 0.50 # 上下文占比到这个比例时压缩
threshold_tokens: null # 绝对 token 上限(可选),取比例和绝对值较低者
target_ratio: 0.20 # 保留为最近尾部的比例
protect_last_n: 20 # 最少保留的最近消息数(不压缩)
protect_first_n: 3 # 跨压缩固定的非系统头部消息数(0 = 不固定)
idle_compact_after_seconds: 0 # 空闲压缩(0 = 禁用)
hygiene_hard_message_limit: 5000 # 网关安全阀
hygiene_timeout_seconds: 30 # 摘要模型无输出的最大秒数
hygiene_total_ceiling_seconds: 600 # 即使 token 还在流的绝对上限
hygiene_failure_cooldown_seconds: 300 # 跳过重复失败的 hygiene 尝试
proactive_prune_tokens: 0 # 无 LLM 工具结果裁剪的 token 触发器(0 = 关)
proactive_prune_min_result_chars: 8000 # 裁剪只触及大于此值的工具结果
# 摘要模型/Provider 配在 auxiliary 下:
auxiliary:
compression:
model: "" # 空 = 用主聊天模型。覆盖可用更便宜的,如 "google/gemini-3-flash-preview"
provider: "auto" # "auto" / "openrouter" / "nous" / "codex" / "main" 等
base_url: null # 自定义 OpenAI 兼容端点(覆盖 provider)
Note旧配置里的
compression.summary_model、compression.summary_provider、compression.summary_base_url会在首次加载时自动迁移到auxiliary.compression.*(config 版本 17)。无需手动操作。
触发时机
- Preflight(API 调用前):对话超过模型上下文窗口的 50%
- 网关自动压缩:对话超过 85%(更激进,回合间运行)
网关热重载
Tip编辑
config.yaml里的model.context_length或任何compression.*键,运行中的网关在下一条消息就生效—不用重启网关、不用/reset、不用轮换会话。缓存智能体签名包含这些键,网关看到变化会透明重建智能体。API Key 和工具/技能配置仍走通常的重载路径。
没有可用摘要模型时
如果没有 Provider 可用于压缩,Hermes 会丢弃中间对话轮次而不生成摘要,而不是让会话失败。这是最后的保底—宁可丢上下文也不让对话挂死。
session_search:跨会话检索
session_search 是一个独立工具,让智能体能搜索本地会话库里过去的会话,或在某个会话内滚动浏览。FTS5 支撑的检索,返回数据库里的实际消息(不调 LLM)。
三种用法
| 形态 | 参数 | 用途 |
|---|---|---|
| 发现 | 传 query | 跨会话搜索关键词 |
| 滚动 | 传 session_id + around_message_id | 在某会话内定位消息 |
| 浏览 | 不传参数 | 列出会话 |
智能体在干活时可以自己调 session_search 找历史上下文。比如你问「上周我们讨论的那个 bug 修复方案是什么」,智能体可以搜历史会话找答案。
命令行会话管理
Hermes 提供完整的 hermes sessions 命令集:
# 列出最近会话(默认最近 20 个)
hermes sessions list
# 按平台过滤
hermes sessions list --source telegram
# 显示更多
hermes sessions list --limit 50
有标题时输出显示标题、预览、相对时间戳:
Title Preview Last Active ID
──────────────────────────────────────────────────────────────────────────────
refactoring auth Help me refactor the auth module please 2h ago 20250305_091523_a
my project #3 Can you check the test failures? yesterday 20250304_143022_e
会话导出
hermes sessions export 是一个入口支持所有导出格式,用 --format 选:
| 格式 | 输出 | 用途 |
|---|---|---|
jsonl(默认) | 每会话一个 JSON 对象 | 备份、机器回环 |
md / qmd | 每会话一个 Markdown/Quarto 文件+清单 | 可读归档、笔记 |
html | 单个自包含页面(多会话有侧边栏) | 分享、浏览 |
trace | Claude Code JSONL | HF Agent Trace Viewer、--upload |
# 全部会话导出 JSONL
hermes sessions export backup.jsonl
# 按平台导出
hermes sessions export telegram-history.jsonl --source telegram
# 单个会话导出
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
# 脱敏(API Key、token、凭据)后导出
hermes sessions export backup.jsonl --redact
# 导出为 HTML(自包含,可分享)
hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html
# 只导出用户提示词(建提示词库)
hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts
Tip
--redact会擦除导出内容里的 API Key、token、凭据。任何打算分享的导出都建议加上。trace 格式默认就脱敏(因为设计上要离开本机),--no-redact可手动关闭。
只导出用户提示词
--only user-prompts 只导出你写的提示词—没有助手回复、工具输出、系统上下文。适合建提示词库或回顾自己问过什么:
# 每条提示词一个 JSONL 记录(会话 ID、索引、时间戳、文本)
hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts
# Markdown,直接到 stdout
hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md
上传到 Hugging Face
--format trace --upload 把 trace 推到你自己的私有 hermes-traces 数据集(读 HF_TOKEN):
# 单个会话上传到 HF traces 数据集
hermes sessions export --format trace --session-id 20250305_091523_a1b2c3d4 --upload
--upload 默认私有,加 --public 才公开。
会话清理
# 删除特定会话(带确认)
hermes sessions delete 20250305_091523_a1b2c3d4
# 清理老会话
hermes sessions prune
Warning压缩减少活跃上下文,但不是隐私删除。
/compress把旧消息摘要化,原始数据仍在 SQLite 里。要真正删除用hermes sessions prune或hermes sessions delete。
导出后删除
--delete-after-verified 模式先导出验证再删除,限 --session-id 使用,需要 --yes:
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
因为删除父会话也会删除其委托/子智能体会话,这个模式会先单独导出并验证每个委托,再删任何东西。如果导出期间委托集合变了,拒绝删除。
什么算进上下文
Hermes 存会话历史是为了恢复对话,但不会反复重发它处理过的每个字节。每轮模型看到的是:选定的 system prompt、当前对话窗口、Hermes 为该轮显式注入的内容。
媒体附件按回合级输入处理:
- 图片可能原生附加到下次模型调用,或在不支持原生视觉时预分析成文本描述
- 音频在配置了语音转文字后转成文本
- 文档可包含提取的文本;其他类型通常用本地路径+简短说明表示
- 附件路径和提取/派生文本可能出现在转录里,但原始图片、音频、二进制字节不会重复复制到未来提示词
Note上下文增长最常见的原因不是媒体文件本身,而是冗长文本:粘贴的转录、完整日志、大工具输出、长 diff、重复状态报告、详细证明转储。优先用摘要、文件路径、聚焦摘录、工具查询,而不是把大工件复制进聊天。
常见踩坑
1. 不开 WAL 模式:默认模式下一个写操作阻塞所有读。Gateway 场景下智能体写消息时,另一个平台的读取请求会挂住。
2. 存整个 messages 列表而不是增量:每轮对话后全量写入而不是只写新增。对话越长写入越慢。
3. 把 session_id 写死:每次启动用同一个 session_id,所有对话混在一起。session_id 应该每次新对话生成一个。
4. 以为压缩等于删除:压缩是把「不必常驻活跃上下文」的内容换一种表示。旧历史通过 session 链保留。
5. 只在撞上限后才处理:更好做法是三层递进—旧输出先裁剪、找边界、再摘要。不是一上来就调 LLM。
6. 摘要只写成一句空话:如果摘要没保住目标、决定、文件、下一步,它对继续工作没帮助。
7. 用主模型做摘要:压缩是系统操作,不是用户请求。用便宜的辅助模型就够了。
8. 不保护尾部:把最近的消息也压缩了,智能体立刻忘记刚才在做什么。
9. 在 Gateway 里不分 chat_id:不同平台的不同聊天窗口应该是不同的 session。所有消息混到一个 session 里,智能体把 A 用户的对话当成 B 用户的上下文。
这一章讲完了会话存储、恢复、压缩、检索的全链路。到这里,Hermes Agent 的核心机制—从安装配置、模型管理、Agent 循环到会话上下文—就串起来了。后面的章节会进入工具系统、技能、委托、MCP 这些更高层的能力扩展。