Hermes 是个很好的开源架构,非常值得我们学习。
1. 整体架构
Hermes 的组件并不复杂,核心是 Agent Loop 及一套开箱即用的 Tools / Skills / Memory。
记住:多种入口汇入同一个 Loop;Loop 再挂 Tools、Skills,以及三路 Memory。
1.1 Agent Loop
用户每发一条消息,就跑一轮完整的循环。
细节说明:
- Build Context:组装 SP、
SOUL.md、项目 Context Files(AGENTS.md 等)、user.md、消息侧等 - Tool 调用 是内循环:LLM 可连续多次调用工具(搜索、读写文件等),直到认为不必要再调用,才出最终回复
- Memory Update 闭环学习:
- 时机:本轮最终回复已经给出后
- Agent 审视本轮对话,判断有没有值得记住的内容,再决定是否写入
user.md/memory.md
1.2 Context 构建
Hermes 把 Context 拆成两层时间尺度,搞混就会以为「写了 memory 立刻进 SP」或「每条消息都重新读盘」:
| 词 | 含义 | 对 Context 的影响 |
|---|---|---|
| Turn(单轮) | 用户发 1 条 消息 → 跑完一整次 Loop(可含多次 Tool)→ 最终回复 | 每轮 Build Context:复用冻结的 SP + 追加消息侧;可压缩;可挂 tool result 旁路 |
| Session(会话) | 一次连续对话容器(Desktop 侧边栏一条对话 / CLI 一次聊天 / Gateway 同一个 session_id) | 启动时 Prompt Assembly 组装并 冻结 SP;整段 transcript 挂同一 Session |
官方:Context Files · Prompt Assembly · Sessions
1.2.1 发给 LLM 的两块
每次调模型,实际送出去的是两块:冻结的 SP + 本轮消息侧。
stable → context → volatile,是叠三层的先后顺序(不是三个并列选项)。先放最不该变的,再放项目规矩,最后放记忆这类相对容易变的。
| 层 | 白话 | 典型从哪来 |
|---|---|---|
| stable | 最稳、几乎整段 Session 不动 | 人格 SOUL.md、Tools / Skills 怎么用、环境 / 平台提示 |
| context | 这个项目的规矩 | 可选的 system_message + 项目文件只选一种 |
| volatile | 相对会变,但仍冻进这份 SP | memory.md / user.md 快照、可选 External、时间行 |
「volatile」容易误会成「每轮都换」。这里只是相对 stable 更容易在下一次组装时变;当前 Session 里这三层一起冻住,不会每说一句就刷新。
1.2.2 Session 级
Session 开始时做一次 Prompt Assembly,结果就是本 Session 冻结的 SP。
打个比方:开一个新会话像开机装系统——把人格、项目规矩、记忆快照一次性装好,之后聊多少句都用这一份,不会每说一句就重装一次。下图为 Prompt Assembly。
要点:
- 装好就不再改:
memory.md/user.md进 SP 的是开会话那一刻的内容。聊天中途 Agent 用 memory tool 写了新东西,磁盘立刻更新,但这一份 SP 里还是旧的。
所以会出现「我明明让它记住了,它却像没记住」——它记住了,只是要下次开会话才装进去。想立刻生效,重开一个 Session 最省事。 - 为什么不实时刷:为了省钱和稳。模型这边有 prefix cache——只要开头这段一字不变,后面每轮都能便宜很多;一改就整段重算。顺带也省了每轮重新读盘。
- 什么时候才会重装:新开 Session,或 Compression 压缩历史时顺手重组一次。
- 不管从哪进来,装法都一样:CLI 和 Gateway 用的是同一套 SP 组装规则。Gateway 麻烦的是另一头——它每次只收到最新一条消息,得先把历史从数据库里捞回来,见 §1.4.2。
- 项目规矩最多只装一份:
.hermes.md→AGENTS.md→CLAUDE.md→.cursorrules,从上往下第一个命中的就用它,不叠加。直接闲聊、目录里一个都没有,这一层是空的,正常。SOUL.md(人格)走单独的位置,不和项目文件抢。
1.2.3 Turn 级
用户每发一条消息,Loop 在调用 LLM 之前走 Build Context。同 Session 内不重装整份 SP,而是:
| 步骤 | Turn 内行为 | 会不会改冻结的 SP |
|---|---|---|
| 压缩检查 | 每 Turn 前;context 报错时再强制 | 一般只改 消息侧;少数 rebuild 路径除外 |
| 拼请求 | 冻结的 SP + 消息侧 | 否 |
| Tool 内循环 | 可多次;子目录 Context Files 渐进式披露:Tool 摸到新路径才加载,挂 tool result | 否(不改 SP) |
| 最终回复后 | transcript 写入 state.db;可写 user.md / memory.md | 否(下个 Session 才进 SP) |
和 Session 对照记:
| 维度 | Session | Turn |
|---|---|---|
| SP | 启动组装并冻结 | 复用 |
| 消息侧 | 同一 session id 下累积 | 每轮带「到目前为止」的历史 |
| Context Files(根) | 启动注入 SP | 每轮随 SP 带着 |
| Context Files(子目录) | 每目录最多发现一次 | 某轮 tool 摸到路径时注入 |
| Compression | 配置按实例/Session | 检查动作按 Turn |
| Memory 写盘 | 跨 Session 持久 | 可在某轮发生,不影响本 Session 的 SP |
1.3 Context 的压缩
Hermes 把这件事叫 Compression,就是词典里的 Compaction / Autocompact
何时压:用量 ≥ 主模型 窗口 × 阈值(默认 50%)。Gateway 进 Agent 前另有一道约 85% 的兜底;模型因 context 报错也会强制压。token 优先用 API 返回的 usage,没有则粗估。
# config.yaml
compression:
threshold: 0.50 # 触发比例
target_ratio: 0.20 # 近期消息保留预算 = 触发阈值 token × 此值
protect_last_n: 20 # 尾部至少保留多少条(预算优先,这条是下限)例(200K 窗口、默认配置):触发线 100K token;尾部预算约 20K;摘要上限约 10K。
1.3.1 压缩算法
思路像整理一沓聊天记录:开头几页留着(交代这事是干嘛的),最近几页原样留着(正在做的事),中间那一大摞换成一页纪要。
对应到消息列表,就是切成三段:
| 段 | 留什么 | 怎么处理 |
|---|---|---|
| head | 开头:系统消息 + 最初一两轮 | 原样保留。多压几次之后,这层保护会变弱 |
| middle | 中间那些旧回合 | 压成一份摘要,这是省 token 的主力 |
| tail | 最近的对话 | 原样保留。从后往前按 token 预算留,预算不够也至少留 N 条 |
四步分别在干嘛:
- ① 剪枝:先捡便宜的省。
不调模型,纯本地清理。最近那段不动,往前的:一条几百行的命令输出收成一行(工具名、命令、结果);同样的结果出现好几次只留一份;调用参数太大就截断。很多时候光这一步就够了。 - ② 定边界:划出哪些要压。
算出 head / middle / tail 的位置。这里有条硬规矩:一次工具调用和它的结果不能被拆到两段去——只留下「我调了 grep」却没有结果,模型会看不懂。 - ③ 摘要:把 middle 写成一页纪要。
交给一个辅助模型(不是主模型)按固定模板写。这个 Session 如果之前压过、已经有一份摘要,就在旧摘要上改,而不是从头重写。你也可以/compress 某主题,告诉它这次重点保留什么。 - ④ 组装:拼回一份新的消息列表。
开头 + 这份摘要 + 最近对话。顺手把落单的工具消息(有调用没结果、或反过来)清掉。摘要上带内部标记,发给 API 之前会剥掉。
几个数:摘要目标长度约是被压内容的 20%,并且夹在 2K~10K token 之间——太短会丢关键信息,太长就白压了。
压不动怎么办:消息本来就少、或中间没有可压的窗口,那就不压,记一次「无效压缩」。连续两次无效就暂时不再自动触发,免得每轮都白跑一遍陷入死循环。
1.3.2 摘要长什么样
摘要(Summary)开头会声明:仅作背景参考——模型应只响应摘要之后的最新用户消息;memory.md / user.md 仍然有效。带 Historical 的标题是故意的,防止把旧待办当成还要继续做。
| 区块 | 作用 |
|---|---|
| Historical Task Snapshot | 用户最近未完成的输入(尽量原文) |
| Goal / Constraints & Preferences | 总目标、约束与偏好 |
| Completed Actions | 编号:做了什么、路径/命令、结果、用了哪个工具 |
| Active State | 工作目录、分支、改动文件、测试状态等 |
| Historical In-Progress / Blocked | 压缩当时进行中的事、阻塞与报错 |
| Key Decisions / Resolved Questions | 关键决策、已回答过的问题 |
| Historical Pending Asks / Remaining Work | 陈旧待办——默认不要自动接着做 |
| Relevant Files / Critical Context | 相关文件与关键值(密钥写成 [REDACTED]) |
额外规则:已完成的事写成带日期的过去式;再次压缩时更新上一份摘要,而不是从头重写。
1.3.3 失败时怎么办
1.4 Gateway 消息网关
Gateway 是一层消息网关:让同一个 Agent 能在 Telegram、WhatsApp、Email、Slack、Discord、SMS 等渠道上收发消息。相对 CLI,可以记成两句话:
- 常驻在线:CLI 是你敲命令才起进程,聊完退出就没了。Gateway 是后台一直挂着,自己盯各渠道(listen / poll / webhook)。人不在终端前,Telegram 半夜来一条也能立刻收下、跑一个 Turn。
- 多端同一套 Core:各渠道只负责收发;转成统一入站格式后,交给的是同一个 Agent Core,走和 CLI 一样的 Build Context → Loop → Memory Update。没有「Telegram 版 Agent」和「Slack 版 Agent」两套人格;
SOUL.md/user.md/memory.md、Tools、Skills 全共用。
共用 Core ≠ 共用对话。 每条会话线用 Session Key(如 telegram:<provider_session_id>:…)分开,各自独立 Session、独立 transcript。共享的是人格和长期记忆;Telegram 说过的话,Slack 那边不会自动看见,除非已经写进 memory.md 或 External。
相对 CLI,它多了两件 Context 相关的硬活:按 Session 拼历史,以及 并发 Turn 的排队/打断。
1.4.1 入站消息变成 Turn
| 步骤 | Session 还是 Turn | 做什么 |
|---|---|---|
| 解析 Session Key | Session | telegram:<provider_session_id>:… 定位同一条对话线程 |
| Hygiene | Session 用量,在本 Turn 进 Agent 前 | 超长历史先压一刀,用的就是 §1.3 的 Compression |
| 拉 transcript | Session 累积 → 交给本 Turn | CLI 内存里本来就有;Gateway 每次只收到一条新消息 |
| Session Manager | 多 Turn 并发 | 上一条还在跑时,新消息 queue / interrupt / steer |
| Agent Loop | 一个 Turn | 复用/组装 Context → LLM↔Tools → 回复 → Memory Update |
1.4.2 Gateway 的「拼 Context」更重
- CLI:进程内 Session 连续,历史天然就在内存里。
- Gateway:进程常驻,但 入站往往只有最新一条;必须:
- 用 Session Key 从 SQLite 拉齐 transcript,拼出消息侧;
- 再走与 CLI 相同的 Build Context / 冻结 SP 逻辑(§1.2)。
没有这一步,Agent 会把每条 Telegram 消息当成「失忆的新会话」。
1.5 Memory
Memory 贯穿 Loop、Compression、Gateway。这里只钉 和 Context 的交接:谁在 Session 启动进 SP,谁在 Turn 末尾写盘。
Hermes 记忆分 三路:
-
Markdown 文件记忆(常用):
SOUL.md:人格(常需自建;仅HERMES_HOME)。user.md:用户事实(Agent 可更新)。memory.md:任意有用知识(结合目标判断是否写入)。
-
SQLite 内部记忆(存在 Session database 里,同一段对话存两份):
- transcript:完整的结构化流水账(角色、工具调用都在)。Gateway 用「渠道前缀 + session id」定位同一条线,按需拉出来拼进当前 Turn。
- Bare text / FTS:同一段对话剥成纯文本,再对这份纯文本建全文索引。搜的是库里各 Session 落过盘的对话原文,不是
memory.md,也不是当前窗口。所以能按词找回「上周那个 bug」,而不必把所有旧 Session 塞进窗口。
-
External Memory(可选第三方记忆):
- 不在 Hermes 本机 Markdown / SQLite 里,而是 mem0 / SuperMemory / Honcho 这类外部服务。
- 没配就没有;读写时机、格式都跟 provider,不走
user.md/memory.md那套规则。 - 读:Build Context 时可额外召回一段,常作为 ephemeral / External 块挂在消息侧,不受 Session 启动时冻进 SP 的 Markdown 快照限制。
- 写:Turn 结束、出完回复后,适配层按 provider 同步本轮(对照表「仅配置后」)。
- 用途:跨设备、跨 Session、跨产品复用长期记忆;本机两路仍负责人格、用户事实和完整 transcript。
1.5.1 何时更新 Memory
先分清:写入(Write) 和 读进 Context(Read)。下图按一个 Turn 的先后顺序走一遍,每步各自读什么、写什么:
对照表:谁、何时、写什么
| 记忆载体 | 何时写入 | 触发条件 | 谁决定写 | 默认是否发生 |
|---|---|---|---|---|
| SQLite transcript | 每个 Turn 之后 | 有对话就落库 | 系统自动 | 是 |
| Bare text / FTS | 随 transcript | 便于检索 | 系统自动 | 是 |
| user.md | Turn 末尾 Memory Update;或中途 memory tool | 出现用户画像信息 | Agent | 有料才写 |
| memory.md | ① Turn 末尾 ② 踩坑自改进 ③ 用户显式「记住」 | 可复用事实 | Agent / 用户 | 否(有内容才写) |
| SOUL.md | 不走每轮自动 Update | 人手或显式让 Hermes 写 | 用户主导 | 否 |
| External | 视 provider(常 Turn 结束后) | 已配置 | Provider + 适配层 | 仅配置后 |
容易混的三点
-
「每轮都 Memory Update」≠「每轮都改 user.md / memory.md」
审视每轮可跑;只有「值得记」才落盘。transcript 才是每轮必写。 -
写盘 ≠ 当前 Turn/Session 的 SP 已更新
冻结 SP 是为了 prompt cache;活数据在 tool result / 下个 Session。 -
三种写入入口
- 隐式:Loop 末尾审视 / 自改进 / External
- 显式:用户说「记住:…」
- 人手改 Markdown(尤其
SOUL.md)