研究日期:2026-07-23(Asia/Shanghai)
主仓库:NousResearch/hermes-agent
源码快照:3f9944bad92ed00f9116cfbad6326cceecb39151
快照提交时间:2026-07-22T23:36:45-04:00
包版本:0.19.0
最新正式版本:v2026.7.20,Hermes Agent v0.19.0(Quicksilver,2026-07-20)

版本声明

本文研究的是 2026-07-23 抓取的 main 快照,它比最新正式版本标签晚约三天。仓库变化极快,所以正文不把 main 中的实现自动描述为所有稳定安装都已具备;关键源码链接全部钉到完整 SHA。官方 v0.19.0 release 宣称首轮 time-to-first-token 从约 4.3 秒降到约 0.9 秒,这是项目方的发布数据,不当作独立基准测试。

结论先行

Hermes Agent 的长期记忆不是一个统一的数据库,而是四种不同可见时机:

  1. MEMORY.md / USER.md 在 system prompt 构建时形成冻结快照;
  2. 外部 provider 的静态说明也只在 system prompt 构建时进入稳定前缀;
  3. 与本轮问题相关的 recall 在第一条模型请求前取得,追加到当前 user message 的 api_content 旁路;
  4. 完整轮次结束后,外部记忆写回和下一轮预热进入单 worker FIFO。

最有价值的设计不是“缓存记忆”,而是 persist what you send:Hermes 把当时真正发给模型的 user message 字节与干净 transcript 一起保存。下一轮、甚至进程重启后,历史消息会重放同一份 api_content,因此外部 recall 可以每轮变化,已经发生过的 prompt prefix 仍然按字节稳定。

这是一种时序架构:记录、召回、采用为 system-level identity、持久化和再次可见,被拆成了不同事件。

产品层、架构层、代码层

产品层

  • Hermes 是跨 CLI、TUI、desktop、gateway、ACP 和 API 的通用 agent,而不是单独的 memory SDK。
  • 内置文件记忆强调小、可读、可编辑;SQLite session history 负责会话连续性与检索;Honcho、Mem0、Hindsight、OpenViking 等外部 provider 提供自动召回和写回。
  • 同时最多启用一个外部 memory provider,但内置文件记忆可以并存。

架构层

  • Gateway 负责授权、路由、session ownership、并发 lease、历史加载、agent cache 和结果落库。
  • AIAgent 是各入口共享的窄腰,拥有稳定 system prompt、model/tool loop、memory manager 和 SessionDB。
  • TurnContext 是记忆时序的关键边界:它在第一条 LLM 请求前执行 provider recall,组成 api_content,再把 inbound user turn 持久化。
  • MemoryManager 统一 provider 注册、静态 prompt、当前轮 prefetch、完成轮 sync、下一轮 warmup、session boundary 和 bounded shutdown。
  • SessionDB 用 SQLite + WAL + FTS5 保存 transcript;content 留下干净语义,api_content 保存真实 wire bytes。

代码层

gateway/run.py::_handle_message
  → SessionStore.get_or_create_session()
  → acquire turn lease by resolved session_id
  → load_transcript()
  → _run_agent()
    → reuse/create AIAgent
    → AIAgent.run_conversation()
      → agent/turn_context.py::build_turn_context
        → MemoryManager.on_turn_start()
        → MemoryManager.prefetch_all()
        → compose_user_api_content()
        → persist inbound user row
      → agent/conversation_loop.py
        → replay historical api_content
        → model call
        → persist assistant tool_calls before side effects
        → model_tools.handle_function_call()
        → append tool result
        → repeat until final response
      → turn_finalizer.finalize_turn()
        → sync_all()
        → queue_prefetch_all()
  → gateway persists/delivers without duplicating agent-owned rows

一条真实请求链

以下以 gateway 中的一条消息为例:用户先说“以后这个项目的 staging PostgreSQL 端口是 55432,请记住”,下一轮再问“帮我检查 staging 数据库”。

1. Gateway 先占住 session

GatewayRunner._handle_message() 做授权和命令处理后,在任何 await 之前把 _AGENT_PENDING_SENTINEL 放入 _running_agents。否则语音、图片分析或 session hygiene 期间到来的第二条消息可能为同一 session 启动第二个 agent,交错写 transcript。

session 解析完成后,Gateway 再按最终 session_id 获取 turn lease,把“加载历史 → 执行 → flush”串行化。它解决的是两个不同 routing key 指向同一个 session 的别名竞态。

证据:

  • gateway/run.py:10284-10295
  • gateway/run.py:11747-11769
  • gateway/run.py:12374-12429
  • gateway/run.py:12628-12654

2. system prompt 不是每轮重新拼

AIAgent 初始化时建立 MemoryStore,从 MEMORY.md / USER.md 读取 live entries,并另存一份 _system_prompt_snapshotbuild_system_prompt() 把身份、工具规则、项目上下文、文件记忆、外部 provider 静态块和日期拼起来;结果缓存于 agent,只在新 session 或 compression 触发 invalidation 后重建。

因此本轮调用 memory tool 把端口写进 MEMORY.md,磁盘与 live state 会立刻改变,但当前 system prompt 不会中途变字节。压缩重建或新 session 才采用新的文件快照。

证据:

  • agent/agent_init.py:1572-1600
  • tools/memory_tool.py:123-146
  • tools/memory_tool.py:178-215
  • agent/system_prompt.py:480-550
  • agent/system_prompt.py:553-561

3. 外部 recall 走 user-message 旁路

在当前轮进入 tool loop 前,build_turn_context() 先调用 on_turn_start(),随后只做一次 prefetch_all(query)。外部 provider 的调用放进 daemon thread,但当前轮最多会等待 8 秒;超时则本轮无 recall,且在卡住的调用返回前不再为同一 provider 并发启动第二个 prefetch。

召回结果不会改 system prompt,而是被包进 <memory-context>,与 plugin context 一起追加到当前 user message 的 API 副本:

# 简化伪代码,不是原仓库逐字摘录
recall = memory_manager.prefetch_all(clean_user_text)
wire_content = compose_user_api_content(clean_user_text, recall, plugin_context)

message["content"] = clean_user_text
message["api_content"] = wire_content
session_db.append_message(message)  # 第一条模型请求前

第一次模型请求看到的于是是:

帮我检查 staging 数据库

<memory-context>
这个项目的 staging PostgreSQL 端口是 55432
</memory-context>

而 transcript 的 content 仍是用户原话。

证据:

  • agent/memory_manager.py:515-585
  • agent/turn_context.py:49-81
  • agent/turn_context.py:939-1015
  • agent/turn_context.py:1012-1028

4. “发了什么,就重放什么”

Hermes 不能只保存干净 user text。若下一轮历史里缺少上轮 recall,provider 看到的 prefix 会从上轮 user message 处开始分叉,后面的 assistant/tool chain 都需要重新 prefill。

所以 api_content 与消息一起写入 SQLite。构造 API payload 时:

  • 当前轮使用刚组成的 api_content
  • 历史 user / assistant message 若有 sidecar,就用它替换 content
  • sidecar 自身不会发送给 provider;
  • 若 compaction 或清理逻辑重写了 message content,Hermes 会丢弃旧 sidecar,宁可发生一次 cache miss,也不重放已被删除的内容。

测试用本地 mock provider 验证了两个不变量:同一轮的多次 model call 使用相同 user bytes;全新 AIAgent 从 SessionDB 恢复后,turn N+1 重放的 turn N user message 与 turn N 当时发送的 JSON 字节相等。

证据:

  • agent/turn_context.py:84-116
  • agent/conversation_loop.py:973-1021
  • agent/conversation_loop.py:1046-1065
  • hermes_state.py:5454-5581
  • tests/agent/test_api_content_sidecar.py

5. tool call 先落库,再执行副作用

模型返回 tool calls 后,conversation loop 先把 assistant tool-call message 追加并持久化,再分派工具。这样进程若在副作用期间退出,恢复逻辑至少知道模型已经请求过什么,不会只留下孤立的 tool result。

安全 tool batch 可以并行,但调度器把 sequential call 当作 barrier;tool result 再回灌 message list,循环继续到 final response 或 iteration budget。

证据:

  • agent/conversation_loop.py:5267-5317
  • run_agent.py:6396-6436
  • model_tools.py:1055-1377

6. 只有完成的轮次才进入外部长期记忆

turn finalizer 在最终响应完成后调用 _sync_external_memory_for_turn()。若 interrupted=True、没有最终响应或没有 user-origin message,就跳过写回;半截回复、被打断的 tool chain 不应成为持久事实。

正常轮次的 sync_all()queue_prefetch_all() 不在线程内阻塞用户响应,而是排入单 worker:

sync turn N
→ warm next-turn recall for turn N
→ sync turn N+1
→ warm next-turn recall for turn N+1

FIFO 保证轮次顺序。真正 session 结束时才执行 on_session_end() 和 shutdown,不会在每次 run_conversation() 后杀死 provider。

证据:

  • run_agent.py:3661-3720
  • agent/turn_finalizer.py:583-608
  • agent/memory_manager.py:628-684
  • agent/memory_manager.py:688-747
  • agent/memory_manager.py:855-914

7. 持久化是 best-effort,但不是静默丢弃

关机先给后台 FIFO 最多 5 秒排空。如果 provider 永久卡住,Hermes 不让进程无限等待;仍未开始的 write / prefetch 会被取消并计数,已经运行的 daemon task 与进程一起结束,日志记录 abandoned_writesabandoned_prefetches 和 detached active tasks。

这是明确取舍:对话可用性优先于第三方记忆后端的绝对写入保证。需要严格 durability 的场景必须在 provider 内增加 outbox、幂等键或独立 worker,不能把 sync_turn() 当事务提交。

证据:

  • agent/memory_manager.py:42-47
  • agent/memory_manager.py:1134-1212
  • tests/agent/test_memory_async_sync.py

记忆时序矩阵

| 记忆路径 | 写入 / 读取时机 | 本轮可见性 | 下轮 / 重启可见性 | 关键代价 | |---|---|---|---|---| | MEMORY.md / USER.md live state | agent init 读取;tool call 可写 | 工具结果可见;已缓存 system prompt 不变 | 新 session 或 compression rebuild 进入 prompt | 当前行为会暂时使用旧快照 | | 内置文件快照 | system prompt 构建 | 全轮稳定 | 随 session prompt 恢复或重建 | 有字符上限,只适合精选事实 | | 外部 provider 静态块 | system prompt 构建 | 全轮稳定 | 新 agent / rebuild 更新 | 不能承载每轮动态 recall | | 外部 prefetch() | 本轮第一条模型请求前,最多等 8 秒 | 通过 api_content 立即可见 | sidecar 原样重放 | 超时会跳过;卡住时不并发重试 | | sync_turn() | 完整、未中断轮次结束后 | 不改变本轮 | provider 完成写入后可召回 | 后台 best-effort,进程异常可丢 | | queue_prefetch() | 完整轮次结束后 | 不可见 | 为下一轮预热 | 与写回共享单 worker,慢写会拖后预热 | | SessionDB transcript | user turn 在第一条 LLM call 前;assistant/tool 随循环写 | 支撑 crash recovery | SQLite + WAL + FTS5 恢复 / 搜索 | 多进程写争用需重试与维护 | | api_content sidecar | 与 message 一起保存 | 当前 wire bytes | 历史按字节重放 | 存储量增加;内容重写时必须失效 |

五个设计判断与取舍

1. system prompt 冻结,动态 recall 下沉到 user message

  • 收益:provider prefix cache 可复用,memory backend 仍能每轮更新召回。
  • 成本:外部 context 的指令优先级低于 system;内置文件新写入不会立刻成为 prompt identity。
  • 失败方式:若把动态 context 又塞回 system prompt,会重现 issue #13631 描述的 cache invalidation。
  • 适用:长 session、多次 tool round、prefix caching 显著的模型。

2. contentapi_content 分开保存

  • 收益:人类看到干净 transcript,模型得到可重放的历史 wire bytes。
  • 成本:每个 rewrite / compaction / export 路径都必须理解 sidecar,维护面扩大。
  • 失败方式:sidecar 与真实发送内容漂移会造成错误重放;Hermes 选择在不确定时删除 sidecar。

3. 当前 recall 有超时,完成轮写回异步化

  • 收益:需要 recall 的当前问题可以使用结果;第三方写入不会让界面长期保持“agent running”。
  • 成本:首轮仍可能为 recall 多等最多 8 秒;后台写入不是事务 durability。
  • 失败方式:provider 卡死时当前轮跳过 recall,关机期限后可能放弃 queued write。

4. 一个外部 provider,而不是任意组合

  • 收益:避免 tool schema 膨胀、生命周期冲突和两个后端争夺同一记忆所有权。
  • 成本:不能直接把多个专长后端串成 ensemble。
  • 适用:个人助手和单一治理边界;复杂企业 memory mesh 需要在 provider 内部聚合。

5. session history 服从插入顺序

  • 收益:按 SQLite autoincrement id 恢复,避免 NTP 回拨、休眠恢复导致 timestamp 倒序,把 assistant tool call 排到 tool result 后面。
  • 成本:跨库导入与分支必须维护稳定插入语义。
  • 失败方式:用 wall-clock 排序可能破坏 tool-call/result 邻接并触发 provider HTTP 400。

证据:

  • hermes_state.py:6161-6216

现场 issue 与 release 证据

Issue #13631:动态 Honcho context 破坏 prefix cache

  • 状态:closed / completed,2026-06-08 关闭。
  • issue 描述旧架构把不断变化的 Honcho block 放进 system prompt,导致每隔若干轮重建 agent 和 cache prefix。
  • 当前源码把静态 provider block 留在 system prompt,把本轮 recall 放入 user-message api_content,并通过 sidecar 重放历史字节。
  • 文章只说“当前实现解决了 issue 所描述的架构问题”,不声称所有 provider / API 模式都有同等 cache 命中率。MoA 与 codex_app_server 明确绕过 sidecar stamping。

Issue #4889:skill 展开污染 memory query

  • 状态:closed / completed,2026-04-04 关闭。
  • slash skill 会把完整 skill body 展开到 model-facing message。若直接拿它做 memory search / embedding,会把指令脚手架当用户意图。
  • 当前 MemoryManager._strip_skill_scaffolding() 在 provider fan-out 前统一提取真实 user instruction;focused tests 覆盖 prefetch、warmup 和 sync。

Issue #17251:compression 后 memory 降级

  • 状态:closed / not planned,2026-06-10 关闭。
  • 它提供的是历史故障语境,不足以证明当前快照仍存在同一 bug。当前 source 在 compression invalidation 时重载文件记忆,并在 message content 被重写时删除旧 api_content
  • 正文用它说明 compaction 与 memory precedence 的长期张力,不把 closed/not planned 等同于“已修复”。

Issue #17154:第三方架构审计

  • 状态:closed / not planned,2026-07-12 关闭。
  • 这是一份外部审计式 issue,可作为 gateway 体积、restart continuity 和 policy coverage 的问题线索,但不是官方安全结论,也不是本文主要证据。

v0.19.0 Quicksilver

  • 发布时间:2026-07-20。
  • 项目方宣称第一轮 TTFT 约下降 80%,并强调 desktop/TUI 性能、durable delivery ledger、secret manager、smart approvals 等。
  • 本文只把 release 用作版本与产品演进背景;记忆时序判断来自当前源码和测试。

对旧 HTML / v0.1 稿的校准

  1. “会话开始后记忆冻结”只适用于已进入 cached system prompt 的文件快照和 provider 静态块,不适用于全部 memory。
  2. 外部 recall 不是“下一 session 才可见”:当前轮第一条模型请求就能通过 api_content 看见。
  3. 外部 write 也不是“结束后一定落盘”:它是完成轮后的后台 best-effort 工作,关机排空有 5 秒上限。
  4. 官方 user guide 的“injects provider context into the system prompt”是概括性说法;ABC 与当前 source 明确把 static block 和 per-turn prefetched recall 分开。
  5. “prefetch 完全不阻塞”也过强:下一轮 warmup 是后台任务,但当前轮 prefetch_all() 会等待 provider,默认最多 8 秒。
  6. 当前仓库约 219,040 stars(2026-07-23 GitHub API 快照),旧 HTML 的 218.8k 应更新。
  7. 原稿引用的 cbc1054... 已过时,最终稿统一钉到 3f9944...

代表性测试与本轮验证

本轮执行:

uv run --with pytest pytest -q \
  tests/agent/test_api_content_sidecar.py \
  tests/agent/test_memory_async_sync.py \
  tests/run_agent/test_memory_sync_interrupted.py \
  tests/agent/test_memory_skill_scaffolding.py

结果:75 passed in 170.19s

覆盖的关键行为:

  • sidecar 在 SessionDB 中原样 round-trip;
  • 当前轮多次 model call 使用相同 wire bytes;
  • 新 agent / 新进程恢复后,历史消息重放相同 bytes;
  • 慢 provider 不阻塞 turn completion;
  • 单 worker 保证完成轮写回顺序;
  • bounded shutdown 会显式统计被放弃的 write;
  • interrupted turn 不进入外部长期记忆;
  • skill body 不污染 provider query。

局限与不适用边界

  1. 写回不是事务 outbox。 对医疗、财务、合规审计等要求“响应成功即记忆必达”的系统,需要额外 durability 层。
  2. 文件快照存在有意的陈旧窗口。 一次 memory tool write 不会立刻改变当前 system prompt;这是 cache 与一致性的交换。
  3. 动态 recall 的优先级更低。 它位于 user message 旁路,不能替代 system policy。
  4. 第三方 provider 扩大隐私边界。 messages 可能包含 tool call、文件路径和命令输出;provider 必须说明哪些内容离开本机。
  5. 单 provider 简化了所有权,也限制组合。
  6. 核心控制面仍很大。 gateway/run.pyrun_agent.pyhermes_state.py 都是大型多职责文件,读懂一次请求需要跨越较长链路。
  7. 不是每个 API mode 都有同样 sidecar 行为。 codex_app_server 和 MoA 有明确例外。
  8. 缓存稳定不等于缓存命中。 上游 provider 是否缓存、key 如何定义、model 或 tool schema 是否变化,仍由更多条件决定。

同类边界比较

| 方案 | 强项 | 与 Hermes 当前设计的差别 | |---|---|---| | 只用向量库做 top-k RAG | 实现简单、召回灵活 | 常忽略 prompt prefix、写回时机、interrupted turn 和历史 bytes 重放 | | 只把 profile 塞进 system prompt | 优先级高、行为直观 | 每次 profile 变化都可能让前缀失效;不适合高频动态 recall | | OpenClaw workspace memory | 更强调 workspace 文件、Gateway 控制面和长期委托 | Hermes 在 external provider 生命周期与 api_content replay 上更显式 | | 独立 memory SDK(如 Mem0) | 专注抽取、搜索和 storage backend | Hermes 不是替代 memory SDK,而是规定它进入 agent turn 的时序和失败语义 |

代码阅读索引

  1. gateway/run.py:10284-10295 — gateway 主链说明。
  2. gateway/run.py:11747-11769 — pending sentinel。
  3. gateway/run.py:12374-12654 — session、lease、history。
  4. gateway/run.py:20587-20847 — agent cache / construction。
  5. gateway/run.py:21065-21414 — history 恢复与 run_conversation()
  6. agent/agent_init.py:1572-1667 — built-in / external memory 初始化。
  7. tools/memory_tool.py:123-215 — live state 与 frozen snapshot。
  8. agent/system_prompt.py:480-561 — prompt memory 与 rebuild。
  9. agent/memory_provider.py:43-174 — provider 生命周期契约。
  10. agent/memory_manager.py:354-684 — single-select、prefetch、sync。
  11. agent/memory_manager.py:1134-1212 — bounded shutdown。
  12. agent/turn_context.py:49-126api_content composition / replay helpers。
  13. agent/turn_context.py:939-1028 — current-turn memory timing。
  14. agent/conversation_loop.py:973-1065 — API payload 与 stable system prefix。
  15. agent/conversation_loop.py:5267-5317 — tool-call persistence / execution。
  16. run_agent.py:3661-3720 — completed-turn memory commit gate。
  17. agent/turn_finalizer.py:583-608 — per-turn finalization,不等于 session end。
  18. hermes_state.py:1381-1484 — SQLite、WAL、并发。
  19. hermes_state.py:5454-5581 — message + api_content 写入。
  20. hermes_state.py:6161-6216 — insertion-order replay。

主要来源

写作时必须回答的三个问题

  1. 如果一条记忆刚被写下,它什么时候才有资格影响 agent 的高优先级身份判断?
  2. 为了 prompt cache 而保存“当时真正发出的字节”,会不会让 transcript 变成两份并行真相?
  3. 当记忆写回是 best-effort 时,哪些产品可以接受,哪些行业必须再加一层 durable outbox?