前言
Hermes Agent 是 Nous Research 开源的 self-improving AI agent。它不是又一个"对话 + 工具调用"的演示项目,而是一个可以 7×24 运行在 $5 VPS 上、通过 Telegram 和你对话、从工作经验中自动提炼技能、越用越懂你的个人工作代理。
这本书不是使用教程——Hermes 已经有完整的官方文档。这本书要回答的问题是:构建这样一个 self-improving personal agent,在工程上需要解决哪些问题,Hermes 做了什么选择?
全书以 Hermes 的四个核心设计赌注为分析框架:
- Learning Loop — agent 从经验中创建技能、自我改进、主动持久化知识
- CLI-First — 终端是第一等公民,不是 Web UI 的附庸
- Personal Long-Term — 跨会话记忆、用户建模、越用越懂你
- Run Anywhere — 从 $5 VPS 到 GPU 集群,从本地到 Telegram
每一章的分析都会回扣到至少一个设计赌注,帮助读者理解具体的工程决策为什么是这样的。
阅读准备
前置知识
- Python:能读懂 Python 3.11+ 代码,了解 async/await、装饰器、ABC
- LLM 基础:了解 system prompt、tool calling、context window 等基本概念
- CLI/服务端:了解进程管理、SQLite、HTTP API 的基本概念
- 不需要:不需要了解 Hermes 的使用方法,不需要机器学习背景
推荐阅读路径
路径 A:全栈理解(想完整理解系统的开发者)
前言 → 第 2 章 → 第 3 章 → 第 4 章 → 按顺序读完
路径 B:想做类似产品(想构建自己的 agent 的开发者)
第 1 章 → 第 4 章 → 第 6 章 → 第 8 章 → 第 11 章 → 第 14 章
路径 C:稳定性工程(关心"怎么让 agent 7×24 不崩溃"的 SRE/运维)
第 20 章 → 第 21 章 → 第 10 章 → 第 19 章
路径 D:记忆与学习(关心"agent 如何越用越懂你"的研究者)
第 8 章 → 第 10 章 → 第 11 章 → 第 12 章 → 第 15 章
全书知识地图
graph TD
subgraph "第一部分:为什么是 Hermes"
CH01[Ch01 设计赌注]
CH02[Ch02 仓库地图]
end
subgraph "第二部分:一次请求的旅程"
CH03[Ch03 请求旅程]
CH04[Ch04 AIAgent 内核]
CH05[Ch05 提示词系统]
end
subgraph "第三部分:能力层"
CH06[Ch06 工具系统]
CH07[Ch07 工具剖面]
CH08[Ch08 技能系统]
CH09[Ch09 子代理委托]
end
subgraph "第四部分:状态与记忆"
CH10[Ch10 SessionDB]
CH11[Ch11 Memory Provider]
CH12[Ch12 上下文压缩]
end
subgraph "第五部分:多平台"
CH13[Ch13 CLI/TUI]
CH14[Ch14 Gateway]
CH15[Ch15 定时调度]
CH16[Ch16 执行环境]
end
subgraph "第六部分:工程基础"
CH17[Ch17 配置系统]
CH18[Ch18 模型抽象]
CH19[Ch19 并发模型]
CH20[Ch20 进程生命周期]
CH21[Ch21 运行时容错]
CH22[Ch22 测试体系]
end
subgraph "第七部分:收束"
CH23[Ch23 设计哲学]
end
CH02 --> CH03
CH03 --> CH04
CH04 --> CH05
CH04 --> CH06
CH06 --> CH07
CH06 --> CH08
CH04 --> CH09
CH04 --> CH10
CH10 --> CH11
CH11 --> CH12
CH04 --> CH13
CH04 --> CH14
CH14 --> CH15
CH07 --> CH16
CH04 --> CH19
CH14 --> CH20
CH20 --> CH21
CH01 --> CH23
style CH04 fill:#f96,stroke:#333,stroke-width:3px
style CH08 fill:#9cf,stroke:#333,stroke-width:2px
style CH11 fill:#9cf,stroke:#333,stroke-width:2px
style CH20 fill:#fc9,stroke:#333,stroke-width:2px
图中高亮节点:Ch04 AIAgent 内核(橙色)是全书枢纽,几乎所有章节依赖它;Ch08 技能系统和 Ch11 Memory Provider(蓝色)是 Hermes 差异化的核心;Ch20 进程生命周期(黄色)是稳定性的基石。
阅读标记说明
- 源码引用:使用
文件名:行号格式,如run_agent.py:416表示run_agent.py文件第 416 行 - 流程图:使用 Mermaid 格式,可在 mdbook 中直接渲染
- 设计赌注回扣:每章结尾会标注本章回扣了哪个设计赌注
- 跨章引用:使用"详见第 N 章"格式
关于代码版本
本书分析基于 Hermes Agent v0.8.0(2026 年 4 月)。Hermes 是一个活跃开发的项目,源码可能在你阅读时已有变化。每章末尾的"版本演化说明"会标注分析基准版本和已知变化。
致谢
感谢 Nous Research 团队将 Hermes Agent 开源,让我们有机会深入学习一个生产级 AI agent 的工程实践。
不只是另一个 Agent:Hermes 的设计赌注
本章核心源码:
README.md、pyproject.toml、run_agent.py(416+ AIAgent 类定义)
定位:全书导论。本章不讨论代码细节,而是建立分析框架——Hermes 的四个设计赌注。这些赌注贯穿后续 22 章的每一个工程决策。 前置依赖:无。适用场景:初次接触 Hermes,需要理解它与其他 agent 工具的本质差异。
一个奇怪的开源项目
2025 年底,Nous Research 开源了一个 AI agent 项目。它不是用 TypeScript 写的 Web 应用,不是可以拖拽连线的 workflow 编辑器,也不是用 Jupyter Notebook 展示的 demo。它是一个 Python CLI 程序,运行在终端里,通过 pip install 安装,用 SQLite 存数据,用 Markdown 文件存技能。
这个项目叫 Hermes Agent。
乍看之下它像是一个逆潮流的产品:当整个行业在做 Web UI、做 SaaS 平台、做多人协作时,Hermes 选择了终端、单用户、本地存储。但仔细读完它的代码库,9431 行的核心编排器、数十个工具实现、16 个 Gateway 平台类型、8 个可插拔的记忆后端,会让你发现这些"逆潮流"的选择不是偶然的。它们是四个深思熟虑的设计赌注。
本章定义这四个赌注,分析它们与同类工具的差异,并提供一张全书的阅读路线图。
四个设计赌注
所谓"设计赌注",是指那些在项目早期做出的、难以逆转的、影响全局的架构选择。它们不是功能需求——功能可以迭代,架构选择一旦确定,后续所有代码都在它的约束下生长。
Hermes 的四个赌注分别是:
赌注一:Learning Loop — Agent 能从经验中学习
大多数 agent 框架的知识是静态的:开发者写 prompt、注册工具、配置 RAG pipeline,agent 在这个固定的知识框架内运行。用户遇到的问题和 agent 找到的解法不会被保留——下次遇到同样的问题,agent 从头开始。
Hermes 赌的是:agent 应该能从工作经验中提炼过程性知识(技能),在后续工作中检索和使用这些知识,并在使用过程中持续改进它们。
这个赌注的工程含义:
- 技能系统(第 8 章):agent 将解决复杂问题的过程写成 Markdown 文件(技能),存储在当前
HERMES_HOME/skills/目录下(默认 profile 下表现为~/.hermes/skills/)。技能不是文档——它们有 YAML frontmatter 描述平台兼容性、工具依赖和标签,支持条件加载和按需检索 - Memory Nudge(第 4 章):编排器内置计数器(
_skill_nudge_interval = 10),每 10 次工具调用检查一次是否应该创建技能。这不是可选的"记忆功能",而是编排循环的核心组成部分 - Background Review(第 4 章):
_spawn_background_review()在后台线程中启动一个独立的 AIAgent 实例,审查对话历史,决定是否创建或改进技能。这个审查不阻塞当前响应 - 使用时改进:system prompt 中的
SKILLS_GUIDANCE指示模型"发现技能过时或不准确时立即 patch",形成正反馈循环
Learning Loop 不是一个附加功能,而是 Hermes 的核心假设:一个 agent 如果不能从经验中学习,就只是一个更好的 chatbot。
赌注二:CLI-First — 终端是第一等公民
"CLI-First"不是说"只有 CLI"——Hermes 的 Gateway 当前支持 16 个平台类型(其中 15 个是消息/交互平台,另有 1 个 local 调试入口)。CLI-First 的含义是:终端交互体验被当作核心产品来设计,不是 Web UI 的降级替代。
这个选择的证据:
- cli.py 有 8736 行(第 13 章):这不是一个调用
input()的薄壳。它是一个完整的 TUI(Terminal User Interface),基于 prompt_toolkit 构建,支持多行编辑、流式输出、slash command 自动补全、会话历史导航、中断和重定向 - 回调体系(第 4 章):
AIAgent的 11 个回调接口的设计优先服务于 CLI 场景——stream_delta_callback为流式渲染设计,clarify_callback为交互式确认设计,tool_gen_callback为实时显示工具参数设计 - 配置系统(第 17 章):
hermes model、hermes tools、hermes config set都是交互式 CLI 命令,不需要手动编辑 YAML 文件 - 终端后端(第 16 章):六种执行环境(Local、Docker、SSH、Daytona、Singularity、Modal)都通过终端接口暴露,不依赖 Web dashboard
CLI-First 的架构后果是深远的:因为终端是主入口,Hermes 的状态管理基于文件系统和 SQLite(而非云数据库),进程模型基于单进程多线程(而非微服务),部署模型基于 pip install(而非 Docker compose + 数据库 + 缓存)。
赌注三:Personal Long-Term — 个人的、长期的
Hermes 不是一个共享的 AI 助手平台。它是一个属于你个人的 agent,理解你的工作习惯、记住你的偏好、随时间推移建立对你的认知模型。
工程实现:
- 跨会话记忆(第 10、11 章):
SessionDB持久化所有会话历史到 SQLite,MEMORY.md和USER.md存储长期记忆和用户档案。FTS5 全文检索让 agent 可以搜索过去的对话 - 用户建模(第 11 章):Honcho provider 实现 dialectic 用户建模——不只是记住你说过什么,而是推断你的工作模式、偏好和习惯
- 记忆 Nudge(第 4 章):
_memory_nudge_interval = 10,每 10 个 user turn 检查一次是否应该更新记忆。这让记忆积累是自动的,不需要用户主动说"记住这个" - 单用户数据模型(第 10 章):当前
HERMES_HOME/下的所有数据(state.db、skills/、config.yaml)都属于一个用户;默认 profile 下这个目录表现为~/.hermes/。Gateway 场景下通过user_id隔离不同的消息平台用户,但底层仍然是单实例
"Personal"意味着 Hermes 不追求多租户、不追求团队协作、不追求 SaaS。这个限制是有意的——多租户架构会让记忆系统、技能系统和配置系统的复杂度倍增,而这些系统恰好是 Hermes 的核心差异。
赌注四:Run Anywhere — 从 $5 VPS 到 GPU 集群
Hermes 的部署目标不是"运行在开发者的笔记本上",而是"运行在任何你有 SSH 的地方"。这意味着:
- 零外部依赖(第 10 章):数据存储用 SQLite(不需要 PostgreSQL),搜索用 FTS5(不需要 Elasticsearch),调度用 croniter(不需要 Redis)。
pip install后即可运行 - 资源敏感(第 19 章):同步编排器 + 按需 async 桥接的并发模型控制内存占用。IterationBudget 防止 agent 无限循环消耗资源。上下文压缩在 token 使用接近阈值时自动触发
- 多平台投射(第 14 章):同一个 agent 进程可以同时连接 Telegram、Discord、Slack,通过 Gateway 接收消息。你可以在 $5 VPS 上运行
hermes gateway start,然后通过手机上的 Telegram 与 agent 对话 - 六种终端后端(第 16 章):Local、Docker、SSH、Daytona、Singularity、Modal。Daytona 和 Modal 提供 serverless 持久化——环境在空闲时休眠,按需唤醒
- Fallback Provider(第 4 章):主模型不可用时自动切换到备选模型链,不需要人工干预
Run Anywhere 的极端测试是:在一台只有 512MB RAM 的 $5 VPS 上,通过 Telegram 与 Hermes 对话,agent 执行命令、创建技能、压缩上下文、自动管理会话——全部正常工作。
赌注之间的张力与协同
四个赌注不是独立的,它们之间存在张力和协同:
| Learning Loop | CLI-First | Personal | Run Anywhere | |
|---|---|---|---|---|
| Learning Loop | — | 技能文件用 Markdown 存在本地文件系统,CLI 的文件操作优势 | 技能是个人的,不共享 | 技能文件跟随部署,不依赖云服务 |
| CLI-First | CLI 是技能创建和查看的最佳界面 | — | 终端天然是单用户的 | CLI 程序天然可以在任何有终端的地方运行 |
| Personal | 个人使用才能积累有意义的技能和记忆 | — | — | 个人部署不需要多租户基础设施 |
| Run Anywhere | — | — | — | — |
最大的张力在 CLI-First 和 Run Anywhere 之间:如果 agent 运行在远程 VPS 上,用户怎么通过 CLI 交互?Hermes 的回答是 Gateway——它不放弃 CLI 的交互品质,而是通过 BasePlatformAdapter 将相同的编排逻辑投射到消息平台。用户可以在本地用 CLI,也可以在远程用 Telegram,两种方式共享同一个 AIAgent 内核。
与同类工具的设计差异
理解 Hermes 的设计赌注,最好的方式是和同类工具做架构层面的对比。以下不是功能对比表(那种表格到处都是),而是分析每个工具做了什么设计选择,为什么。
Claude Code:厂商绑定的极致优化
Claude Code 是 Anthropic 官方的编码 agent。它的设计选择:
- 单模型绑定:只支持 Claude 系列模型。这让它可以针对 Claude 的特性做深度优化——prompt caching 策略、thinking budget 控制、extended thinking 集成——但代价是完全锁定在一个 provider
- 无持久学习:Claude Code 有 CLAUDE.md 项目上下文文件,但没有 agent 自主创建和改进的技能系统。它的知识在会话结束后不会增长
- 编码专用:工具集聚焦于代码编辑(search、edit、bash),不提供通用的浏览器、文件管理、消息平台集成
- 短会话模型:每次运行是一个独立任务,不追求跨会话记忆和用户建模
Hermes 与 Claude Code 的核心差异不在功能多少,而在时间维度:Claude Code 优化的是单次编码任务的效率,Hermes 优化的是长期协作关系的价值积累。前者像一个顶级外包,后者像一个长期助理。
Cursor:IDE 内的 AI 增强
Cursor 选择了一条完全不同的路:嵌入 IDE。
- 编辑器绑定:Cursor 是 VS Code fork,AI 能力深度集成到编辑器的 UI 和操作模型中。这给了它无与伦比的代码上下文理解(AST、文件树、符号引用),但也意味着它只能在 Cursor 编辑器内运行
- Tab 补全优先:Cursor 的核心体验是 inline completion 和 Chat + Apply,不是自主执行。用户始终在循环中,agent 的自主性有限
- 无多平台:Cursor 不能运行在 Telegram 上,不能作为 systemd 服务持续运行,不能在 VPS 上无人值守
- 共享知识库:Cursor 的 Rules 和 .cursorrules 是项目级配置,不是 agent 从经验中自主积累的
Hermes 与 Cursor 的核心差异在自主性光谱上的位置:Cursor 是"人在循环中的 AI 增强编辑器",Hermes 是"可以自主执行的 AI agent"。两者服务于不同的使用场景。
Aider:纯粹的编码 agent
Aider 是开源社区中最成功的编码 agent 之一。它和 Hermes 的相似之处最多——都是 CLI 程序,都是 Python,都支持多模型。
- 编码专精:Aider 的工具集完全面向代码——diff 编辑、git 集成、代码搜索。它不做浏览器、不做消息平台、不做定时任务
- 无学习闭环:Aider 有
.aider.conf.yml和 conventions file,但没有 agent 自主创建的技能系统。它的"记忆"限于 git history 和 repository map - 单会话模型:Aider 的会话不持久化,没有跨会话搜索和记忆
- 轻量架构:Aider 的代码库比 Hermes 小一个数量级,因为它不需要处理多平台、记忆管理、技能系统等横切关注点
Hermes 与 Aider 的差异在范围:Aider 做好了"CLI 编码 agent"这一件事,Hermes 试图做"通用个人 agent"。Aider 的简洁是优势——更少的代码意味着更少的 bug。Hermes 的广度是赌注——它赌通用 agent 的长期价值高于专用工具。
Open Interpreter:概念先驱
Open Interpreter 是最早的"让 LLM 执行代码"开源项目之一。它证明了一个概念:agent 可以在本地终端执行任意代码。
- 代码执行优先:Open Interpreter 的核心是一个 REPL——让模型写 Python/Shell/JS 并执行。工具系统相对简单
- 轻量级状态:没有 SQLite 持久化,没有跨会话记忆,没有用户建模
- 单平台:只有 CLI 和 Python SDK 两种入口
- 迭代速度放缓:作为概念先驱,Open Interpreter 在后续迭代中将重心转向了 01 Light 硬件和 OS 模式
Hermes 从 Open Interpreter 的方向继续前进——保留了"agent 执行代码"的核心能力,但在此基础上构建了完整的记忆系统、技能系统、多平台投射和生产级稳定性。
对比总结
| 维度 | Hermes | Claude Code | Cursor | Aider |
|---|---|---|---|---|
| 学习闭环 | 技能系统 + background review | 无 | 无 | 无 |
| 记忆持久化 | SQLite + Memory Provider | 会话级 | 工作区级 | 无 |
| 多平台入口 | CLI + 16 消息平台 | CLI | IDE | CLI |
| 模型支持 | 任意 OpenAI/Anthropic 兼容 | 仅 Claude | 多模型(封闭) | 多模型 |
| 自主性 | 高(自主创建技能、后台审查) | 中 | 低(人在循环中) | 中 |
| 部署范围 | $5 VPS ~ GPU 集群 | 本地 | 本地 | 本地 |
这个对比不是说 Hermes 在每个维度都更好——Claude Code 在单次编码任务上可能比 Hermes 更高效,Cursor 在编辑器内的体验更流畅,Aider 的简洁让它更容易维护。差异在于:这些工具优化的是单次交互的效率,Hermes 优化的是长期关系的价值积累。
"Self-Improving"的工程含义
Hermes 自称"self-improving AI agent"。这不是营销用语——它有具体的工程含义。
"Self-improving"在 Hermes 中由三个机制组成:
机制一:技能系统
技能是 agent 从工作经验中提炼的过程性知识。工程实现:
用户提出复杂任务
→ agent 用 5+ 个工具调用完成任务
→ system prompt 中的 SKILLS_GUIDANCE 提示"完成复杂任务后保存为技能"
→ agent 调用 skill_manage(action="create") 创建技能
→ 技能存储为当前 HERMES_HOME/skills/category/skill-name.md(默认 profile 下表现为 ~/.hermes/skills/category/skill-name.md)
→ 下次遇到类似任务时,agent 从索引中找到并检索这个技能
→ 如果发现技能过时,agent 调用 skill_manage(action="patch") 改进
关键点:技能的创建和改进由 agent 自主完成,不需要用户参与。用户感知到的是"agent 第二次做这件事比第一次快"。
机制二:Memory Nudge
编排器内置的定期检查机制:
每 10 个 user turn → 检查是否应该更新 MEMORY.md / USER.md
每 10 个 tool iteration → 检查是否应该创建技能
Nudge 不强制行动——它触发一个后台审查(_spawn_background_review),由一个独立的 AIAgent 实例分析对话历史并决定是否值得保存。这个设计避免了两个极端:不 nudge 则 agent 永远不会主动学习,过度 nudge 则每次对话都浪费 token 在无意义的审查上。
机制三:Background Review
后台审查是 Learning Loop 的最后一环:
# run_agent.py:1718 (简化)
def _spawn_background_review(self, messages_snapshot, ...):
def _run_review():
review_agent = AIAgent(model=self.model, max_iterations=8, quiet_mode=True)
review_agent._memory_store = self._memory_store # 共享记忆
review_agent.run_conversation(prompt, messages_snapshot)
threading.Thread(target=_run_review, daemon=True).start()
三个关键设计选择:
- 独立 agent:review agent 有自己的 IterationBudget(max_iterations=8),不会无限消耗资源
- 共享记忆:review agent 直接写入主 agent 的
_memory_store,不需要 IPC - 后台线程:daemon 线程,不阻塞当前响应,进程退出时自动终止
这三个机制共同构成了一个完整的学习闭环:工作 → nudge 检查 → 后台审查 → 创建/改进技能和记忆 → 下次工作时使用。
阅读路线图
全书 23 章 + 4 个附录,覆盖 Hermes 的完整架构。以下按四种读者画像提供推荐路径:
路径 A:全栈理解
适合:想完整理解 Hermes 系统设计的开发者
前言 → Ch02 仓库地图
→ Ch03 请求旅程 → Ch04 AIAgent 内核 → Ch05 提示词系统
→ Ch06 工具系统 → Ch07 工具剖面 → Ch08 技能系统 → Ch09 子代理
→ Ch10 SessionDB → Ch11 Memory Provider → Ch12 上下文压缩
→ Ch13 CLI/TUI → Ch14 Gateway → Ch15 定时调度 → Ch16 执行环境
→ Ch17 配置系统 → Ch18 模型抽象 → Ch19 并发模型
→ Ch20 进程生命周期 → Ch21 运行时容错 → Ch22 测试体系
→ Ch23 设计哲学
建议节奏:Part 2(Ch03-05)是核心,理解后再读其他章节会顺畅得多。
路径 B:想构建类似系统
适合:想构建自己的 AI agent 的开发者,关心"怎么设计"而非"怎么用"
Ch01 设计赌注(本章)
→ Ch04 AIAgent 内核(编排循环设计)
→ Ch06 工具系统(自注册 + 按需暴露)
→ Ch08 技能系统(learning loop 实现)
→ Ch11 Memory Provider(可插拔记忆后端)
→ Ch14 Gateway(多平台投射)
→ Ch19 并发模型(async/sync 桥接)
→ Ch23 设计哲学
这条路径聚焦设计决策和 trade-off,跳过了具体的工具实现细节。
路径 C:稳定性工程
适合:关心"怎么让 agent 7x24 不崩溃"的 SRE、运维或后端开发者
Ch20 进程生命周期(信号处理、优雅关停)
→ Ch21 运行时容错(SafeWriter、重试、断线重连)
→ Ch10 SessionDB(SQLite WAL、jitter retry)
→ Ch19 并发模型(线程隔离、IterationBudget)
→ Ch14 Gateway(多平台连接管理)
→ Ch12 上下文压缩(长对话 token 控制)
→ Ch22 测试体系(400+ 个测试如何保障稳定)
这四章构成 Hermes 的"四层稳定性防御":进程级 → 运行时级 → 数据级 → 架构级。
路径 D:记忆与学习
适合:关心"agent 如何越用越懂你"的 AI 研究者或产品经理
Ch08 技能系统(过程性知识的创建和检索)
→ Ch10 SessionDB(会话持久化和跨会话搜索)
→ Ch11 Memory Provider(8 种记忆后端对比)
→ Ch12 上下文压缩(长对话中如何保留重要信息)
→ Ch05 提示词系统(记忆如何注入 system prompt)
→ Ch04 AIAgent 内核(nudge 和 background review)
→ Ch15 定时调度(自动化记忆整理)
这条路径追踪的是 Hermes 的"Personal Long-Term"赌注如何在工程上实现。
一个赌注的代价
每个赌注都有代价。本书不回避它们:
- Learning Loop 的代价:技能系统增加了 system prompt 的 token 开销(索引部分)和后台 API 调用成本(background review)。低质量的技能可能误导 agent
- CLI-First 的代价:
cli.py的 8736 行代码维护成本高。终端的渲染限制(无法显示图片、表格有宽度限制)需要额外处理 - Personal 的代价:单用户假设让 Hermes 不适合作为团队共享的 AI 平台。数据隔离依赖文件系统权限
- Run Anywhere 的代价:SQLite 的并发写入限制需要 application-level 的 jitter retry。六种终端后端各有不同的配置和调试复杂度
这些代价在后续章节的"设计启示"部分会详细讨论。好的工程分析不回避 trade-off。
开始旅程
从下一章开始,我们将进入 Hermes 的代码。第 2 章提供一张仓库地图——五层架构的全景视图——让你知道后续每一章分析的代码在系统中的位置。
如果你已经读过第 2 章及后续内容(这是本书最后写完的一章),欢迎回到这里重新审视四个赌注。在读完全书后回看这些设计选择,你会有不同的理解。
设计赌注回扣:本章定义了全部四个设计赌注:Learning Loop(技能系统 + nudge + background review)、CLI-First(终端作为第一等公民)、Personal Long-Term(跨会话记忆 + 用户建模)、Run Anywhere(零外部依赖 + 多平台投射)。后续每一章都将回扣到至少一个赌注。
版本演化说明
本章分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 四个设计赌注自项目创建以来保持稳定。具体的工程实现(如技能系统的条件加载、Gateway 的平台数量)随版本迭代持续扩展,但核心的架构假设未变。
仓库地图:五层架构速览
本章核心源码:
hermes_cli/main.py、run_agent.py、model_tools.py、hermes_state.py、gateway/run.py
定位:本章建立源码空间感,将 hermes-agent 仓库按五层划分,标注每层关键文件与职责。 前置依赖:无。适用场景:初次接触 Hermes 源码,需要快速建立全局地图。
为什么需要一张地图
Hermes Agent 的代码库不小:十余个顶层 Python 文件、二十多个 agent 核心模块、数十个工具实现(含子目录)、17 个 Gateway 平台类型、数十个 CLI 模块、400+ 个测试文件。如果按目录树从上到下浏览,你会迷失在细节里。
但这些代码不是杂乱堆砌的。它们按照一个清晰的分层模型组织:入口层、编排层、能力层、状态层、平台层。每一层有明确的职责边界,层与层之间的依赖方向是单向的——上层依赖下层,下层不感知上层的存在。
理解这张地图,后续每一章你都知道自己在哪个位置。
五层架构总览
graph TB
subgraph "入口层 — 用户如何触达 Agent"
CLI["hermes_cli/main.py<br/>CLI 命令入口"]
TUI["cli.py (8736行)<br/>交互式 TUI"]
GW["gateway/run.py<br/>消息平台网关"]
BATCH["batch_runner.py<br/>批量轨迹生成"]
CRON["cron/scheduler.py<br/>定时调度"]
end
subgraph "编排层 — Agent 的大脑"
AGENT["run_agent.py (9431行)<br/>AIAgent 核心编排器"]
TOOLS["model_tools.py<br/>工具发现与调度"]
AGENT_MOD["agent/ (25个模块)<br/>编排支撑"]
end
subgraph "能力层 — Agent 能做什么"
REGISTRY["tools/registry.py<br/>工具注册表"]
TOOL_IMPL["tools/ (69个 .py, 含子目录)<br/>工具实现"]
SKILLS["skills/ (26类)<br/>过程性知识"]
MCP["tools/mcp_tool.py<br/>MCP 外部能力"]
PLUGINS["plugins/<br/>插件系统"]
end
subgraph "状态层 — Agent 记住什么"
STATE["hermes_state.py<br/>SessionDB (SQLite)"]
MEM_MGR["agent/memory_manager.py<br/>记忆编排"]
MEM_PROV["agent/memory_provider.py<br/>记忆抽象"]
COMPRESS["agent/context_compressor.py<br/>上下文压缩"]
end
subgraph "平台层 — Agent 如何到达用户"
PLATFORMS["gateway/platforms/ (17个)<br/>消息平台适配"]
SESSION["gateway/session.py<br/>会话管理"]
ACP["acp_adapter/<br/>Agent Client Protocol"]
end
CLI --> AGENT
TUI --> AGENT
GW --> AGENT
BATCH --> AGENT
CRON --> AGENT
AGENT --> TOOLS
AGENT --> AGENT_MOD
TOOLS --> REGISTRY
REGISTRY --> TOOL_IMPL
AGENT --> MEM_MGR
AGENT --> COMPRESS
MEM_MGR --> MEM_PROV
MEM_MGR --> STATE
GW --> PLATFORMS
GW --> SESSION
style AGENT fill:#f96,stroke:#333,stroke-width:3px
图中高亮的
run_agent.py(9431 行)是整个系统的心脏,几乎所有入口最终都调用到它。
设计选择:为什么是五层而不是其他分法
在分析 Hermes 的代码组织之前,值得先说明这个五层模型的来源和选择理由。
Hermes 没有在代码中显式声明分层——不存在一个 layers.py 或分层配置文件。五层划分是从代码的依赖方向和职责边界中自然浮现的:
- 为什么不是三层?(表现层/业务层/数据层)因为 Hermes 的"表现层"实际上包含了两个截然不同的关注点:CLI 交互和消息平台适配。它们的代码量、复杂度和抽象模式完全不同,硬塞进一层会掩盖结构。
- 为什么不是洋葱架构? Hermes 不是一个 Web 服务——它没有 HTTP 请求/响应的生命周期。它的核心循环是
模型调用 → 工具执行 → 模型调用,这个循环不适合用 middleware/handler 链来描述。 - 为什么要区分编排层和能力层? 因为编排层(
run_agent.py)控制的是"什么时候做什么",能力层(tools/)控制的是"具体怎么做"。两者的变化频率不同:添加一个新工具不需要改动编排逻辑,改变对话策略不需要改动工具实现。
这个分层模型最大的价值是让你快速判断"某个问题属于哪一层"——定位到层之后,搜索范围立即缩小到几个文件。
入口层:用户如何触达 Agent
入口层回答一个问题:用户的消息从哪里进入系统?
Hermes 有五个入口,它们共享同一个编排层,但各自处理不同的交互模式:
| 入口 | 文件 | 行数 | 职责 |
|---|---|---|---|
| CLI 命令 | hermes_cli/main.py | — | hermes model、hermes tools、hermes config 等子命令入口 |
| 交互式 TUI | cli.py | 8736 | prompt_toolkit 驱动的终端交互界面,支持多行编辑、流式输出、slash command |
| 消息网关 | gateway/run.py | — | 同时连接 Telegram、Discord、Slack 等平台,接收消息并路由到 Agent |
| 批量运行 | batch_runner.py | — | 批量生成对话轨迹,用于 RL 训练和模型评估 |
| 定时调度 | cron/scheduler.py | — | croniter 驱动的定时任务,支持投递到消息平台 |
关键设计:这五个入口不是五个不同的 agent,而是同一个 AIAgent 的五种触发方式。它们通过不同的回调(callback)适配各自的 IO 模式,但核心编排逻辑完全共享。这就是为什么 cli.py 和 gateway/run.py 各有近万行代码——它们不是薄壳,而是各自领域的完整交互层。
详见第 13 章(CLI)、第 14 章(Gateway)、第 15 章(Cron)。
编排层:Agent 的大脑
编排层包含两个核心文件和一个支撑模块目录,它们构成整个系统的重心。
run_agent.py — AIAgent 核心编排器
run_agent.py 是全项目最大的单文件(9431 行),包含 AIAgent 类(定义在第 416 行)。它负责:
- 初始化决策链(config → memory → prompt → model client → session)
run_conversation()大循环:构建消息 → 调用模型 → 解析工具调用 → 执行工具 → 循环- Iteration budget 管理(防止 agent 无限循环)
- Fallback provider 切换(主模型不可用时自动降级)
- 11 个回调接口(
tool_progress_callback、stream_delta_callback、clarify_callback等,让同一个内核适配 CLI、Gateway、Batch 三种场景) - Token 使用量追踪与成本估算
详见第 4 章。
model_tools.py — 工具发现与调度
model_tools.py 是 agent 与工具系统之间的桥梁。它的核心职责:
_discover_tools():在启动时导入所有工具模块,触发自注册(model_tools.py:132)get_tool_definitions():根据 enabled/disabled toolset 过滤工具 schema(model_tools.py:234)handle_function_call():接收模型返回的工具调用,路由到正确的 handler(model_tools.py:459)_run_async():async→sync 桥接,让异步工具在同步编排器中运行(model_tools.py:81)
详见第 6 章。
agent/ — 编排支撑模块
agent/ 目录是编排层的"工具箱"——25 个模块提供 prompt 构建、记忆管理、模型适配、重试策略等横切关注点。这些模块本身不驱动对话循环,而是被 AIAgent 在循环的不同阶段调用。
agent/
├── prompt_builder.py # System prompt 多块拼装
├── prompt_caching.py # Prompt cache 优化
├── memory_manager.py # 记忆编排器
├── memory_provider.py # MemoryProvider ABC (17 个方法)
├── builtin_memory_provider.py # 内置记忆(MEMORY.md + USER.md)
├── context_compressor.py # 上下文压缩
├── context_engine.py # 可插拔上下文引擎 ABC
├── model_metadata.py # 模型能力探测与缓存
├── usage_pricing.py # Token 成本追踪
├── retry_utils.py # 重试与退避策略
├── skill_utils.py # 技能发现与解析
├── smart_model_routing.py # 智能模型路由
├── anthropic_adapter.py # Anthropic API 适配
├── credential_pool.py # API key 池管理
├── context_references.py # 上下文引用追踪
├── redact.py # 敏感信息脱敏
└── ... (共 25 个文件)
之所以把 agent/ 放在编排层而非状态层,是因为它的模块绝大多数服务于编排逻辑——prompt 构建、模型路由、重试策略都是"如何驱动对话"的一部分。其中 memory_manager.py、memory_provider.py、context_compressor.py 虽然处理状态,但它们的调用者是编排层的 AIAgent,从依赖方向看属于编排层的组成部分。
各模块的深入分析分散在第 4-12 章和第 18-19 章。
能力层:Agent 能做什么
能力层是 Hermes 最"宽"的一层,包含三个子系统:工具、技能、插件。
工具系统 — tools/
tools/
├── registry.py # ToolRegistry 单例 + ToolEntry 元数据
├── terminal_tool.py # 命令执行(6 种后端)
├── file_tools.py # 文件读写
├── browser_tool.py # 浏览器自动化
├── delegate_tool.py # 子代理委托
├── mcp_tool.py # MCP 外部能力接入
├── skills_tool.py # 技能检索与管理
├── code_execution_tool.py # Python/JS 代码执行
├── session_search_tool.py # 跨会话搜索
├── process_registry.py # 后台进程追踪
├── browser_providers/ # 浏览器后端(Browserbase, Firecrawl 等)
└── ... (共数十个 .py 文件,含子目录)
工具系统的核心抽象是 ToolRegistry(tools/registry.py:48)和 ToolEntry(tools/registry.py:24)。每个工具在模块加载时调用 registry.register() 自注册,但对仓库内置工具来说,仍然需要把模块接入 _discover_tools() 并纳入 toolsets.py,这样它才会真正暴露给模型。
数十个工具相关文件被分组为若干 toolset(如 terminal、web、memory),通过 toolsets.py 管理可用性。用户可以按 toolset 粒度启用/禁用工具。
详见第 6、7 章。
技能系统 — skills/
skills/
├── apple/ # macOS 自动化
├── autonomous-ai-agents/ # 自主代理模式
├── creative/ # 创意写作
├── data-science/ # 数据分析
├── devops/ # 运维自动化
├── diagramming/ # 图表生成
├── domain/ # 领域知识
└── ... (共 26 个类别)
技能是 Hermes "self-improving" 理念的核心。它们不是静态文档,而是 Markdown + YAML frontmatter 格式的过程性知识,agent 可以在工作中自主创建和改进。技能的发现、索引和条件加载由 agent/skill_utils.py 处理。
详见第 8 章。
插件系统 — plugins/
plugins/
└── memory/
├── honcho/ # Plastic Labs 用户建模
├── hindsight/ # 时序滑动窗口记忆
├── mem0/ # 向量数据库后端
├── holographic/ # 压缩全息存储
├── openviking/ # 语义嵌入记忆
├── retaindb/ # 保留策略记忆
├── supermemory/ # 多源聚合记忆
└── byterover/ # 替代向量存储
当前代码库里最成熟的插件生态确实集中在 memory provider,提供 8 个可选的外部记忆后端。每个 provider 实现 MemoryProvider ABC(agent/memory_provider.py,定义了 17 个方法);在运行时,外部 provider 由 MemoryManager 编排,而内置记忆仍主要走 MemoryStore 的直接接线。
详见第 11 章。
状态层:Agent 记住什么
状态层的核心是 hermes_state.py,它实现了基于 SQLite 的会话持久化。
| 组件 | 文件 | 职责 |
|---|---|---|
| SessionDB | hermes_state.py (1304 行) | SQLite 持久化:sessions 表 + messages 表 + messages_fts 虚拟表(FTS5 全文检索) |
| WAL 并发安全 | hermes_state.py:164-214 | BEGIN IMMEDIATE 事务 + 15 次 jitter retry,支持 Gateway 多平台并发写入 |
| FTS5 检索 | hermes_state.py | 跨会话全文搜索,支撑 session_search 工具和 LLM 摘要回忆 |
SessionDB 类(hermes_state.py:115)是一个精简的 SQLite 封装,不使用 ORM。这个选择是有意的:Hermes 需要在 $5 VPS 上运行,SQLite 的零配置和单文件部署比 PostgreSQL 更适合这个场景。WAL 模式让 Gateway 的多个平台可以并发读取,而写入通过 application-level jitter retry 解决锁竞争。
详见第 10 章。
平台层:Agent 如何到达用户
gateway/platforms/ — 17 个平台类型
gateway/platforms/
├── base.py # BasePlatformAdapter ABC
├── telegram.py # Telegram(webhook + polling)
├── telegram_network.py # Telegram 网络传输层(telegram.py 的辅助模块)
├── discord.py # Discord(多 guild、线程、反应)
├── slack.py # Slack(多 workspace、线程)
├── whatsapp.py # WhatsApp(Twilio 桥接)
├── signal.py # Signal(signal-cli 桥接)
├── matrix.py # Matrix(E2E、spaces)
├── email.py # Email(IMAP/SMTP)
├── feishu.py # 飞书
├── wecom.py # 企业微信
├── weixin.py # 个人微信
├── dingtalk.py # 钉钉
├── api_server.py # REST API 端点
├── homeassistant.py # Home Assistant IoT
├── mattermost.py # Mattermost
├── sms.py # SMS(Twilio)
└── webhook.py # 自定义 Webhook
所有平台适配器继承 BasePlatformAdapter ABC(gateway/platforms/base.py:470),实现 connect()、disconnect()、send() 等核心方法。Gateway 进程可以同时连接多个平台,每个平台的会话通过 SessionSource / SessionContext(gateway/session.py)独立管理。
详见第 14 章。
acp_adapter/ — Agent Client Protocol
acp_adapter/
├── entry.py # 入口模块
├── server.py # ACP HTTP 服务器
├── session.py # ACP 会话管理
├── tools.py # ACP 工具暴露
├── auth.py # 认证
├── permissions.py # 权限控制
└── events.py # SSE 事件流
ACP Adapter 让外部系统通过 Agent Client Protocol 标准接口与 Hermes 交互。目前已实现核心的 HTTP 服务、会话管理、SSE 事件流、认证和权限模块。
详见第 23 章。
源码走读:从入口到编排的调用链
光看目录结构还是抽象的。下面用一个最简单的例子——用户在 CLI 中发送一条消息——追踪代码如何从入口层流入编排层。
第一步:CLI 入口
用户运行 hermes 命令,hermes_cli/main.py 的 main() 函数被调用。对于交互式会话,它转发到 cli.py 的 run_cli() 函数:
# cli.py 中的核心调用(简化)
agent = AIAgent(
config=config,
session_db=session_db,
# ... 回调适配
stream_delta_callback=on_stream_delta,
clarify_callback=on_clarify,
)
response = agent.run_conversation(user_message)
关键观察:cli.py 构造 AIAgent 时注入了 TUI 专用的回调函数(如 stream_delta_callback 用于流式渲染、clarify_callback 用于交互式确认)。这些回调是入口层与编排层之间的适配接口。
第二步:Gateway 入口(对比)
Gateway 走的是另一条路径,但最终也构造同一个 AIAgent:
# gateway/run.py 中的核心调用(简化)
agent = AIAgent(
config=config,
session_db=session_db,
# ... 不同的回调适配
stream_delta_callback=on_platform_stream,
status_callback=on_platform_status,
)
response = agent.run_conversation(platform_message)
相同的 AIAgent,不同的回调——这就是"共享编排层 + 可插拔入口"的工程实现。第 3 章会完整追踪这条调用链从 run_conversation() 到模型调用再到工具执行的全过程。
第三步:编排层分发
进入 AIAgent.run_conversation() 后,编排层通过 model_tools.py 将工具调用分发到能力层:
# model_tools.py:459(简化)
def handle_function_call(name, arguments, ...):
entry = registry.get(name) # 从 ToolRegistry 查找
if entry.is_async:
return _run_async(entry.handler(**arguments))
return entry.handler(**arguments)
registry.get(name) 查找的是能力层的 ToolRegistry,entry.handler 指向具体工具的实现函数。编排层不知道工具的具体逻辑,只负责调用和收集结果。
第 3 章将完整展开这条调用链。
测试目录:架构的另一面镜子
tests/
├── run_agent/ → 编排层(AIAgent 大循环)
├── tools/ → 能力层(工具系统)
├── skills/ → 能力层(技能系统)
├── agent/ → 编排支撑(agent/ 模块)
├── gateway/ → 平台层(Gateway + 平台适配)
├── hermes_cli/ → 入口层(CLI 命令)
├── cli/ → 入口层(TUI 交互)
├── cron/ → 入口层(定时调度)
├── acp/ → 平台层(ACP 适配)
├── plugins/ → 能力层(插件系统)
├── honcho_plugin/ → 能力层(Honcho 记忆插件)
├── environments/ → 研究(RL 环境)
├── e2e/ → 端到端集成测试
├── integration/ → 集成测试
└── fakes/ → 测试用 mock 对象
测试目录的结构几乎是生产代码的镜像。如果你想了解某个子系统的行为,往往从对应的测试目录入手比直接读源码更高效——测试用例展示了"这个模块应该怎么被使用"。
400+ 个测试文件共享一个关键基础设施:tests/conftest.py 中的 _isolate_hermes_home fixture(标记为 autouse=True)。它在每个测试运行前将 HERMES_HOME 重定向到临时目录,确保测试之间完全隔离——不会读到用户的真实配置,也不会写入用户的 ~/.hermes。这个设计让 400+ 个测试可以安全地并行运行。
详见第 22 章。
支撑文件:不在五层之内但不可忽略
| 文件 | 职责 |
|---|---|
toolsets.py | 工具分组定义,控制 toolset 可用性 |
toolset_distributions.py | Toolset 分发配置 |
hermes_constants.py | 全局常量 |
hermes_logging.py | 日志配置 |
hermes_time.py | 时间工具函数 |
utils.py | 通用工具函数 |
mcp_serve.py | MCP 服务器模式(Hermes 作为 MCP server 暴露能力) |
mini_swe_runner.py | 小型 SWE benchmark 运行器 |
rl_cli.py | RL 训练 CLI |
trajectory_compressor.py | 轨迹压缩(训练数据处理) |
设计启示
Hermes 的五层分层服务于一个核心目标:让同一个 agent 内核能同时服务于五种不同的触发方式。这个设计带来三个直接收益:
- Bug fix 自动传播:编排层的修复(如 retry 逻辑改进)自动惠及 CLI、Gateway、Cron 所有入口
- 平台扩展解耦:新增消息平台只需实现
BasePlatformAdapter,不需要改动编排层或能力层 - 测试分层独立:每一层可以独立测试,不依赖其他层的真实实现
第 3 章将从编排层的 run_conversation() 入手,展开一次完整请求在系统中的旅程。
设计赌注回扣:本章呈现的五层架构直接服务于 Run Anywhere 赌注——正因为编排层与平台层彻底分离,Hermes 才能同时跑在终端、Telegram、Discord 和定时任务中。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 目录结构和文件数量可能随版本迭代变化,但五层架构模型和层间依赖方向在可预见的未来不会改变。
从用户输入到最终响应
本章核心源码:
cli.py、gateway/run.py、run_agent.py、model_tools.py
定位:本章用一次完整请求串起系统全貌,追踪消息如何从用户输入流经 CLI/Gateway → AIAgent → 模型 → 工具 → 响应。 前置依赖:第 2 章(仓库地图)。适用场景:想理解"一条消息在系统里到底经历了什么"。
为什么需要追踪一次请求
第 2 章给出了静态的仓库地图——五层架构和关键文件。但光看地图不够,你还需要看到消息如何流动。
一次看似简单的对话——用户说"帮我搜索一下最近的 Python CVE",agent 调用 web_search 工具,返回结果——实际上穿越了入口层、编排层、能力层、状态层四层代码,经历了 system prompt 拼装、plugin hook、memory prefetch、模型 API 调用、工具调度、结果回注、session 持久化等十余个步骤。
本章用一次完整请求作为线索,把这些步骤串起来。后续章节会深入每个环节,但这一章让你先看到全貌。
完整流程图
sequenceDiagram
participant U as 用户
participant E as 入口层<br/>(CLI/Gateway)
participant A as AIAgent<br/>(run_agent.py)
participant M as 模型 API
participant T as 工具系统<br/>(model_tools.py)
participant S as 状态层<br/>(SessionDB)
U->>E: 输入消息
E->>A: 构造 AIAgent + 注入回调(详见第 2 章)
A->>A: run_conversation()
A->>A: 安全防护 + 输入清洗
A->>A: 构建 system prompt(首次)
A->>A: 预飞行压缩(如需)
A->>A: Plugin pre_llm_call hook
A->>A: Memory prefetch
A->>A: 组装 api_messages
A->>M: API 调用(streaming)
M-->>A: 响应 + tool_calls
alt 包含 tool_calls
A->>T: _execute_tool_calls()
T->>T: 并行/串行执行工具
T-->>A: 工具结果
A->>A: 结果回注 messages
A->>S: 保存 session log
A->>M: 下一轮 API 调用
Note over A,M: 循环直到无 tool_calls<br/>或 budget 耗尽
end
M-->>A: 最终文本响应
A->>A: Plugin post_llm_call hook
A->>S: 持久化完整会话
A->>A: Memory sync_all()
A->>A: on_session_end hook
A-->>E: 返回结果
E-->>U: 展示响应
第一步:消息进入入口层
第 2 章已经展示了 CLI 和 Gateway 如何构造 AIAgent 并注入各自的回调(详见第 2 章"源码走读"节)。这里不再重复构造代码,直接关注消息进入 run_conversation() 之后发生了什么。
核心要点回顾:五个入口(CLI、Gateway、Batch、Cron、ACP)都构造同一个 AIAgent,通过 11 个回调(stream_delta_callback、clarify_callback、tool_start_callback 等)适配各自的 IO 模式。编排逻辑完全共享。
第二步:run_conversation() 启动
消息进入 AIAgent.run_conversation()(run_agent.py:6800)后,编排器在进入主循环前执行一系列准备工作。这些准备工作不是可选的"nice-to-have"——每一步都防止了一类生产环境的崩溃或性能问题。
2.1 安全防护与输入清洗
# run_agent.py:6830
_install_safe_stdio() # 包装 stdout/stderr 防 broken pipe
# run_agent.py:6840-6841
if isinstance(user_message, str):
user_message = _sanitize_surrogates(user_message)
第一行安装 _SafeWriter,防止 systemd/Docker 场景下 stdout 写入失败导致崩溃(详见第 21 章)。第二行清除剪贴板粘贴可能带入的无效 surrogate 字符——Google Docs 等富文本编辑器粘贴的内容可能包含无效 UTF-8,会导致 JSON 序列化崩溃。
此外,编排器还会执行 fallback provider 恢复(_restore_primary_runtime(),如果上一轮触发了降级则恢复主模型)和死连接清理(_cleanup_dead_connections(),检测并清除 provider 故障遗留的僵尸 socket)。
2.2 System Prompt 构建(首次)
# run_agent.py:6952-6968
if self._cached_system_prompt is None:
stored_prompt = None
if conversation_history and self._session_db:
session_row = self._session_db.get_session(self.session_id)
if session_row:
stored_prompt = session_row.get("system_prompt")
if stored_prompt:
self._cached_system_prompt = stored_prompt # 续会话:复用缓存
else:
self._cached_system_prompt = self._build_system_prompt(system_message) # 新会话:构建
System prompt 只在新会话的首次调用时构建,后续调用复用缓存。这不是简单的性能优化——Anthropic 的 prompt caching 要求 system prompt 逐字节一致才能命中缓存。如果每次调用都重新构建(比如 memory 变化导致内容不同),缓存命中率会大幅下降。这个"prompt cache 敏感"的设计贯穿整个请求流程——后续的 memory context 和 plugin context 都注入 user message 而非 system prompt,同样是为了保护缓存。
详见第 5 章(提示词系统)。
2.3 预飞行压缩
# run_agent.py:7000-7049(简化,实际支持最多 3 轮多 pass 压缩)
if self.compression_enabled:
_preflight_tokens = estimate_request_tokens_rough(
messages, system_prompt=active_system_prompt, tools=self.tools
)
if _preflight_tokens >= self.context_compressor.threshold_tokens:
for _pass in range(3): # 多 pass:处理超大历史 + 小上下文窗口
messages, active_system_prompt = self._compress_context(messages, ...)
if _preflight_tokens < self.context_compressor.threshold_tokens:
break
在进入主循环前,编排器估算当前消息的 token 数(包括工具 schema 占用的 20-30K+ token)。如果已经接近模型的上下文窗口限制——比如用户切换到了一个窗口更小的模型——立即执行压缩。支持最多 3 轮多 pass 压缩,处理超大历史记录的极端情况。
详见第 12 章(上下文压缩)。
2.4 Plugin Hook
# run_agent.py:7063-7082
_plugin_user_context = ""
_pre_results = _invoke_hook("pre_llm_call", session_id=..., user_message=..., ...)
# 插件返回的上下文拼接到 _plugin_user_context
插件的 pre_llm_call 钩子在主循环前触发。插件可以返回额外的上下文信息,这些信息会被注入到 user message(不是 system prompt),保护 prompt cache。
2.5 Memory Prefetch
# run_agent.py:7098-7108
if self._memory_manager:
_ext_prefetch_cache = self._memory_manager.prefetch_all(original_user_message) or ""
如果配置了外部 memory provider(如 Honcho 或 Mem0),在进入主循环前预取一次相关记忆。预取结果被缓存,在整个主循环中复用——不会每次工具调用都重新预取(10 次工具调用 = 10 倍延迟 + 成本)。
详见第 11 章(Memory Provider)。
第三步:主循环——模型调用与工具执行
准备工作完成后,进入核心循环(run_agent.py:7111):
# run_agent.py:7111(伪代码,实际逻辑内联在 while 循环中)
while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:
# 1. 检查中断请求
if self._interrupt_requested:
break
# 2. 消耗 iteration budget
api_call_count += 1
if not self.iteration_budget.consume():
break # 预算耗尽
# 3. 组装 api_messages(内联,非独立方法)
# - 复制 messages,在 user message 中注入 memory + plugin context
# - 前置 system prompt
# - 应用 prompt caching
# - 清理孤立的 tool results
# 4. 调用模型 API(优先 streaming)
response = self._interruptible_streaming_api_call(api_messages, ...)
# 5. 解析响应
if assistant_message.tool_calls:
self._execute_tool_calls(assistant_message, messages, ...)
continue # 继续循环
else:
final_response = assistant_message.content # 最终响应
break
退出条件
主循环有以下退出路径:
| 退出条件 | 触发场景 | 类型 |
|---|---|---|
| 无 tool_calls | 模型给出了最终文本响应 | 正常退出(最常见) |
| 中断请求 | 用户发送了新消息或按了 Ctrl+C | 正常退出 |
| Budget 耗尽 | iteration_budget 用完(默认 90 次) | 正常退出 |
| max_iterations | 达到最大 API 调用次数 | 正常退出 |
| context_length_exceeded | 压缩后仍超限 | 错误退出 |
| 不可重试的 4xx 错误 | 认证失败、模型不存在等 | 错误退出 |
| 所有重试耗尽 | 3 次重试全部失败 | 错误退出 |
3.1 消息组装
每次 API 调用前,编排器将内部 messages 列表转换为 api_messages(run_agent.py:7168-7249)。关键操作:
# run_agent.py:7168-7249(简化)
api_messages = []
for idx, msg in enumerate(messages):
api_msg = msg.copy()
# 在当前 turn 的 user message 中注入 memory context + plugin context
if idx == current_turn_user_idx and msg.get("role") == "user":
if _ext_prefetch_cache:
api_msg["content"] += build_memory_context_block(_ext_prefetch_cache)
if _plugin_user_context:
api_msg["content"] += "\n\n" + _plugin_user_context
api_messages.append(api_msg)
# 前置 system prompt
api_messages = [{"role": "system", "content": effective_system}] + api_messages
# 应用 prompt caching + 清理孤立 tool results
api_messages = apply_anthropic_cache_control(api_messages, ...)
api_messages = self._sanitize_api_messages(api_messages)
重要设计:原始 messages 列表不被修改——memory context 和 plugin context 的注入只存在于 api_messages 副本中,不会持久化到 session DB。这保证了下一轮对话加载历史时不会看到上一轮的注入内容。
3.2 模型 API 调用
编排器优先使用 streaming 路径(_interruptible_streaming_api_call,run_agent.py:7358),即使没有 stream consumer。原因不是为了流式渲染,而是为了健康检测:streaming 模式提供 90 秒 stale-stream 检测和 60 秒 read timeout,防止 provider 保持连接但不返回响应时无限挂起。非 streaming 的 _interruptible_api_call 仅作为 fallback。
API 调用失败时,有最多 3 次重试(run_agent.py:7285+),包含:
- 指数退避
- 429 限流处理
context_length_exceeded时自动压缩并重试- 主模型不可用时切换到 fallback provider
详见第 21 章(运行时防御与容错)。
3.3 工具执行
当模型响应包含 tool_calls 时,编排器通过 _execute_tool_calls()(run_agent.py:5930)执行工具。它首先判断是否可以并行执行:
# run_agent.py:5930(简化)
def _execute_tool_calls(self, assistant_message, messages, ...):
tool_calls = assistant_message.tool_calls
if not _should_parallelize_tool_batch(tool_calls):
return self._execute_tool_calls_sequential(...)
return self._execute_tool_calls_concurrent(...)
如果一批工具调用都是只读的(如 read_file、web_search),或者虽然有写操作但目标路径不重叠,就使用 ThreadPoolExecutor 并行执行。否则串行执行。
每个工具的实际调用通过 _invoke_tool()(run_agent.py:5953)分发。工具被分为四类路由:
# run_agent.py:5953(简化,展示四类路由)
def _invoke_tool(self, function_name, function_args, ...):
if function_name in ("todo", "session_search", "memory", "clarify"):
return self._handle_builtin_tool(...) # 1. Agent 内置工具
elif self._memory_manager and self._memory_manager.has_tool(function_name):
return self._memory_manager.handle_tool_call(...) # 2. 外部 Memory Provider 工具
elif function_name == "delegate_task":
return delegate_task(...) # 3. 子代理委托
else:
return handle_function_call(...) # 4. ToolRegistry 分发
| 路由类别 | 示例工具 | 原因 |
|---|---|---|
| Agent 内置 | memory、todo、session_search、clarify | 需要访问 agent 内部状态(memory store、todo store、session DB、clarify callback) |
| Memory Provider 工具 | Honcho/Mem0 自定义工具 | 外部 memory provider 动态注册的工具 |
| 子代理委托 | delegate_task | 创建新 AIAgent 实例,分配独立 iteration budget |
| ToolRegistry | 其余所有工具 | 通过 model_tools.py 的 handle_function_call() 分发 |
详见第 6 章(工具系统)、第 9 章(子代理委托)、第 11 章(Memory Provider)。
第四步:循环结束与响应返回
当模型返回不包含 tool_calls 的响应时,主循环退出(run_agent.py:8876-8878)。退出循环后,编排器执行收尾工作(run_agent.py:9080+):
- Trajectory 保存:如果启用了轨迹记录(batch/RL 训练场景),保存完整对话轨迹
- Task 资源清理:
_cleanup_task_resources()清理本轮对话使用的临时资源 - Plugin hook:触发
post_llm_call钩子 - Session 持久化:将完整的 messages 列表写入 SQLite SessionDB
- Memory sync:
self._memory_manager.sync_all()通知所有 memory provider 同步本轮对话 - Memory prefetch 排队:
self._memory_manager.queue_prefetch_all()为下一轮对话预取记忆(后台执行,不阻塞当前响应) - Background review:如果达到 nudge 阈值,后台检查是否需要持久化记忆或创建技能
- Plugin hook:触发
on_session_end钩子 - 返回结果:将 final_response 和 messages 打包返回给入口层
入口层拿到结果后,CLI 渲染最终响应,Gateway 推送到消息平台。
设计选择:为什么是同步循环
整个 run_conversation() 是一个同步方法——它在调用线程上阻塞运行,直到对话完成才返回。这个选择看起来反直觉(为什么不用 async?),但有三个实际理由:
- 工具执行依赖顺序:大多数工具调用之间有隐含依赖(先读文件再修改),同步循环让控制流天然有序
- 错误处理简单:try/except 即可,不需要处理 async 取消、event loop 生命周期等问题
- Gateway 并发由线程实现:Gateway 为每个用户会话在独立线程中运行
run_conversation(),不需要 async 来实现并发
异步工具通过 _run_async()(model_tools.py:81)桥接到同步循环中——这个桥接机制在每个线程维护一个持久 event loop,避免反复创建和销毁。
详见第 19 章(并发模型)。
设计启示
追踪一次请求的旅程,可以看到三个贯穿全流程的设计原则:
- 回调驱动的多入口适配:同一个
AIAgent通过 11 个回调适配 CLI、Gateway、Batch 等场景,编排逻辑零冗余 - 防御性的每一步:SafeWriter、surrogate 清洗、死连接检测、预飞行压缩、interrupt 检查——主循环的每一步都有对应的防御措施
- 缓存与注入的分离:所有动态内容(memory context、plugin context)注入 user message 而非 system prompt,保护 Anthropic prompt cache 命中率
第 4 章将深入 AIAgent 类本身,拆解它的初始化链、callback 体系和 iteration budget 设计。
设计赌注回扣:本章展示的请求旅程同时回扣了四个设计赌注:CLI-First(CLI 是第一等入口)、Run Anywhere(同一编排逻辑扩展到 Gateway/Cron/Batch)、Personal Long-Term(memory prefetch/sync 贯穿请求生命周期)、Learning Loop(收尾阶段的 background review 检查是否需要创建技能)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。
run_conversation()的主循环骨架在早期公开版本时就已经成形;v0.4.0-v0.8.0 期间的主要变化集中在压缩前处理、memory/plugin 注入点,以及为了 prompt cache 稳定性而添加的保护逻辑。
AIAgent 内核:同步编排器为何是系统心脏
本章核心源码:
run_agent.py(全文,重点 416-1140 行初始化、168-210 行 IterationBudget)
定位:本章完整拆解
AIAgent类——全书最重要的对象。从初始化决策链到回调体系、IterationBudget、fallback provider,理解这个 9431 行的同步编排器如何成为系统心脏。 前置依赖:第 3 章(请求旅程)。适用场景:想理解 AIAgent 的内部设计决策,或准备扩展/修改 agent 行为。
为什么 AIAgent 值得一整章
第 3 章追踪了一次请求穿过系统的旅程,AIAgent.run_conversation() 是旅程的核心。但第 3 章只展示了"消息怎么流动",没有回答"这个编排器为什么这么设计"。
AIAgent 类定义在 run_agent.py:416,它的 __init__ 方法有 45+ 个参数(run_agent.py:433-486),初始化代码超过 700 行(到第 1140 行)。这不是膨胀——每个参数和每段初始化代码都对应一个具体的工程问题。本章拆解这些问题和 Hermes 的解法。
run_agent.py 内部结构全景
在深入细节之前,先看这 9431 行代码的内部结构。下图按代码行号从上到下展示了 run_agent.py 的六大区域和它们之间的调用关系:
graph TB
subgraph "辅助类 (1-415 行)"
SW["_SafeWriter<br/>broken pipe 防护<br/>111-131"]
IB["IterationBudget<br/>线程安全迭代计数<br/>168-210"]
PAR["_should_parallelize_tool_batch<br/>并行安全判断<br/>265-330"]
end
subgraph "AIAgent.__init__ (416-1140 行)"
INIT1["阶段1-3: 配置 + API模式 + 回调"]
INIT2["阶段4: LLM 客户端构造<br/>Anthropic / OpenAI / Codex"]
INIT3["阶段5-6: 工具加载 + 记忆初始化"]
INIT4["阶段7: 压缩器初始化"]
INIT1 --> INIT2 --> INIT3 --> INIT4
end
subgraph "内部方法 (1140-6800 行)"
BG["_spawn_background_review<br/>后台记忆/技能审查<br/>1718"]
PERSIST["_persist_session / _flush_messages<br/>Session 持久化<br/>1842-1957"]
TRAJ["_save_trajectory<br/>轨迹保存<br/>2123"]
PROMPT["_build_system_prompt<br/>系统提示词拼装<br/>2582"]
SANITIZE["_sanitize_api_messages<br/>消息清洗与修复<br/>2757"]
STREAM["_interruptible_streaming_api_call<br/>流式 API 调用 + 健康检测<br/>4282"]
API_KW["_build_api_kwargs<br/>API 请求构建<br/>5229"]
FALLBACK["_restore_primary_runtime<br/>Fallback 恢复<br/>4928"]
COMPRESS["_compress_context<br/>上下文压缩<br/>5835"]
FLUSH_MEM["flush_memories<br/>记忆刷盘<br/>5677"]
end
subgraph "工具执行 (5930-6590 行)"
EXEC["_execute_tool_calls<br/>入口:并行/串行判断<br/>5930"]
INVOKE["_invoke_tool<br/>四路分发<br/>5953"]
CONC["_execute_tool_calls_concurrent<br/>ThreadPoolExecutor<br/>6027"]
SEQ["_execute_tool_calls_sequential<br/>逐个执行 + 显示<br/>6251"]
EXEC --> PAR
EXEC --> CONC
EXEC --> SEQ
CONC --> INVOKE
SEQ --> INVOKE
end
subgraph "run_conversation 主循环 (6800-9431 行)"
RC_PREP["准备阶段<br/>SafeWriter + 清洗 + prompt<br/>+ 预飞行压缩 + hooks<br/>+ memory prefetch"]
RC_LOOP["主循环<br/>while budget > 0:<br/> 组装消息 → API 调用<br/> → 解析响应 → 工具执行"]
RC_EXIT["退出与收尾<br/>persist + sync + hooks<br/>+ background review"]
RC_PREP --> RC_LOOP --> RC_EXIT
end
INIT4 -.-> RC_PREP
RC_PREP --> PROMPT
RC_PREP --> COMPRESS
RC_LOOP --> STREAM
RC_LOOP --> EXEC
RC_LOOP --> SANITIZE
RC_EXIT --> PERSIST
RC_EXIT --> FLUSH_MEM
RC_EXIT --> BG
style RC_LOOP fill:#f96,stroke:#333,stroke-width:3px
style INIT2 fill:#9cf,stroke:#333,stroke-width:2px
style EXEC fill:#fc9,stroke:#333,stroke-width:2px
图中高亮:主循环(橙色,2400 行)是系统心脏;LLM 客户端构造(蓝色)决定了与哪个 provider 通信;工具执行(黄色)是能力层的入口。
从图中可以看出 run_agent.py 的结构不是"一个大方法",而是围绕主循环的辐射状架构——主循环调用内部方法,内部方法调用辅助类。理解了这张图,就知道每一段代码在 9431 行中的位置和角色。
AIAgent 初始化的七个阶段
AIAgent.__init__() 的 700 行初始化代码可以分为七个阶段,每个阶段解决一类问题:
graph LR
A["1. 基础配置<br/>模型、参数、标志"] --> B["2. API 模式<br/>检测与路由"]
B --> C["3. 回调注册<br/>11 个适配接口"]
C --> D["4. LLM 客户端<br/>构造与认证"]
D --> E["5. 工具加载<br/>过滤与验证"]
E --> F["6. 记忆初始化<br/>builtin + provider"]
F --> G["7. 压缩器<br/>上下文管理"]
阶段 1:基础配置(run_agent.py:527-553)
# run_agent.py:529-533
self.model = model
self.max_iterations = max_iterations
self.iteration_budget = iteration_budget or IterationBudget(max_iterations)
self.tool_delay = tool_delay
self.quiet_mode = quiet_mode
第一阶段存储基础参数。值得注意的是 iteration_budget:如果调用方提供了 budget(子代理场景),使用传入的实例;否则创建新的。这让父子代理可以使用独立的 budget(详见"IterationBudget"节)。
阶段 2:API 模式检测与路由(run_agent.py:559-590)
Hermes 支持三种 API 模式,检测逻辑如下:
# run_agent.py:559-581(简化)
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
self.api_mode = api_mode # 显式指定
elif self.provider == "openai-codex":
self.api_mode = "codex_responses" # Codex 专用
elif self.provider == "anthropic" or "api.anthropic.com" in base_url:
self.api_mode = "anthropic_messages" # Anthropic 原生
elif base_url.endswith("/anthropic"):
self.api_mode = "anthropic_messages" # 第三方 Anthropic 兼容
elif self._is_direct_openai_url():
self.api_mode = "codex_responses" # 直连 OpenAI 用 Responses API
else:
self.api_mode = "chat_completions" # 默认:OpenAI 兼容
| API 模式 | 使用场景 | 原因 |
|---|---|---|
chat_completions | OpenRouter、大多数第三方 | 最广泛兼容的标准 |
anthropic_messages | Anthropic 直连、MiniMax、DashScope | Anthropic 原生 API 支持 prompt caching |
codex_responses | OpenAI 直连、Codex | GPT-5.x 的 tool calling + reasoning 需要 Responses API |
这个检测必须在客户端构造之前完成,因为不同 API 模式需要不同的客户端类型(OpenAI SDK vs Anthropic SDK)。
阶段 3:回调注册(run_agent.py:592-602)
# run_agent.py:592-602
self.tool_progress_callback = tool_progress_callback
self.tool_start_callback = tool_start_callback
self.tool_complete_callback = tool_complete_callback
self.thinking_callback = thinking_callback
self.reasoning_callback = reasoning_callback
self.clarify_callback = clarify_callback
self.step_callback = step_callback
self.stream_delta_callback = stream_delta_callback
self.status_callback = status_callback
self.tool_gen_callback = tool_gen_callback
self.background_review_callback = None # line 546
11 个回调接口,每个服务于一个具体场景:
| 回调 | 触发时机 | CLI 用途 | Gateway 用途 |
|---|---|---|---|
stream_delta_callback | 模型输出每个 token | 流式渲染到终端 | 推送到平台 |
thinking_callback | API 调用等待时 | 显示思考动画 | 显示"正在输入..." |
tool_start_callback | 工具开始执行 | 显示工具名 | 发送状态 |
tool_complete_callback | 工具执行完成 | 显示结果预览 | 更新状态 |
clarify_callback | agent 需要用户确认 | 弹出输入框 | 发送消息等待回复 |
step_callback | 每轮 API 调用开始 | — | 触发 agent:step 事件 |
status_callback | 状态信息更新 | — | 推送到平台 |
tool_progress_callback | 工具执行中间状态 | 进度条 | — |
reasoning_callback | 模型 reasoning 输出 | 渲染 reasoning | — |
tool_gen_callback | 工具调用生成中 | 显示工具参数 | — |
background_review_callback | 后台记忆/技能保存完成 | — | 推送通知到平台 |
回调的设计原则:编排层决定"什么时候"调用,入口层决定"怎么处理"。这让同一个编排逻辑零修改适配所有入口。如果一个回调是 None,编排层跳过该通知——不会报错,也不会降级。
阶段 4:LLM 客户端构造(run_agent.py:708-828)
这是初始化中最复杂的阶段,分三条路径:
路径 A:Anthropic 原生(api_mode == "anthropic_messages")
# run_agent.py:716-730
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
effective_key = api_key or resolve_anthropic_token() or ""
self._anthropic_client = build_anthropic_client(effective_key, base_url)
self.client = None # 不需要 OpenAI 客户端
路径 B:显式凭据(调用方直接提供 api_key + base_url)
# run_agent.py:737-744
client_kwargs = {"api_key": api_key, "base_url": base_url}
路径 C:Provider Router 自动解析(无显式凭据)
# run_agent.py:761-763
from agent.auxiliary_client import resolve_provider_client
_routed_client, _ = resolve_provider_client(self.provider or "auto", model=self.model)
三条路径最终都构造出可用的 LLM 客户端。一个重要的细节:如果是 Claude on OpenRouter,会自动注入 x-anthropic-beta: fine-grained-tool-streaming header(run_agent.py:802-812),让 Anthropic 逐 token 流式返回工具调用参数——否则 OpenRouter 的上游代理会在 Claude 沉默思考时超时断开。
阶段 5:工具加载与过滤(run_agent.py:855-883)
# run_agent.py:856-860
self.tools = get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=self.quiet_mode,
)
self.valid_tool_names = {tool["function"]["name"] for tool in self.tools}
通过 model_tools.py 的 get_tool_definitions() 获取工具 schema 列表。这个调用触发了完整的工具发现流程(导入所有工具模块 → 自注册 → 按 toolset 过滤)。valid_tool_names 集合后续用于验证模型返回的 tool call 是否合法。
阶段 6:记忆系统初始化(run_agent.py:969-1070)
记忆初始化分两层:
内置记忆(run_agent.py:969-992):
# run_agent.py:984-990
if self._memory_enabled or self._user_profile_enabled:
from tools.memory_tool import MemoryStore
self._memory_store = MemoryStore(
memory_char_limit=mem_config.get("memory_char_limit", 2200),
user_char_limit=mem_config.get("user_char_limit", 1375),
)
self._memory_store.load_from_disk() # 从 MEMORY.md / USER.md 加载
外部 Memory Provider(run_agent.py:996-1070):
# run_agent.py:1028-1054(简化)
if _mem_provider_name:
self._memory_manager = MemoryManager()
_mp = load_memory_provider(_mem_provider_name)
if _mp and _mp.is_available():
self._memory_manager.add_provider(_mp)
self._memory_manager.initialize_all(
session_id=self.session_id,
platform=platform or "cli",
user_id=self._user_id, # Gateway 场景的用户隔离
)
外部 provider 初始化后,它注册的工具会被追加到 self.tools 列表(run_agent.py:1064-1070)。这意味着 Honcho 或 Mem0 的自定义工具和内置工具以完全相同的方式暴露给模型。
详见第 11 章(Memory Provider 架构)。
阶段 7:上下文压缩器初始化(run_agent.py:1087-1140)
# run_agent.py:1132-1140(简化)
self.context_compressor = ContextCompressor(
model=self.model,
threshold_percent=compression_threshold, # 默认 0.50
protect_first_n=3, # 保护前 3 条消息
protect_last_n=compression_protect_last, # 默认保护最后 20 条
config_context_length=_config_context_length,
)
self.compression_enabled = compression_enabled
压缩器需要知道模型的上下文窗口长度来计算阈值。这个长度的获取有三个来源:config.yaml 显式配置 > custom_providers 配置 > model_metadata 自动探测。
详见第 12 章(上下文压缩)。
IterationBudget:防止 Agent 失控
# run_agent.py:168-209
class IterationBudget:
"""Thread-safe iteration counter for an agent."""
def __init__(self, max_total: int):
self.max_total = max_total
self._used = 0
self._lock = threading.Lock()
def consume(self) -> bool:
"""Try to consume one iteration. Returns True if allowed."""
with self._lock:
if self._used >= self.max_total:
return False
self._used += 1
return True
def refund(self) -> None:
"""Give back one iteration (e.g. for execute_code turns)."""
with self._lock:
if self._used > 0:
self._used -= 1
IterationBudget 看起来简单,但它解决了一个关键问题:如何防止 agent 在工具调用循环中无限运转?
设计要点:
- 线程安全:
threading.Lock()保护计数器,因为 Gateway 场景下多个会话可能在不同线程并发运行 - 独立预算:父代理默认 90 次(
max_iterations),子代理独立分配(delegation.max_iterations,默认 50 次),互不影响 - 退款机制:
execute_code工具的调用会被refund()——因为程序化工具调用是低优先级的,不应消耗面向用户的 budget - 压力预警:当 budget 使用达到 70% 时注入 caution,90% 时注入 warning,提醒模型尽快收敛(
run_agent.py:648-650)
# run_agent.py:648-650
self._budget_caution_threshold = 0.7 # 70% — nudge to start wrapping up
self._budget_warning_threshold = 0.9 # 90% — urgent, respond now
压力预警不是作为独立消息注入(那会破坏消息结构),而是嵌入到工具结果的 JSON 中——模型在读取工具结果时自然看到预警信息。
Fallback Provider 链
# run_agent.py:830-853
if isinstance(fallback_model, list):
self._fallback_chain = [
f for f in fallback_model
if isinstance(f, dict) and f.get("provider") and f.get("model")
]
elif isinstance(fallback_model, dict):
self._fallback_chain = [fallback_model]
else:
self._fallback_chain = []
Hermes 支持 fallback provider 链——当主模型不可用(限流、overload、连接失败)时,按顺序尝试备选模型。配置示例:
# config.yaml
fallback_providers:
- provider: anthropic
model: claude-sonnet-4-20250514
- provider: openai
model: gpt-4o
Fallback 的触发不在初始化阶段,而是在 run_conversation() 的 API 重试逻辑中(run_agent.py:7285+)。当所有重试耗尽后,编排器切换到 fallback chain 的下一个 provider。切换后的状态会在下一轮 run_conversation() 调用时通过 _restore_primary_runtime() 恢复。
背景自我改进:Nudge 与 Background Review
Hermes 的 "Learning Loop" 赌注不只是一个概念,而是编码在 AIAgent 中的具体机制:
# run_agent.py:973-976, 1072-1076
self._memory_nudge_interval = 10 # 每 10 个 user turn 检查一次记忆
self._skill_nudge_interval = 10 # 每 10 个 tool iteration 检查一次技能
self._turns_since_memory = 0
self._iters_since_skill = 0
当计数器达到阈值时,编排器在主循环结束后调用 _spawn_background_review()(run_agent.py:1718):
# run_agent.py:1718-1764(简化)
def _spawn_background_review(self, messages_snapshot, review_memory=False, review_skills=False):
def _run_review():
review_agent = AIAgent(model=self.model, max_iterations=8, quiet_mode=True)
review_agent._memory_store = self._memory_store # 共享记忆存储
review_agent.run_conversation(user_message=prompt, conversation_history=messages_snapshot)
threading.Thread(target=_run_review, daemon=True).start()
这个方法:
- 创建一个独立的
AIAgent实例(max_iterations=8,限制 budget) - 共享主 agent 的
_memory_store(让 review agent 可以直接写入记忆) - 在后台线程中运行,不阻塞当前响应
- 将 review agent 自己的 nudge 设为 0(避免递归触发)
- 完成后通过
background_review_callback通知平台(Gateway 场景下推送到消息平台)
这就是 Hermes 如何在不打断用户的情况下自动审查对话、提炼记忆和创建技能。
中断机制
# run_agent.py:609-611
self._interrupt_requested = False
self._interrupt_message = None
self._client_lock = threading.RLock()
中断机制解决一个交互问题:用户在 agent 执行工具时发送了新消息,或在 CLI 按了 Ctrl+C。编排器需要能安全地中断当前循环,而不是等所有工具执行完毕。
中断传播到子代理:
# run_agent.py:615-617
self._delegate_depth = 0 # 0 = 顶层 agent
self._active_children = [] # 运行中的子代理
self._active_children_lock = threading.Lock()
当顶层 agent 被中断时,它会遍历 _active_children 设置每个子代理的 _interrupt_requested = True,实现递归中断。
活动追踪
# run_agent.py:661-664
self._last_activity_ts: float = time.time()
self._last_activity_desc: str = "initializing"
self._current_tool: str | None = None
self._api_call_count: int = 0
活动追踪不是日志——它是 Gateway 超时处理器的数据源。当 Gateway 因超时杀死 agent 时,这些字段告诉运维人员"agent 被杀时正在做什么",以及"距离上次活动过了多久"。这在调试 agent 卡住问题时极为有用。
run_conversation() 主循环的控制流
初始化完成后,AIAgent 的核心能力通过 run_conversation()(run_agent.py:6800)暴露。第 3 章已追踪了消息在主循环中的流动路径,这里从控制流角度补充关键设计:
循环结构
# run_agent.py:7111(简化)
while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:
if self._interrupt_requested: break # 退出条件 1
if not self.iteration_budget.consume(): break # 退出条件 2
response = self._interruptible_streaming_api_call(...)
if assistant_message.tool_calls:
self._execute_tool_calls(...) # 有工具 → 继续循环
continue
else:
final_response = assistant_message.content # 无工具 → 退出条件 3
break
退出条件枚举
| 退出条件 | 代码位置 | 类型 |
|---|---|---|
| 模型无 tool_calls | run_agent.py:8876 | 正常完成 |
| 用户中断 | run_agent.py:7116 | 正常中断 |
| Budget 耗尽 | run_agent.py:7125 | 预算限制 |
| context_length_exceeded 压缩失败 | 主循环内 retry | 错误退出 |
| 不可重试 4xx 错误 | run_agent.py:8295+ | 错误退出 |
| 所有重试耗尽 | run_agent.py:8448+ | 错误退出 |
| finish_reason=length 续写耗尽 | run_agent.py:7638+ | 边界退出 |
收尾流程
循环退出后,编排器执行 9 步收尾(run_agent.py:9080+):trajectory 保存 → 资源清理 → post_llm_call hook → session 持久化 → memory_manager.sync_all() → prefetch 排队 → background review(如触发 nudge)→ on_session_end hook → 返回结果。
第 3 章已对主循环和收尾流程做了完整分析,此处仅补充控制流视角。
为什么 run_agent.py 有 9431 行没有拆分
这可能是读者翻开源码时的第一个疑问:一个 9431 行的 Python 文件,129 个方法,为什么不模块化?
先看这 9431 行到底装了什么:
| 职责 | 行数范围 | 约行数 | 占比 |
|---|---|---|---|
run_conversation() 主循环 | 6800-9200 | ~2400 | 25% |
__init__ 初始化链 | 433-1140 | ~700 | 7% |
| 工具执行(串行 + 并行) | 5930-6590 | ~660 | 7% |
| API 调用与流式处理 | 4170-5230 | ~1060 | 11% |
| 错误恢复(重试、fallback、压缩) | 分散在主循环内 | ~800 | 9% |
| Session/Trajectory 持久化 | 1840-2400 | ~560 | 6% |
| System prompt 构建 | 2580-2740 | ~160 | 2% |
| 消息清洗与安全检查 | 2750-3680 | ~930 | 10% |
| Streaming 解析(Codex/Anthropic/OpenAI) | 3690-4170 | ~480 | 5% |
| 辅助方法(日志、调试、格式化) | 散布 | ~680 | 7% |
| 内部类(_SafeWriter, IterationBudget 等) | 1-416 | ~415 | 4% |
不拆分的实际原因
1. 循环引用的工程成本
run_conversation() 的 2400 行主循环直接访问 self._memory_manager、self.context_compressor、self._session_db、self.tools、self.iteration_budget 等十余个实例属性。如果把主循环拆到 agent/conversation_loop.py,要么传入十几个参数,要么传入 self 本身——后者等于没拆。
Python 的模块系统在处理深度互引时远不如 Rust/Go 的包系统优雅。run_agent.py 的 129 个方法中,约 80% 需要访问 AIAgent 的实例状态。拆分后的每个模块仍然紧密耦合到 AIAgent,带来的不是解耦而是间接性。
2. 主循环的内聚性
run_conversation() 的 2400 行不是因为它做了太多不相关的事,而是因为 agent 对话循环本质上就是复杂的:
准备 → API 调用 → 流式解析 → 工具判断 → 并行/串行执行 → 错误恢复
↑ |
└────────── 重试 / 压缩 / fallback / interrupt ←──────────┘
这个循环中的每一步都可能触发错误恢复(API 限流 → 重试 → fallback → 压缩 → 重试),错误恢复的路径又可能回到循环的任何阶段。拆分成独立模块会把这个状态机的边打散,让控制流变得更难追踪。
3. 增量演化的代价
Hermes 从 v0.1 到 v0.8 的 9 个版本中,run_agent.py 持续增长。每个版本添加的功能(prompt caching、fallback 链、预飞行压缩、plugin hooks、Codex Responses API 支持等)都嵌入主循环的不同阶段。重构为模块化架构需要冻结功能开发——对一个快速迭代的开源项目来说,这个代价通常被推迟。
已经拆出去的模块
值得注意的是,Hermes 并非没有模块化意识。agent/ 目录下的 25 个模块就是从 run_agent.py 逐步剥离出来的:
agent/prompt_builder.py(983 行):system prompt 的无状态拼装函数agent/prompt_caching.py(72 行):Anthropic cache control 逻辑agent/context_compressor.py(696 行):上下文压缩算法agent/memory_manager.py(367 行):记忆编排agent/retry_utils.py:重试策略agent/model_metadata.py:模型能力探测
这些模块有一个共同特征:它们是无状态或弱状态的,不需要深度访问 AIAgent 实例。能拆的已经拆了,剩下的是确实需要 self 的有状态逻辑。
对读者的建议
不要试图线性阅读 run_agent.py 的 9431 行。推荐的阅读方式:
- 从
__init__(416 行)开始,理解初始化的七个阶段(本章已分析) - 跳到
run_conversation()(6800 行),只看主循环骨架(第 3 章已分析) - 按需深入具体的错误恢复路径或工具执行逻辑
这个文件的"正确心理模型"不是"一个巨大的类",而是"一个带有状态的有限状态机,包含了所有状态转换逻辑"。
设计启示
拆解 AIAgent 的初始化与主循环,可以提炼出四个设计原则:
- 初始化即决策:50+ 个参数不是配置的堆砌,而是在构造时就确定了 API 模式、prompt caching 策略、工具集合、记忆后端等关键决策。这些决策在整个
run_conversation()生命周期中不会改变(除非 fallback 触发),让编排逻辑可以安全地做出假设 - 回调是适配层的接口:11 个回调让编排层和入口层保持单向依赖——编排层不知道也不关心自己运行在 CLI 还是 Telegram 还是 Cron 中
- Budget 是安全网:IterationBudget + 压力预警 + background review 的组合让 agent 既有足够的自主性(90 次迭代),又有收敛机制(70%/90% 预警),还有自我改进能力(background review)——这三者的平衡是 Hermes 作为"长期协作代理"的核心
- 有状态内聚优于无状态拆分:9431 行的
run_agent.py不是设计缺陷而是权衡结果——当 80% 的方法需要深度访问实例状态时,强行模块化带来的不是解耦而是间接性。Hermes 的策略是"能拆的拆出去(agent/25 个模块),剩下的保持内聚"
第 5 章将深入 system prompt 的构建过程——_build_system_prompt() 如何将 identity、memory、skills、context files、platform hints 拼装成一个 prompt cache 友好的系统提示。
设计赌注回扣:本章回扣了全部四个赌注:CLI-First(回调体系让 CLI 成为第一等入口)、Run Anywhere(同一 AIAgent 适配所有平台)、Personal Long-Term(记忆初始化 + nudge 机制)、Learning Loop(
_spawn_background_review实现自动技能创建)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。
AIAgent.__init__的参数列表随功能持续扩展。独立的IterationBudget在 v0.3.0 发布窗口就已出现,_spawn_background_review的后台审查则在 v0.4.0 发布窗口进入主线;此后的变化主要是围绕 provider 路由、回调面和初始化职责继续堆叠。
提示词系统:不是文案,而是架构
本章核心源码:
agent/prompt_builder.py(983 行)、agent/prompt_caching.py(72 行)、run_agent.py:2582-2741
定位:本章揭示 Hermes 的提示词系统如何被 prompt caching 和模型兼容性反向塑造为一个架构决策,而非简单的文案拼接。 前置依赖:第 4 章(AIAgent 内核)。适用场景:想理解 system prompt 的多块拼装逻辑、注入策略和 prompt cache 优化。
为什么提示词是架构问题
在大多数 LLM 应用中,system prompt 是一段写好的文本。但在 Hermes 中,system prompt 是由 7 个独立来源 在运行时动态拼装的——而且拼装顺序、注入位置、缓存策略都直接影响 API 成本和模型行为。
一个具体的数字:Anthropic 的 prompt caching 可以将输入 token 成本降低约 75%。但缓存命中的前提是 system prompt 逐字节一致。这意味着任何"每次请求都不同"的内容——比如动态生成的 memory snapshot——如果放在 system prompt 中,会导致每次 API 调用都 cache miss,成本翻四倍。
Hermes 的解法是:把稳定内容放 system prompt,把动态内容注入 user message。这个看似简单的规则,实际上塑造了整个提示词系统的架构。
System Prompt 的七层拼装
AIAgent._build_system_prompt()(run_agent.py:2582)负责拼装 system prompt。源码注释(run_agent.py:2590-2597)将其描述为 7 层:Identity → User system prompt → Memory → Skills → Context files → Date/time → Platform hint。下面按代码执行顺序分析每一层(为教学清晰起见,将工具行为引导和工具使用强制单独列出):
graph TD
A["1. Agent Identity<br/>SOUL.md 或 DEFAULT_AGENT_IDENTITY"] --> FINAL
B["2. 工具行为引导<br/>MEMORY_GUIDANCE / SKILLS_GUIDANCE / ..."] --> FINAL
C["3. 工具使用强制<br/>TOOL_USE_ENFORCEMENT(按模型注入)"] --> FINAL
D["4. 用户/Gateway 系统消息<br/>system_message 参数"] --> FINAL
E["5. 持久记忆快照<br/>MEMORY.md + USER.md(冻结)"] --> FINAL
F["6. 技能索引<br/>skills/ 目录扫描"] --> FINAL
G["7. 上下文文件 + 时间戳 + 平台提示<br/>AGENTS.md, .cursorrules, 平台 hints"] --> FINAL
FINAL["System Prompt<br/>(prompt_parts join)"]
第 1 层:Agent Identity
# run_agent.py:2599-2609
if not self.skip_context_files:
_soul_content = load_soul_md() # 尝试加载当前 HERMES_HOME/SOUL.md
if _soul_content:
prompt_parts = [_soul_content] # SOUL.md 存在 → 用它作为身份
if not _soul_loaded:
prompt_parts = [DEFAULT_AGENT_IDENTITY] # 否则 → 内置默认身份
DEFAULT_AGENT_IDENTITY(agent/prompt_builder.py:134)是一段 7 行的基础身份描述。SOUL.md 则是用户自定义的"灵魂文件"——它可以完全替换默认身份,让同一个 Hermes 实例表现为不同的角色。
第 2 层:工具行为引导
# run_agent.py:2612-2620
if "memory" in self.valid_tool_names:
tool_guidance.append(MEMORY_GUIDANCE) # 记忆使用规范
if "session_search" in self.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE) # 跨会话搜索规范
if "skill_manage" in self.valid_tool_names:
tool_guidance.append(SKILLS_GUIDANCE) # 技能创建规范
关键设计:引导文本只在对应工具实际加载时才注入。如果用户禁用了 memory toolset,MEMORY_GUIDANCE 不会出现在 system prompt 中——既避免了模型困惑("你让我用 memory 工具但我没有"),也减少了 token 开销。
这些引导文本定义在 agent/prompt_builder.py:144-186,每段都经过精心设计。以 MEMORY_GUIDANCE 为例(agent/prompt_builder.py:144):
"Memory is injected into every turn, so keep it compact and focused on facts that will still matter later. Prioritize what reduces future user steering..."
这段文本的核心指令是"优先保存能减少用户未来纠正的信息"——这直接服务于 Personal Long-Term 赌注。
第 3 层:工具使用强制
# run_agent.py:2632-2656
if self.valid_tool_names:
if _should_enforce:
prompt_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE)
if "gemini" in model_lower or "gemma" in model_lower:
prompt_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
if "gpt" in model_lower or "codex" in model_lower:
prompt_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
这是 Hermes 应对模型差异的关键机制。TOOL_USE_ENFORCEMENT_GUIDANCE(agent/prompt_builder.py:173)解决一个普遍问题:某些模型倾向于描述自己会做什么,而不是实际调用工具做。
不同模型家族有不同的弱点:
- GPT/Codex:倾向于在部分结果后放弃,需要
OPENAI_MODEL_EXECUTION_GUIDANCE强调持久执行 - Gemini/Gemma:倾向于冗长输出,需要
GOOGLE_MODEL_OPERATIONAL_GUIDANCE强调简洁和并行工具调用
匹配规则可以通过 config.yaml 的 agent.tool_use_enforcement 控制:"auto"(默认,匹配已知模型列表)、true(所有模型)、false(禁用)、或自定义模型名模式列表。
第 4 层:用户/Gateway 系统消息
# run_agent.py:2662-2663
if system_message is not None:
prompt_parts.append(system_message)
入口层可以通过 system_message 参数注入额外的系统指令。Gateway 用这个传递平台特定的约束(如 Telegram 的消息长度限制),批量运行用这个传递任务描述。
第 5 层:持久记忆快照
# run_agent.py:2665-2681
if self._memory_store:
if self._memory_enabled:
mem_block = self._memory_store.format_for_system_prompt("memory")
prompt_parts.append(mem_block) # MEMORY.md 内容
if self._user_profile_enabled:
user_block = self._memory_store.format_for_system_prompt("user")
prompt_parts.append(user_block) # USER.md 内容
# 外部 Memory Provider 的系统提示块
if self._memory_manager:
_ext_mem_block = self._memory_manager.build_system_prompt()
prompt_parts.append(_ext_mem_block)
内置记忆(MEMORY.md + USER.md)在构建时被冻结进 system prompt。这意味着即使 agent 在本轮对话中修改了记忆,system prompt 中的记忆快照不会更新——直到 session 被压缩重建。
这是有意的:冻结快照保证了 system prompt 在整个 session 内的字节级稳定,最大化 prompt cache 命中。
第 6 层:技能索引
# run_agent.py:2685-2701
has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
if has_skills_tools:
skills_prompt = build_skills_system_prompt(
available_tools=self.valid_tool_names,
available_toolsets=avail_toolsets,
)
prompt_parts.append(skills_prompt)
build_skills_system_prompt()(agent/prompt_builder.py:529)扫描当前 HERMES_HOME/skills/ 和配置的外部技能目录,生成一个技能列表索引;默认 profile 下前者表现为 ~/.hermes/skills/。索引只包含技能名称和一句话描述——完整内容在模型需要时通过 skill_view 工具按需加载。
详见第 8 章(技能系统)。
第 7 层:上下文文件 + 时间戳 + 平台提示
# run_agent.py:2703-2739
context_files_prompt = build_context_files_prompt(cwd=_context_cwd, skip_soul=_soul_loaded)
prompt_parts.append(context_files_prompt) # AGENTS.md, .cursorrules, HERMES.md
timestamp_line = f"Conversation started: {now.strftime(...)}"
prompt_parts.append(timestamp_line) # 当前日期时间
platform_key = (self.platform or "").lower()
if platform_key in PLATFORM_HINTS:
prompt_parts.append(PLATFORM_HINTS[platform_key]) # 平台格式提示
最后拼接上下文文件、时间戳和平台特定的格式提示。上下文文件经过 prompt injection 检测后才注入。
Prompt Injection 防护
agent/prompt_builder.py:36-73 实现了上下文文件的安全扫描:
# agent/prompt_builder.py:36-47
_CONTEXT_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'system\s+prompt\s+override', "sys_prompt_override"),
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
# ... 共 10 个模式
]
当 AGENTS.md 或 .cursorrules 文件匹配到任一威胁模式时,该文件内容被替换为一条警告消息([BLOCKED: filename contained potential prompt injection]),不会被注入到 system prompt 中。
这个扫描同时检测不可见 Unicode 字符(如零宽空格 \u200b),防止攻击者利用不可见字符隐藏指令。
拼装完成后:缓存与注入策略
System Prompt → Prompt Cache
_build_system_prompt() 拼装完成后,结果被缓存到 self._cached_system_prompt,在整个 session 内不再重建。
在 API 调用时,apply_anthropic_cache_control()(agent/prompt_caching.py:41)为消息注入缓存断点:
# agent/prompt_caching.py:41-72
def apply_anthropic_cache_control(api_messages, cache_ttl="5m", native_anthropic=False):
"""system_and_3 策略:system prompt + 最后 3 条非 system 消息"""
marker = {"type": "ephemeral"}
# 1. System prompt 断点
if messages[0].get("role") == "system":
_apply_cache_marker(messages[0], marker)
# 2-4. 最后 3 条消息断点(滚动窗口)
remaining = 4 - breakpoints_used
non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"]
for idx in non_sys[-remaining:]:
_apply_cache_marker(messages[idx], marker)
Anthropic 允许最多 4 个缓存断点。Hermes 的策略是:1 个给 system prompt(最稳定),3 个给最后 3 条消息(滚动窗口)。这让多轮对话的前缀可以被缓存,输入 token 成本降低约 75%。
动态内容 → User Message
第 3 章已经展示过:memory prefetch 结果和 plugin context 注入到 user message,不是 system prompt:
# run_agent.py:7177-7188(API 调用时)
if idx == current_turn_user_idx:
if _ext_prefetch_cache:
api_msg["content"] += build_memory_context_block(_ext_prefetch_cache)
if _plugin_user_context:
api_msg["content"] += "\n\n" + _plugin_user_context
这些注入只存在于 api_messages 副本中,不修改原始 messages 列表,也不持久化到 session DB。
为什么有些内容在 System Prompt,有些在 User Message?
| 位置 | 内容 | 原因 |
|---|---|---|
| System Prompt | Agent identity, MEMORY.md 快照, skills 索引, context files | 每个 session 内不变 → 缓存友好 |
| User Message | Memory prefetch, plugin context | 每轮可能不同 → 放 system prompt 会破坏缓存 |
| 不注入 | Ephemeral system prompt | API 调用时临时追加在 system prompt 后,不持久化 |
这个分离策略的直接效果:一个 20 轮对话的 session,前 19 轮的 system prompt 完全一致,每轮只有 user message 中的 memory context 不同。Anthropic 的 prefix cache 命中率接近 100%。
设计启示
提示词系统的设计揭示了 Hermes 的一个核心方法论:让经济约束驱动架构决策。
- 成本驱动的分离:prompt cache 的经济性要求 system prompt 稳定,这反向塑造了"稳定内容放 system prompt,动态内容放 user message"的注入策略
- 条件注入:工具引导文本只在工具实际加载时注入,既省 token 又避免模型困惑
- 模型特异性注入:不同模型家族的弱点(GPT 放弃、Gemini 冗长)通过差异化的强制文本弥补,而不是试图找一个"通用 prompt"
- 安全扫描前置:上下文文件在注入前经过 injection 检测,拦截潜在的恶意指令
第 6 章将转向能力层,拆解工具系统的自注册、按需暴露和安全调度机制。
设计赌注回扣:本章的提示词系统直接服务于 Personal Long-Term(MEMORY.md 快照注入 system prompt、memory prefetch 注入 user message)和 Learning Loop(SKILLS_GUIDANCE 引导 agent 主动创建和改进技能、skills 索引让 agent 知道自己有哪些积累的知识)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 SOUL.md 身份层、skills 索引和 system prompt caching 这套设计,在 v0.4.0-v0.5.0 发布窗口就已经比较清晰;之后 v0.5.0-v0.7.0 之间继续叠加了工具使用强制、更多防注入保护和更细的缓存边界控制。
system_and_3这一缓存策略本身则属于很早就稳定下来的部分。
工具系统:自注册、按需暴露、安全调度
本章核心源码:
tools/registry.py(335 行)、model_tools.py(577 行)、toolsets.py
定位:本章把 Hermes 的工具系统作为一个小型插件架构案例来写,涵盖三层发现机制、ToolRegistry 单例设计、toolset 分组、并发执行与安全调度。 前置依赖:第 4 章(AIAgent 内核)。适用场景:想理解工具如何被发现、注册、暴露给模型和安全调度。
为什么工具系统值得单独一章
Hermes 有数十个工具相关文件(含子目录),支持三类来源(内置、MCP、插件系统),可以按 toolset 粒度启用/禁用,并在执行时自动判断是否可以并行。这不是一个简单的函数调用表,而是一个完整的插件架构。
理解这个架构,你就能回答这些问题:
- 怎么添加一个新的内置工具?(答:至少三步,工具文件 +
_discover_tools()导入 +toolsets.py接线) - 怎么控制哪些工具暴露给模型?(答:toolset 过滤)
- 模型返回的 tool call 怎么被路由到正确的 handler?(答:ToolRegistry.dispatch)
- 多个工具调用怎么安全地并行?(答:路径冲突检测)
三层工具发现
工具发现发生在 model_tools.py 的模块加载时,分三层:
graph TD
A["model_tools.py 被 import"] --> B["_discover_tools()"]
B --> C["内置工具<br/>importlib.import_module()"]
C --> D["tools/*.py 在 import 时<br/>调用 registry.register()"]
A --> E["MCP 发现<br/>discover_mcp_tools()"]
E --> F["读取 config.yaml → mcp_servers<br/>启动 MCP 进程"]
F --> G["MCP 工具<br/>registry.register()"]
A --> H["Plugin 发现<br/>discover_plugins()"]
H --> I["扫描 user / project / entry_points<br/>加载插件"]
I --> J["插件工具<br/>registry.register()"]
D --> K["ToolRegistry<br/>单例"]
G --> K
J --> K
第 1 层:内置工具
_discover_tools()(model_tools.py:132)硬编码了 20 个内置工具模块的导入列表:
# model_tools.py:132-170
def _discover_tools():
_modules = [
"tools.web_tools",
"tools.terminal_tool",
"tools.file_tools",
"tools.browser_tool",
"tools.delegate_tool",
"tools.mcp_tool",
"tools.code_execution_tool",
# ... 共 20 个(honcho_tools 已迁移为 memory provider 插件)
]
for mod_name in _modules:
try:
importlib.import_module(mod_name)
except Exception as e:
logger.warning("Could not import tool module %s: %s", mod_name, e)
_discover_tools() # 模块加载时立即执行
每个工具模块在 import 时调用 registry.register()。但对仓库内置工具来说,这还不是全部工作:
- 新建
tools/*.py - 把模块加入
model_tools.py的_discover_tools()导入列表 - 把工具接入
toolsets.py,否则它即使注册成功也不一定会暴露给模型
也就是说,registry.register() 是注册动作,不是“让新内置工具真正可达”的全部步骤。
工具模块的注册模式如下:
# 以 tools/web_tools.py 为例(模式)
from tools.registry import registry
registry.register(
name="web_search",
toolset="web",
schema={"description": "Search the web", "parameters": {...}},
handler=web_search_handler,
check_fn=lambda: bool(os.getenv("EXA_API_KEY")),
requires_env=["EXA_API_KEY"],
is_async=False,
emoji="🔍",
)
关键设计:try/except 包裹每个模块导入。如果某个可选工具的依赖缺失(比如 fal_client 没装),只会 log warning,不会阻止其他工具加载。
第 2 层:MCP 工具
# model_tools.py:172-177
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
MCP(Model Context Protocol)工具来自外部进程。discover_mcp_tools() 读取 config.yaml 中的 mcp_servers 配置,启动 MCP server 进程,获取它们暴露的工具 schema,然后调用同一个 registry.register() 注册。
MCP 工具支持热更新:当 MCP server 发送 notifications/tools/list_changed 信号时,Hermes 会 deregister 旧工具并重新注册。ToolRegistry.deregister()(tools/registry.py:95)在移除工具时会清理 toolset check,避免残留。
第 3 层:插件系统
# model_tools.py:179-184
from hermes_cli.plugins import discover_plugins
discover_plugins()
Hermes 当前的插件发现不是单一路径,而是三路扫描:
- 当前
HERMES_HOME/plugins/下的用户插件(默认 profile 下表现为~/.hermes/plugins/) ./.hermes/plugins/下的项目插件(需开启HERMES_ENABLE_PROJECT_PLUGINS)- Python
entry_points暴露的 pip 插件
对工具系统而言,三类插件最后都会落到同一个事实:导入插件代码后,由插件自行调用 registry.register() 把工具注入注册表。
三层统一的价值
三种来源最终都注册到同一个 ToolRegistry 单例中。这意味着:
- 模型看到的是统一的工具列表,不区分来源
- 调度逻辑只需要一套
- 新增工具来源只要最终能调用
registry.register(),就不需要改动调度代码
ToolRegistry:核心抽象
ToolRegistry(tools/registry.py:48)是一个模块级单例(tools/registry.py:290):
# tools/registry.py:290
registry = ToolRegistry()
它提供四组能力:
注册
# tools/registry.py:59-93
def register(self, name, toolset, schema, handler, check_fn=None,
requires_env=None, is_async=False, description="", emoji="",
max_result_size_chars=None):
self._tools[name] = ToolEntry(...)
Schema 检索(带可用性过滤)
# tools/registry.py:116-143
def get_definitions(self, tool_names, quiet=False):
result = []
for name in sorted(tool_names):
entry = self._tools.get(name)
if entry.check_fn and not entry.check_fn():
continue # 可用性检查失败 → 跳过
result.append({"type": "function", "function": {**entry.schema, "name": name}})
return result
check_fn 是延迟执行的——只在构建 schema 列表时调用,不在注册时调用。这意味着环境变量的设置可以在注册之后、schema 检索之前完成。
调度
# tools/registry.py:149-166
def dispatch(self, name, args, **kwargs):
entry = self._tools.get(name)
if not entry:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
if entry.is_async:
from model_tools import _run_async
return _run_async(entry.handler(args, **kwargs))
return entry.handler(args, **kwargs)
except Exception as e:
return json.dumps({"error": f"Tool execution failed: {type(e).__name__}: {e}"})
两个关键设计:
- Async 桥接:异步 handler 通过
_run_async()自动桥接到同步调用链 - 异常捕获:所有异常被转换为 JSON error 格式返回,永远不会向上抛出导致编排层崩溃
ToolEntry:元数据容器
# tools/registry.py:24-45
class ToolEntry:
__slots__ = (
"name", "toolset", "schema", "handler", "check_fn",
"requires_env", "is_async", "description", "emoji",
"max_result_size_chars",
)
__slots__ 优化内存。对这样一个会注册数十个工具的系统来说,slots 比普通实例 dict 更紧凑。
Toolset:分组与过滤
工具通过 toolset 字段分组。get_tool_definitions()(model_tools.py:234)负责按 toolset 过滤:
# model_tools.py:234(简化)
def get_tool_definitions(enabled_toolsets=None, disabled_toolsets=None, quiet_mode=False):
# 确定最终的工具集合
all_names = registry.get_all_tool_names()
if enabled_toolsets:
# 只保留指定 toolset 中的工具
names = {n for n in all_names if registry.get_toolset_for_tool(n) in enabled_toolsets}
elif disabled_toolsets:
# 移除指定 toolset 中的工具
names = {n for n in all_names if registry.get_toolset_for_tool(n) not in disabled_toolsets}
else:
names = set(all_names)
return registry.get_definitions(names, quiet=quiet_mode)
用户通过 hermes tools 命令或 config.yaml 控制 toolset 的启用/禁用。
工具调度流程
当模型返回 tool_calls 时,编排层通过以下路径将调用分发到正确的 handler:
graph TD
A["模型返回 tool_calls"] --> B["AIAgent._invoke_tool()"]
B --> C{工具名称?}
C -->|"todo/session_search/memory/clarify"| D["Agent 内置处理<br/>(需要 agent 内部状态)"]
C -->|"memory_manager 动态工具"| E["MemoryManager.handle_tool_call()<br/>(外部 provider 注册)"]
C -->|"delegate_task"| F["delegate_task()<br/>(spawn 子代理)"]
C -->|"其他"| G["model_tools.handle_function_call()"]
G --> H["ToolRegistry.dispatch()"]
H --> I{is_async?}
I -->|是| J["_run_async(handler)"]
I -->|否| K["handler(args)"]
J --> L["返回 JSON 结果"]
K --> L
D --> L
E --> L
F --> L
并发执行与安全调度
第 3 章提到编排器会判断工具是否可以并行执行。这个判断的核心逻辑在 _should_parallelize_tool_batch()(run_agent.py:265):
# run_agent.py:214-235
_NEVER_PARALLEL_TOOLS = frozenset({"clarify"}) # 交互式工具,永不并行
_PARALLEL_SAFE_TOOLS = frozenset({ # 只读工具,总是可以并行
"read_file", "search_files", "session_search",
"skill_view", "skills_list", "vision_analyze",
"web_extract", "web_search",
"ha_get_state", "ha_list_entities", "ha_list_services",
})
_PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"})
_MAX_TOOL_WORKERS = 8
# run_agent.py:265(简化)
def _should_parallelize_tool_batch(tool_calls) -> bool:
tool_names = [tc.function.name for tc in tool_calls]
# 1. 有交互式工具 → 不并行
if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names):
return False
# 2. 全部是只读工具 → 并行
if all(name in _PARALLEL_SAFE_TOOLS for name in tool_names):
return True
# 3. 有文件操作 → 检查路径是否重叠
if any(name in _PATH_SCOPED_TOOLS for name in tool_names):
paths = [extract_path(tc) for tc in tool_calls]
if has_overlapping_paths(paths):
return False # 路径重叠 → 不并行
return True
return False # 默认不并行
并行执行使用 ThreadPoolExecutor,最多 8 个并发线程(_MAX_TOOL_WORKERS = 8)。
危险命令检测
_is_destructive_command()(run_agent.py:254)对 terminal 工具的命令进行启发式检测:
# run_agent.py:237-262
_DESTRUCTIVE_PATTERNS = re.compile(r"""(?:^|\s|&&|\|\||;|`)(?:
rm\s|rmdir\s| # 删除
mv\s| # 移动/重命名
sed\s+-i| # 原地编辑
truncate\s|dd\s|shred\s| # 覆写
git\s+(?:reset|clean|checkout)\s # Git 破坏性操作
)""", re.VERBOSE)
匹配到的命令会触发审批机制——通过 clarify_callback 让用户确认执行。
设计启示
Hermes 的工具系统展示了一个实用的插件架构模式:
- 自注册消除了统一 registry 文件:每个工具在 import 时自注册;但对仓库内置工具来说,仍要接入
_discover_tools()和toolsets.py - 延迟可用性检查:
check_fn在 schema 检索时才执行,不在注册时执行——解耦了工具代码和运行环境 - 异常隔离:工具导入失败、check 失败、执行失败都被捕获并降级为 log/JSON error,永远不崩溃编排层
- 并行安全由数据决定:不需要每个工具声明"我能不能并行"——通过分析工具名和参数路径自动判断
第 7 章将选取 Terminal、File、Browser、MCP 四个代表性工具做深度剖面。
设计赌注回扣:工具系统的三层发现机制直接服务于 Run Anywhere 赌注——MCP 工具让 Hermes 可以接入任何实现了 MCP 协议的外部能力,而 pip 插件让社区可以扩展工具集,不受 Hermes 核心代码库的限制。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。
ToolRegistry作为独立模块早在 v0.3.0 之前就已出现,并不是 v0.7.0 才抽出来的。之后的演化重点主要在于把 MCP、插件系统和 toolset 过滤逐步接到同一条注册与调度链上。
四个工具剖面
本章核心源码:
tools/terminal_tool.py(1627 行)、tools/file_tools.py(835 行)、tools/browser_tool.py(2178 行)、tools/mcp_tool.py(2186 行)
定位:本章不平均讲全部工具,而是挑最能体现设计思想的四类做深度剖面:Terminal(执行环境抽象)、File(读写防护)、Browser(资源生命周期)、MCP(外部能力热更新)。 前置依赖:第 6 章(工具系统)。适用场景:想理解具体工具如何实现,或准备开发新工具。
为什么选这四个
在数十个工具相关文件中,大多数都遵循相同的模式:定义 schema → 实现 handler → 调用 registry.register()。但有四个工具代表了四种不同的设计问题:
| 工具 | 行数 | 核心设计问题 |
|---|---|---|
| Terminal | 1627 | 如何抽象六种执行环境 |
| File | 835 | 如何在 agent 自主操作时保护文件安全 |
| Browser | 2178 | 如何管理有状态外部资源的生命周期 |
| MCP | 2186 | 如何动态接入外部能力并支持热更新 |
剖面一:Terminal — 六种执行后端的统一抽象
tools/terminal_tool.py 是项目中最复杂的工具之一。它的核心问题不是"怎么执行命令",而是"怎么让同一个 execute_command 工具在本地、Docker、SSH、Daytona、Singularity 和 Modal 六种环境中透明运行"。
后端架构
六种后端实现在 tools/environments/ 目录下,每种继承自 BaseEnvironment:
tools/environments/
├── base.py # BaseEnvironment ABC
├── local.py # 本地执行
├── docker.py # Docker 容器
├── ssh.py # SSH 远程执行
├── daytona.py # Daytona serverless(环境休眠/唤醒)
├── singularity.py # Singularity/Apptainer(HPC 场景)
├── modal.py # Modal 直连(GPU 云)
├── managed_modal.py # Modal 托管模式
└── persistent_shell.py # 持久 shell 会话(SSH/本地复用)
后端选择通过环境变量 TERMINAL_ENV 或 config.yaml 的 terminal.backend 配置。一旦选定,对模型和编排层完全透明,模型不知道自己在调用本地 shell 还是远程 Docker 容器。
环境生命周期管理
每个后端实例有生命周期。terminal_tool.py 的后台清理线程每 60 秒检查一次(tools/terminal_tool.py:715):
# tools/terminal_tool.py(清理逻辑简化)
# 每 60 秒检查一次
if inactive_seconds > TERMINAL_LIFETIME_SECONDS: # 默认 300 秒
# 检查 process_registry 中是否有活跃后台进程
if not has_active_background_processes():
cleanup_environment(env)
关键细节:清理前会检查 process_registry 中是否有活跃的后台进程。如果 agent 启动了一个长时间运行的 build 任务,环境不会被过早清理。
危险命令检测
_is_destructive_command()(run_agent.py:254)在执行前检查命令模式(详见第 6 章)。匹配到的命令通过 clarify_callback 请求用户确认。
设计要点
Terminal 工具最有价值的设计决策是让执行环境成为正交关注点。添加新后端(比如未来的 Kubernetes Pod)只需要:
- 在
tools/environments/下实现BaseEnvironment的子类 - 在
terminal_tool.py的配置解析中添加一个新的后端名称
不需要修改编排层、工具系统或任何其他工具。
剖面二:File — 读写防护与结果控制
tools/file_tools.py 注册了 4 个工具:read_file、write_file、patch、search_files。它的设计问题不是"怎么读写文件",而是"怎么防止 agent 的文件操作失控"。
结果大小控制
# tools/file_tools.py:832-835
registry.register(name="read_file", ..., max_result_size_chars=float('inf')) # 无限
registry.register(name="write_file", ..., max_result_size_chars=100_000) # 100K
registry.register(name="patch", ..., max_result_size_chars=100_000) # 100K
registry.register(name="search_files", ..., max_result_size_chars=100_000) # 100K
read_file 的 max_result_size_chars 设为 float('inf')——允许读取任意大小的文件。但这不意味着无限制:read_file_tool 有自己的 offset 和 limit 参数(tools/file_tools.py:280),默认只返回前 500 行。模型需要显式请求更多内容。
write_file 和 patch 则设置了 100K 字符的硬上限。超过这个限制的结果会被截断并写入临时文件,返回文件路径让模型按需读取。
并发安全
File 工具的 read_file 在第 6 章的 _PARALLEL_SAFE_TOOLS 集合中,可以安全并行。write_file 和 patch 在 _PATH_SCOPED_TOOLS 中——只有当目标路径不重叠时才允许并行。
剖面三:Browser — 有状态资源的生命周期管理
tools/browser_tool.py(2178 行)是工具系统里最大的实现文件之一。它的核心设计问题是:浏览器会话是有状态的外部资源,怎么防止泄漏?
三层清理防线
graph TD
A["第 1 层:超时清理线程<br/>每 30 秒检查不活跃会话"] --> D["会话释放"]
B["第 2 层:atexit 紧急清理<br/>进程退出时触发"] --> D
C["第 3 层:Provider 级 emergency_cleanup<br/>单个会话强制释放"] --> D
第 1 层:后台守护线程(tools/browser_tool.py:367)每 30 秒检查一次,清理超过 BROWSER_SESSION_INACTIVITY_TIMEOUT(默认 300 秒)不活跃的会话。
第 2 层:atexit.register(_emergency_cleanup_all_sessions)(tools/browser_tool.py:407)在进程退出时触发紧急清理。使用 _cleanup_done 标志防止重复执行。
第 3 层:每个 browser provider(Browserbase、Firecrawl、browser_use)实现自己的 emergency_cleanup(session_id) 方法,处理 provider 级别的资源释放。
为什么不用 SIGTERM handler
tools/browser_tool.py:401-404 的注释解释了为什么不用信号处理器:
Previous versions installed SIGINT/SIGTERM handlers, but this corrupts the coroutine state and makes the process unkillable. atexit is safer.
信号处理器在 asyncio 环境中会破坏协程状态——而浏览器工具的底层大量使用 asyncio(Playwright、CDP 连接等)。atexit 更安全,因为它在 Python 解释器正常退出流程中被调用。
设计要点
Browser 工具的教训是:有状态外部资源必须有多层清理防线。单一清理机制(比如只靠超时)在进程崩溃时会失效。三层防线(超时 + atexit + provider 级清理)确保了在正常退出、异常退出、甚至 provider 内部错误三种场景下都能释放资源。
剖面四:MCP — 外部能力的动态接入
tools/mcp_tool.py(2186 行)实现了 Model Context Protocol 的客户端。它的设计问题是:怎么让外部进程的工具像内置工具一样被发现、注册和调度?
发现流程
# model_tools.py:172-177
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
discover_mcp_tools()(tools/mcp_tool.py:1950)读取 config.yaml 中的 MCP server 配置,为每个 server 启动进程,通过 MCP 协议获取工具列表,然后为每个工具调用 registry.register()。
MCP 工具的名称会添加 server 前缀以避免冲突(如 mcp_github_create_issue)。
热更新:tools/list_changed
MCP 协议支持 server 端通知 client 工具列表变化。Hermes 的处理(tools/mcp_tool.py:755):
# tools/mcp_tool.py(简化)
# 收到 notifications/tools/list_changed
# 1. Deregister 该 server 所有旧工具
for prefixed_name in old_tool_names:
registry.deregister(prefixed_name)
# 2. 重新获取工具列表
new_tools = await server.list_tools()
# 3. 注册新工具
for tool in new_tools:
registry.register(prefixed_name, ...)
这个 deregister → re-fetch → register 的模式让 MCP server 可以在运行时添加/移除工具,Hermes 会自动跟上——不需要重启。
设计要点
MCP 工具展示了 ToolRegistry 设计的灵活性:deregister() 方法的存在让动态工具管理成为可能。如果 registry 只支持 register() 不支持 deregister(),MCP 热更新就无法实现。
四个剖面的共同启示
| 剖面 | 核心问题 | Hermes 的解法 | 可复用的模式 |
|---|---|---|---|
| Terminal | 执行环境多态 | BaseEnvironment ABC + 正交配置 | 策略模式解耦环境差异 |
| File | 结果大小控制 | max_result_size_chars + offset/limit | 注册时声明资源限制 |
| Browser | 有状态资源泄漏 | 三层清理防线 | atexit > 信号处理器 |
| MCP | 动态能力接入 | deregister + re-register | Registry 支持双向操作 |
第 8 章将转向技能系统——Hermes "self-improving" 理念的核心实现。
设计赌注回扣:Terminal 的六种后端直接服务于 Run Anywhere(从本地到 Modal GPU 集群透明切换);MCP 的动态接入服务于 Learning Loop(agent 可以通过 MCP 扩展自己的能力边界,不受核心代码库限制)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 终端后端、browser 清理和 MCP 动态发现都不是同一个 release 一次做完的。可以明确确认的是:Daytona 已在 v0.3.0 发布窗口出现,
notifications/tools/list_changed驱动的 MCP 热更新在 v0.6.0 发布窗口出现;其余隔离与清理策略则在后续几个 release 中持续收紧。
技能系统:从经验中学习的闭环
本章核心源码:
agent/skill_utils.py(442 行)、tools/skills_tool.py(1376 行)、tools/skill_manager_tool.py(742 行)
定位:本章分析 Hermes "self-improving" 理念的核心实现——技能系统。技能不是静态文档,而是 agent 从工作中提炼、在使用中改进、按条件加载的过程性知识。 前置依赖:第 5 章(提示词系统)、第 6 章(工具系统)。适用场景:想理解"self-improving agent"如何在工程上实现。
为什么技能系统是 Hermes 的核心差异
大多数 agent 框架有工具系统("agent 能做什么")和 prompt 系统("agent 知道什么")。Hermes 多了第三个系统——技能系统("agent 学到了什么")。
三者的关系:
| 系统 | 内容类型 | 变化频率 | 来源 |
|---|---|---|---|
| 工具 | 可执行的函数 | 低(开发者添加) | 代码 |
| Prompt | 身份和规则 | 低(配置/硬编码) | 配置文件 |
| 技能 | 过程性知识 | 高(agent 自主创建/改进) | 工作经验 |
技能填补了一个空白:agent 在解决一个复杂问题后,如果没有技能系统,下次遇到同类问题需要从头探索。有了技能系统,agent 可以把解决过程提炼为可复用的技能——下次直接检索使用。
这就是 Learning Loop 赌注的工程实现。
技能的数据模型
每个技能是一个 Markdown 文件,带有 YAML frontmatter:
---
metadata:
hermes:
fallback_for_toolsets: [web]
tags: [automation, workflow]
platforms: [macos, linux]
---
# 使用 Exa API 搜索学术论文
## 问题
标准 web_search 对学术论文的检索效果不佳...
## 方案
使用 Exa 的 neural search 模式,配合 category=research...
## 示例
```python
result = web_search(query="transformer attention mechanism", ...)
### Frontmatter 解析
`parse_frontmatter()`(`agent/skill_utils.py:52`)负责解析 YAML frontmatter:
```python
# agent/skill_utils.py:52-86
def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
if not content.startswith("---"):
return {}, content
end_match = re.search(r"\n---\s*\n", content[3:])
yaml_content = content[3 : end_match.start() + 3]
try:
parsed = yaml_load(yaml_content) # CSafeLoader(快速)+ fallback
except Exception:
# Fallback: simple key:value parsing for malformed YAML
for line in yaml_content.strip().split("\n"):
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip()
return frontmatter, body
设计选择:优先使用 CSafeLoader(C 实现,快);如果 YAML 格式有问题,fallback 到逐行 key: value 解析。这让系统对格式不完美的技能文件保持鲁棒。
条件加载
Frontmatter 支持多种条件字段:
平台过滤(agent/skill_utils.py:92):
platforms: [macos, linux] # 只在 macOS 和 Linux 上加载
skill_matches_platform() 将 platforms 列表与 sys.platform 比较。缺失或空表示兼容所有平台。
工具依赖(agent/skill_utils.py:240):
metadata:
hermes:
fallback_for_toolsets: [web] # 当 web toolset 不可用时激活
requires_toolsets: [terminal] # 需要 terminal toolset 才加载
requires_tools: [web_search] # 需要特定工具才加载
extract_skill_conditions() 提取这些条件,由 build_skills_system_prompt() 在构建技能索引时评估。
禁用列表(agent/skill_utils.py:121):
# config.yaml
skills:
disabled: [deprecated-skill]
platform_disabled:
telegram: [desktop-only-skill]
get_disabled_skill_names() 支持全局禁用和按平台禁用。Gateway 的 Telegram 平台可以禁用只在桌面端有意义的技能。
技能的生命周期
graph LR
A["发现<br/>扫描 skills/ 目录"] --> B["索引<br/>提取 name + description"]
B --> C["注入<br/>写入 system prompt"]
C --> D["检索<br/>skill_view 按需加载"]
D --> E["使用<br/>模型根据内容执行"]
E --> F["创建<br/>skill_manage create"]
F --> G["改进<br/>skill_manage patch"]
G --> D
阶段 1:发现
get_all_skills_dirs()(agent/skill_utils.py:226)返回技能搜索路径:
# agent/skill_utils.py:226-234
def get_all_skills_dirs() -> List[Path]:
dirs = [get_hermes_home() / "skills"] # 当前 HERMES_HOME/skills/(总是第一位)
dirs.extend(get_external_skills_dirs()) # config.yaml 中的 external_dirs
return dirs
阶段 2:索引
build_skills_system_prompt()(agent/prompt_builder.py:529)扫描所有技能目录,为每个技能提取名称和一句话描述,生成索引列表注入 system prompt。
索引只包含标题和描述——完整内容在模型需要时通过工具按需加载。这个设计控制了 system prompt 的 token 开销:26 个技能类别的索引只占几百 token,但完整内容可能超过 50K token。
阶段 3-4:检索与使用
模型通过 skill_view 工具按名称检索技能的完整内容:
# tools/skills_tool.py:787
def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
# 在所有 skills 目录中搜索匹配的技能文件
# 返回完整的 Markdown 内容
阶段 5-6:创建与改进
这是 Learning Loop 的核心。模型通过 skill_manage 工具创建和改进技能:
# tools/skill_manager_tool.py:569
def skill_manage(action: str, name: str = None, content: str = None,
patch: str = None, ...):
# action = "create": 创建新技能
# action = "patch": 改进现有技能
# action = "delete": 删除技能
SKILLS_GUIDANCE(agent/prompt_builder.py:164)引导模型的行为:
"After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill... When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage(action='patch') — don't wait to be asked."
两个关键指令:
- 自主创建:完成复杂任务后主动保存为技能
- 使用时改进:发现技能过时或不准确时立即 patch
这让技能系统形成了一个正反馈循环:使用越多 → 发现越多问题 → 改进越多 → 质量越高 → 使用越有效。
Nudge 机制:主动触发学习
agent 不会"忘记"创建技能——AIAgent 中的 nudge 机制会定期提醒:
# run_agent.py:1072-1076
self._skill_nudge_interval = 10 # 每 10 个 tool iteration 检查
self._iters_since_skill = 0
当模型在一次对话中使用了 10+ 个工具调用但没有触碰 skill_manage,编排器会在 _spawn_background_review() 中启动一个后台 agent 审查对话内容,判断是否值得创建技能(详见第 4 章)。
与 agentskills.io 的对接
Hermes 的技能格式兼容 agentskills.io 开放标准。这意味着:
- 社区创建的技能可以直接放入当前
HERMES_HOME/skills/(默认 profile 下表现为~/.hermes/skills/) - Hermes 创建的技能可以分享到 Skills Hub
- 不同 agent 框架之间的技能可以互通
技能 vs 工具 vs Prompt:三种知识的区分
| 维度 | 工具 | Prompt | 技能 |
|---|---|---|---|
| 形式 | Python 函数 | 文本块 | Markdown 文件 |
| 变更频率 | 低(版本发布) | 低(配置修改) | 高(agent 自主创建) |
| 作者 | 开发者 | 开发者/用户 | Agent 自身 |
| 加载方式 | 模块导入 | 构建时注入 system prompt | 索引注入 + 按需检索 |
| 可改进 | 否(需改代码) | 手动 | Agent 自动 patch |
这个区分不是学术分类,而是有工程后果的:如果技能被实现为工具,每次添加技能就需要修改代码;如果技能被实现为 prompt,所有技能内容都会占用 system prompt token。Markdown + 按需检索的方案既让 agent 自主管理,又控制了 token 开销。
设计启示
技能系统的设计展示了 "self-improving" 的工程实现路径:
- 低成本创建:Markdown 文件 + YAML frontmatter,没有 schema 验证、没有编译、没有注册步骤
- 渐进式加载:索引在 system prompt,完整内容按需检索——控制基础 token 开销
- 正反馈循环:使用时发现问题 → 立即 patch → 下次使用时更好
- Nudge 而非强制:通过 background review 提醒,不强制每次都创建技能
第 9 章将分析子代理与委托机制——另一种扩展 agent 能力的方式。
设计赌注回扣:本章是 Learning Loop 赌注的核心实现。技能系统让 Hermes 从"能用工具做事的 agent"升级为"能从经验中学习的 agent"。
SKILLS_GUIDANCE的"使用时立即 patch"指令和_spawn_background_review的自动审查构成了完整的学习闭环。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 技能文件发现、
skill_manage和基础 skills 目录迁移,在 v0.3.0 发布窗口之前就已出现;v0.4.0 发布窗口又加入了 background review 驱动的自动审查。之后 v0.5.0-v0.8.0 之间继续补足条件加载、展示层和 agentskills.io 兼容细节。
子代理与委托:递归编排的设计
本章核心源码:
tools/delegate_tool.py(978 行)、run_agent.py(IterationBudget 168-209 行,中断传播 609-617 行)
定位:本章分析子代理的 spawn 机制、IterationBudget 在父子间的分配、会话隔离策略、以及 Memory Provider 的 on_delegation 钩子。 前置依赖:第 4 章(AIAgent 内核)、第 6 章(工具系统)。适用场景:想理解 agent 如何并行化工作流,或设计多 agent 协作系统。
为什么委托不只是"调用一个工具"
模型可以调用 delegate_task 把一个子任务交给另一个 agent 处理。这听起来像一次普通的工具调用,但实际涉及三个非平凡的工程问题:
- 资源隔离:子代理需要独立的 iteration budget、session、memory context
- 中断传播:用户中断时,父代理需要通知所有活跃的子代理
- 深度控制:防止子代理递归 spawn 孙代理导致资源爆炸
子代理的构造
delegate_task 最终通过 _build_child_agent()(tools/delegate_tool.py:202)构造子代理:
# tools/delegate_tool.py:287-316(简化)
child = AIAgent(
model=effective_model, # 继承父代理模型
max_iterations=max_iterations, # 独立预算(默认 50)
quiet_mode=True, # 静默运行
ephemeral_system_prompt=child_prompt, # 子任务专用指令
platform=parent_agent.platform, # 继承平台信息
skip_context_files=True, # 不加载 AGENTS.md 等
skip_memory=True, # 不加载记忆系统
session_db=parent_agent._session_db, # 共享 SessionDB
parent_session_id=parent_agent.session_id, # 建立父子血缘
iteration_budget=None, # 创建独立 budget
)
child._delegate_depth = parent_agent._delegate_depth + 1
继承与隔离的平衡
| 属性 | 策略 | 原因 |
|---|---|---|
| 模型/Provider/API key | 继承 | 子代理使用相同的 LLM 服务 |
| Toolset | 继承(支持覆盖) | 子代理通常需要和父代理相同的工具能力 |
| IterationBudget | 独立创建 | 防止子代理消耗父代理的预算 |
| Memory | 跳过 | 子代理不需要用户记忆,减少 token 开销 |
| Context files | 跳过 | 子代理不需要 AGENTS.md、SOUL.md |
| SessionDB | 共享 | 子代理的对话记录写入同一个数据库 |
| Session ID | 新建(parent_session_id 链接) | 保持会话血缘但独立记录 |
关键设计:skip_memory=True 和 skip_context_files=True。子代理不加载用户的 MEMORY.md、USER.md 和 SOUL.md——这不仅减少了约 2000+ token 的 system prompt 开销,更重要的是避免了子代理误修改用户记忆。
Reasoning Effort 配置
子代理现在支持通过 delegation.reasoning_effort 配置独立的推理强度(tools/delegate_tool.py:315-332):
delegation:
reasoning_effort: "low" # 有效值: none / minimal / low / medium / high / xhigh
解析优先级遵循"显式覆盖 > 继承":
- delegation 配置覆盖:如果
delegation.reasoning_effort有值且合法,子代理使用该值 - 继承父代理:否则继承父代理的
reasoning_config
# tools/delegate_tool.py:315-332(简化)
parent_reasoning = getattr(parent_agent, "reasoning_config", None)
child_reasoning = parent_reasoning
delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip()
if delegation_effort:
parsed = parse_reasoning_effort(delegation_effort)
if parsed is not None:
child_reasoning = parsed
else:
logger.warning("Unknown delegation.reasoning_effort '%s', inheriting parent level", delegation_effort)
这个设计让用户可以对子代理使用更低的推理强度以节省 token 成本——子代理的任务通常比主 agent 更聚焦,不需要同等深度的推理。
IterationBudget 的独立分配
# run_agent.py:168-209
class IterationBudget:
def __init__(self, max_total: int):
self.max_total = max_total
self._used = 0
self._lock = threading.Lock()
父代理默认 90 次迭代,子代理默认 50 次迭代(通过 config.yaml 的 delegation.max_iterations 配置)。两者互不消耗——子代理的 50 次迭代不会从父代理的 90 次中扣除。
这意味着一次对话的总迭代次数可能超过 90——如果父代理 spawn 了 3 个子代理,理论最大值是 90 + 3×50 = 240 次。这是有意的设计:子代理处理的是独立子任务,不应受父代理预算的约束。
execute_code 的退款机制
# IterationBudget.refund()
def refund(self) -> None:
with self._lock:
if self._used > 0:
self._used -= 1
execute_code 工具(通过 RPC 调用工具的程序化接口)的迭代会被退款。原因:程序化工具调用是 agent 内部的效率优化手段,不应消耗面向用户的 budget。
中断传播
当用户中断父代理时,需要通知所有活跃的子代理:
# tools/delegate_tool.py:327-334
# 注册子代理用于中断传播
if hasattr(parent_agent, '_active_children'):
with parent_agent._active_children_lock:
parent_agent._active_children.append(child)
# run_agent.py:609-617
self._interrupt_requested = False
self._interrupt_message = None
self._active_children = [] # 运行中的子代理列表
self._active_children_lock = threading.Lock()
中断时,父代理遍历 _active_children,设置每个子代理的 _interrupt_requested = True。子代理在主循环的每次迭代开始时检查这个标志。
深度控制
# tools/delegate_tool.py:319
child._delegate_depth = parent_agent._delegate_depth + 1
_delegate_depth 从 0(顶层 agent)开始递增。子代理在构造时会检查深度——默认不允许超过 1 层(即子代理不能 spawn 孙代理)。这防止了递归委托导致的资源爆炸。
Memory Provider 的 on_delegation 钩子
当子代理完成任务后,MemoryProvider 的 on_delegation() 钩子被触发:
# agent/memory_provider.py(ABC 方法)
def on_delegation(self, task: str, result: str, **kwargs) -> None:
"""Called when a delegate task completes. Observe subagent work."""
pass
这让外部 memory provider(如 Honcho)可以观察子代理的工作内容,将其纳入用户建模。例如:如果用户经常把"代码审查"委托给子代理,Honcho 可以记录这个偏好。
详见第 11 章(Memory Provider)。
并行委托
delegate_task 支持同时 spawn 多个子代理处理不同子任务。每个子代理在独立线程中运行,通过 ThreadPoolExecutor 并行执行。结果收集后合并返回给父代理。
# tools/delegate_tool.py(并行执行简化)
with ThreadPoolExecutor(max_workers=min(len(tasks), 4)) as executor:
futures = [executor.submit(_run_single_child, i, task, child) for i, (task, child) in enumerate(zip(tasks, children))]
results = [f.result() for f in futures]
设计启示
子代理机制展示了"扩展 agent 能力"的两种互补方式:
| 方式 | 适用场景 | 优势 | 代价 |
|---|---|---|---|
| 工具 | 单步操作(搜索、读写文件) | 低开销,一次调用 | 不能多步推理 |
| 子代理 | 多步任务(代码审查、独立调研) | 完整的 agent 推理能力 | 独立 budget + LLM 调用 |
Hermes 通过 delegation.max_iterations 让用户控制子代理的成本上限,通过 _delegate_depth 防止递归失控,通过 skip_memory + skip_context_files 最小化子代理的 token 开销。
第 10 章将进入状态层,分析 SessionDB 如何持久化这些父子代理的对话记录。
设计赌注回扣:子代理机制服务于 Run Anywhere(子代理在独立线程中运行,Gateway 场景下多个用户的子代理可以并发执行)和 Learning Loop(
on_delegation钩子让 memory provider 观察子代理行为,纳入用户建模)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 子代理机制在 v0.3.0 发布窗口就已经具备雏形;独立的
IterationBudget也属于这一时期的设计。之后 v0.4.0-v0.6.0 之间继续补齐了更强的后台审查、中断传播和深度控制等细节。v0.8.x 新增了delegation.reasoning_effort配置,允许子代理使用独立于父代理的推理强度。
Session 与 SessionDB:会话状态的持久化
本章核心源码:
hermes_state.py(1304 行)
定位:本章拆解 SessionDB 的 SQLite schema、WAL 并发安全、FTS5 全文检索和 session 血缘链。 前置依赖:第 4 章(AIAgent 内核)。适用场景:想理解对话如何被持久化,或需要查询历史会话数据。
为什么需要一个专用的会话存储
Hermes 作为长期运行的个人代理,每一次对话都需要持久化,不仅为了历史回顾,更为了跨会话回忆(session_search 工具)、上下文压缩后的 session splitting,以及 Gateway 多平台并发写入。
早期版本使用 per-session JSONL 文件。这在单用户 CLI 场景下勉强可用,但 Gateway 场景(多平台并发、需要跨会话搜索)暴露了文件方案的局限。SessionDB 引入 SQLite 作为结构化主存储,提供查询、全文检索和并发安全;不过当前代码仍处于迁移期,Gateway 还保留着 legacy JSONL 的兼容双写与回退路径。
Schema 设计
hermes_state.py:36 定义了三张核心表:
-- hermes_state.py:41-68
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL, -- "cli", "telegram", "discord", ...
user_id TEXT, -- Gateway 场景的用户标识
model TEXT,
system_prompt TEXT, -- 冻结的 system prompt 快照
parent_session_id TEXT, -- 压缩后的父 session 链接
started_at REAL NOT NULL,
ended_at REAL,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL,
title TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- "user", "assistant", "tool"
content TEXT,
tool_call_id TEXT,
tool_calls TEXT, -- JSON 序列化的工具调用
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
-- FTS5 虚拟表:跨会话全文搜索
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
为什么存 system_prompt?
sessions.system_prompt 存储了每个 session 的完整 system prompt 快照。这不是为了审计,而是为了在会话恢复、缓存失效或进程重建时复原同一段冻结前缀。Gateway 在正常路径下会优先复用 _agent_cache 中的 AIAgent 实例(详见第 14 章),但当缓存被驱逐、进程重启、或需要根据历史记录继续会话时,SessionDB 中保存的 system prompt 快照就成为精确恢复 prompt cache 前缀的重要依据(详见第 5 章“System Prompt 构建”节)。
parent_session_id:压缩后的血缘链
当上下文压缩触发 session splitting 时(详见第 12 章),Hermes 创建一个新 session,将压缩后的消息写入新 session,并通过 parent_session_id 链接到旧 session。这形成了一条血缘链——用户可以追溯一次长对话的完整历史。
WAL 并发安全
SessionDB 需要在多进程/多线程环境下安全工作:CLI + Gateway + Cron 可能同时写入同一个 state.db。
WAL 模式
# hermes_state.py:157
self._conn.execute("PRAGMA journal_mode=WAL")
WAL(Write-Ahead Logging)模式允许多个读者和一个写者并发工作。读者不会被写者阻塞,写者也不会被读者阻塞。
应用级 Jitter Retry
SQLite 的内置 busy handler 使用确定性的退避时间表——这在多个进程同时争抢写锁时会产生 convoy effect(所有进程在相同的时间点重试,再次冲突)。
Hermes 的解法(hermes_state.py:164-214):
# hermes_state.py:164-214
def _execute_write(self, fn):
for attempt in range(15): # 最多 15 次重试
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE") # 立即获取写锁
try:
result = fn(self._conn)
self._conn.commit()
except BaseException:
self._conn.rollback()
raise
# 每 50 次写入做一次 PASSIVE checkpoint
self._write_count += 1
if self._write_count % 50 == 0:
self._try_wal_checkpoint()
return result
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower():
jitter = random.uniform(0.020, 0.150) # 20-150ms 随机延迟
time.sleep(jitter)
continue
raise
三个关键设计:
- BEGIN IMMEDIATE:在事务开始时就获取写锁(而非默认的 deferred 模式在 commit 时才获取)——让锁竞争在最早时刻暴露
- 随机 jitter:20-150ms 的随机延迟打破 convoy effect
- PASSIVE checkpoint:每 50 次写入触发一次 WAL checkpoint,将 WAL 帧回写到主数据库文件——防止 WAL 文件无限增长
为什么不用 ORM
SessionDB 直接使用 sqlite3 模块,没有 ORM。原因:
- Hermes 需要精确控制事务边界(BEGIN IMMEDIATE)
- ORM 的连接池和自动事务管理会与自定义的 jitter retry 冲突
- 查询简单到不需要 ORM 的抽象
FTS5 全文检索
messages_fts 虚拟表让 agent 可以搜索所有历史会话的消息内容。session_search 工具(tools/session_search_tool.py)使用它实现跨会话回忆:
# 用户:"上次我们讨论的那个 deploy 脚本在哪?"
# Agent 调用 session_search(query="deploy script")
# → FTS5 搜索 messages_fts → 返回匹配的历史消息
FTS5 的优势是零配置全文搜索——不需要外部搜索引擎,不需要向量嵌入,不需要网络调用。对于个人代理的历史搜索场景,这比复杂的 RAG 方案更实用。
设计启示
SessionDB 展示了"为个人代理选择存储方案"的关键考量:
- SQLite > PostgreSQL:零配置、单文件部署、无守护进程——适合 $5 VPS 场景
- WAL > 默认 journal:支持并发读写——Gateway 多平台并发的必需
- 应用级 retry > SQLite busy handler:随机 jitter 打破 convoy effect
- FTS5 > 向量搜索:对于精确的文本匹配,FTS5 更简单可靠
第 11 章将分析 Memory Provider——在 SessionDB 之上的可插拔记忆层。
设计赌注回扣:SessionDB 直接服务于 Personal Long-Term(FTS5 让 agent 可以搜索所有历史对话)和 Run Anywhere(WAL + jitter retry 让 SQLite 在多进程/多平台并发下稳定工作,不需要外部数据库服务)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月),Schema Version 6。 SessionDB 和 SQLite 会话存储早在 v0.3.0 之前就已经进入代码库;之后一直演化到今天的 Schema Version 6。到本书写作时,它仍处在“SQLite 主存储 + legacy transcript 双写兼容”的迁移阶段,成本字段和会话元数据也在最近几个 release 中持续扩展。
Memory Provider 架构:可插拔的记忆后端
本章核心源码:
agent/memory_provider.py(231 行)、agent/memory_manager.py(367 行)、agent/builtin_memory_provider.py(114 行)、plugins/memory/
定位:本章将 Memory Provider 系统作为“如何设计可插拔记忆系统”的案例来写,涵盖 MemoryProvider ABC、MemoryManager 的职责边界、当前运行时里内置记忆与外部 provider 的接线关系,以及 8 个外部插件。 前置依赖:第 10 章(SessionDB)。适用场景:想理解 agent 如何实现"越用越懂你",或准备开发自定义 memory provider。
为什么记忆需要可插拔
SessionDB(第 10 章)存储的是会话状态——完整的消息历史。但"记忆"不等于"历史"。
- 历史是原始数据:用户在第 37 次对话中说了什么
- 记忆是提炼后的知识:用户偏好 Python 而非 JavaScript、用户的项目使用 PostgreSQL
不同的记忆策略各有优劣:有的擅长用户建模(Honcho),有的擅长向量检索(Mem0),有的擅长时序窗口(Hindsight)。Hermes 的解法不是选一种,而是提供一个可插拔的抽象层,让用户按需选择。
MemoryProvider ABC
MemoryProvider(agent/memory_provider.py:42)定义了 17 个方法的抽象接口:
graph TD
subgraph "生命周期方法"
INIT["initialize()"] --> PROMPT["system_prompt_block()"]
PROMPT --> PREFETCH["prefetch()"]
PREFETCH --> SYNC["sync_turn()"]
SYNC --> SHUTDOWN["shutdown()"]
end
subgraph "事件钩子"
ON_TURN["on_turn_start()"]
ON_SESSION["on_session_end()"]
ON_COMPRESS["on_pre_compress()"]
ON_DELEGATE["on_delegation()"]
ON_WRITE["on_memory_write()"]
end
subgraph "工具接口"
SCHEMA["get_tool_schemas()"]
HANDLE["handle_tool_call()"]
end
subgraph "配置"
AVAIL["is_available()"]
CONFIG["get_config_schema()"]
SAVE["save_config()"]
end
核心生命周期(4 个 @abstractmethod)
# agent/memory_provider.py:42-139
class MemoryProvider(ABC):
@property
@abstractmethod
def name(self) -> str: ... # "honcho", "mem0", ...
@abstractmethod
def is_available(self) -> bool: ... # 依赖检查
@abstractmethod
def initialize(self, session_id, **kwargs): ... # 连接/初始化
@abstractmethod
def system_prompt_block(self) -> str: ... # 注入 system prompt 的文本
数据流方法(默认空实现)
| 方法 | 调用时机 | 作用 |
|---|---|---|
prefetch(query) | 主循环前 | 根据用户消息预取相关记忆 |
queue_prefetch(query) | 主循环后 | 为下一轮预取排队(后台) |
sync_turn(user, assistant) | 主循环后 | 将本轮对话同步到记忆存储 |
shutdown() | session 结束 | 清理连接 |
事件钩子(可选)
| 钩子 | 触发时机 | 用途 |
|---|---|---|
on_turn_start(turn, message) | 每轮对话开始 | 每轮 tick(如更新用户模型) |
on_session_end(messages) | session 结束 | 最终记忆归档 |
on_pre_compress(messages) | 压缩前 | 在消息被压缩丢弃前提取信息 |
on_delegation(task, result) | 子代理完成 | 观察子代理工作 |
on_memory_write(action, target, content) | 内置记忆被修改 | 镜像内置记忆的写入 |
on_memory_write 值得特别说明:当 agent 通过内置 memory 工具修改 MEMORY.md 时,外部 provider 会收到通知。这让 Honcho 可以将内置记忆的变更纳入自己的用户模型——两个系统保持同步。
MemoryManager:抽象目标与当前接线
MemoryManager(agent/memory_manager.py:72)的抽象目标是统一编排内置 provider 和一个外部 provider;agent/builtin_memory_provider.py 也已经为此准备好了适配层:
# agent/memory_manager.py:72-86
class MemoryManager:
def __init__(self):
self._providers: List[MemoryProvider] = []
self._tool_provider_map: Dict[str, MemoryProvider] = {}
def add_provider(self, provider: MemoryProvider):
self._providers.append(provider)
for schema in provider.get_tool_schemas():
self._tool_provider_map[schema["name"]] = provider
但当前主运行路径还没有完全收敛到这条抽象线上。run_agent.py 里的接线是双轨的:
- 内置记忆
MEMORY.md / USER.md仍由MemoryStore直接加载,并直接注入 system prompt MemoryManager当前主路径承接的是外部 provider:初始化、system prompt block、prefetch、tool routing、sync、压缩前钩子
换句话说,BuiltinMemoryProvider 更像是代码库已经存在的统一抽象方向,而不是当前运行时唯一的内置记忆接线方式。
Fan-out 容错
所有 fan-out 方法都对每个 provider 独立 try/except:
# agent/memory_manager.py:204(简化)
def sync_all(self, user_content, assistant_content, *, session_id=""):
for provider in self._providers:
try:
provider.sync_turn(user_content, assistant_content, session_id=session_id)
except Exception as exc:
logger.warning("Memory provider %s sync failed: %s", provider.name, exc)
一个 provider 失败不影响其他 provider,也不影响主对话流程。这是 graceful degradation 原则的直接应用。当前主路径通常只挂一个外部 provider,但 manager 的接口已经按多 provider 容错来设计。
为什么只允许一个外部 provider
设计上限制最多一个外部 provider。原因很直接:多个外部 provider 的 prefetch 结果可能冲突,tool schema 可能重名,成本也会线性增长。Hermes 的现实取舍不是“堆多个后端”,而是“保留一套内置记忆,再允许用户额外接一个最想要的外部记忆后端”。
BuiltinMemoryProvider 与当前主路径
agent/builtin_memory_provider.py(114 行)实现了最简单的记忆方案:
MEMORY.md:agent 的持久记忆(用户偏好、环境细节、工具经验)USER.md:用户画像(角色、背景、习惯)
需要特别注意:当前主路径里,真正负责读写这两份 Markdown 的仍是 MemoryStore(tools/memory_tool.py),不是 BuiltinMemoryProvider。 也就是说,这个 provider 已经存在,但运行时还没有完全把内置记忆切到 provider 管理器里。现阶段已经落地的桥接点主要有两处:
- system prompt 仍由
run_agent.py直接从MemoryStore读取并拼装 - 当内置
memory工具写入 MEMORY.md / USER.md 时,run_agent.py会调用MemoryManager.on_memory_write()通知外部 provider
这种状态反映的是一次增量重构中的中间形态:抽象已经建立,统一接线尚未彻底完成。无论是旧路径还是抽象路径,字符数上限仍由 memory_char_limit=2200、user_char_limit=1375 控制。
8 个外部 Provider
| Provider | 方案 | 特色 |
|---|---|---|
| Honcho | Dialectic 用户建模 | Plastic Labs 的对话式用户模型,自动生成用户画像 |
| Hindsight | 时序滑动窗口 | 按时间衰减的记忆,近期对话权重高 |
| Mem0 | 向量数据库 | Qdrant 后端,语义相似度检索 |
| Holographic | 压缩全息存储 | 将对话压缩为高密度表示 |
| OpenViking | 语义嵌入 | 嵌入向量驱动的语义记忆 |
| RetainDB | 保留策略 | 可配置的记忆保留规则 |
| SuperMemory | 多源聚合 | 聚合多个来源的记忆 |
| ByteRover | 替代向量存储 | 轻量级向量存储方案 |
每个 provider 实现 MemoryProvider ABC 的对应方法。以 Honcho 为例:它的 prefetch() 返回用户的对话式画像,sync_turn() 将每轮对话提交给 Honcho API 更新用户模型,on_memory_write() 将内置记忆的变更同步到 Honcho 的知识图谱。
设计启示
Memory Provider 系统展示了可插拔架构的三个关键决策:
- ABC 定义生命周期而非行为:17 个方法中只有 4 个是 abstract——其余 13 个有默认空实现,让 provider 只需实现自己关心的部分
- 增量收敛而非一次性替换:Hermes 先引入统一的 provider 抽象,再逐步把原有内置记忆逻辑向它收拢,这降低了重构风险
- Mirror hook:
on_memory_write让内置记忆和外部 provider 可以保持同步,即使在“双轨接线”阶段也能减少两套记忆系统的分歧
第 12 章将分析上下文压缩——当对话太长时,如何在不丢失关键信息的情况下缩减消息历史。
设计赌注回扣:Memory Provider 是 Personal Long-Term 赌注的核心基础设施。Honcho 的用户建模让 agent "越用越懂你"从理念变为可测量的工程实现。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。
MemoryProvider/MemoryManager这套可插拔抽象可以明确定位到 v0.7.0 发布窗口。它进入主线之后,provider hook 和外部记忆后端的生态在 v0.7.0-v0.8.0 之间快速成型。
上下文管理:长对话为什么不会失控
本章核心源码:
agent/context_compressor.py(696 行)、run_agent.py(预飞行压缩 7000-7049 行,主循环内压缩触发)
定位:本章分析 Hermes 如何在长会话中维持可持续性——从 tool output pruning 到结构化摘要到 session splitting。 前置依赖:第 10 章(SessionDB)、第 11 章(Memory Provider)。适用场景:想理解 agent 如何处理长对话而不崩溃或丢失关键信息。
为什么长对话是个工程问题
LLM 的上下文窗口是有限的(128K-200K tokens)。一个包含 30 次工具调用的对话可能消耗 80K+ tokens——接近窗口上限。超限时 API 返回 context_length_exceeded 错误,对话被中断。
Hermes 的解法不是简单地截断旧消息,而是一个分层压缩策略:
graph TD
A["第 1 步:Tool Output Pruning<br/>廉价,无 LLM 调用"] --> B["第 2 步:Head/Tail 保护<br/>保护首尾消息"]
B --> C["第 3 步:Middle 摘要<br/>LLM 生成结构化摘要"]
C --> D["第 4 步:Session Splitting<br/>创建新 session,链接血缘"]
D --> E["迭代更新<br/>后续压缩更新已有摘要"]
ContextEngine:可插拔上下文引擎
ContextCompressor 现在继承自 ContextEngine ABC(agent/context_engine.py:32)。这个抽象基类将上下文管理层变成了一个可插拔的扩展点——第三方引擎(如 LCM、自定义摘要引擎)可以替换内置的压缩器,只需在配置中指定 context.engine。
ContextEngine ABC 定义了三个核心方法:
update_from_response():在每次模型响应后更新引擎的内部状态(如 token 使用量追踪)should_compress():判断当前上下文是否需要压缩compress():执行实际的压缩操作
此外,ABC 还定义了可选的生命周期钩子和工具 schema 接口,让自定义引擎可以向 agent 注入额外的工具(例如,一个基于 LCM 的引擎可能暴露 "recall_context" 工具,让模型主动从压缩历史中检索信息)。
内置的 ContextCompressor 完整实现了这个 ABC,行为与之前一致。这个重构的价值在于不破坏现有行为的前提下打开了扩展性——用户可以通过配置切换引擎,而编排层(AIAgent)只面向 ContextEngine 接口编程。
ContextCompressor
ContextCompressor(agent/context_compressor.py:53)封装了完整的压缩策略:
# agent/context_compressor.py:64-94(关键参数)
class ContextCompressor:
def __init__(self, model, threshold_percent=0.50, protect_first_n=3,
protect_last_n=20, summary_target_ratio=0.20, ...):
self.context_length = get_model_context_length(model, ...)
self.threshold_tokens = int(self.context_length * threshold_percent)
self.tail_token_budget = int(self.threshold_tokens * summary_target_ratio)
| 参数 | 默认值 | 含义 |
|---|---|---|
threshold_percent | 0.50 | 使用达到 50% 上下文时触发压缩 |
protect_first_n | 3 | 保护前 3 条消息(通常是 system + 第一轮对话) |
protect_last_n | 20 | 保护最后 20 条消息 |
summary_target_ratio | 0.20 | 尾部保护预算占阈值的 20% |
触发时机
压缩在两个地方触发:
- 预飞行(
run_agent.py:7000-7049):进入主循环前,估算 token 数,如超阈值立即压缩 - 响应后(主循环内):API 返回
context_length_exceeded或usage.prompt_tokens超阈值时压缩
第 1 步:Tool Output Pruning
# agent/context_compressor.py:155-190(简化)
def _prune_old_tool_results(self, messages, protect_tail_count):
"""Replace old tool result contents with a short placeholder."""
pruned = 0
for i in range(len(messages) - protect_tail_count):
msg = messages[i]
if msg.get("role") == "tool" and len(msg.get("content", "")) > 200:
msg["content"] = "[Tool result pruned — original too large]"
pruned += 1
return messages, pruned
第一步最廉价:不调用 LLM,只是将老的 tool 结果(超过 200 字符)替换为占位符。工具结果通常是大块 JSON(文件内容、搜索结果),压缩比很高。
第 2 步:Head/Tail 保护
消息列表: [sys, u1, a1, u2, a2, tool1, u3, a3, ..., u20, a20]
|← head 保护 →| |← tail 保护 →|
(前 3 条) (最后 20 条)
|← middle 区域 →|
↓ 这部分被摘要
Head 保护前 3 条消息(system prompt + 第一轮对话——建立上下文的关键)。Tail 保护最后 20 条消息(最近的对话上下文)。Middle 区域是压缩目标。
第 3 步:结构化摘要
Middle 区域的消息被发送给 LLM 生成结构化摘要:
# agent/context_compressor.py:253(摘要模板关键字段)
# Goal: 对话的主要目标
# Progress: 完成了什么
# Key Decisions: 做出的重要决策
# Modified Files: 修改过的文件及原因
# Next Steps: 下一步计划
摘要使用辅助模型(可配置,通常选择便宜快速的模型),不消耗主模型的 token 预算。
迭代更新
如果之前已经有过一次压缩,_previous_summary 存储了上一次的摘要。新一次压缩不是从零开始,而是更新已有摘要:
# agent/context_compressor.py:122
self._previous_summary: Optional[str] = None
这让多次压缩后的摘要仍然保持连贯——不会丢失早期的重要信息。
第 4 步:Session Splitting
压缩完成后,Hermes 创建一个新 session:
- 新 session 的
parent_session_id指向旧 session(建立血缘链,详见第 10 章) - 摘要作为新 session 的第一条消息
- 尾部保护的消息搬到新 session
- 新 session 获得新的 IterationBudget
用户感知不到 session 切换——对话无缝继续。但在 SessionDB 中,一次长对话实际上被记录为一条 session 链。
Memory Provider 的 on_pre_compress 钩子
压缩发生前,MemoryManager 会通知外部 memory provider:
# agent/memory_provider.py:163
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Extract information from messages before they are compressed away."""
return ""
Provider 可以在消息被压缩丢弃前提取有价值的信息——比如 Honcho 可以从即将被压缩的对话中提取用户偏好变更,确保信息不会在压缩中丢失。
压缩对 Prompt Cache 的影响
压缩触发 session splitting 后,system prompt 需要重建(因为新 session 的 system prompt 需要包含摘要)。这意味着 Anthropic 的 prompt cache 会 miss 一次。
但这是可接受的代价——压缩本身就意味着对话已经很长(消耗了 50%+ 上下文窗口),此时一次 cache miss 的成本远小于 context_length_exceeded 错误导致的对话中断。
设计启示
上下文压缩展示了"渐进式降级"的设计哲学:
- 先做廉价操作:tool output pruning 不需要 LLM 调用,可能就够了
- 保护两端:Head(建立上下文)和 Tail(最近对话)比 Middle 更重要
- 摘要而非截断:LLM 生成的结构化摘要保留了关键决策和上下文
- 迭代更新:多次压缩不会丢失早期信息
这些策略让 Hermes 可以维持超过 100 轮的长对话而不崩溃,也不丢失关键上下文。
设计赌注回扣:上下文压缩服务于 Personal Long-Term(通过 on_pre_compress 钩子在压缩前保存记忆,确保长对话中的重要信息不丢失)和 Run Anywhere(压缩后的 session splitting 让 SQLite 写入保持小事务,减少 WAL 锁竞争)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 Context compression 相关逻辑在 v0.3.0 之前就已经出现;之后 v0.4.0-v0.7.0 之间逐步补上了结构化摘要、session splitting、多轮预压缩和 provider 侧
on_pre_compress钩子。v0.8.0 引入了ContextEngineABC(agent/context_engine.py),将压缩层重构为可插拔架构,第三方引擎可通过context.engine配置项替换内置的ContextCompressor。
CLI/TUI:终端优先的交互层
本章核心源码:
cli.py(8736 行)、hermes_cli/main.py(5580 行)、hermes_cli/commands.py(1025 行)
定位:本章拆解 Hermes 的终端交互层——从
hermes命令的入口分发到HermesCLI的 prompt_toolkit TUI,理解 CLI-First 赌注如何在 8736 行代码中落地。 前置依赖:第 3 章(请求旅程)、第 4 章(AIAgent 内核)。适用场景:想理解 CLI 的交互机制,或准备添加新的 slash command。
为什么 CLI 需要 8736 行
一般认为 CLI 是"轻量"的入口——解析参数、调用 API、打印结果。但 Hermes 的 CLI 不是一个简单的命令行包装器,它是一个完整的 TUI 应用:
- 多行编辑:用户可以用 Alt+Enter 输入多行消息,而非被限制在一行
- 流式渲染:模型响应逐 token 渲染到终端,支持 Markdown 格式化
- 中断与重定向:用户在 agent 运行时输入新消息,agent 被中断并处理新消息
- 状态栏:底部显示模型名、token 用量、上下文占用等实时信息
- 多模态交互:剪贴板图片粘贴、语音输入/TTS 输出、sudo 密码提示
- Slash 命令系统:40+ 个命令,支持自动补全、别名、分类帮助
这些交互需求让 cli.py 膨胀到 8736 行——但这不是随意膨胀,而是 CLI-First 赌注的工程代价。
入口层架构
graph TD
A["hermes 命令"] --> B["hermes_cli/main.py<br/>argparse 分发"]
B --> C1["hermes chat<br/>默认"]
B --> C2["hermes setup"]
B --> C3["hermes gateway"]
B --> C4["hermes cron"]
B --> C5["hermes doctor"]
B --> C6["hermes model"]
B --> C7["hermes tools"]
B --> C8["hermes config"]
B --> C9["hermes sessions"]
B --> C10["hermes acp"]
C1 --> D["cli.py:main 8525"]
D --> E["HermesCLI.__init__"]
E --> F1["单次查询模式<br/>cli.py:8696"]
E --> F2["交互模式<br/>cli.py:7064 run"]
F2 --> G["prompt_toolkit<br/>Application"]
两层入口
Hermes 的 CLI 有两层入口。第一层是 hermes_cli/main.py,它使用 argparse 将 hermes 命令分发到不同的子命令:
# hermes_cli/main.py:0-43(文档字符串展示了完整的子命令列表)
"""
Usage:
hermes # Interactive chat (default)
hermes chat # Interactive chat
hermes gateway # Run gateway in foreground
hermes gateway start # Start gateway as service
hermes setup # Interactive setup wizard
hermes cron # Manage cron jobs
hermes doctor # Check configuration and dependencies
hermes acp # Run as an ACP server for editor integration
hermes sessions browse # Interactive session picker with search
"""
注意第一行——hermes 不带子命令时直接进入交互聊天。这不是偶然,而是 CLI-First 的设计决策:最常用的操作不需要记忆任何子命令。
第二层是 cli.py:main()(cli.py:8525),它是交互聊天的真正入口。通过 python-fire 将函数参数暴露为命令行选项:
# cli.py:8525-8546
def main(
query: str = None,
q: str = None,
toolsets: str = None,
skills: str | list[str] | tuple[str, ...] = None,
model: str = None,
provider: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
verbose: bool = False,
quiet: bool = False,
compact: bool = False,
list_tools: bool = False,
gateway: bool = False,
resume: str = None,
worktree: bool = False,
w: bool = False,
checkpoints: bool = False,
pass_session_id: bool = False,
):
Profile 系统
在任何模块导入之前,main.py 执行了一个关键的预处理——profile override(hermes_cli/main.py:82-136):
# hermes_cli/main.py:82-83
def _apply_profile_override() -> None:
"""Pre-parse --profile/-p and set HERMES_HOME before module imports."""
Profile 允许用户维护多个独立的 Hermes 配置(不同的 API key、不同的记忆、不同的 skills)。这个预处理必须在所有模块导入之前执行,因为很多模块在 import 时就读取 HERMES_HOME 并缓存为模块级常量。如果 profile 切换发生在导入之后,HERMES_HOME 的变化就无法传播到已经缓存了旧值的模块。
TTY 保护
交互式子命令(hermes tools、hermes setup、hermes model)需要终端输入。当它们被管道调用时(如 echo "" | hermes tools),curses 和 input() 会空转消耗 100% CPU。_require_tty() 守护(hermes_cli/main.py:52-66)在这些命令执行前检查 stdin 是否是终端,阻止非交互式调用:
# hermes_cli/main.py:52-66
def _require_tty(command_name: str) -> None:
"""Exit with a clear error if stdin is not a terminal."""
if not sys.stdin.isatty():
print(
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
f"It cannot be run through a pipe or non-interactive subprocess.",
file=sys.stderr,
)
sys.exit(1)
HermesCLI 类:TUI 的核心
HermesCLI(cli.py:1307)是整个 CLI 的核心。它的职责不仅是调用 AIAgent,还包括构建一个完整的 prompt_toolkit 应用。
初始化链
HermesCLI.__init__()(cli.py:1315-1456)解析来自三个来源的配置(优先级从高到低):
| 来源 | 示例 | 覆盖关系 |
|---|---|---|
| CLI 参数 | --model claude-opus-4-20250514 | 最高优先 |
| config.yaml | model.default: gpt-5.3-codex | 中间 |
| 环境变量 | HERMES_INFERENCE_PROVIDER=openrouter | 最低(仅部分场景生效) |
一个重要的设计决策:LLM_MODEL 和 OPENAI_MODEL 环境变量不被检查(cli.py:1379-1383)。在多 agent 场景中,这些环境变量可能被其他工具设置,会导致意外的模型切换。config.yaml 是唯一权威来源。
# cli.py:1379-1386
# Model comes from: CLI arg or config.yaml (single source of truth).
# LLM_MODEL/OPENAI_MODEL env vars are NOT checked — config.yaml is
# authoritative. This avoids conflicts in multi-agent setups where
# env vars would stomp each other.
_model_config = CLI_CONFIG.get("model", {})
_config_model = (_model_config.get("default") or _model_config.get("model") or "") \
if isinstance(_model_config, dict) else (_model_config or "")
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
prompt_toolkit TUI 架构
run() 方法(cli.py:7064)构建了 prompt_toolkit 的 Application,这是一个成熟的终端 UI 框架:
# cli.py:7064-7110(关键状态初始化)
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
self._agent_running = False
self._pending_input = queue.Queue() # 正常输入(命令 + 新查询)
self._interrupt_queue = queue.Queue() # agent 运行时的中断消息
self._should_exit = False
两个 Queue 的设计是 TUI 交互的核心:
stateDiagram-v2
[*] --> Idle
Idle --> Processing: 用户输入进入 pending_input
Processing --> Idle: agent 返回结果
Processing --> Interrupted: 用户新消息进入 interrupt_queue
Interrupted --> Processing: agent 中断后处理新消息
Processing --> Clarify: agent 调用 clarify 工具
Clarify --> Processing: 用户选择或输入回答
Processing --> Approval: 危险命令需要审批
Approval --> Processing: 用户批准或拒绝
Idle --> [*]: /quit
run() 还初始化了大量的 UI 状态变量(cli.py:7123-7159):
_clarify_state/_clarify_freetext:clarify 工具的问答状态_sudo_state:sudo 密码提示状态_approval_state:危险命令审批状态_secret_state:密钥输入状态_attached_images:剪贴板图片附件_voice_mode/_voice_recording:语音模式状态
每种状态都有对应的 deadline(超时时间),防止用户忘记响应时无限阻塞 agent。
按键绑定与输入路由
run() 方法注册了关键的按键绑定(cli.py:7183-7313)。Enter 键的路由逻辑是 TUI 中最复杂的部分——它根据当前 UI 状态决定输入去向:
| UI 状态 | Enter 行为 | 目标 |
|---|---|---|
| Sudo 密码提示 | 提交密码 | _sudo_state["response_queue"] |
| Secret 输入 | 提交密钥 | _secret_state["response_queue"] |
| 危险命令审批 | 确认选择 | _approval_state["response_queue"] |
| Clarify 自由文本 | 提交答案 | _clarify_state["response_queue"] |
| Clarify 选择模式 | 确认选项 | _clarify_state["response_queue"] |
| Agent 运行中 + 文本 | 中断或排队 | _interrupt_queue 或 _pending_input |
| Agent 空闲 + 文本 | 提交查询 | _pending_input |
# cli.py:7279-7287
@kb.add('escape', 'enter')
def handle_alt_enter(event):
"""Alt+Enter inserts a newline for multi-line input."""
event.current_buffer.insert_text('\n')
@kb.add('c-j')
def handle_ctrl_enter(event):
"""Ctrl+Enter (c-j) inserts a newline."""
event.current_buffer.insert_text('\n')
Alt+Enter 和 Ctrl+Enter 插入换行,让用户在不提交的情况下输入多行消息。这是 CLI 超越传统 readline REPL 的关键能力。
中断与重定向
当用户在 agent 运行时输入新消息,有两种模式(cli.py:1358-1360):
# cli.py:1358-1360
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
- interrupt 模式(默认):新消息进入
_interrupt_queue,agent 被中断,立即处理新消息 - queue 模式:新消息进入
_pending_input,等待当前 turn 完成后处理
中断模式是 CLI-First 的核心交互创新——用户不需要等 agent 完成当前任务才能发送新指令。chat() 方法(cli.py:6424)在每个 API 调用和工具执行之间轮询 _interrupt_queue,一旦发现新消息就设置 agent._interrupt_requested = True。
Slash 命令系统
命令注册表
所有 slash 命令定义在 hermes_cli/commands.py 的 COMMAND_REGISTRY(commands.py:45-144)中:
# hermes_cli/commands.py:26-38
@dataclass(frozen=True)
class CommandDef:
"""Definition of a single slash command."""
name: str # 规范名:"background"
description: str # 人类可读描述
category: str # "Session", "Configuration" 等
aliases: tuple[str, ...] = () # 别名:("bg",)
args_hint: str = "" # 参数占位符:"<prompt>"
subcommands: tuple[str, ...] = () # tab 补全的子命令
cli_only: bool = False # 仅 CLI 可用
gateway_only: bool = False # 仅 Gateway 可用
gateway_config_gate: str | None = None # config 门控
注册表是单一事实来源(commands.py:0-8 的文档明确声明)。CLI 帮助、Gateway 分发、Telegram BotCommands、Slack 子命令映射、自动补全——所有消费方都从 COMMAND_REGISTRY 派生数据。
命令按功能分类:
| 分类 | 命令数 | 示例 |
|---|---|---|
| Session | 16 | /new, /retry, /undo, /branch, /compress, /background |
| Configuration | 9 | /model, /prompt, /yolo, /reasoning, /voice |
| Tools & Skills | 7 | /tools, /skills, /cron, /browser, /plugins |
| Info | 7 | /help, /usage, /insights, /platforms, /paste |
| Exit | 1 | /quit(别名 /exit, /q) |
插件命令注册
第三方插件可以通过 register_plugin_command() 动态注册新命令(commands.py:172-175):
# hermes_cli/commands.py:172-175
def register_plugin_command(cmd: CommandDef) -> None:
"""Append a plugin-defined command to the registry and refresh lookups."""
COMMAND_REGISTRY.append(cmd)
rebuild_lookups()
rebuild_lookups()(commands.py:178-199)重建所有派生的查找字典——这保证了插件命令在注册后立即出现在帮助、自动补全和 Gateway 分发中。
CLI/Gateway 命令分离
CommandDef 的 cli_only 和 gateway_only 标志控制命令在不同入口的可见性:
/clear:cli_only=True——清屏在消息平台上没有意义/approve、/deny:gateway_only=True——CLI 用内建 UI 处理审批/model、/new:两端都可用
gateway_config_gate 字段提供了更细粒度的控制(commands.py:97):/verbose 命令默认是 cli_only,但如果 config.yaml 中设置了 display.tool_progress_command: true,Gateway 端也会启用它。
回调适配
CLI 通过 AIAgent 的 11 个回调接口适配终端 IO(详见第 4 章)。核心的适配模式是将 agent 的同步回调桥接到 prompt_toolkit 的异步 UI:
当 agent 调用 clarify 工具时,clarify_callback 将问题和选项注入 _clarify_state,prompt_toolkit 的渲染循环检测到状态变化后切换 UI 为选择模式。用户用方向键选择后,答案通过 response_queue(一个 queue.Queue)返回给 agent 线程。这个跨线程通信机制同样用于 sudo 密码输入(_sudo_state)、密钥捕获(_secret_state)和危险命令审批(_approval_state)。
main() 的分支逻辑
main()(cli.py:8525)根据参数走不同路径:
| 路径 | 触发条件 | 行为 |
|---|---|---|
| Gateway | --gateway | 启动消息平台网关(cli.py:8584) |
| List tools | --list-tools | 打印工具列表并退出 |
| 单次查询(安静) | -q "..." --quiet | 执行查询,仅打印结果和 session_id |
| 单次查询(正常) | -q "..." | 显示 banner,执行查询 |
| 交互模式 | 无 -q | 启动 TUI REPL(cli.py:8732) |
Worktree 隔离(cli.py:8593-8614)在交互模式和单次查询模式下都可用。当 --worktree 或 -w 被指定时,_setup_worktree() 创建一个独立的 git worktree,让当前 agent 实例在隔离的分支上工作:
# cli.py:8597-8608
use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
if use_worktree:
_repo = _git_repo_root()
if _repo:
_prune_stale_worktrees(_repo)
wt_info = _setup_worktree()
if wt_info:
_active_worktree = wt_info
os.environ["TERMINAL_CWD"] = wt_info["path"]
atexit.register(_cleanup_worktree, wt_info)
Worktree 信息还会注入 agent 的 system prompt(cli.py:8669-8678),让 agent 知道自己在隔离分支上工作,应该 commit 和创建 PR。
设计启示
拆解 CLI/TUI 的 8736 行代码,可以提炼出三个设计原则:
-
交互复杂度在入口层消化:中断/重定向、多模态输入、密码提示等交互逻辑全部在 CLI 层处理,编排层(AIAgent)通过回调看到的是简单的同步接口。这让同一个 AIAgent 可以零修改适配 Gateway 等完全不同的交互模型
-
单一命令注册表:
COMMAND_REGISTRY是 slash 命令的唯一事实来源,所有消费方(CLI 帮助、Gateway、Telegram、Slack、自动补全)都从中派生。新增命令只需添加一个CommandDef,五个消费方自动更新 -
渐进式复杂度:
hermes(无参数)直接进入聊天,hermes -q单次查询,hermes --toolsets定制工具集,hermes -w隔离工作区。从简单到复杂,用户按需解锁功能
设计赌注回扣:本章是 CLI-First 赌注的核心体现。8736 行的 TUI 代码证明 Hermes 不把终端当作 Web UI 的"降级版"——多行编辑、流式渲染、中断重定向、状态栏、语音输入等功能让终端体验与图形界面对等。同时,回调体系和命令注册表也回扣了 Run Anywhere 赌注:同一套命令系统同时服务 CLI 和 Gateway。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。
HermesCLI的 prompt_toolkit TUI 和统一命令注册表在 v0.3.0 发布窗口前后就已经存在。Worktree 隔离同样属于 v0.3.0 窗口内较早落地的能力,而busy_input_mode则可以明确放到 v0.5.0 发布窗口。
Gateway:把同一个 Agent 投射到 17 个平台
本章核心源码:
gateway/run.py(7620 行)、gateway/session.py(1081 行)、gateway/platforms/base.py(1696 行)、gateway/config.py(957 行)
定位:本章拆解 Hermes 的 Gateway 子系统——如何通过一个
BasePlatformAdapterABC 将同一个 AIAgent 投射到 Telegram、Discord、Slack、WhatsApp 等 17 个平台类型,以及 session 管理如何在多平台场景下保持一致。 前置依赖:第 4 章(AIAgent 内核)、第 13 章(CLI/TUI)。适用场景:想理解 Gateway 的架构,或准备接入新的消息平台。
为什么需要 Gateway
CLI 是 Hermes 的第一等入口,但不是唯一入口。当你希望在手机上通过 Telegram 和 agent 对话,或者在团队 Slack 频道中共享 agent 能力,你需要的不是另一个 agent 实现,而是一个协议适配层。
Gateway 解决三个问题:
- 协议桥接:将 Telegram 的 Bot API、Discord 的 WebSocket、Slack 的 Events API 等不同协议统一为
MessageEvent → AIAgent → SendResult的标准流程 - 会话管理:在无状态的消息平台上维护有状态的对话——session 创建、过期重置、跨平台隔离
- 持久运行:作为 systemd 服务或后台进程持续运行,不需要用户保持终端连接
Gateway 架构总览
graph TB
subgraph "消息平台"
T["Telegram Bot API"]
D["Discord WebSocket"]
S["Slack Events API"]
W["WhatsApp Cloud API"]
SG["Signal"]
MX["Matrix"]
MM["Mattermost"]
HA["Home Assistant"]
DT["DingTalk"]
FS["Feishu"]
WC["WeCom"]
WX["Weixin"]
EM["Email IMAP"]
SM["SMS Twilio"]
WH["Webhook"]
AS["API Server"]
end
subgraph "Gateway Layer"
BA["BasePlatformAdapter ABC<br/>base.py:470"]
GR["GatewayRunner<br/>run.py:461"]
SS["SessionStore<br/>session.py:503"]
end
subgraph "Agent Layer"
AI["AIAgent<br/>run_agent.py"]
end
T --> BA
D --> BA
S --> BA
W --> BA
SG --> BA
MX --> BA
MM --> BA
HA --> BA
DT --> BA
FS --> BA
WC --> BA
WX --> BA
EM --> BA
SM --> BA
WH --> BA
AS --> BA
BA --> GR
GR --> SS
GR --> AI
BasePlatformAdapter:17 个平台类型的公约数
BasePlatformAdapter(gateway/platforms/base.py:470)是所有平台适配器的抽象基类。它定义了平台适配器必须实现的三个核心方法:
# gateway/platforms/base.py:470-632
class BasePlatformAdapter(ABC):
"""Base class for platform adapters."""
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._running = False
self._active_sessions: Dict[str, asyncio.Event] = {}
self._pending_messages: Dict[str, MessageEvent] = {}
self._background_tasks: set[asyncio.Task] = set()
@abstractmethod
async def connect(self) -> bool:
"""Connect to the platform and start receiving messages."""
pass
@abstractmethod
async def disconnect(self) -> None:
"""Disconnect from the platform."""
pass
@abstractmethod
async def send(self, chat_id: str, content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send a message to a chat."""
pass
可选方法与渐进增强
除了三个必须实现的抽象方法,BasePlatformAdapter 还定义了一系列可选方法,每个都有合理的默认行为:
| 方法 | 默认行为 | 覆盖后效果 |
|---|---|---|
send_typing() | 无操作 | 显示"正在输入..."指示器 |
edit_message() | 返回 success=False | 流式编辑消息 |
send_image() | 将 URL 作为文本发送 | 原生图片附件 |
send_voice() | 将路径作为文本发送 | 原生语音消息 |
send_video() | 将路径作为文本发送 | 原生视频播放 |
send_document() | 将路径作为文本发送 | 原生文件下载 |
send_image_file() | 将路径作为文本发送 | 本地图片附件 |
send_animation() | 委托给 send_image() | GIF 自动播放 |
这个设计让新平台可以用最少的代码启动(只需 connect + disconnect + send),然后逐步增强富媒体能力。
消息标准化
所有平台的入站消息都被转换为 MessageEvent(base.py:380-433):
# gateway/platforms/base.py:380-411
@dataclass
class MessageEvent:
text: str
message_type: MessageType = MessageType.TEXT
source: SessionSource = None
raw_message: Any = None
message_id: Optional[str] = None
media_urls: List[str] = field(default_factory=list)
media_types: List[str] = field(default_factory=list)
reply_to_message_id: Optional[str] = None
reply_to_text: Optional[str] = None
auto_skill: Optional[str] = None
timestamp: datetime = field(default_factory=datetime.now)
MessageType 枚举(base.py:367-378)涵盖了所有消息类型:TEXT、LOCATION、PHOTO、VIDEO、AUDIO、VOICE、DOCUMENT、STICKER、COMMAND。
媒体缓存
消息平台的媒体 URL 通常是临时的(如 Telegram 的文件 URL 一小时后过期)。BasePlatformAdapter 提供了三套缓存工具(base.py:84-364):
- 图片缓存:
cache_image_from_url()(base.py:112)——下载并缓存到当前HERMES_HOME/cache/images/(默认 profile 下表现为~/.hermes/cache/images/),带重试和 SSRF 防护 - 音频缓存:
cache_audio_from_url()(base.py:228)——同样模式,用于语音消息的 STT 转写 - 文档缓存:
cache_document_from_bytes()(base.py:314)——文件名净化 + 路径遍历防护
缓存文件定期清理(cleanup_image_cache(max_age_hours=24)),防止磁盘膨胀。
发送重试
_RETRYABLE_ERROR_PATTERNS(base.py:453-463)定义了可重试的错误模式:
# gateway/platforms/base.py:453-463
_RETRYABLE_ERROR_PATTERNS = (
"connecterror", "connectionerror", "connectionreset",
"connectionrefused", "connecttimeout", "network",
"broken pipe", "remotedisconnected", "eoferror",
)
注意,普通的 read/write timeout 不在重试列表中——因为非幂等操作(如 send_message)在超时时可能已经到达服务器,重试会导致重复发送。只有 connect timeout(连接未建立)才安全重试。平台适配器可以通过设置 SendResult.retryable = True 显式标记安全重试的场景。
Typing 指示器
_keep_typing() 方法(base.py:957-986)持续发送 typing 指示器:
# gateway/platforms/base.py:957-975
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None):
"""Continuously send typing indicator until cancelled."""
try:
while True:
if chat_id not in self._typing_paused:
await self.send_typing(chat_id, metadata=metadata)
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
Telegram/Discord 的 typing 状态 5 秒后自动消失,所以需要每 2 秒刷新一次。_typing_paused 集合在危险命令审批等待期间暂停 typing——对 Slack 尤其重要,因为 Slack 的 assistant_threads_setStatus 会禁用用户的输入框。
17 个平台类型
Gateway 当前支持的 17 个 Platform 枚举值定义在 gateway/config.py:47-66:
# gateway/config.py:47-66
class Platform(Enum):
LOCAL = "local"
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
MATRIX = "matrix"
HOMEASSISTANT = "homeassistant"
EMAIL = "email"
SMS = "sms"
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
FEISHU = "feishu"
WECOM = "wecom"
WEIXIN = "weixin"
每个平台都有一个对应的适配器文件(gateway/platforms/ 目录)。LOCAL 代表本地 CLI 终端——它不是消息平台,而是用于统一 session 管理的虚拟平台。
注意 WEIXIN 和 WECOM 的区别:WECOM 是企业微信(WeCom),面向企业内部通信,通过企业微信 API 接入;WEIXIN 是个人微信(WeChat),面向个人用户,通过独立的个人微信适配器(gateway/platforms/weixin.py)接入。两者的协议、认证方式和消息格式完全不同。
Matrix 适配器:mautrix 迁移与 E2EE
Matrix 适配器(gateway/platforms/matrix.py)已从 matrix-nio 重写为 mautrix-python(matrix.py:4-5)。这次迁移带来了几个重要改进:
- 可选的端到端加密(E2EE):支持持久化的 crypto state,加密会话在 Gateway 重启后无需重新验证
- 防 bot 循环:适配器忽略
m.notice类型的消息,防止 bot-to-bot 的消息回环——这在多个 bot 共存的 Matrix room 中尤为重要
Weixin 适配器:个人微信的接入架构
gateway/platforms/weixin.py(1669 行)是 Gateway 中最新也是最独特的适配器之一。它通过腾讯的 iLink Bot API 接入个人微信账号,与企业微信(WeCom)的接入方式完全不同。
认证:QR 码扫码登录
个人微信没有 OAuth 或 API Key 认证。Hermes 实现了完整的 QR 码交互登录流程(weixin.py:839):
sequenceDiagram
participant U as 用户手机微信
participant H as hermes gateway setup
participant I as iLink Bot API
H->>I: GET /ilink/bot/get_bot_qrcode
I-->>H: 返回 qrcode + qrcode_img_content
H->>H: 终端打印 QR 码(qrcode 库)
U->>I: 手机扫码
H->>I: 轮询 /ilink/bot/get_qrcode_status
I-->>H: status=scaned
Note over U: 用户在手机上确认
I-->>H: status=success + token + account_id
H->>H: 保存凭据到 ~/.hermes/weixin/accounts/
扫码状态轮询支持超时(480 秒)、二维码过期自动刷新(最多 3 次)、以及跨区域重定向(scaned_but_redirect 状态切换 API 端点)。
消息收发:Long-poll + context_token
Weixin 适配器使用 long-poll 模式接收消息(weixin.py:1078,getupdates 端点,35 秒超时)。每条入站消息携带一个 context_token,出站回复时必须回传当前对话对象的最新 context_token——这是 iLink Bot API 的核心约束。
ContextTokenStore(每个账号独立存储)负责维护 {sender_id → context_token} 映射,确保并发对话不会混淆 token。
媒体处理:AES-128-ECB 加密 CDN
微信的媒体文件(图片、视频、文件、语音)通过加密 CDN 传输。适配器实现了完整的下载-解密流程(weixin.py:537):
- 从消息中提取
encrypted_query_param和 AES key - 从微信 CDN(
novac2c.cdn.weixin.qq.com)下载加密数据 - 使用 AES-128-ECB 解密(
_aes128_ecb_decrypt,weixin.py:144) - 缓存到本地临时文件,传递给 agent 处理
上传方向类似:先请求上传 URL,加密后上传到 CDN,获取 encrypted_param 用于消息引用。
安全策略
# weixin.py:984-993
self._dm_policy = "open" # DM 策略:open / allowlist / disabled
self._group_policy = "disabled" # 群聊默认禁用(安全保守)
self._allow_from = [...] # DM 白名单
self._group_allow_from = [...] # 群聊白名单
默认 DM 开放但群聊禁用——这是个人微信场景的合理默认。群聊环境中 bot 被大量 @mention 可能导致 API 成本失控。
Token 冲突保护
连接时使用 acquire_scoped_lock("weixin-bot-token", ...) 防止同一个微信 token 被多个 Gateway 进程同时使用(weixin.py:1029-1046)。iLink Bot API 的 long-poll 连接是排他性的,多个进程争抢会导致消息丢失。
GatewayRunner:生命周期管理
GatewayRunner(gateway/run.py:461)是 Gateway 的主控制器,管理所有平台适配器的生命周期和消息路由。
初始化
# gateway/run.py:461-569
class GatewayRunner:
def __init__(self, config: Optional[GatewayConfig] = None):
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
# Session 管理
self.session_store = SessionStore(
self.config.sessions_dir, self.config,
has_active_processes_fn=lambda key: process_registry.has_active_for_session(key),
)
# Agent 缓存——保留 prompt cache
self._agent_cache: Dict[str, tuple] = {}
# DM 配对存储
from gateway.pairing import PairingStore
self.pairing_store = PairingStore()
三个关键设计决策:
-
Agent 缓存(
run.py:506-513):_agent_cache按 session key 缓存 AIAgent 实例。没有这个缓存,每条消息都会创建新的 AIAgent,重建 system prompt——打破 Anthropic 的 prompt caching,成本增加约 10 倍 -
Process Registry 集成(
run.py:488-492):SessionStore 接收一个has_active_processes_fn回调,用于检查 session 是否有活跃的后台进程。有活跃进程的 session 不会被自动重置 -
DM 配对(
run.py:557-559):未授权用户发送 DM 时,系统生成一个配对码。用户在 CLI 输入配对码完成授权,此后该平台的 DM 才会被路由到 agent
Agent 缓存的失败运行保护
_agent_cache 有一个重要的改进:failed-run no-evict gating。当一次 agent 运行失败(例如模型不可用、fallback 错误等)时,缓存的 agent 实例不会被驱逐。这看似违反直觉——失败了为什么要保留?原因是 MCP 重启循环问题:如果驱逐了缓存的 agent,下一条消息会重建 AIAgent,重新初始化所有 MCP server 连接。如果 MCP server 本身就是导致失败的原因(如超时或不可用),重新初始化只会再次触发失败,形成"失败→驱逐→重建→失败"的循环。保留缓存的 agent 实例让下一次消息可以跳过重建,直接使用已有的(可能部分可用的)agent。
优雅重启:drain-and-restart
Gateway 支持不停机重启——不是传统的 kill-and-relaunch,而是 drain-and-restart 模式(gateway/restart.py + gateway/run.py:491-532)。
当收到重启请求时,Gateway 进入 _draining 状态:
- 设置 draining 标志:新到达的消息被拒绝或延迟,正在处理的消息允许完成
- 等待 drain 超时:
restart_drain_timeout控制等待在途请求完成的最大时间 - detached restart:当前进程以退出码 75(
EX_TEMPFAIL,POSIX 标准的"临时失败")退出。外部进程管理器(如 systemd)看到这个退出码后知道应该立即重启服务,而非按指数退避延迟重启 - 服务自请求重启:Gateway 可以在进程内部触发自身的重启(例如配置变更后),通过
restart.py中的 detached restart 逻辑实现,不依赖外部信号
这个设计让 Gateway 在配置变更、代码更新或 MCP server 重新初始化时能零丢消息地完成重启。退出码 75 的选择也值得注意——它区分于退出码 0(正常退出,systemd 不会重启)和退出码 1(异常退出,systemd 会按配置的 RestartSec 延迟重启)。
Session 管理
SessionSource:消息来源
SessionSource(gateway/session.py:72-154)描述一条消息的来源:
# gateway/session.py:72-91
@dataclass
class SessionSource:
platform: Platform
chat_id: str
chat_name: Optional[str] = None
chat_type: str = "dm" # "dm", "group", "channel", "thread"
user_id: Optional[str] = None
user_name: Optional[str] = None
thread_id: Optional[str] = None
chat_topic: Optional[str] = None
user_id_alt: Optional[str] = None # Signal UUID
chat_id_alt: Optional[str] = None # Signal group internal ID
Session Key 构建
build_session_key()(gateway/session.py:444-500)是 session 隔离的核心逻辑:
# gateway/session.py:444-500(简化)
def build_session_key(source: SessionSource,
group_sessions_per_user: bool = True,
thread_sessions_per_user: bool = False) -> str:
platform = source.platform.value
if source.chat_type == "dm":
if source.chat_id:
if source.thread_id:
return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{source.chat_id}"
return f"agent:main:{platform}:dm"
# Group/channel: optionally isolate per user
key_parts = ["agent:main", platform, source.chat_type]
if source.chat_id:
key_parts.append(source.chat_id)
if source.thread_id:
key_parts.append(source.thread_id)
# Thread sessions default to shared (all participants share context)
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
isolate_user = False
if isolate_user and participant_id:
key_parts.append(str(participant_id))
return ":".join(key_parts)
关键规则:
- DM:每个 chat_id(+ 可选的 thread_id)一个 session
- 群组/频道:默认按用户隔离(每个用户独立上下文)
- 线程:默认共享(所有参与者看到同一个对话),可配置为按用户隔离
Session 重置策略
SessionResetPolicy(gateway/config.py:95-135)控制 session 何时被重置:
# gateway/config.py:95-109
@dataclass
class SessionResetPolicy:
mode: str = "both" # "daily", "idle", "both", "none"
at_hour: int = 4 # 每日重置时间(0-23 小时)
idle_minutes: int = 1440 # 空闲超时(默认 24 小时)
notify: bool = True # 重置时通知用户
notify_exclude_platforms: tuple = ("api_server", "webhook")
四种模式:
| 模式 | 触发条件 | 适用场景 |
|---|---|---|
daily | 每天 at_hour 时重置 | 日报类助手 |
idle | 空闲超过 idle_minutes 后重置 | 一般对话 |
both | daily 或 idle 任一触发(默认) | 推荐默认 |
none | 永不自动重置 | 由压缩器管理上下文 |
SessionStore._should_reset()(gateway/session.py:626-668)在每次消息到达时评估重置策略:
# gateway/session.py:649-668(简化)
def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[str]:
policy = self.config.get_reset_policy(
platform=source.platform,
session_type=source.chat_type
)
if policy.mode == "none":
return None
now = _now()
if policy.mode in ("idle", "both"):
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
if now > idle_deadline:
return "idle"
if policy.mode in ("daily", "both"):
today_reset = now.replace(hour=policy.at_hour, minute=0, second=0)
if now.hour < policy.at_hour:
today_reset -= timedelta(days=1)
if entry.updated_at < today_reset:
return "daily"
return None
PII 脱敏
当 redact_pii=True 时,Gateway 在构建 system prompt 之前对用户 ID 和聊天 ID 进行哈希脱敏(gateway/session.py:34-57):
# gateway/session.py:37-39
def _hash_id(value: str) -> str:
"""Deterministic 12-char hex hash of an identifier."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
脱敏仅对 _PII_SAFE_PLATFORMS(WhatsApp、Signal、Telegram)生效(session.py:191-198)。Discord 被排除在外,因为 Discord 的 mention 格式 <@user_id> 需要真实 ID——如果脱敏,LLM 就无法正确 @ 用户。
SessionContext 与 System Prompt 注入
build_session_context_prompt()(gateway/session.py:202-340)构建注入 agent system prompt 的上下文信息:
# gateway/session.py:202-206
def build_session_context_prompt(
context: SessionContext,
*,
redact_pii: bool = False,
) -> str:
"""Build the dynamic system prompt section that tells the agent about its context."""
注入的信息包括:
- 消息来源平台和描述
- 频道主题(为 agent 提供频道用途上下文)
- 是否为多用户线程(
session.py:263-271) - 平台行为限制(如 Slack/Discord 不能搜索频道历史)
- 已连接的平台列表
- 各平台的 home channel
- Cron 任务的投递选项
设计启示
Gateway 子系统的设计可以提炼出三个原则:
-
公约数 ABC + 渐进增强:
BasePlatformAdapter的三个抽象方法(connect/disconnect/send)是所有平台的最小公约数,可选方法(send_image/send_voice/edit_message 等)允许平台按能力渐进增强。新平台用 50 行代码就能启动。Weixin 适配器的加入再次验证了这个模式的可扩展性 -
Session 隔离策略是可配置的:DM/群组/线程各有不同的隔离规则,重置策略有四种模式。这些不是硬编码的——因为不同的使用场景(个人助手 vs 团队 bot)需要不同的隔离语义
-
脱敏与平台行为耦合:PII 脱敏不是全局开关,而是根据平台特性选择性应用。这种务实的做法避免了"脱敏后功能损坏"的问题
设计赌注回扣:本章是 Run Anywhere 赌注的核心体现。17 个平台类型通过一个 ABC 共享同一个 AIAgent,session 管理策略让同一个 agent 既能做私人助手(DM 隔离),又能做团队 bot(线程共享)。PII 脱敏和平台行为注入也回扣了 Personal Long-Term 赌注——agent 需要知道自己在哪个平台、和谁对话,才能提供个性化服务。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 Gateway 与
BasePlatformAdapter的基础骨架早于 v0.3.0 就已出现;之后 v0.3.0-v0.8.0 之间持续扩张平台覆盖,并不断补充 session 重置、脱敏和投递行为。_agent_cache可以明确定位到 v0.4.0 发布窗口,它是 prompt cache 相关优化的重要节点。v0.8.0 阶段新增了 drain-and-restart 优雅重启机制、agent cache 的 failed-run no-evict 保护、Matrix mautrix 迁移(含可选 E2EE)以及个人微信(Weixin)适配器,平台总数从 16 增至 17。
定时调度与批量运行
本章核心源码:
cron/scheduler.py(904 行)、cron/jobs.py(759 行)、hermes_cli/cron.py(290 行)、batch_runner.py(1287 行)、environments/
定位:本章拆解 Hermes 的定时调度(Cron)和批量运行(Batch)子系统——agent 如何在无人值守的情况下自动执行任务、投递结果到消息平台,以及如何批量生成训练轨迹。 前置依赖:第 14 章(Gateway)。适用场景:想理解 cron 调度机制,或构建自动化 agent 任务。
为什么需要定时调度
CLI 和 Gateway 都是被动的——等待用户发送消息。但一个真正有用的 personal agent 需要主动性:每天早上推送新闻摘要、每小时检查 GitHub Issues、每周生成项目报告。
Cron 子系统解决的核心问题是:如何让同一个 AIAgent 在无人交互的场景下自动执行任务,并将结果投递到用户选择的平台?
同时,Hermes 的训练和评估流程(Learning Loop 赌注的基础设施)需要批量运行 agent 并收集轨迹。batch_runner.py 提供了这个能力。
Cron 子系统架构
graph TB
subgraph "用户创建 Job"
U1["CLI: hermes cron create"]
U2["Chat: /cron create"]
U3["Agent: cronjob tool"]
end
subgraph "Job 存储"
JF["{HERMES_HOME}/cron/jobs.json<br/>jobs.py"]
end
subgraph "调度器"
TK["tick()<br/>scheduler.py:811"]
FL["文件锁<br/>.tick.lock"]
end
subgraph "执行"
RJ["run_job()<br/>scheduler.py:519"]
AI["AIAgent<br/>quiet_mode=True"]
end
subgraph "投递"
DR["_deliver_result()<br/>scheduler.py:198"]
LP["Live Adapter 路径"]
SP["Standalone HTTP 路径"]
end
subgraph "输出"
OF["{HERMES_HOME}/cron/output/"]
TG["Telegram"]
DC["Discord"]
SL["Slack"]
OT["其他平台..."]
end
U1 --> JF
U2 --> JF
U3 --> JF
JF --> TK
TK --> FL
FL --> RJ
RJ --> AI
AI --> DR
DR --> LP
DR --> SP
LP --> TG
LP --> DC
SP --> SL
DR --> OF
Job 存储:jobs.py
Job CRUD
所有 cron job 都存储在当前 profile 的 HERMES_HOME/cron/jobs.json(默认 profile 下是 ~/.hermes/cron/jobs.json,见 cron/jobs.py:36-37)。create_job()(jobs.py:366-464)是创建 job 的核心函数:
# cron/jobs.py:366-379
def create_job(
prompt: str,
schedule: str,
name: Optional[str] = None,
repeat: Optional[int] = None,
deliver: Optional[str] = None,
origin: Optional[Dict[str, Any]] = None,
skill: Optional[str] = None,
skills: Optional[List[str]] = None,
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
script: Optional[str] = None,
) -> Dict[str, Any]:
每个 job 包含以下核心字段:
| 字段 | 说明 | 示例 |
|---|---|---|
prompt | 发送给 agent 的指令 | "总结最近 24 小时的 GitHub Issues" |
schedule | 调度配置 | {"kind": "cron", "expr": "0 9 * * *"} |
deliver | 投递目标 | "origin", "telegram", "local" |
origin | 创建来源(用于 origin 投递) | {"platform": "telegram", "chat_id": "123"} |
skills | 预加载的技能列表 | ["github-auth", "project-analyzer"] |
model | 每 job 模型覆盖 | "claude-sonnet-4-20250514" |
script | 数据收集脚本路径 | "check_prices.py" |
repeat | 运行次数限制 | None(无限)或 1(一次性) |
调度解析
parse_schedule()(jobs.py:117-203)支持四种调度格式:
# jobs.py:117-203(简化)
def parse_schedule(schedule: str) -> Dict[str, Any]:
"""
Parse schedule string into structured format.
Examples:
"30m" → once in 30 minutes
"every 30m" → recurring every 30 minutes
"0 9 * * *" → cron expression
"2026-02-03T14:00" → once at timestamp
"""
| 格式 | 解析为 | 示例 |
|---|---|---|
| 持续时间 | once(一次性) | "30m" → 30 分钟后执行一次 |
every + 持续时间 | interval(周期) | "every 2h" → 每 2 小时 |
| Cron 表达式 | cron | "0 9 * * *" → 每天早上 9 点 |
| ISO 时间戳 | once(定时) | "2026-02-03T14:00" → 指定时间 |
一次性 job 自动设置 repeat=1,完成后自动删除。
防重入的 At-Most-Once 语义
advance_next_run()(jobs.py:627-652)在 job 执行之前将 next_run_at 推进到下一个周期:
# jobs.py:627-652
def advance_next_run(job_id: str) -> bool:
"""Preemptively advance next_run_at for a recurring job before execution.
Call this BEFORE run_job() so that if the process crashes mid-execution,
the job won't re-fire on the next gateway restart. This converts the
scheduler from at-least-once to at-most-once for recurring jobs.
"""
这是一个有意识的 trade-off:丢失一次运行比在崩溃循环中重复执行几十次要好。一次性 job 不受影响——它们可以在重启后重试。
过期 Job 快进
当 Gateway 宕机一段时间后重启,积压的 job 不会全部立即触发。get_due_jobs()(jobs.py:655-731)实现了智能快进:
# jobs.py:700-724(简化)
grace = _compute_grace_seconds(schedule)
if kind in ("cron", "interval") and (now - next_run_dt).total_seconds() > grace:
# Job missed its window — fast-forward to next future run
new_next = compute_next_run(schedule, now.isoformat())
logger.info("Job '%s' missed its scheduled time. Fast-forwarding to: %s",
job.get("name"), new_next)
continue # Skip this run
Grace window 根据调度周期动态计算(_compute_grace_seconds,jobs.py:252-281):周期的一半,限制在 120 秒到 2 小时之间。这意味着日任务在宕机 2 小时内重启可以补跑,但超过 2 小时就跳过。
调度器:scheduler.py
tick() 函数
tick()(scheduler.py:811-901)是调度器的核心——Gateway 每 60 秒在后台线程调用一次:
# scheduler.py:811-825
def tick(verbose: bool = True, adapters=None, loop=None) -> int:
"""Check and run all due jobs.
Uses a file lock so only one tick runs at a time.
"""
_LOCK_DIR.mkdir(parents=True, exist_ok=True)
lock_fd = open(_LOCK_FILE, "w")
if fcntl:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
文件锁(位于当前 HERMES_HOME/cron/.tick.lock)确保即使 Gateway 进程内的 ticker、独立守护进程和手动 hermes cron tick 同时运行,也只有一个 tick 实际执行。
run_job():执行单个 Job
run_job()(scheduler.py:519-809)创建一个独立的 AIAgent 来执行 job:
# scheduler.py:648-669(简化)
agent = AIAgent(
model=turn_route["model"],
api_key=turn_route["runtime"].get("api_key"),
base_url=turn_route["runtime"].get("base_url"),
provider=turn_route["runtime"].get("provider"),
max_iterations=max_iterations,
disabled_toolsets=["cronjob", "messaging", "clarify"],
quiet_mode=True,
skip_memory=True, # Cron 不使用记忆
platform="cron",
session_id=_cron_session_id,
session_db=_session_db,
)
三个关键设计决策:
-
禁用 cronjob/messaging/clarify 工具集(
scheduler.py:664):cron job 不能自己创建新 job(防止递归),不能发送消息(由_deliver_result统一处理),不能请求用户确认(无人在线) -
skip_memory=True(scheduler.py:667):cron 的 system prompt 不包含用户记忆。因为 cron 任务的上下文和交互式对话不同,注入记忆会干扰 cron prompt 的意图 -
Inactivity-based timeout(
scheduler.py:675-742):job 不是用固定超时,而是用不活跃超时——只要 agent 持续在调用工具和 API,就可以运行数小时;但如果 API 挂起或工具卡住超过 600 秒无活动,就被终止
SILENT 标记
当 cron agent 判断没有新内容需要报告时,它可以返回 [SILENT](scheduler.py:53-54):
# scheduler.py:53-54
SILENT_MARKER = "[SILENT]"
调度器检测到 [SILENT] 后跳过投递(scheduler.py:872-874),但输出仍保存到本地文件用于审计。这避免了"每天 9 点推送一条'没有新内容'"的噪音。
数据收集脚本
_run_job_script()(scheduler.py:351-426)在 agent 执行前运行一个 Python 脚本,将其输出注入 prompt:
# scheduler.py:370-382
scripts_dir = get_hermes_home() / "scripts"
raw = Path(script_path).expanduser()
if raw.is_absolute():
path = raw.resolve()
else:
path = (scripts_dir / raw).resolve()
# Guard against path traversal
try:
path.relative_to(scripts_dir_resolved)
except ValueError:
return False, "Blocked: script path resolves outside the scripts directory"
安全约束:脚本必须位于当前 HERMES_HOME/scripts/ 目录内(默认 profile 下通常表现为 ~/.hermes/scripts/)。路径遍历(../../etc/passwd)和符号链接逃逸都被检测和拒绝。脚本输出在注入 prompt 前经过 redact_sensitive_text() 脱敏。
投递路由
_deliver_result()(scheduler.py:198-345)将 job 输出投递到目标平台。投递有两条路径:
- Live Adapter 路径(
scheduler.py:287-313):如果 Gateway 正在运行,通过已连接的平台适配器发送。这支持端到端加密(如 Matrix E2EE 房间) - Standalone HTTP 路径(
scheduler.py:321-337):如果 Live Adapter 不可用,直接通过 HTTP API 发送。作为降级方案
Live Adapter 失败时自动回退到 Standalone 路径——双路径设计确保了投递的可靠性。
CLI 管理界面
hermes_cli/cron.py(290 行)提供了 cron 的命令行管理界面:
# hermes_cli/cron.py:253-290
def cron_command(args):
"""Handle cron subcommands."""
subcmd = getattr(args, 'cron_command', None)
if subcmd is None or subcmd == "list":
cron_list(show_all)
elif subcmd == "status":
cron_status()
elif subcmd in {"create", "add"}:
return cron_create(args)
elif subcmd == "edit":
return cron_edit(args)
elif subcmd == "pause":
return _job_action("pause", args.job_id, "Paused")
elif subcmd == "resume":
return _job_action("resume", args.job_id, "Resumed")
支持的子命令:list、create、edit、pause、resume、run(立即触发)、remove、status、tick(手动执行一次调度)。
cron_status()(hermes_cli/cron.py:127-158)检查 Gateway 进程是否运行——如果没有,cron job 不会自动触发,需要提醒用户启动 Gateway。
批量运行:batch_runner.py
batch_runner.py(1287 行)是 Learning Loop 赌注的基础设施。它支持从数据集批量运行 agent 并收集训练轨迹:
# batch_runner.py:0-20(文档字符串)
"""
Batch Agent Runner
- Dataset loading and batching
- Parallel batch processing with multiprocessing
- Checkpointing for fault tolerance and resumption
- Trajectory saving in the proper format (from/value pairs)
- Tool usage statistics aggregation across all batches
"""
主要能力:
| 特性 | 说明 |
|---|---|
| 并行处理 | multiprocessing.Pool 并行执行多个 prompt |
| 检查点恢复 | 中断后可从上次完成的批次继续 |
| 轨迹记录 | 保存完整的对话轨迹(from/value 格式),用于 SFT/RLHF |
| 工具统计 | 跨批次汇总工具调用的成功/失败次数 |
| 工具集分布 | 支持按概率分布随机分配工具集,增加训练数据多样性 |
RL 环境:environments/
environments/ 目录包含多个强化学习训练环境(environments/hermes_swe_env/、environments/web_research_env.py、environments/terminal_test_env/ 等)。这些环境将 AIAgent 封装在 OpenAI Gym 风格的接口中,用于 RL 训练和评估。
environments/hermes_base_env.py 定义了基础环境类,提供 step() / reset() 接口。agent_loop.py 实现了 agent 在环境中的执行循环。
设计启示
拆解 Cron 和 Batch 子系统,可以提炼出三个设计原则:
-
At-Most-Once 语义:通过在执行前推进
next_run_at,Cron 选择了"宁可漏跑一次也不重复执行"的策略。这对连接外部 API、发送消息等有副作用的任务至关重要 -
双路径投递:Live Adapter + Standalone HTTP 的双路径设计让投递既能利用 Gateway 的实时连接(E2EE 支持),又能在 Gateway 不完全可用时降级为独立 HTTP 调用
-
共享 AIAgent,差异化配置:Cron 使用和 CLI/Gateway 完全相同的
AIAgent,但通过disabled_toolsets、skip_memory、quiet_mode等参数定制行为。这比维护一个独立的"cron agent"更简单、更一致
设计赌注回扣:本章同时回扣了 Run Anywhere 和 Learning Loop 两个赌注。Cron 子系统让 agent 在无人值守场景下自动执行任务并投递到任意平台(Run Anywhere)。Batch runner 和 RL environments 为 agent 的训练和评估提供基础设施(Learning Loop)。SILENT 标记和数据收集脚本也体现了 Hermes 对自动化场景的深入思考。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 Cron 和 batch 基础设施在 v0.3.0 之前就已经出现;v0.3.0-v0.8.0 之间逐步补齐了调度形式、投递目标、job 状态和守护逻辑。可以明确确认的是,
script预执行数据收集字段属于 v0.8.0 发布窗口的新能力。
执行环境后端:从本地到云端的六种选择
本章核心源码:
tools/terminal_tool.py(1627 行)、tools/environments/base.py(113 行)、tools/environments/local.py、tools/environments/docker.py、tools/environments/ssh.py、tools/environments/daytona.py、tools/environments/modal.py、tools/environments/singularity.py
定位:本章拆解 Hermes 的终端执行后端——agent 的
terminal工具如何通过BaseEnvironmentABC 在本地、Docker、SSH、Daytona、Modal、Singularity 六种后端中执行命令,以及每种后端的安全模型和持久化策略。 前置依赖:第 6 章(工具系统)。适用场景:想理解 agent 如何执行命令,或准备配置隔离执行环境。
为什么需要多后端
Agent 执行 shell 命令是最强大的能力之一,也是最危险的。不同场景对执行环境有截然不同的需求:
| 场景 | 需求 | 推荐后端 |
|---|---|---|
| 个人开发 | 最快速度、访问本地文件 | local |
| 团队共享 bot | 隔离、防止互相干扰 | Docker |
| 远程服务器管理 | 在目标机器上执行 | SSH |
| 无状态云执行 | 按需启动、用完销毁 | Modal |
| GPU/HPC 工作负载 | 利用集群资源 | Singularity |
| Serverless 持久沙箱 | 文件系统跨会话保留 | Daytona |
BaseEnvironment ABC 让 agent 的 terminal 工具完全不知道自己运行在哪种后端上——相同的 execute("ls -la") 调用在六种后端上语义一致。
后端架构
graph TB
TT["terminal_tool.py<br/>terminal_tool()"]
TT --> BE["BaseEnvironment ABC<br/>environments/base.py:26"]
BE --> L["LocalEnvironment<br/>environments/local.py"]
BE --> D["DockerEnvironment<br/>environments/docker.py"]
BE --> S["SSHEnvironment<br/>environments/ssh.py"]
BE --> DY["DaytonaEnvironment<br/>environments/daytona.py"]
BE --> M["ModalEnvironment<br/>environments/modal.py"]
BE --> SG["SingularityEnvironment<br/>environments/singularity.py"]
L --> LS["本地 subprocess"]
D --> DS["Docker container"]
S --> SS["SSH ControlMaster"]
DY --> DYS["Daytona SDK<br/>云沙箱"]
M --> MS["Modal SDK<br/>云沙箱 + 快照"]
SG --> SGS["Apptainer/Singularity<br/>容器 + overlay"]
BaseEnvironment ABC
BaseEnvironment(tools/environments/base.py:26)定义了所有后端必须实现的接口:
# tools/environments/base.py:26-59
class BaseEnvironment(ABC):
"""Common interface for all Hermes execution backends."""
def __init__(self, cwd: str, timeout: int, env: dict = None):
self.cwd = cwd
self.timeout = timeout
self.env = env or {}
@abstractmethod
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
"""Execute a command, return {"output": str, "returncode": int}."""
...
@abstractmethod
def cleanup(self):
"""Release backend resources (container, instance, connection)."""
...
接口极简——execute() 和 cleanup() 两个方法。返回值是一个 dict,包含 output(stdout + stderr 合并)和 returncode。
共享辅助方法
BaseEnvironment 提供了几个共享的辅助方法,消除跨后端的重复代码:
# tools/environments/base.py:64-76
def _prepare_command(self, command: str) -> tuple[str, str | None]:
"""Transform sudo commands if SUDO_PASSWORD is available."""
from tools.terminal_tool import _transform_sudo_command
return _transform_sudo_command(command)
def _build_run_kwargs(self, timeout: int | None,
stdin_data: str | None = None) -> dict:
"""Build common subprocess.run kwargs for non-interactive execution."""
_prepare_command() 处理 sudo 命令转换——当 SUDO_PASSWORD 可用时,将 sudo xxx 转换为管道密码输入的形式,让非交互式执行可以通过 sudo 认证。
execute_oneshot()(base.py:94-105)是一个旁路方法:对于维护持久 shell 的后端(local、SSH),它绕过 shell 锁直接执行命令,支持和长时间运行的 execute() 并发使用。
六种后端详解
1. LocalEnvironment:零开销本地执行
# tools/environments/local.py:0
"""Local execution environment with interrupt support and non-blocking I/O."""
LocalEnvironment 直接在宿主机上通过 subprocess 执行命令。它是最快的后端(零容器/网络开销),但也是最"危险"的——agent 可以访问宿主机的所有文件和进程。
关键设计——环境变量隔离(local.py:30-79):
# tools/environments/local.py:28-29
# Hermes-internal env vars that should NOT leak into terminal subprocesses.
_HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_"
_build_provider_env_blocklist()(local.py:31-79)动态构建一个环境变量黑名单,防止 Hermes 的 API key、provider 配置等泄露到用户命令的子进程中。这个黑名单从 provider registry 自动派生——新增 provider 自动被覆盖,不需要手动维护。
LocalEnvironment 支持 PersistentShellMixin——通过一个长生命周期的 bash 进程维护跨命令的状态(cwd、环境变量、shell 变量)。
2. DockerEnvironment:安全加固的容器隔离
# tools/environments/docker.py:0-5
"""Docker execution environment for sandboxed command execution.
Security hardened (cap-drop ALL, no-new-privileges, PID limits),
configurable resource limits (CPU, memory, disk), and optional filesystem
persistence via bind mounts.
"""
DockerEnvironment 在 Docker 容器中执行命令。安全加固措施包括:
- 能力丢弃:
--cap-drop ALL——容器没有任何 Linux capabilities - 禁止提权:
--security-opt no-new-privileges——容器内的进程不能通过 setuid 等机制获取新权限 - PID 限制:防止 fork bomb
- 资源限制:可配置 CPU、内存、磁盘
Docker 可执行文件不仅搜索 PATH,还检查常见安装位置(docker.py:29-33):
# tools/environments/docker.py:29-33
_DOCKER_SEARCH_PATHS = [
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/Applications/Docker.app/Contents/Resources/bin/docker",
]
这解决了 Docker Desktop 安装后 docker 不在 PATH 中的常见问题(尤其是 macOS)。
环境变量转发经过严格验证(docker.py:39-61):变量名必须匹配 ^[A-Za-z_][A-Za-z0-9_]*$,防止注入攻击。
3. SSHEnvironment:远程执行与连接持久化
# tools/environments/ssh.py:0
"""SSH remote execution environment with ControlMaster connection persistence."""
SSHEnvironment 通过 SSH 在远程机器上执行命令。关键特性:
- ControlMaster 持久化(
ssh.py:64-79):首次连接建立后,后续命令通过 Unix socket 复用连接,避免每次执行都经历 TCP 握手 + SSH 认证 - 安全隔离:agent 不能修改自身代码——执行发生在完全不同的机器上
- 中断支持:前台命令可中断——本地 SSH 进程被 kill 后,还会通过 ControlMaster socket 向远端发送 kill 信号
# tools/environments/ssh.py:64-79
def _build_ssh_command(self, extra_args: list | None = None) -> list:
cmd = ["ssh"]
cmd.extend(["-o", f"ControlPath={self.control_socket}"])
cmd.extend(["-o", "ControlMaster=auto"])
cmd.extend(["-o", "ControlPersist=300"])
cmd.extend(["-o", "BatchMode=yes"])
cmd.extend(["-o", "StrictHostKeyChecking=accept-new"])
cmd.extend(["-o", "ConnectTimeout=10"])
ControlPersist=300 让主连接在最后一个 SSH 会话关闭后保留 5 分钟。这意味着连续的工具调用不会反复建立新连接——第一次调用 3 秒,后续调用 100ms。
当 persistent=True 时,SSHEnvironment 也支持持久 shell——远端维护一个长生命周期的 bash 进程,通过文件 IPC(stdout/stderr/exit-code 写入临时文件,通过 ControlMaster 快速读取)实现状态保持。
4. DaytonaEnvironment:Serverless 持久沙箱
# tools/environments/daytona.py:0-6
"""Daytona cloud execution environment.
Uses the Daytona Python SDK to run commands in cloud sandboxes.
Supports persistent sandboxes: when enabled, sandboxes are stopped on cleanup
and resumed on next creation, preserving the filesystem across sessions.
"""
Daytona 提供了 serverless 沙箱,核心优势是文件系统持久化——沙箱在 cleanup 时被 stop(而非 delete),下次创建时 resume:
# tools/environments/daytona.py:74-99(简化)
if self._persistent:
# 1. Try name-based lookup
try:
self._sandbox = self._daytona.get(sandbox_name)
self._sandbox.start()
logger.info("Daytona: resumed sandbox %s for task %s",
self._sandbox.id, task_id)
except DaytonaError:
self._sandbox = None
# 2. Legacy fallback: find sandbox by label
if self._sandbox is None:
page = self._daytona.list(labels=labels, page=1, limit=1)
if page.items:
self._sandbox = page.items[0]
self._sandbox.start()
沙箱通过 task_id 关联——同一个任务的多次执行恢复到上次的文件系统状态。资源限制支持 CPU、内存(单位 MB,内部转 GiB)和磁盘(上限 10GB)。
5. ModalEnvironment:快照式云沙箱
# tools/environments/modal.py:0-4
"""Modal cloud execution environment using the native Modal SDK directly.
Uses Sandbox.create() + Sandbox.exec() instead of the older runtime
wrapper, while preserving Hermes' persistent snapshot behavior across sessions.
"""
Modal 后端有两个变体:
- ModalEnvironment(
modal.py):直接使用 Modal SDK,通过快照(snapshot)实现文件系统持久化 - ManagedModalEnvironment(
managed_modal.py):通过 tool-gateway HTTP API 使用 Modal,不需要本地 Modal 凭据
Modal 的快照持久化与 Daytona 的 stop/resume 不同——Modal 在沙箱空闲时销毁沙箱,但保存一个文件系统快照(modal_snapshots.json)。下次创建时从快照恢复:
# tools/environments/modal.py:24-69
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
def _get_snapshot_restore_candidate(task_id: str) -> tuple[str | None, bool]:
"""Return a snapshot id and whether it came from the legacy key format."""
snapshots = _load_snapshots()
namespaced_key = _direct_snapshot_key(task_id)
snapshot_id = snapshots.get(namespaced_key)
if isinstance(snapshot_id, str) and snapshot_id:
return snapshot_id, False
# Legacy fallback
legacy_snapshot_id = snapshots.get(task_id)
if isinstance(legacy_snapshot_id, str) and legacy_snapshot_id:
return legacy_snapshot_id, True
return None, False
ManagedModalEnvironment(managed_modal.py:35)面向没有 Modal 账号的用户——通过 Nous 的 tool-gateway 代理调用 Modal,凭据由 gateway 管理。
6. SingularityEnvironment:HPC 容器
# tools/environments/singularity.py:0-5
"""Singularity/Apptainer persistent container environment.
Security-hardened with --containall, --no-home, capability dropping.
Supports configurable resource limits and optional filesystem persistence
via writable overlay directories that survive across sessions.
"""
Singularity(现更名为 Apptainer)是 HPC 集群上的主流容器方案。与 Docker 不同,Singularity 不需要 root 权限,适合共享集群环境。
安全加固:
--containall:完全隔离容器的文件系统、PID、IPC--no-home:不挂载宿主机的 home 目录- Capability 丢弃
文件系统持久化通过 writable overlay 目录实现——overlay 存储在宿主机上的快照目录中(singularity_snapshots.json),跨会话保留。
_find_singularity_executable()(singularity.py:27-41)自动检测 apptainer 或 singularity——两个名字都支持,因为 Apptainer 是 Singularity 的社区分支。
安全模型对比
graph LR
subgraph "无隔离"
L["Local<br/>完全访问宿主机"]
end
subgraph "进程隔离"
SS["SSH<br/>远程机器隔离"]
end
subgraph "容器隔离"
D["Docker<br/>cap-drop ALL<br/>no-new-privileges"]
SG["Singularity<br/>containall<br/>no-home"]
end
subgraph "云沙箱隔离"
DY["Daytona<br/>按需沙箱"]
M["Modal<br/>快照沙箱"]
end
L -.->|"最快<br/>最危险"| L
SS -.->|"网络延迟<br/>机器隔离"| SS
D -.->|"中等开销<br/>强隔离"| D
SG -.->|"无需 root<br/>HPC 友好"| SG
DY -.->|"Serverless<br/>持久文件系统"| DY
M -.->|"Serverless<br/>快照恢复"| M
| 后端 | 隔离级别 | 持久化 | 启动开销 | 适用场景 |
|---|---|---|---|---|
| Local | 无 | 天然持久 | 0 | 个人开发 |
| Docker | 容器 | bind mount | 1-3 秒 | 团队 bot、CI |
| SSH | 机器 | 远端持久 | 首次 3s,后续 100ms | 服务器管理 |
| Daytona | 云沙箱 | stop/resume | 5-10 秒 | Serverless 隔离 |
| Modal | 云沙箱 | 快照 | 5-15 秒 | GPU 任务 |
| Singularity | 容器(无 root) | overlay | 1-3 秒 | HPC 集群 |
terminal_tool.py:统一入口
terminal_tool.py(1627 行)是 agent 的 terminal 工具的实现。它根据 TERMINAL_ENV 环境变量(或 config.yaml 的 terminal.backend)选择后端:
# tools/terminal_tool.py:0-11
"""
Terminal Tool Module
Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes
"""
除了后端选择,terminal_tool.py 还提供了两个跨后端的安全机制:
危险命令审批
_check_all_guards()(terminal_tool.py:144-147)在命令执行前检查安全规则:
# tools/terminal_tool.py:144-147
def _check_all_guards(command: str, env_type: str) -> dict:
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
return _check_all_guards_impl(command, env_type,
approval_callback=_approval_callback)
安全检查包括模式匹配(正则匹配危险命令模式)和 tirith 安全扫描器(基于策略引擎的更细粒度检查)。不安全的命令会弹出审批提示——在 CLI 中通过 prompt_toolkit UI 交互,在 Gateway 中通过 /approve 或 /deny 命令。
Sudo 密码管理
# tools/terminal_tool.py:111-112
_cached_sudo_password: str = ""
Sudo 密码在 CLI 会话中缓存(_cached_sudo_password),避免每次 sudo 命令都需要用户输入。密码输入通过可注册的回调(_sudo_password_callback)路由到 CLI 的密码输入 UI 或 Gateway 的消息流。
中断支持
# tools/terminal_tool.py:54
from tools.interrupt import is_interrupted, _interrupt_event
全局中断事件让终端工具在命令执行期间也能响应用户中断。长时间运行的子进程在检测到中断时被 kill,避免用户等待 timeout。
设计启示
六种后端的设计可以提炼出三个原则:
-
ABC 极简化:
BaseEnvironment只有execute()和cleanup()两个抽象方法。复杂的持久化、安全加固、连接管理等逻辑完全在子类中实现。这让新后端的接入成本极低——实现两个方法就能工作 -
持久化策略多样化:Local 天然持久、Docker 用 bind mount、SSH 用远端文件系统、Daytona 用 stop/resume、Modal 用快照、Singularity 用 overlay。每种策略都针对其后端的运行模型优化,而非强制统一
-
安全是分层的:环境变量隔离(local)、能力丢弃(Docker/Singularity)、机器隔离(SSH)、沙箱隔离(Daytona/Modal)提供不同级别的安全保障。危险命令审批和 sudo 管理作为跨后端的通用安全层叠加在上面
设计赌注回扣:本章是 Run Anywhere 赌注的直接体现。同一个
terminal工具,通过配置切换就能在本地笔记本、Docker 容器、远程服务器、云沙箱和 HPC 集群上执行命令。六种后端覆盖了从 $5 VPS 到 GPU 集群的完整部署谱系,每种后端都有针对性的安全模型和持久化策略。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 执行环境抽象本身早在 v0.3.0 之前就已经存在;Daytona 后端和 persistent shell 也都在 v0.3.0 发布窗口附近进入主线。之后的几个 release 主要是在扩展后端种类、收紧运行时隔离,以及补上 managed tool gateway 这一类托管执行路径。
配置与 Profiles:多实例隔离的工程学
本章核心源码:
hermes_cli/config.py、hermes_cli/main.py、hermes_cli/env_loader.py、hermes_cli/profiles.py
定位:本章拆解 Hermes 的配置加载管线——从硬编码默认值到环境变量到 YAML 文件到 profile 隔离——理解一个"Run Anywhere"的 agent 如何在 $5 VPS、NixOS、Docker 和开发者笔记本上用同一套代码正确初始化。 前置依赖:第 4 章(AIAgent 初始化)。适用场景:需要理解 Hermes 配置优先级、多实例部署、或自定义配置扩展。
为什么配置系统值得单独讨论
大多数 CLI 工具的配置很简单:一个 YAML 文件加几个环境变量。但 Hermes 面对的场景远比典型 CLI 复杂:
- 多入口共享配置:CLI、Gateway、Cron 三个入口必须读到同一份配置,且 Gateway 作为 systemd 服务运行时没有用户的 shell 环境变量
- 多实例并行:一个用户可能运行两个 Hermes 实例(一个 "coder" profile 用 Claude,一个 "researcher" profile 用 GPT-5),它们的 sessions、memories、skills 必须完全隔离
- 密钥安全:API keys 不能写进 config.yaml(会被 git commit),需要独立的 .env 文件
- 环境兼容:同一份代码要在 NixOS(声明式配置)、Docker(环境变量注入)、和普通 Linux 安装(交互式 setup)中正确工作
这些需求驱动了一个比"读个 YAML"复杂得多的配置管线。
配置加载优先级
Hermes 的配置加载是一个五层叠加的过程,后面的层覆盖前面的层:
graph TB
A["1. DEFAULT_CONFIG<br/>硬编码默认值<br/>config.py:201"] --> B["2. Shell 环境变量<br/>os.environ"]
B --> C["3. ~/.hermes/.env<br/>用户密钥文件<br/>override=True"]
C --> D["4. config.yaml<br/>用户 YAML 配置<br/>deep merge"]
D --> E["5. 项目 .env<br/>开发者 fallback<br/>override=False"]
E --> F["${VAR} 展开<br/>config.py:1818"]
F --> G["最终配置对象"]
style A fill:#e8e8e8,stroke:#333
style D fill:#f96,stroke:#333,stroke-width:2px
style G fill:#9f9,stroke:#333,stroke-width:2px
第一层:硬编码默认值(config.py:201-551)
DEFAULT_CONFIG 是一个 550 行的 Python 字典,定义了 Hermes 所有配置项的默认值:
# hermes_cli/config.py:201-210
DEFAULT_CONFIG = {
"model": "",
"providers": {},
"fallback_providers": [],
"toolsets": ["hermes-cli"],
"agent": {
"max_turns": 90,
"gateway_timeout": 1800,
"tool_use_enforcement": "auto",
},
# ... 350+ 行的嵌套配置
}
这个字典的关键设计决策:每个配置项都有合理的默认值。用户可以只配置一个 model 字段和一个 API key,其他一切都有默认行为。
值得注意的嵌套结构:
| 配置节 | 行号 | 用途 |
|---|---|---|
agent | 207-219 | 编排参数(max_turns、gateway_timeout、tool_use_enforcement) |
terminal | 222-259 | 执行环境(backend、docker_image、container_cpu/memory/disk) |
browser | 261-273 | 浏览器工具(inactivity_timeout、command_timeout、camofox) |
compression | 288-296 | 上下文压缩策略(threshold、target_ratio、protect_last_n) |
auxiliary | 304-368 | 8 个辅助任务各自的 provider/model/base_url/timeout |
memory | 441-451 | 记忆系统(memory_char_limit、user_char_limit、provider) |
delegation | 453-464 | 子代理配置(独立 model/provider、max_iterations=50、reasoning_effort) |
security | 522-533 | 安全扫描(redact_secrets、tirith、website_blocklist) |
logging | 542-547 | 文件日志(level、max_size_mb、backup_count) |
第二层 + 第三层:环境变量与 .env 文件
hermes_cli/main.py 在所有模块导入之前加载环境变量(main.py:139-143):
# hermes_cli/main.py:139-143
from hermes_cli.config import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(project_env=PROJECT_ROOT / '.env')
load_hermes_dotenv() 的加载顺序有精心设计(env_loader.py:18-45):
# hermes_cli/env_loader.py:37-42
if user_env.exists():
_load_dotenv_with_fallback(user_env, override=True) # 覆盖 shell exports
if project_env_path and project_env_path.exists():
_load_dotenv_with_fallback(project_env_path, override=not loaded) # 仅填充缺失值
关键逻辑:当前 HERMES_HOME/.env 使用 override=True,默认 profile 下表现为 ~/.hermes/.env。也就是说,它会覆盖 shell 中已有的环境变量。这解决了一个实际问题:用户更新了 .env 中的 API key,但 shell 里还缓存着旧值。项目 .env 则用 override=not loaded——如果用户 .env 存在,项目 .env 只填充缺失值;如果用户 .env 不存在(开发者场景),项目 .env 也可以覆盖 shell exports。
Hermes 管理着大量的环境变量,按用途分为四类(config.py:574-1206):
| 类别 | 示例 | 数量 |
|---|---|---|
| Provider | OPENROUTER_API_KEY、ANTHROPIC_API_KEY、DEEPSEEK_API_KEY | ~25 |
| Tool | EXA_API_KEY、FAL_KEY、TAVILY_API_KEY、CAMOFOX_URL | ~15 |
| Messaging | TELEGRAM_BOT_TOKEN、DISCORD_BOT_TOKEN、SLACK_BOT_TOKEN | ~20 |
| Setting | HERMES_MAX_ITERATIONS、MESSAGING_CWD、SUDO_PASSWORD | ~8 |
第四层:config.yaml 加载与合并(config.py:1903-1927)
load_config() 是配置管线的核心函数:
# hermes_cli/config.py:1903-1927
def load_config() -> Dict[str, Any]:
config = copy.deepcopy(DEFAULT_CONFIG)
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
config = _deep_merge(config, user_config)
return _expand_env_vars(
_normalize_root_model_keys(
_normalize_max_turns_config(config)
)
)
三个关键步骤:
- Deep merge:用户配置覆盖默认值,但只覆盖用户明确设置的字段。如果用户的 config.yaml 只有
model: claude-opus-4-6,其他 350+ 个字段都保持默认 - 历史兼容性修正:
_normalize_root_model_keys()将旧版的根级provider:迁移到model.provider:;_normalize_max_turns_config()将根级max_turns:迁移到agent.max_turns: - 变量展开:
_expand_env_vars()处理 config.yaml 中的${VAR}引用
第五层:${VAR} 展开(config.py:1818-1835)
# hermes_cli/config.py:1818-1835
def _expand_env_vars(obj):
if isinstance(obj, str):
return re.sub(
r"\${([^}]+)}",
lambda m: os.environ.get(m.group(1), m.group(0)),
obj,
)
if isinstance(obj, dict):
return {k: _expand_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_env_vars(item) for item in obj]
return obj
递归遍历整个配置树,将所有 ${VAR} 替换为环境变量的值。未解析的变量保持原样(返回 m.group(0) 即原文),让调用方可以检测到未设置的变量。实际使用示例:
delegation:
base_url: "${SUBAGENT_ENDPOINT}"
api_key: "${SUBAGENT_API_KEY}"
HERMES_HOME 隔离
Hermes 选择 ~/.hermes 作为 home 目录而非 XDG 的 ~/.config/hermes,因为这个目录不仅存储配置,还包含 sessions 数据库、记忆文件、技能文件、日志等——它是一个完整的运行时隔离单元:
~/.hermes/ <-- 默认 HERMES_HOME
├── config.yaml <-- 配置
├── .env <-- API keys (0600 权限)
├── SOUL.md <-- 人格定义
├── sessions/state.db <-- SQLite 会话数据库
├── memories/ <-- MEMORY.md + USER.md
├── skills/ <-- 技能文件
├── logs/ <-- agent.log + errors.log
├── cron/ <-- 定时任务配置
├── gateway.pid <-- Gateway PID 文件
├── gateway_state.json <-- Gateway 运行时状态
├── context_length_cache.yaml <-- 模型上下文长度缓存
└── profiles/ <-- 其他 profile
├── coder/ <-- 完全独立的 HERMES_HOME
└── researcher/
ensure_hermes_home() 在首次运行时创建这个目录树并设置安全权限(config.py:185-194):
# hermes_cli/config.py:185-194
def ensure_hermes_home():
home = get_hermes_home()
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home) # 0700
for subdir in ("cron", "sessions", "logs", "memories"):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
_ensure_default_soul_md(home) # 首次创建默认 SOUL.md
Profile 系统
Profile 创建与目录结构(profiles.py:1-80)
Profile 系统让用户运行多个完全独立的 Hermes 实例。每个 profile 是一个独立的 HERMES_HOME 目录:
# hermes_cli/profiles.py:36-45
_PROFILE_DIRS = [
"memories", "sessions", "skills", "skins",
"logs", "plans", "workspace", "cron",
]
_CLONE_CONFIG_FILES = ["config.yaml", ".env", "SOUL.md"]
_CLONE_SUBDIR_FILES = [
"memories/MEMORY.md", # 记忆文件也是 identity 的一部分
"memories/USER.md",
]
Profile 目录位于 ~/.hermes/profiles/<name>/,"default" profile 就是 ~/.hermes 本身——零迁移成本。Profile 名称受 _PROFILE_ID_RE = r"^[a-z0-9][a-z0-9_-]{0,63}$" 约束。
Profile Override 时序(main.py:83-137)
Profile 切换有一个关键的时序约束:必须在所有 Hermes 模块导入之前完成。因为许多模块在导入时就缓存了 HERMES_HOME 的值。
_apply_profile_override() 的三步逻辑:
- 命令行参数(
main.py:90-98):检查--profile/-p标志 - Sticky 默认(
main.py:100-110):读取~/.hermes/active_profile文件 - 环境变量设置(
main.py:113-124):将 profile 路径写入os.environ["HERMES_HOME"]
# hermes_cli/main.py:120-123
except Exception as exc:
# A bug in profiles.py must NEVER prevent hermes from starting
print(f"Warning: profile override failed ({exc}), using default", file=sys.stderr)
return
注意防御性处理:profiles.py 中的 bug 永远不阻止 Hermes 启动。Profile 是增值功能,不能让核心功能不可用。
安全性设计
文件权限(config.py:159-173)
# hermes_cli/config.py:159-173
def _secure_dir(path):
os.chmod(path, 0o700) # 目录:仅所有者可访问
def _secure_file(path):
os.chmod(path, 0o600) # 文件:仅所有者可读写
Managed 模式(config.py:54-133)
NixOS 和 Homebrew 管理的安装通过两个信号检测:
# hermes_cli/config.py:67-79
def get_managed_system() -> Optional[str]:
raw = os.getenv("HERMES_MANAGED", "").strip()
if raw:
return _MANAGED_SYSTEM_NAMES.get(raw.lower(), raw)
managed_marker = get_hermes_home() / ".managed"
if managed_marker.exists():
return "NixOS"
return None
HERMES_MANAGED 环境变量由 systemd service 设置,.managed marker 文件由 NixOS activation script 创建。双信号确保无论从 systemd 还是交互式 shell 启动都能正确识别 managed 模式。在 managed 模式下,hermes config edit 等修改操作会被拦截并提示用户使用包管理器。
新增配置键与 Gateway 环境桥接
v0.8.x 新增了若干配置键,以及 Gateway 将配置值桥接为环境变量的机制。
agent.restart_drain_timeout
控制 Gateway 在重启前等待正在运行的 agent 完成工作的超时秒数。默认值 60 秒(config.py:276):
agent:
restart_drain_timeout: 60 # 秒;0 = 不等待,立刻中断
Gateway 的 GatewayRunner 在收到重启信号后使用此值(gateway/run.py:1804):先等待活跃 agent 自然结束,超时后中断剩余 agent。解析由 parse_restart_drain_timeout()(gateway/restart.py:14)完成,无效值静默回退到默认值。
delegation.reasoning_effort
配置子代理的推理强度。有效值:"none"、"minimal"、"low"、"medium"、"high"、"xhigh"(hermes_constants.py:140-148):
delegation:
reasoning_effort: "low" # 子代理使用低推理强度,节省 token
解析优先级(tools/delegate_tool.py:315-332):delegation 配置覆盖 > 继承父代理的 reasoning 配置。如果值无法识别,记录 warning 并回退到父代理级别。
Gateway 配置到环境变量的桥接
Gateway 启动时将若干配置值桥接为环境变量(gateway/run.py:183-190),使不直接读取 config.yaml 的模块也能获取配置:
| 配置路径 | 环境变量 | 说明 |
|---|---|---|
agent.gateway_timeout | HERMES_AGENT_TIMEOUT | agent 执行超时 |
agent.gateway_timeout_warning | HERMES_AGENT_TIMEOUT_WARNING | 超时预警 |
agent.restart_drain_timeout | HERMES_RESTART_DRAIN_TIMEOUT | 重启排空超时 |
桥接逻辑遵循"不覆盖已有值"原则——只在环境变量尚未设置时才写入,确保运维人员通过环境变量的显式覆盖不被配置文件意外覆盖。
配置验证与迁移
结构验证(config.py:1363-1399)
validate_config_structure() 检测常见的 YAML 格式错误:
# hermes_cli/config.py:1380-1391
cp = config.get("custom_providers")
if isinstance(cp, dict):
issues.append(ConfigIssue(
"error",
"custom_providers is a dict — it must be a YAML list",
"Change to:\n custom_providers:\n - name: my-provider\n ..."
))
版本迁移(config.py:549-566)
# hermes_cli/config.py:559-566
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", ...],
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
}
_config_version 跟踪 schema 版本(当前为 12)。升级后只提示用户配置自上次版本以来新增的环境变量。
设计启示
Hermes 的配置系统展示了三个值得借鉴的工程模式:
- 五层叠加而非单一来源:defaults -> env -> .env -> yaml -> expansion 的分层让不同部署方式(Docker 环境变量注入、NixOS 声明式配置、开发者 .env)自然工作,不需要为每种部署写不同的加载逻辑
- Profile 即 HERMES_HOME 重定向:不是在配置中加一个
active_profile字段做逻辑隔离,而是直接重定向整个 HERMES_HOME 目录。Profile 之间是物理隔离——sessions、memories、skills 完全独立,不可能交叉污染 - 防御性加载:profile 解析失败不阻止启动,YAML 解析失败回退默认值,managed 检测用双信号——每一步都有 fallback
设计赌注回扣:本章直接服务于 Run Anywhere 赌注——五层配置优先级让 Hermes 在 Docker(环境变量注入)、NixOS(声明式配置)、$5 VPS(手动 .env)和开发者笔记本(项目 .env fallback)上用同一套代码正确初始化。Profile 隔离让同一台机器上的多个 agent 实例互不干扰。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 Profile 系统是在 v0.6.0 发布窗口进入主线的,managed install 相关能力和 profile 导入导出细节则继续演化到 v0.8.0。
DEFAULT_CONFIG的_config_version当前为 12;配置加载的五层优先级结构在最近几个 release 中保持稳定。v0.8.x 新增了agent.restart_drain_timeout(Gateway 重启排空超时)和delegation.reasoning_effort(子代理推理强度)两个配置键,以及 Gateway 配置到环境变量的桥接机制。
模型抽象与 Provider 兼容层
本章核心源码:
run_agent.py(559-581 API 模式检测)、agent/model_metadata.py、agent/usage_pricing.py
定位:本章拆解 Hermes 如何用一套编排逻辑适配 Anthropic、OpenAI、DeepSeek、本地 Ollama 等数十个 LLM provider——从 API 模式自动检测到上下文窗口探测到成本追踪。 前置依赖:第 4 章(AIAgent 初始化阶段 2/4)。适用场景:想理解 Hermes 如何做到"换个 model 字段就能切 provider",或需要接入新的 LLM provider。
为什么模型抽象是 Run Anywhere 的基础
"Run Anywhere"不只是指运行在不同的操作系统上——它同样意味着运行在不同的 LLM provider 上。Hermes 的用户群体横跨:
- Anthropic API 直连用户(需要 prompt caching、extended thinking)
- OpenAI API 用户(需要 Responses API、reasoning tokens)
- OpenRouter 中转用户(需要统一计费、多模型切换)
- 本地模型用户(Ollama、LM Studio、vLLM、llama.cpp)
- 国产模型用户(DeepSeek、DashScope/Qwen、GLM、Kimi、MiniMax)
- 企业平台用户(GitHub Copilot、Hugging Face Inference)
每个 provider 的 API 格式、认证方式、上下文限制、定价模型都不同。Hermes 需要在编排层之下建立一个兼容层,让 AIAgent.run_conversation() 不需要知道底层是 Claude 还是 GPT 还是 Qwen。
三种 API 模式
Hermes 将所有 LLM provider 归类为三种 API 模式(run_agent.py:559-581):
graph LR
subgraph "API Mode Detection"
INPUT["model + provider + base_url"] --> CHECK{"检测逻辑<br/>run_agent.py:559-581"}
CHECK -->|"provider == anthropic<br/>或 URL 含 api.anthropic.com"| AM["anthropic_messages"]
CHECK -->|"provider == openai-codex<br/>或直连 OpenAI"| CR["codex_responses"]
CHECK -->|"其他所有情况"| CC["chat_completions"]
end
AM --> SDK_A["Anthropic SDK<br/>Messages API"]
CR --> SDK_O["OpenAI SDK<br/>Responses API"]
CC --> SDK_C["OpenAI SDK<br/>Chat Completions API"]
style CC fill:#9cf,stroke:#333,stroke-width:2px
检测逻辑详解
# run_agent.py:559-581(简化)
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
self.api_mode = api_mode # 显式指定优先
elif self.provider == "openai-codex":
self.api_mode = "codex_responses"
elif self.provider == "anthropic" or "api.anthropic.com" in base_url:
self.api_mode = "anthropic_messages"
elif base_url.rstrip("/").endswith("/anthropic"):
self.api_mode = "anthropic_messages" # MiniMax、DashScope 的 Anthropic 兼容端点
else:
self.api_mode = "chat_completions" # 默认
# 直连 OpenAI 时自动切换到 Responses API
if self.api_mode == "chat_completions" and self._is_direct_openai_url():
self.api_mode = "codex_responses" # run_agent.py:580-581
| API 模式 | 使用场景 | 选择原因 |
|---|---|---|
chat_completions | OpenRouter、大多数第三方、本地模型 | 最广泛兼容的标准,几乎所有 provider 都支持 |
anthropic_messages | Anthropic 直连、MiniMax /anthropic 端点、DashScope /anthropic 端点 | 原生支持 prompt caching(cache_control blocks)、extended thinking |
codex_responses | OpenAI 直连 | GPT-5.x 的 tool calling + reasoning 需要 Responses API |
一个有趣的细节:URL 以 /anthropic 结尾的第三方端点(run_agent.py:569-573)被自动识别为 Anthropic 兼容模式。这是因为 MiniMax 和 DashScope 等国产 provider 提供了 Anthropic Messages API 兼容端点,Hermes 通过 URL 约定自动适配。
检测的时序约束
API 模式检测必须在 LLM 客户端构造之前完成(第 4 章的阶段 2 在阶段 4 之前),因为不同模式需要不同的 SDK 客户端:
anthropic_messages→build_anthropic_client()构造 Anthropic SDK 客户端chat_completions/codex_responses→ 构造 OpenAI SDK 客户端
Provider 路由
当用户没有显式指定 provider 和 base_url 时,Hermes 通过 resolve_provider_client() 自动路由:
# run_agent.py:761-763(简化)
from agent.auxiliary_client import resolve_provider_client
_routed_client, _ = resolve_provider_client(
self.provider or "auto", model=self.model
)
这里要区分两层职责:
- 主路由器在
agent/auxiliary_client.py的resolve_provider_client(),负责根据 provider、model、base_url、认证方式实际构造客户端 agent/model_metadata.py里的_URL_TO_PROVIDER只是辅助表,用于在模型元数据解析时根据 base URL 推断 provider
后者不是“真正决定客户端怎么建”的主路由器,它更像是元数据层的补充信号:
# agent/model_metadata.py:180-197
_URL_TO_PROVIDER: Dict[str, str] = {
"api.openai.com": "openai",
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"api.moonshot.ai": "kimi-coding",
"dashscope.aliyuncs.com": "alibaba",
"openrouter.ai": "openrouter",
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
# ... 共 16 个端点映射
}
上下文窗口探测
上下文窗口长度决定了 Hermes 的压缩策略、消息截断和 prompt 构建。但不同 provider 的同一个模型可能有不同的上下文限制(例如 Claude Opus 4.6 在 Anthropic 直连是 1M tokens,在 GitHub Copilot 只有 128K)。
get_model_context_length() 不是线性的“查 10 张表”,而是一条带分支的解析链(model_metadata.py:838-965):
| 优先级 | 来源 | 代码行 |
|---|---|---|
| 0 | 用户显式配置(config.yaml 的 context_length) | 860-861 |
| 1 | 持久化缓存(context_length_cache.yaml) | 868-872 |
| 2 | 活跃端点 /models API(仅真正的自定义未知端点) | 879-895 |
| 3 | 自定义本地端点的直接查询与早期 fallback 分支 | 897-909 |
| 4 | Anthropic /v1/models API | 911-917 |
| 5 | provider-aware 查找(先 Nous,再 models.dev) | 919-939 |
| 6 | OpenRouter 实时 API 元数据 | 942-944 |
| 7 | 硬编码默认值(DEFAULT_CONTEXT_LENGTHS) | 947-955 |
| 8 | 本地端点的最后一次查询机会 | 958-962 |
| 9 | 默认 fallback(128K) | 965 |
最关键的顺序修正是第 5、6 步:provider-aware 查找先于 OpenRouter 的通用元数据。这正是 Hermes 用来处理“同一个模型在不同 provider 上上下文窗口不同”的关键细节。
本地服务器自动探测(model_metadata.py:258-314)
当 base_url 指向本地地址时,Hermes 通过探针请求自动识别服务器类型:
# agent/model_metadata.py:258-314(简化)
def detect_local_server_type(base_url: str) -> Optional[str]:
# LM Studio: /api/v1/models
# Ollama: /api/tags(检查返回体包含 "models" 键)
# llama.cpp: /v1/props(检查 "default_generation_settings")
# vLLM: /version
Ollama 的探测有一个细节:LM Studio 在 /api/tags 路径也返回 200 但内容不同({"error": "Unexpected endpoint"}),所以 Hermes 不仅检查状态码,还验证返回体包含 "models" 键(model_metadata.py:283-290)。
错误驱动的上下文探测(model_metadata.py:574-599)
当正常探测失败时,Hermes 还能从 API 错误消息中提取上下文限制:
# agent/model_metadata.py:574-598
def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
patterns = [
r'(?:max|limit)\s*(?:context\s*)?(?:length|size)?\s*(?:is|of|:)?\s*(\d{4,})',
r'context\s*(?:length|size)\s*(?:is|of|:)?\s*(\d{4,})',
r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
]
配合 CONTEXT_PROBE_TIERS(model_metadata.py:73-79)的阶梯探测机制:
CONTEXT_PROBE_TIERS = [128_000, 64_000, 32_000, 16_000, 8_000]
当首次 API 调用因为上下文超限失败时,Hermes 从错误消息中解析出实际限制,或者按阶梯降级重试。发现的限制会被持久化到 context_length_cache.yaml(model_metadata.py:538-556),后续请求直接使用缓存值。
Token 估算:粗略与精确
Hermes 使用两层 token 估算:
粗略估算(model_metadata.py:968-999)
# agent/model_metadata.py:968-972
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks."""
return len(text) // 4
4 chars/token 的粗略估算用于预检——在 API 调用之前快速判断消息是否会超过上下文窗口。这比调用 tokenizer 快几个数量级。estimate_request_tokens_rough() 还包含了 tool schema 的估算(model_metadata.py:981-999),因为 50+ 个工具的 schema 可以占用 20-30K tokens。
精确追踪(usage_pricing.py:420-478)
API 调用返回后,normalize_usage() 将三种 API 格式的 usage 统一为 CanonicalUsage:
# agent/usage_pricing.py:420-478
def normalize_usage(response_usage, *, provider=None, api_mode=None) -> CanonicalUsage:
if mode == "anthropic_messages" or provider_name == "anthropic":
# Anthropic: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens
...
elif mode == "codex_responses":
# Codex: input_tokens 包含缓存; input_tokens_details.cached_tokens 分离
...
else:
# Chat Completions: prompt_tokens 包含缓存; prompt_tokens_details.cached_tokens 分离
...
三种 API 对缓存 token 的表示方式完全不同:
| 字段 | Anthropic | Codex Responses | Chat Completions |
|---|---|---|---|
| 输入 token | input_tokens | input_tokens(含缓存) | prompt_tokens(含缓存) |
| 缓存读取 | cache_read_input_tokens | input_tokens_details.cached_tokens | prompt_tokens_details.cached_tokens |
| 缓存写入 | cache_creation_input_tokens | input_tokens_details.cache_creation_tokens | prompt_tokens_details.cache_write_tokens |
CanonicalUsage(usage_pricing.py:28-44)将这些差异统一为 5 个标准字段:input_tokens、output_tokens、cache_read_tokens、cache_write_tokens、reasoning_tokens。
成本追踪
计费路由(usage_pricing.py:306-330)
resolve_billing_route() 将 model + provider + base_url 映射到计费路线:
# agent/usage_pricing.py:306-330
def resolve_billing_route(model_name, provider=None, base_url=None) -> BillingRoute:
if provider_name == "openai-codex":
return BillingRoute(..., billing_mode="subscription_included") # 订阅制,零成本
if "openrouter.ai" in base:
return BillingRoute(..., billing_mode="official_models_api") # 从 OpenRouter API 获取定价
if provider_name == "anthropic":
return BillingRoute(..., billing_mode="official_docs_snapshot") # 使用硬编码快照
if "localhost" in base:
return BillingRoute(..., billing_mode="unknown") # 本地模型,无法计费
多源定价查找(usage_pricing.py:390-417)
get_pricing_entry() 按优先级查找定价数据:
- 订阅制(如 GitHub Copilot):直接返回零成本
- OpenRouter API:从
/models端点获取实时定价 - 自定义端点 /models:从端点元数据中提取定价
- 官方文档快照:使用硬编码的定价表
官方文档快照(usage_pricing.py:83-287)
_OFFICIAL_DOCS_PRICING 是一个精心维护的定价快照,覆盖了主流 provider 的 17 个模型:
# agent/usage_pricing.py:83-96
_OFFICIAL_DOCS_PRICING = {
("anthropic", "claude-opus-4-20250514"): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
),
# ... Google Gemini, OpenAI GPT-4.1, DeepSeek, etc.
}
使用 Decimal 而非 float 是刻意的——金额计算不能有浮点误差。每个条目都附带了 source_url 和 pricing_version,让用户可以验证定价来源。
成本估算(usage_pricing.py:481-557)
# agent/usage_pricing.py:529-536
if entry.input_cost_per_million is not None:
amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION
if entry.cache_read_cost_per_million is not None:
amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION
CostResult 的 status 字段告诉调用方这个成本的可信度:"actual"(provider API 提供)、"estimated"(从快照/元数据计算)、"included"(订阅制)、"unknown"(无法计算)。
CodexAuxiliaryClient:辅助任务的端点适配
当主 agent 使用 Codex Responses API(codex_responses 模式)时,存在一个端点形状不匹配的问题:辅助任务(压缩摘要、标题生成、会话搜索等)的代码使用的是 client.chat.completions.create() 接口,但 Codex 端点只暴露 Responses API,不支持 /chat/completions 路径。
CodexAuxiliaryClient(agent/auxiliary_client.py:424)通过适配器模式解决这个问题:
# agent/auxiliary_client.py:424-439
class CodexAuxiliaryClient:
"""OpenAI-client-compatible wrapper that routes through Codex Responses API.
Consumers can call client.chat.completions.create(**kwargs) as normal."""
def __init__(self, real_client: OpenAI, model: str):
self._real_client = real_client
adapter = _CodexCompletionsAdapter(real_client, model)
self.chat = _CodexChatShim(adapter)
调用方仍然写 client.chat.completions.create(messages=..., model=...),但 _CodexCompletionsAdapter 内部将 chat.completions 请求翻译为 Responses API 调用,再将 Responses 格式的返回值包装回 chat.completions 的响应形状。
这不是简单的 provider 路由——第 18 章前面讨论的 resolve_provider_client() 决定"用哪个 provider",而 CodexAuxiliaryClient 解决的是"同一个 provider 内两种 API 端点形状不兼容"的问题。对应的异步版本 AsyncCodexAuxiliaryClient(auxiliary_client.py:462)通过 asyncio.to_thread() 桥接同步适配器,供 web_tools、session_search 等异步消费方使用。
类似的适配器还有 AnthropicAuxiliaryClient(同文件),用于将 chat.completions 调用翻译为 Anthropic Messages API 格式。三个辅助客户端适配器共同确保了:无论主 agent 使用哪种 API 模式,辅助任务代码都只需要一套 chat.completions 接口。
Provider 前缀处理(model_metadata.py:22-61)
用户配置模型时可能使用各种格式:anthropic/claude-opus-4.6、local:qwen3.5:27b、deepseek:latest。_strip_provider_prefix() 需要区分 provider 前缀和 Ollama 的 model:tag 格式:
# agent/model_metadata.py:44-61
def _strip_provider_prefix(model: str) -> str:
prefix, suffix = model.split(":", 1)
if prefix_lower in _PROVIDER_PREFIXES:
# Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
return model # "qwen3.5:27b" → 保持原样
return suffix # "local:my-model" → "my-model"
return model # "some-model:tag" → 保持原样
_PROVIDER_PREFIXES 包含 30+ 个已知的 provider 名称(model_metadata.py:25-35),_OLLAMA_TAG_PATTERN 匹配 Ollama tag 的常见格式(数字+b、latest、q 量化标记等)。
设计启示
- 模式检测而非配置声明:大多数用户只配置 model 和 api_key,API 模式由 provider + base_url 自动推导。这减少了配置出错的可能性,同时允许高级用户通过
api_mode字段显式覆盖 - 10 级 fallback 链:上下文窗口探测不依赖单一数据源。任何一级失败都有下一级接管,最差情况回退到 128K 的安全默认值。错误驱动的探测(从 API 错误中解析限制)是最后的兜底
- 统一 token 语义:三种 API 对缓存 token 的表示差异很大,但
CanonicalUsage将它们统一为 5 个标准字段。这让上层代码(成本计算、usage 显示、压缩决策)不需要知道底层 API 的差异
设计赌注回扣:本章直接服务于 Run Anywhere 赌注——三种 API 模式的自动检测、30+ provider 的路由映射、10 级上下文探测 fallback,让用户只需改一个
model配置就能在 Anthropic、OpenAI、本地 Ollama、国产 DeepSeek 之间无缝切换。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 模型兼容层在 v0.5.0-v0.8.0 之间扩张很快:API 模式、provider-aware 上下文窗口探测和价格快照都不是一次成形的。可以明确确认的是,models.dev / Anthropic
/v1/models这一类 provider-aware 元数据能力在 v0.7.0 发布窗口已经出现,并在 v0.8.0 继续强化。v0.8.x 新增了CodexAuxiliaryClient适配器,解决了主 agent 使用 Codex Responses API 时辅助任务仍需 chat.completions 接口的端点形状不匹配问题。
并发模型:为什么不全用 async
本章核心源码:
run_agent.py(_MAX_TOOL_WORKERS、6145-6153 并发工具执行)、model_tools.py:81(_run_async())、hermes_state.py(164-214 jitter retry)、gateway/session_context.py(contextvars 会话隔离)
定位:本章解释 Hermes 为什么选择同步编排器 + 异步桥接的混合并发模型,而不是全 async——从
_run_async()的三种路径到工具并发的 ThreadPoolExecutor 到 SQLite WAL 的锁竞争解法。 前置依赖:第 4 章(AIAgent 同步编排器)、第 6 章(工具系统)。适用场景:想理解 Hermes 为什么不全 async,或需要调试并发相关的问题。
为什么 AIAgent 是同步的
这个问题在第 4 章被简单提及,本章展开讨论。
一个 LLM agent 的核心循环是:构建消息 → 调用模型 → 解析工具调用 → 执行工具 → 循环。这个循环是天然串行的——你必须等模型返回才能知道要执行哪些工具,必须等工具执行完才能把结果喂给模型。async/await 在这里没有并发收益,只会增加复杂度。
但 Hermes 的入口层有两种截然不同的运行环境:
- CLI 模式:Python 主线程,没有 event loop,直接调用
agent.run_conversation() - Gateway 模式:asyncio event loop 驱动,平台消息通过
await接收
如果 AIAgent 是 async 的,CLI 模式需要 asyncio.run() 包装——看似简单,但引入了一个关键问题:async 上下文中不能调用同步阻塞函数(如 input()、subprocess.run()),否则会阻塞 event loop。Hermes 的工具系统大量使用同步阻塞 API(subprocess、文件 IO、SQLite),把它们全部改成 async 的工作量和出错概率都很高。
Hermes 的解法是:编排层同步,异步工具通过桥接函数在同步上下文中运行。
graph TB
subgraph "CLI 入口"
CLI["主线程<br/>无 event loop"]
end
subgraph "Gateway 入口"
GW["asyncio event loop"]
GW_THREAD["agent 运行在<br/>独立线程"]
end
subgraph "AIAgent (同步编排器)"
AGENT["run_conversation()<br/>同步大循环"]
TOOLS["工具执行<br/>ThreadPoolExecutor"]
ASYNC_BRIDGE["_run_async()<br/>model_tools.py:81"]
end
subgraph "异步工具"
BROWSER["browser_tool<br/>(playwright)"]
MCP["mcp_tool<br/>(async transport)"]
end
CLI --> AGENT
GW --> GW_THREAD --> AGENT
AGENT --> TOOLS
TOOLS --> ASYNC_BRIDGE
ASYNC_BRIDGE --> BROWSER
ASYNC_BRIDGE --> MCP
style AGENT fill:#f96,stroke:#333,stroke-width:2px
style ASYNC_BRIDGE fill:#ff9,stroke:#333,stroke-width:2px
_run_async():同步-异步桥接的唯一入口
_run_async()(model_tools.py:81)是 Hermes 中所有 sync → async 转换的单一入口点。它需要处理三种运行上下文:
# model_tools.py:81-115(简化)
def _run_async(coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# 路径 A:已有 event loop(Gateway / RL 环境)
# → 在新线程中运行,避免嵌套 loop 冲突
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
else:
# 路径 B/C:无 event loop(CLI 主线程 / worker 线程)
# → 使用 per-thread 持久 loop
loop = _get_or_create_persistent_loop()
return loop.run_until_complete(coro)
三种路径的必要性
路径 A(Gateway 内部调用):Gateway 运行在 asyncio event loop 中,agent 在独立线程执行。如果 agent 的工具需要执行 async 代码,不能在当前线程的 loop 上 run_until_complete()——因为那个 loop 正在运行。解法是再开一个线程让 asyncio.run() 创建自己的 loop。
路径 B(CLI 主线程):CLI 没有 running loop。这里使用持久 event loop(_get_or_create_persistent_loop(),model_tools.py:66-78)而非 asyncio.run()。原因是 asyncio.run() 创建-销毁 loop 的生命周期会导致 httpx/AsyncOpenAI 等异步客户端在 GC 时触发 "Event loop is closed" 错误。
# model_tools.py:66-78
_worker_thread_local = threading.local()
def _get_or_create_persistent_loop() -> asyncio.AbstractEventLoop:
loop = getattr(_worker_thread_local, 'loop', None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_worker_thread_local.loop = loop
return loop
路径 C(Worker 线程):并发工具执行在 ThreadPoolExecutor 的 worker 线程中。每个 worker 线程需要自己的持久 loop(通过 threading.local() 实现),避免与主线程的 loop 竞争,也避免 asyncio.run() 的 create-and-destroy 生命周期问题。
工具并发执行
ThreadPoolExecutor 策略(run_agent.py:6144-6159)
当模型在一次响应中返回多个工具调用(parallel tool calling),Hermes 使用 ThreadPoolExecutor 并发执行:
# run_agent.py:235
_MAX_TOOL_WORKERS = 8
# run_agent.py:6144-6153
max_workers = min(num_tools, _MAX_TOOL_WORKERS)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for i, (tc, name, args) in enumerate(parsed_calls):
f = executor.submit(_run_tool, i, tc, name, args)
futures.append(f)
concurrent.futures.wait(futures)
_MAX_TOOL_WORKERS = 8 是一个经过调优的上限:
- 太小(如 2):多个
terminal.execute()调用无法并行,延迟累加 - 太大(如 32):每个线程持有自己的 event loop 和可能的 subprocess,内存和文件描述符开销过高
- 8 是在"典型模型输出 2-5 个并行工具调用"场景下的平衡点
为什么不用 asyncio.gather()
虽然 async 世界有 asyncio.gather() 做并发,但 Hermes 的大多数工具是同步阻塞的——subprocess.run() 执行 shell 命令、sqlite3 执行查询、os.path.exists() 检查文件。在 async context 中运行这些同步代码需要 loop.run_in_executor(),本质上还是线程池。直接用 ThreadPoolExecutor 更简单、更透明。
SQLite WAL 并发
问题场景
当 Gateway 同时处理多个平台的消息时,多个 agent 线程可能并发写入同一个 state.db。SQLite 的 WAL(Write-Ahead Logging)模式允许并发读取,但写入仍然是串行的。
Jitter Retry(hermes_state.py:115-214)
SessionDB 使用 application-level jitter retry 解决锁竞争:
# hermes_state.py:123-136
class SessionDB:
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
_CHECKPOINT_EVERY_N_WRITES = 50
# hermes_state.py:164-214(简化)
def _execute_write(self, fn):
for attempt in range(self._WRITE_MAX_RETRIES):
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
result = fn(self._conn)
self._conn.commit()
# 成功后定期 WAL checkpoint
self._write_count += 1
if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
self._try_wal_checkpoint()
return result
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
if attempt < self._WRITE_MAX_RETRIES - 1:
jitter = random.uniform(
self._WRITE_RETRY_MIN_S, # 20ms
self._WRITE_RETRY_MAX_S, # 150ms
)
time.sleep(jitter)
continue
raise
关键设计决策:
- BEGIN IMMEDIATE(
hermes_state.py:183):在事务开始时就获取写锁,而非等到 COMMIT。这让锁竞争在事务开始时就暴露,而不是在 COMMIT 时——后者会导致已完成的工作被浪费 - 随机 jitter 而非固定退避:SQLite 内置的 busy handler 使用确定性的退避调度,会导致 convoy effect——多个 writer 以相同的节奏 sleep 然后同时醒来重试。随机 jitter(20-150ms)自然地打散了竞争者的重试时机
- 短超时 + 多重试:
timeout=1.0(hermes_state.py:150)让 SQLite 快速失败,然后由 application-level 的 15 次 jitter retry 接管。这比让 SQLite 在内置 busy handler 中等待 30 秒更好——因为我们能更精细地控制退避策略
WAL Checkpoint(hermes_state.py:216-229)
# hermes_state.py:216-229
def _try_wal_checkpoint(self) -> None:
"""Best-effort PASSIVE WAL checkpoint. Never blocks, never raises."""
with self._lock:
result = self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
每 50 次成功写入后执行 PASSIVE checkpoint,将 WAL 中已提交的 frames 刷回主数据库文件。PASSIVE 模式不阻塞其他连接的读取——它只刷那些当前没有读者需要的 frames。这防止 WAL 文件在多进程长期运行时无限增长。
Memory 后台预取
记忆系统的初始化(MemoryProvider 的 retrieve() 调用)可能涉及网络请求(如 Honcho API、向量数据库查询),耗时从几十毫秒到几秒不等。Hermes 在 AIAgent.__init__() 中将记忆预取放在后台线程:
# run_agent.py:1028-1054(第 4 章已讨论的记忆初始化)
# 外部 MemoryProvider 的 initialize_all() 在后台预取
self._memory_manager.initialize_all(
session_id=self.session_id,
platform=platform or "cli",
user_id=self._user_id,
)
预取结果在首次构建 system prompt 时合并——如果预取尚未完成,prompt 构建会等待(带超时)。这让初始化和首次 API 调用的延迟尽可能重叠。
contextvars 替代 os.environ:Gateway 会话状态的并发修复
Gateway 并发处理多条平台消息时,每条消息需要携带自己的会话上下文(platform、chat_id、thread_id)。早期实现使用 os.environ 存储这些值:
# 旧代码(已移除)
os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
这在并发场景下是不安全的:os.environ 是进程全局的。当 Message A 和 Message B 同时到达时,Message B 的 os.environ 写入会覆盖 Message A 的值——导致 Message A 的 agent 使用了错误的 thread_id,工具调用和通知发送到错误的聊天线程。
gateway/session_context.py 用 contextvars.ContextVar 替换了 os.environ:
# gateway/session_context.py:39-48
from contextvars import ContextVar
_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="")
_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="")
_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="")
_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="")
ContextVar 是任务局部的:每个 asyncio task(以及它通过 run_in_executor 派生的线程)获得自己的值副本,并发消息之间互不干扰。
设计细节:
- Token 机制保证清理:
set_session_vars()返回 reset tokens,handler 在finally块中调用clear_session_vars(tokens)恢复旧值,防止泄漏 - 向后兼容:
get_session_env()先查 ContextVar,若为空则回退到os.environ——CLI 和 cron 等非并发入口仍然通过环境变量传递会话状态,无需改动 - 最小侵入性:工具代码只需将
os.getenv("HERMES_SESSION_*")替换为get_session_env("HERMES_SESSION_*"),接口签名完全一致
这是一个典型的"正确的并发原语选择"修复:问题不在于缺少锁,而在于使用了错误作用域(进程全局 vs 任务局部)的状态存储。
并发安全的设计模式
IterationBudget 线程安全(run_agent.py:168-209)
第 4 章讨论了 IterationBudget 的功能,这里关注它的并发安全设计:
# run_agent.py:168-209
class IterationBudget:
def __init__(self, max_total: int):
self._used = 0
self._lock = threading.Lock()
def consume(self) -> bool:
with self._lock:
if self._used >= self.max_total:
return False
self._used += 1
return True
虽然 _used 的读写在 CPython 的 GIL 下是原子的,显式锁仍然必要:consume() 需要读取-比较-写入三步操作的原子性——GIL 不保证这个组合是原子的。
中断传播的并发安全(run_agent.py:609-617)
# run_agent.py:609-611, 615-617
self._interrupt_requested = False
self._client_lock = threading.RLock()
self._active_children_lock = threading.Lock()
_client_lock 使用 RLock(可重入锁)而非普通 Lock,因为中断处理可能在持有锁的情况下被递归调用(顶层 agent 中断 → 遍历子代理设置中断 → 子代理的 handler 也需要获取同一个锁)。
设计启示
Hermes 的并发模型展示了一个实用主义的工程选择:
- 同步编排是正确的默认选择:agent 的核心循环是天然串行的,async 在这里增加复杂度而不增加吞吐。只在真正需要的地方(async SDK、并发工具执行)引入并发
- 桥接函数是必要的隔离层:
_run_async()的三种路径处理了三种不同的运行上下文,让每个异步工具不需要知道自己运行在 CLI 还是 Gateway 还是 worker 线程中 - Application-level 锁优于数据库级锁:SQLite 的内置 busy handler 是通用解法,但 Hermes 的 jitter retry 是针对 agent 工作负载特性(短事务、突发写入、多进程)的定制解法,效果更好
设计赌注回扣:本章回扣 Run Anywhere 赌注——同步编排器让 Hermes 在没有 event loop 的 CLI 和有 event loop 的 Gateway 中用同一套代码运行;
_run_async()桥接让同步工具和异步工具在同一个编排循环中共存;jitter retry 让多个 Hermes 进程(CLI + Gateway + worktree agents)共享同一个 SQLite 数据库而不冲突。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 同步主循环加按需 async 桥接的总体形态,在首批公开 release 前后就已形成;v0.4.0-v0.8.0 之间的主要变化集中在 event loop 复用、线程池规模和 SQLite 重试参数的稳定性调优,而不是并发模型本身的彻底翻新。v0.8.x 将 Gateway 会话状态从
os.environ(进程全局)迁移到contextvars.ContextVar(任务局部),修复了并发消息互相覆盖会话上下文的问题。
进程与资源生命周期管理
本章核心源码:
cli.py(8430-8442 信号处理、824-950 worktree 清理)、gateway/run.py(1471-1528 graceful shutdown)、tools/process_registry.py、tools/browser_tool.py(374-478 清理守护线程)、tools/terminal_tool.py(715-811 清理守护线程)、gateway/status.py、hermes_cli/doctor.py
定位:本章拆解 Hermes 的"第 1 层防御"——进程生命周期管理。从信号处理到 Gateway 5 步关停序列、Process Registry 的 LRU/checkpoint/rolling buffer 到 Browser/Terminal 的清理守护线程、worktree 年龄清理和 Doctor 诊断。 前置依赖:第 13 章(CLI)、第 14 章(Gateway)。适用场景:需要理解 Hermes 如何在 SSH 断开、SIGTERM、OOM 等非正常退出时保护数据和清理资源。
为什么生命周期管理是稳定性的第一层
一个 agent 系统和普通 CLI 工具的根本区别在于运行时间和资源持有量。一次 ls 命令持续毫秒,一次 Hermes 会话可以持续数小时。在这期间,Hermes 可能:
- 持有 3 个 subprocess(terminal 命令)
- 打开 1 个浏览器实例(Playwright/Camofox)
- 维护 1 个 SQLite 连接(session state)
- 管理 5 个 Gateway 平台连接(Telegram + Discord + Slack + ...)
- 创建 2 个 git worktree
如果进程被 kill -9、SSH 断开、或 OOM killer 终止,这些资源会成为孤儿。Hermes 的生命周期管理就是为了让非正常退出造成的损害最小化。
信号处理:SIGTERM/SIGHUP → KeyboardInterrupt
# cli.py:8430-8442
def _signal_handler(signum, frame):
"""Handle SIGHUP/SIGTERM by triggering graceful cleanup."""
logger.debug("Received signal %s, triggering graceful shutdown", signum)
raise KeyboardInterrupt()
try:
import signal as _signal
_signal.signal(_signal.SIGTERM, _signal_handler)
if hasattr(_signal, 'SIGHUP'):
_signal.signal(_signal.SIGHUP, _signal_handler)
except Exception:
pass # Signal handlers may fail in restricted environments
Hermes 将 SIGTERM 和 SIGHUP 统一转换为 KeyboardInterrupt。这个选择有三个理由:
- 复用已有的 Ctrl+C 清理路径:Python 的
try/except KeyboardInterrupt已经在各处设置,将信号统一到 KeyboardInterrupt 就能复用这些清理逻辑 - SIGHUP 处理 SSH 断开:当用户的 SSH 连接断开时,终端发送 SIGHUP。如果不处理,进程会被默认行为终止(不运行 atexit handlers)
- 受限环境容错:
except Exception: pass确保在 Docker 或 systemd 等受限环境中信号注册失败不会阻止启动
Gateway 优雅关停:5 步序列
Gateway 的关停是最复杂的生命周期事件——它需要同时处理多个平台连接、运行中的 agent、后台任务和持久化状态。
sequenceDiagram
participant Signal as SIGTERM/SIGHUP
participant GW as Gateway
participant Agent as Running Agents
participant Platform as Platform Adapters
participant Tasks as Background Tasks
participant Status as Status File
Signal->>GW: stop() called
Note over GW: Step 1: Set _running = False
GW->>Agent: interrupt("Gateway shutting down")
Note over Agent: Step 2: Interrupt all running agents
Agent->>Agent: shutdown_memory_provider()
Agent->>Agent: on_session_finalize hook
GW->>Platform: cancel_background_tasks()
GW->>Platform: disconnect()
Note over Platform: Step 3: Disconnect all adapters
GW->>Tasks: task.cancel() for each
Note over Tasks: Step 4: Cancel background tasks
GW->>Status: remove_pid_file()
GW->>Status: write_runtime_status(stopped)
Note over Status: Step 5: Clean up state files
源码走读(gateway/run.py:1471-1528)
# gateway/run.py:1471-1528
async def stop(self) -> None:
self._running = False # Step 1: 阻止新消息处理
for session_key, agent in list(self._running_agents.items()):
agent.interrupt("Gateway shutting down") # Step 2: 中断运行中的 agent
# 触发 plugin hook
_invoke_hook("on_session_finalize", session_id=..., platform="gateway")
# 关闭 memory provider
agent.shutdown_memory_provider()
for platform, adapter in list(self.adapters.items()):
await adapter.cancel_background_tasks() # Step 3a: 取消后台任务
await adapter.disconnect() # Step 3b: 断开平台连接
for _task in list(self._background_tasks):
_task.cancel() # Step 4: 取消 Gateway 级后台任务
self._background_tasks.clear()
self.adapters.clear()
self._running_agents.clear()
remove_pid_file() # Step 5a: 删除 PID 文件
write_runtime_status(gateway_state="stopped", # Step 5b: 记录退出原因
exit_reason=self._exit_reason)
每一步都用 try/except 包裹(代码中省略了异常处理以突出逻辑),确保单个步骤的失败不阻塞后续步骤的执行。
AIAgent.close():统一资源清理
Gateway 的 5 步关停序列处理的是 Gateway 级别的资源。但每个 AIAgent 实例本身也持有大量资源。AIAgent.close()(run_agent.py:2800-2850)提供了统一的 agent 级资源清理,按固定顺序执行 5 个步骤:
- Kill 后台进程:通过 Process Registry 终止该 agent 启动的所有后台子进程
- 清理 Terminal 沙箱:关闭 Docker/Modal/SSH 等沙箱环境,释放容器和远程连接
- 清理 Browser 会话:关闭 Playwright/Camofox 浏览器实例,释放 Chromium 进程或云端 Browserbase 实例
- 关闭子 Agent:递归关闭通过
delegate()创建的子 agent(每个子 agent 也会调用自己的close()) - 关闭 HTTP 客户端:关闭
httpx.AsyncClient,释放连接池
这个方法在两个场景被调用:Gateway 关停时(通过 stop() 中的 agent 中断流程)和 session 重置时(旧 agent 实例被替换前)。5 步顺序是刻意的——后台进程可能依赖沙箱环境,所以先 kill 进程再清理沙箱;子 agent 可能共享浏览器会话,所以先清理浏览器再关闭子 agent。
每一步都用 try/except 包裹,确保单步失败不阻塞后续清理——这与 Gateway 的 5 步关停序列使用相同的防御模式。
Process Registry
Process Registry(tools/process_registry.py)管理通过 terminal(background=true) 启动的后台进程。它解决了三个问题:输出缓冲、进程追踪、崩溃恢复。
核心数据结构(process_registry.py:57-89)
# tools/process_registry.py:57-59
MAX_OUTPUT_CHARS = 200_000 # 200KB rolling output buffer
FINISHED_TTL_SECONDS = 1800 # 已完成进程保留 30 分钟
MAX_PROCESSES = 64 # LRU 清理阈值
# tools/process_registry.py:62-89
@dataclass
class ProcessSession:
id: str # "proc_xxxxxxxxxxxx"
command: str
task_id: str = "" # 沙箱隔离键
session_key: str = "" # Gateway 会话键
pid: Optional[int] = None
process: Optional[subprocess.Popen] = None
output_buffer: str = "" # 最近 200KB 输出
max_output_chars: int = MAX_OUTPUT_CHARS
detached: bool = False # 崩溃恢复后无 pipe
notify_on_complete: bool = False # 完成时通知
LRU 清理
当进程数超过 MAX_PROCESSES(64)时,_prune_if_needed() 按 LRU 策略清理:先移除已完成最久的进程,必要时 kill 最老的运行进程。
JSON Checkpoint(process_registry.py:54)
CHECKPOINT_PATH = get_hermes_home() / "processes.json"
每次 spawn/kill/exit 时,Registry 将进程状态序列化到 processes.json。当 Gateway 崩溃重启时,_load_checkpoint() 恢复已知进程的元数据——虽然 pipe 已断开(标记为 detached=True),但 PID 仍然可用于状态检查和 kill。
Rolling Output Buffer
后台进程的输出通过 daemon reader thread(process_registry.py:224-278)持续读取到 output_buffer 中。当 buffer 超过 MAX_OUTPUT_CHARS(200KB)时,截断前面的内容保留最新的部分。这让 process(action="poll") 总是能返回最近的输出,而不需要让 agent 处理几 MB 的历史日志。
Browser 清理守护线程(browser_tool.py:374-478)
浏览器会话是最重型的资源——每个 session 可能持有一个 Chromium 进程(本地)或一个 Browserbase 云实例(付费)。
双层清理机制
第一层:atexit handler(browser_tool.py:401-407)
# browser_tool.py:401-407
# Register cleanup via atexit only. Previous versions installed
# SIGINT/SIGTERM handlers that called sys.exit(), but this conflicts
# with prompt_toolkit's async event loop.
atexit.register(_emergency_cleanup_all_sessions)
注意注释中的经验教训:早期版本在 SIGINT/SIGTERM handler 中调用 sys.exit(),但这与 prompt_toolkit 的 async event loop 冲突——在 key-binding callback 内部抛出 SystemExit 会破坏协程状态让进程不可杀。现在只用 atexit,依赖 cli.py 的信号处理转换为 KeyboardInterrupt。
第二层:inactivity cleanup thread(browser_tool.py:442-478)
# browser_tool.py:442-478
def _browser_cleanup_thread_worker():
while _cleanup_running:
_cleanup_inactive_browser_sessions() # 检查并清理超时 session
for _ in range(30): # 每 30 秒检查一次
if not _cleanup_running:
break
time.sleep(1) # 1 秒间隔,快速响应停止
_cleanup_inactive_browser_sessions()(browser_tool.py:414-439)检查每个 session 的最后活动时间,超过 BROWSER_SESSION_INACTIVITY_TIMEOUT 的 session 被自动关闭。Sleep 使用 1 秒间隔循环而非 time.sleep(30),让线程能在进程退出时快速响应 _cleanup_running = False。
Terminal 清理守护线程(terminal_tool.py:715-811)
Terminal 环境(Docker/Modal/SSH 等)的清理逻辑与 Browser 类似但有一个独特的考虑——后台进程保活:
# terminal_tool.py:715-727
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
# Check the process registry -- skip cleanup for sandboxes with
# active background processes
from tools.process_registry import process_registry
for task_id in list(_last_activity.keys()):
if process_registry.has_active_processes(task_id):
_last_activity[task_id] = current_time # Keep sandbox alive
如果一个 Docker 容器里还有后台进程在跑(如 pytest -v),即使 agent 5 分钟没有调用过这个容器的 terminal,容器也不会被清理——后台进程的活跃性被传递到沙箱的活跃性。
清理分为两个 phase(terminal_tool.py:729-774):
# terminal_tool.py:729-750(简化)
# Phase 1: 在锁内收集要清理的环境,但不执行清理
with _env_lock:
for task_id, last_time in list(_last_activity.items()):
if current_time - last_time > lifetime_seconds:
env = _active_environments.pop(task_id, None)
envs_to_stop.append((task_id, env))
# Phase 2: 在锁外执行实际清理(Modal/Docker 停止可能阻塞 10-15s)
for task_id, env in envs_to_stop:
env.cleanup()
Phase 分离是为了避免锁持有时间过长:Modal 和 Docker 的 teardown 可能阻塞 10-15 秒,如果在锁内执行,所有并发的 terminal/file 工具调用都会被阻塞。
Worktree 年龄清理(cli.py:824-950)
hermes -w 为每个工作流创建 git worktree。如果会话因 crash 或用户遗忘而未清理,worktree 会持续积累。
三级清理策略(cli.py:824-894)
# cli.py:824-845
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24):
now = time.time()
soft_cutoff = now - (max_age_hours * 3600) # 24h
hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h
| 年龄 | 行为 | 原因 |
|---|---|---|
| < 24h | 跳过 | 会话可能仍在活跃 |
| 24h-72h | 检查是否有未推送 commits,无则删除 | 避免丢失用户工作 |
| > 72h | 强制删除 | 没有什么应该放这么久 |
# cli.py:862-871
if not force:
# 24h-72h: only remove if no unpushed commits
result = subprocess.run(
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
if result.stdout.strip():
continue # Has unpushed commits — skip
孤儿分支清理(cli.py:897-953)
worktree 删除后,对应的 hermes/hermes-* 和 pr-* 分支可能仍然存在。_prune_orphaned_branches() 对比活跃 worktree 的分支列表,删除没有对应 worktree 的分支:
# cli.py:943-947
orphaned = [
b for b in all_branches
if b not in active_branches
and (b.startswith("hermes/hermes-") or b.startswith("pr-"))
]
Gateway 状态持久化(gateway/status.py)
Gateway 的状态通过两个文件持久化:
# gateway/status.py:23-25
_GATEWAY_KIND = "hermes-gateway"
_RUNTIME_STATUS_FILE = "gateway_state.json"
gateway.pid:PID 文件,用于检测 Gateway 是否在运行。CLI 的send_message功能通过检查这个文件来决定是否可用gateway_state.json:运行时状态,包含启动时间、连接的平台、退出原因等。Doctor 诊断和hermes gateway status读取这个文件
PID 文件的存活检测不只是检查文件是否存在——还验证 PID 对应的进程是否真的在运行(status.py:60-67),防止 crash 后 PID 文件残留导致误判。
Doctor 诊断(hermes_cli/doctor.py)
hermes doctor 是一个诊断命令,检查 Hermes 的安装和配置状态。它在生命周期管理中的角色是事后诊断——当用户报告问题时,doctor 能快速定位:
# hermes_cli/doctor.py:1-28
# Load .env so API key checks work
from dotenv import load_dotenv
load_dotenv(_env_path, encoding="utf-8")
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
Doctor 独立加载 .env(不依赖已加载的环境),确保即使在环境变量污染的情况下也能正确诊断。它检查的项目包括:
- API key 是否配置
- Provider 连通性
- 工具依赖是否安装(git、node 等)
- Gateway 状态
- 配置结构错误(通过
validate_config_structure())
多层超时体系
Hermes 的超时不是单一的——它是一个多层递进的体系:
| 层级 | 超时 | 位置 | 用途 |
|---|---|---|---|
| API 调用 | 60s read timeout | run_agent.py streaming path | 单次 API 响应超时 |
| Stale stream | 90s | run_agent.py streaming path | 流式响应停滞检测 |
| 工具执行 | 180s(可配置) | terminal config | 单个 shell 命令超时 |
| Agent 不活跃 | 1800s(可配置) | agent.gateway_timeout | Gateway 场景的 agent 总超时 |
| Browser 不活跃 | 120s | browser config | 浏览器 session 自动关闭 |
| Terminal 不活跃 | 300s | cleanup thread | 沙箱环境自动清理 |
| 进程保留 | 1800s | process registry | 已完成进程的元数据保留 |
设计启示
- 信号统一为 KeyboardInterrupt:不是为每个信号写不同的处理逻辑,而是统一转换为 Python 已有的异常类型,复用已有的清理路径。这简单且可靠
- 清理分 phase:Browser 和 Terminal 的清理都将"决定要清理什么"(快速,在锁内)和"执行清理"(慢速,在锁外)分离,避免锁持有时间过长
- 多级年龄清理:worktree 的 24h/72h 三级策略平衡了"不清理活跃会话"和"不让垃圾无限积累"的矛盾。有未推送 commits 的 worktree 被保护,确保用户工作不丢失
设计赌注回扣:本章服务于 Run Anywhere 赌注——Gateway 的 5 步关停序列让 Hermes 在 systemd 管理的 VPS 上能正确响应
systemctl stop;Process Registry 的 checkpoint 让 Gateway 崩溃重启后能恢复后台进程追踪;信号处理在 Docker、SSH、受限环境中都有 fallback。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 生命周期管理相关能力是在 v0.3.0-v0.8.0 之间分批收紧的:早期先建立 Process Registry、browser/session 清理和基础持久化,后续再逐步补上 PTY、通知、worktree 多级回收和更细的状态恢复逻辑。worktree 清理的显著增强明确属于 v0.8.0 收尾阶段。v0.8.0 还新增了
AIAgent.close()统一资源清理方法,将散落在各处的清理逻辑整合为 5 步有序序列。
运行时防御与容错
本章核心源码:
run_agent.py(111-158 SafeWriter、7285+ API retry)、hermes_state.py(164-214 jitter retry)、agent/retry_utils.py、gateway/platforms/base.py(1046-1099 send retry)、gateway/run.py(1377-1463 reconnection)
定位:本章拆解 Hermes 的"第 2 层防御"——运行时容错机制。从 SafeWriter 的 broken pipe 保护到 API 重试与 fallback、平台消息投递重试、Gateway 断线重连,理解一个长时间运行的 agent 如何在网络抖动、provider 限流、管道断裂等故障中保持可用。 前置依赖:第 19 章(并发模型)、第 20 章(进程生命周期)。适用场景:需要理解 Hermes 的容错策略,或调试生产环境中的间歇性故障。
为什么需要运行时防御
第 20 章讨论了进程级的生命周期管理——信号处理、优雅关停、资源清理。但进程活着不等于系统健康。一个运行数小时的 agent 会遇到各种运行时故障:
- Broken pipe:systemd 服务的 stdout 管道断裂,
print()抛OSError - API 限流:OpenRouter 返回 429 Too Many Requests
- Provider 不可用:Anthropic API overloaded,所有请求超时
- 网络抖动:VPS 到 Telegram 的连接短暂中断
- SQLite 锁竞争:CLI 和 Gateway 同时写入 state.db
这些故障的共同特点是:它们是暂时的。如果 agent 因为一次 broken pipe 或一次 429 就崩溃,用户需要手动重启——这对一个号称"Run Anywhere"的系统是不可接受的。
SafeWriter:Broken Pipe 保护
问题场景
当 Hermes 作为 systemd service 或 Docker 容器运行时,stdout/stderr 连接到 journald 或 Docker log driver。如果日志系统重启、管道缓冲区满、或 socket 被 reset,任何 print() 调用都会抛出 OSError: [Errno 5] Input/output error。
更糟的情况:如果 except handler 内的 print() 也触发 broken pipe,就会产生双重故障——异常处理本身抛出了异常。
SafeWriter 实现(run_agent.py:111-158)
# run_agent.py:111-157
class _SafeWriter:
"""Transparent stdio wrapper that catches OSError/ValueError from broken pipes."""
__slots__ = ("_inner",)
def __init__(self, inner):
object.__setattr__(self, "_inner", inner)
def write(self, data):
try:
return self._inner.write(data)
except (OSError, ValueError):
return len(data) if isinstance(data, str) else 0
def flush(self):
try:
self._inner.flush()
except (OSError, ValueError):
pass
def isatty(self):
try:
return self._inner.isatty()
except (OSError, ValueError):
return False
def __getattr__(self, name):
return getattr(self._inner, name)
设计要点:
- 透明代理:
__getattr__转发所有未显式定义的属性访问到原始 stream,让 SafeWriter 对现有代码完全透明 - 捕获两种异常:
OSError(管道断裂)和ValueError(stream 被关闭后写入,常见于 ThreadPoolExecutor 线程清理时) - 静默失败:
write()返回数据长度(假装成功),而非返回 0 或 None。这防止调用方因为"写入 0 字节"而进入重试循环 - 安装时机(
run_agent.py:160-165):在AIAgent初始化早期包装sys.stdout和sys.stderr
# run_agent.py:160-165
def _install_safe_stdio() -> None:
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is not None and not isinstance(stream, _SafeWriter):
setattr(sys, stream_name, _SafeWriter(stream))
isinstance 检查防止重复包装(子代理场景下 _install_safe_stdio() 可能被多次调用)。
asyncio 异常处理器(cli.py:8444-8449)
# cli.py:8444-8449
def _suppress_closed_loop_errors(loop, context):
# Suppress "Event loop is closed" RuntimeError from httpx transport cleanup
这是 SafeWriter 的补充——httpx 的 AsyncClient.__del__ 在 event loop 关闭后尝试关闭 transport,触发 RuntimeError。自定义的 asyncio exception handler 静默这些错误。
API 重试与 Fallback
重试循环(run_agent.py:7284-7339)
# run_agent.py:7284-7298
retry_count = 0
max_retries = 3
primary_recovery_attempted = False
max_compression_attempts = 3
codex_auth_retry_attempted = False
anthropic_auth_retry_attempted = False
nous_auth_retry_attempted = False
thinking_sig_retry_attempted = False
has_retried_429 = False
restart_with_compressed_messages = False
重试循环维护了多个一次性标志(*_attempted 变量),确保每种特殊恢复策略只尝试一次:
| 标志 | 触发条件 | 恢复策略 |
|---|---|---|
codex_auth_retry_attempted | Codex token 过期 | 刷新 OAuth token 后重试 |
anthropic_auth_retry_attempted | Anthropic token 过期 | 刷新 token 后重试 |
has_retried_429 | 429 Too Many Requests | 退避后重试,可能切 fallback |
restart_with_compressed_messages | 上下文超限 | 压缩消息后重试 |
thinking_sig_retry_attempted | 思考签名错误 | 调整参数后重试 |
Jittered Backoff(agent/retry_utils.py:1-57)
# agent/retry_utils.py:19-57
def jittered_backoff(
attempt: int,
*,
base_delay: float = 5.0,
max_delay: float = 120.0,
jitter_ratio: float = 0.5,
) -> float:
exponent = max(0, attempt - 1)
delay = min(base_delay * (2 ** exponent), max_delay)
# Seed from time + counter for decorrelation
seed = (time.time_ns() ^ (tick * 0x9E3779B9)) & 0xFFFFFFFF
rng = random.Random(seed)
jitter = rng.uniform(0, jitter_ratio * delay)
return delay + jitter
这个 jitter 实现有几个精妙之处:
- Decorrelated jitter:使用
time.time_ns() ^ (tick * 0x9E3779B9)作为 seed,确保即使在粗粒度时钟(某些虚拟机的时钟精度只到毫秒)上,并发的重试者也会得到不同的 jitter 值。0x9E3779B9是 golden ratio 的 32 位近似,提供良好的位散列特性 - Thread-safe counter:
_jitter_counter受_jitter_lock保护,确保每次调用得到唯一的 seed - Additive jitter:delay 是
base + jitter而非base * random——这确保最小延迟不为零
Fallback Provider 链
当主 provider 的所有重试耗尽后,编排器切换到 fallback chain(第 4 章已讨论配置结构)。Fallback 切换发生在重试循环的最外层:
# config.yaml 示例
fallback_providers:
- provider: anthropic
model: claude-sonnet-4-20250514
- provider: openai
model: gpt-4o
切换后的状态会在下一轮 run_conversation() 调用时通过 _restore_primary_runtime() 恢复——fallback 是临时的,不会永久改变用户的配置。
平台消息投递重试(base.py:1046-1099)
Gateway 向消息平台发送响应时也需要容错:
# gateway/platforms/base.py:1046-1105(简化)
async def send_with_retry(self, chat_id, content, *,
max_retries=2, base_delay=2.0) -> SendResult:
result = await self.send(chat_id=chat_id, content=content, ...)
if result.success:
return result
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
# Timeout errors are not safe to retry (message may have been delivered)
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
for attempt in range(1, max_retries + 1):
delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 1)
await asyncio.sleep(delay)
result = await self.send(...)
if result.success:
return result
if not (result.retryable or self._is_retryable_error(result.error)):
break # 错误类型变了,不再重试
关键设计:
- 超时不重试:timeout 错误可能意味着消息已经发送但确认丢失,重试会导致重复发送
- 错误类型变化检测:如果重试过程中错误从"网络错误"变为"格式错误",立即停止重试——这不是暂时性故障
- 投递失败通知:所有重试耗尽后,向用户发送一条轻量级的故障通知(
base.py:1100-1105),让用户知道请求已处理但响应未能投递
# gateway/platforms/base.py:1100-1105
notice = (
"\u26a0\ufe0f Message delivery failed after multiple attempts. "
"Please try again — your request was processed but the response could not be sent."
)
await self.send(chat_id=chat_id, content=notice, ...)
Gateway 断线重连(gateway/run.py:1377-1463)
当某个平台适配器在运行时断开(网络故障、token 过期),Gateway 不会整体停止——它将断开的平台放入 _failed_platforms 队列,由 reconnection loop 在后台重试。
# gateway/run.py:1377-1378
_MAX_ATTEMPTS = 10
_BACKOFF_CAP = 300 # 5 minutes max between retries
重连逻辑(gateway/run.py:1380-1463)
# gateway/run.py:1389-1463(简化)
for platform in list(self._failed_platforms.keys()):
info = self._failed_platforms[platform]
if now < info["next_retry"]:
continue # 还没到重试时间
if info["attempts"] >= _MAX_ATTEMPTS:
# 10 次失败后放弃
del self._failed_platforms[platform]
continue
adapter = self._create_adapter(platform, platform_config)
success = await adapter.connect()
if success:
self.adapters[platform] = adapter
del self._failed_platforms[platform]
else:
if adapter.has_fatal_error and not adapter.fatal_error_retryable:
# 不可恢复的错误(如 token 被撤销),停止重试
del self._failed_platforms[platform]
else:
backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP)
info["next_retry"] = time.monotonic() + backoff
graph LR
FAIL["平台断线"] --> QUEUE["加入 _failed_platforms<br/>backoff=30s"]
QUEUE --> RETRY{"重连成功?"}
RETRY -->|"成功"| RESTORE["恢复到 self.adapters"]
RETRY -->|"失败 + 可重试"| BACK["指数退避<br/>30s → 60s → ... → 300s"]
RETRY -->|"失败 + 不可重试"| GIVE_UP["永久移除"]
RETRY -->|"尝试 >= 10 次"| GIVE_UP
BACK --> QUEUE
style RESTORE fill:#9f9,stroke:#333
style GIVE_UP fill:#f99,stroke:#333
设计要点:
- 指数退避上限 300s:
min(30 * (2^(n-1)), 300)——最大每 5 分钟重试一次,不浪费资源 - 区分可恢复和不可恢复错误:token 被撤销是不可恢复的,继续重试没有意义
- 重连后重建:成功重连后重建 channel directory(
build_channel_directory()),确保消息路由正确 - 独立于其他平台:一个平台的断线不影响其他平台的正常运行
Graceful Degradation 三原则
从以上四个子系统中可以提炼出 Hermes 运行时防御的三个核心原则:
原则 1:静默降级优于崩溃退出
SafeWriter 是这个原则的极致体现——print() 失败不应该杀死 agent。类似地,单个平台的断线不终止 Gateway、单个工具的超时不终止会话、单次 API 429 不终止对话。
原则 2:重试必须有界
每个重试机制都有明确的上限:
| 子系统 | 最大重试次数 | 最大延迟 |
|---|---|---|
| API 调用 | 3 次 | max_delay=120s |
| SQLite 写入 | 15 次 | 150ms per attempt |
| 消息投递 | 2 次 | ~6s total |
| 平台重连 | 10 次 | 300s between |
无界重试会让系统在不可恢复的故障中无限等待,浪费资源并延迟用户感知到问题。
原则 3:恢复策略不重复尝试
API 重试中的 *_attempted 标志确保每种特殊恢复策略只执行一次。如果刷新 Codex OAuth token 后重试仍然失败,不会再次刷新 token——这意味着问题不在 token 过期,需要其他恢复路径(如 fallback provider)。
KeyboardInterrupt 传播安全
一个容易被忽略的防御点:KeyboardInterrupt 可能在任何时刻发生。Hermes 通过以下方式确保中断安全:
- SQLite 事务保护:
_execute_write()的except BaseException包含 rollback(hermes_state.py:187-190),即使 KeyboardInterrupt 打断了事务,数据库也不会处于不一致状态 - atexit 注册:Browser 和 Terminal 的清理通过
atexit.register()注册,即使 KeyboardInterrupt 导致异常传播到顶层,进程退出时仍会执行清理 - Daemon threads:清理线程标记为
daemon=True,当主线程退出时自动终止,不会阻止进程退出
设计启示
- 防御层次而非防御深度:Hermes 的容错不是"一个超级健壮的重试机制",而是在不同层次设置不同的防御——IO 层(SafeWriter)、API 层(jittered retry + fallback)、平台层(send_with_retry)、基础设施层(reconnection loop)。每一层只处理自己能处理的故障
- Jitter 是分布式系统的基本工具:从 SQLite 的 jitter retry 到 API 的 jittered backoff 到 reconnection 的指数退避,Hermes 在所有有竞争的重试场景中都使用了 jitter。确定性退避在并发环境中会形成 convoy effect
- 一次性恢复标志:API 重试中的
*_attempted模式是一种简洁的状态机设计——每种恢复策略只尝试一次,失败后让出路径给其他策略。这避免了重复执行无效的恢复操作
设计赌注回扣:本章直接服务于 Run Anywhere 赌注——SafeWriter 让 Hermes 在 systemd/Docker 环境中不会因为管道断裂崩溃;Gateway 的断线重连让 VPS 上的 agent 能自动恢复网络中断;多层重试机制让 agent 在网络不稳定的环境中保持可用。这些都是"从 $5 VPS 到 GPU 集群"场景的必要条件。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 本章讨论的 safe stdio、消息投递重试、断线重连和随机退避不是一次性落地的,而是在 v0.5.0-v0.8.0 之间持续加固。其中
send_with_retry已在 v0.5.0 发布窗口出现,jittered_backoff()则是 v0.8.0 发布窗口内的新加强。
测试体系:一个复杂 agent 如何被约束住
本章核心源码:
tests/conftest.py、tests/目录结构
定位:本章拆解 Hermes 的测试体系——从
_isolate_hermes_homefixture 的全局隔离设计到测试目录作为架构镜像的组织方式,理解一个拥有 400+ 测试文件的复杂 agent 如何用测试约束自身的正确性。 前置依赖:第 2 章(仓库地图)、第 17-21 章(工程基础各章)。适用场景:想给 Hermes 添加测试,或想借鉴 agent 系统的测试方法论。
为什么 agent 测试比普通软件更难
测试一个 LLM agent 面临三个独特挑战:
- 非确定性输出:同一个 prompt 送给同一个模型,两次返回的内容可能不同。这意味着传统的"输入-输出断言"方法只能覆盖编排逻辑,不能覆盖端到端行为
- 副作用密集:agent 执行工具时会创建文件、运行命令、打开浏览器、发送消息。测试必须隔离这些副作用,否则测试之间会互相污染
- 全局状态耦合:配置文件、会话数据库、记忆文件都存储在
HERMES_HOME(默认~/.hermes)。如果测试读写用户的真实 home 目录,不仅会破坏用户数据,还会因为机器间配置差异导致测试结果不可重复
Hermes 的测试体系围绕这三个挑战构建了三层防线:环境隔离(conftest.py)、架构镜像(目录组织)、稳定性专项(生命周期/中断测试)。
autouse _isolate_hermes_home:测试隔离的基石
fixture 解析(tests/conftest.py:19-41)
# tests/conftest.py:19-41
@pytest.fixture(autouse=True)
def _isolate_hermes_home(tmp_path, monkeypatch):
"""Redirect HERMES_HOME to a temp dir so tests never write to ~/.hermes/."""
fake_home = tmp_path / "hermes_test"
fake_home.mkdir()
(fake_home / "sessions").mkdir()
(fake_home / "cron").mkdir()
(fake_home / "memories").mkdir()
(fake_home / "skills").mkdir()
monkeypatch.setenv("HERMES_HOME", str(fake_home))
# Reset plugin singleton so tests don't leak plugins from ~/.hermes/plugins/
try:
import hermes_cli.plugins as _plugins_mod
monkeypatch.setattr(_plugins_mod, "_plugin_manager", None)
except Exception:
pass
# Tests should not inherit the agent's current gateway/messaging surface
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
这个 fixture 的设计有四个层次:
| 层次 | 代码行 | 作用 |
|---|---|---|
| 目录隔离 | 21-25 | 每个测试在独立的 tmp_path 下创建 HERMES_HOME |
| 环境变量重定向 | 26 | monkeypatch.setenv("HERMES_HOME", ...) |
| Plugin 单例重置 | 29-32 | 防止 plugins 从真实 home 泄漏到测试 |
| Gateway 状态清理 | 35-38 | 删除 gateway 相关环境变量 |
autouse=True 意味着这个 fixture 自动应用于所有测试——开发者不需要显式声明就能获得完全隔离的测试环境。这是一个关键决策:如果改为 opt-in,新加的测试可能忘记声明而意外读写用户的 ~/.hermes。
为什么不用 mock HERMES_HOME
一种替代方案是 monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: fake_path)。Hermes 选择了设置环境变量而非 mock 函数,原因是:
- 覆盖面更广:许多模块在导入时就通过
os.getenv("HERMES_HOME", ...)缓存了路径值。Mock 函数只能影响调用get_hermes_home()的代码,不能影响直接读环境变量的代码 - subprocess 继承:如果测试中启动了子进程(如 terminal 工具测试),子进程会继承环境变量,也指向临时目录。Mock 函数不会传递到子进程
测试目录:架构的镜像
tests/
├── conftest.py <-- 全局 fixture(_isolate_hermes_home 等)
├── run_agent/ <-- 编排层(AIAgent 大循环)
│ ├── test_run_agent.py
│ ├── test_interrupt_propagation.py
│ └── test_real_interrupt_subagent.py
├── agent/ <-- 编排支撑(agent/ 模块)
├── tools/ <-- 能力层(工具系统)
│ ├── test_interrupt.py
│ └── test_code_execution.py
├── skills/ <-- 能力层(技能系统)
├── gateway/ <-- 平台层(Gateway + 平台适配)
├── hermes_cli/ <-- 入口层(CLI 命令)
├── cli/ <-- 入口层(TUI 交互)
│ ├── test_cli_init.py
│ ├── test_cli_interrupt_subagent.py
│ ├── test_cli_retry.py
│ └── test_cli_provider_resolution.py
├── cron/ <-- 入口层(定时调度)
├── acp/ <-- 平台层(ACP 适配)
├── plugins/ <-- 能力层(插件系统)
├── honcho_plugin/ <-- 能力层(Honcho 记忆插件)
├── e2e/ <-- 端到端集成测试
├── integration/ <-- 集成测试
│ └── test_checkpoint_resumption.py
├── environments/ <-- 研究(RL 环境)
├── fakes/ <-- 测试用 mock 对象
└── (顶层测试文件) <-- 跨模块测试
├── test_hermes_state.py
├── test_model_tools.py
├── test_model_tools_async_bridge.py
└── test_hermes_logging.py
测试目录几乎是生产代码的一一对应镜像。这个组织方式的价值是:
- 定位效率:想了解
tools/terminal_tool.py的行为?直接看tests/tools/test_terminal_tool.py - 覆盖率可视:如果生产代码有一个目录
gateway/platforms/但tests/gateway/下没有对应的测试文件,说明测试覆盖有缺口 - 增量开发:修改某个模块时,只需运行对应测试目录即可验证,不需要跑全量测试
全局测试超时(conftest.py:67-119)
SIGALRM 超时(conftest.py:108-119)
# tests/conftest.py:72-73
def _timeout_handler(signum, frame):
raise TimeoutError("Test exceeded 30 second timeout")
# tests/conftest.py:108-119
@pytest.fixture(autouse=True)
def _enforce_test_timeout():
"""Kill any individual test that takes longer than 30 seconds."""
if sys.platform == "win32":
yield
return
old = signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(30)
yield
signal.alarm(0)
signal.signal(signal.SIGALRM, old)
每个测试最多运行 30 秒,超时后抛出 TimeoutError。这防止:
- subprocess 启动后未 terminate 导致测试挂起
- 网络请求没有设置超时导致等待 DNS 解析
- 死锁(多线程测试中偶发的 lock ordering 问题)
Windows 上跳过(SIGALRM 是 Unix-only),其他平台也有 except Exception: pass 的防御。
Event loop fixture(conftest.py:76-105)
# tests/conftest.py:76-105
@pytest.fixture(autouse=True)
def _ensure_current_event_loop(request):
"""Provide a default event loop for sync tests that call get_event_loop()."""
if request.node.get_closest_marker("asyncio") is not None:
yield
return
try:
loop = asyncio.get_event_loop_policy().get_event_loop()
except RuntimeError:
loop = None
created = loop is None or loop.is_closed()
if created:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
yield
finally:
if created and loop is not None:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
Python 3.11+ 不再为同步测试保证默认 event loop。许多 Gateway 测试在同步测试中调用 asyncio.get_event_loop().run_until_complete(),这个 fixture 确保它们总是有可用的 loop。对于已标记 @pytest.mark.asyncio 的测试则跳过——避免与 pytest-asyncio 的 loop 管理冲突。
mock_config fixture(conftest.py:49-64)
# tests/conftest.py:49-64
@pytest.fixture()
def mock_config():
"""Return a minimal hermes config dict suitable for unit tests."""
return {
"model": "test/mock-model",
"toolsets": ["terminal", "file"],
"max_turns": 10,
"terminal": {
"backend": "local",
"cwd": "/tmp",
"timeout": 30,
},
"compression": {"enabled": False},
"memory": {"memory_enabled": False, "user_profile_enabled": False},
"command_allowlist": [],
}
这个 fixture 不是 autouse 的——只有需要构造 AIAgent 的测试才显式使用。它的设计原则是最小化:
max_turns: 10(而非默认 90):防止测试中的 agent 循环太久compression: {enabled: False}:避免测试触发压缩的 API 调用memory: {enabled: False}:避免测试写入记忆文件command_allowlist: []:清空命令白名单,让安全检查处于默认状态
测试分层策略
Hermes 的测试分为四个层次,每层验证不同的关注点:
第一层:单元测试(tests/agent/, tests/tools/)
验证单个模块的行为,mock 所有外部依赖。例如:
tests/agent/下的测试验证 prompt 构建、记忆管理、模型路由等独立模块tests/tools/验证工具注册、schema 生成、权限检查
第二层:集成测试(tests/run_agent/, tests/cli/)
验证模块之间的交互。例如:
tests/run_agent/test_interrupt_propagation.py验证中断信号从父 agent 传播到子 agenttests/cli/test_cli_retry.py验证 CLI 层的 API 重试逻辑
第三层:平台测试(tests/gateway/)
验证 Gateway 和平台适配器的行为。这些测试通常 mock 平台 API,验证消息路由、会话管理、投递重试等逻辑。
第四层:端到端测试(tests/e2e/)
验证完整的用户场景。这些测试启动真实的 Hermes 进程,通过 CLI 输入发送消息,验证工具执行和输出。
稳定性专项测试
第 20-21 章讨论的生命周期管理和运行时防御需要专门的测试来验证:
中断传播测试
tests/run_agent/test_interrupt_propagation.py -- 父→子 agent 中断
tests/run_agent/test_real_interrupt_subagent.py -- 真实子代理中断
tests/tools/test_interrupt.py -- 工具执行中断
tests/cli/test_cli_interrupt_subagent.py -- CLI 中断子代理
中断测试验证的核心问题是:当用户按 Ctrl+C 时,所有活跃的子代理和工具是否都能安全停止? 这些测试通常使用 threading.Timer 模拟延迟中断,然后验证 _interrupt_requested 标志的传播和资源清理。
Checkpoint 恢复测试
tests/integration/test_checkpoint_resumption.py -- 检查点恢复
tests/test_batch_runner_checkpoint.py -- 批量运行检查点
Provider 解析测试
tests/cli/test_cli_provider_resolution.py -- provider 路由正确性
这些测试验证第 18 章讨论的 API 模式检测和 provider 路由在各种输入组合下的行为。
Async 桥接测试
tests/test_model_tools_async_bridge.py -- _run_async() 三种路径
验证第 19 章讨论的 _run_async() 在有/无 running event loop、主线程/worker 线程等不同上下文中的正确行为。
测试基础设施设计模式
从 Hermes 的测试体系中可以提炼出几个通用模式:
模式 1:autouse 全局隔离
将环境隔离做成 autouse=True fixture 而非手动声明。这确保遗忘不会导致泄漏——新测试默认就是隔离的。代价是每个测试都多一点初始化开销(创建临时目录),但这比调试"测试 A 写了 ~/.hermes 导致测试 B 失败"的问题要划算得多。
模式 2:最小化测试配置
mock_config 显式禁用了压缩、记忆、命令白名单等功能。这比"使用默认配置"更好,因为默认配置会随版本变化(新增的配置项可能引入新的外部依赖),而最小化配置是稳定的。
模式 3:超时作为安全网
30 秒全局超时不是为了测试性能,而是为了防止 CI 挂起。一个死锁的测试可以让整个 CI pipeline 等待数小时直到超时。SIGALRM 让这些测试快速失败,输出有用的 traceback。
模式 4:测试目录 = 架构文档
当测试目录和生产代码目录一一对应时,测试目录本身就是架构文档的一部分。新成员看到 tests/gateway/ 和 tests/tools/ 就知道系统有 gateway 和 tools 两个主要子系统,不需要额外的文档来解释。
设计启示
- 隔离是测试可靠性的基础:
_isolate_hermes_home的 autouse 设计确保每个测试在干净的环境中运行。没有这个基础,其他测试技巧都建在沙子上 - 镜像目录组织降低认知负担:不需要索引或搜索来找到某个模块的测试——路径即答案。这对一个有 400+ 测试文件的项目尤为重要
- 稳定性需要专门测试:中断传播、checkpoint 恢复、async 桥接这些行为不会被"正常路径"的测试覆盖到。它们需要专门的测试,刻意制造异常条件来验证防御机制
设计赌注回扣:本章回扣全部四个赌注——测试验证了所有赌注的工程实现:
_isolate_hermes_home确保 profile 隔离(Run Anywhere);记忆和技能的测试验证 Learning Loop 和 Personal Long-Term 的正确性;CLI 交互测试验证 CLI-First 的体验质量;中断传播和 async 桥接测试验证跨入口一致性(Run Anywhere)。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 测试隔离基础设施在很早的公开版本里就已经存在;之后 v0.5.0-v0.8.0 之间持续补上了 plugin 单例清理、gateway 环境变量清理、全局超时和 Python 3.11+ event loop 兼容。测试规模也从早期的几百个文件逐步增长到今天的 400+。
设计哲学与演化方向
本章核心源码:
run_agent.py、agent/skill_utils.py、acp_adapter/、batch_runner.py、environments/
定位:全书收束。不是功能总结,而是从 22 章的工程分析中提炼设计原则,讨论 Hermes 体现的 agent 工程方法论,评估代码库的强弱,展望演化方向。 前置依赖:第 1 章(设计赌注)以及至少阅读过 Part 2-4。适用场景:想从 Hermes 的实践中提炼可迁移的 agent 工程经验。
不是总结,是提炼
写到这里,我们已经拆解了 Hermes 的五层架构、9431 行的核心编排器、数十个工具实现、16 个 Gateway 平台类型、8 个记忆后端、四层稳定性防御。这些是"what"。本章要回答的是"so what"——这些工程实践背后有没有可提炼的方法论,它们对构建类似系统有什么指导意义。
三条 Agent 工程方法论
从 Hermes 的代码中,可以提炼出三条不同于主流 agent 框架的工程方法论。
方法论一:"Learning Loop First"——技能比工具更重要
主流 agent 框架的扩展路径是:加更多的工具。需要搜索?加搜索工具。需要数据库?加数据库工具。需要浏览器?加浏览器工具。工具越多,agent 越"强大"。
Hermes 的路径不同。它当然有很多工具。但 Hermes 的核心假设是:工具解决的是"agent 能做什么",技能解决的是"agent 知道怎么做"。两者的区别是关键的。
一个例子:agent 有 web_search 工具,可以搜索网页。但"如何用 Exa 的 neural search 模式搜索学术论文"是一个技能——它不是工具的能力,而是使用工具的知识。这种知识:
- 不能硬编码在工具实现里(那样工具会无限膨胀)
- 不能放在 system prompt 里(token 成本太高)
- 必须能被 agent 自主创建和改进(否则又回到了"开发者手动维护")
Hermes 的技能系统(第 8 章)正是为此设计的。build_skills_system_prompt() 只将技能索引(标题 + 一句话描述)注入 system prompt,完整内容通过 skill_view 工具按需检索。这个设计在 token 开销(几百 token 的索引)和知识丰富度(数万 token 的技能内容)之间取得了平衡。
"Learning Loop First"的方法论含义:设计一个 agent 系统时,先考虑"agent 如何积累知识",再考虑"agent 有哪些工具"。工具是有限的(开发者添加),知识是无限的(agent 自主创建)。一个拥有 10 个工具 + 100 个技能的 agent,可能比拥有 100 个工具 + 0 个技能的 agent 更有效。
方法论二:"CLI-First 不是 Web-Last"——入口决定架构
这不是一个 UI 偏好的问题。Hermes 选择 CLI 作为第一入口,不是因为开发者"喜欢终端",而是因为终端体验对架构有深刻的塑造作用。
终端塑造了状态管理:因为 CLI 程序运行在用户的机器上,数据自然存储在本地文件系统。这导致了 SQLite + 文件系统的状态管理选择(第 10 章),而非云数据库 + 对象存储。这个选择级联影响了部署模型(pip install 而非 Docker compose)、并发模型(单进程而非微服务)、备份策略(cp 一个目录而非数据库导出)。
终端塑造了交互模型:CLI 的流式输出天然适合 LLM 的逐 token 生成。这导致了 11 个回调接口的设计(第 4 章)——stream_delta_callback 不是可选的"UI 增强",而是编排层的核心接口。当 Gateway 需要适配 Telegram 时,它实现的是同一套回调接口,用 Telegram 的 editMessageText 模拟流式输出。
终端塑造了测试策略:CLI 程序可以通过 subprocess 调用来端到端测试,不需要 Selenium 或 Playwright 来测试 UI。400+ 个测试(第 22 章)大部分是 pytest 单元测试和 subprocess 集成测试,测试基础设施极其轻量。
这个方法论的启示:选择入口不是选择 UI 框架,而是选择架构约束。Web-First 的 agent 天然走向云数据库、微服务、WebSocket;CLI-First 的 agent 天然走向本地存储、单进程、stdio。两条路径没有对错,但一旦选择,后续的工程决策会被深刻影响。
方法论三:"Personal 不是 Shared"——单用户 agent 与多租户平台是不同的系统
多数商业 AI 产品追求多租户——一个部署服务多个用户,用户间通过权限隔离。Hermes 的选择相反:一个部署只服务一个用户(或一个小型信任圈)。
这个选择的工程后果:
配置简化:config.yaml 是一个扁平的 YAML 文件(第 17 章),不需要用户管理系统、权限矩阵、租户隔离。Profile 系统通过多个配置文件实现"多实例",但每个实例仍然是单用户的。
记忆无歧义:MEMORY.md 和 USER.md 不需要标注"属于哪个用户"。当 agent 说"你上次提到过..."时,"你"指的就是唯一的用户。在多租户系统中,记忆的归属和隔离是一个复杂的工程问题。
技能可以自由进化:agent 创建的技能不需要审核流程("这个技能会影响其他用户吗?"),不需要版本控制("哪个版本对哪个用户生效?"),不需要权限管理("谁可以编辑这个技能?")。技能就是一个 Markdown 文件,agent 可以随时 patch。
安全边界简化:命令审批(第 21 章)只需要一个 allowlist,不需要基于角色的访问控制。DM pairing(第 14 章)只需要验证"你是不是机主",不需要 OAuth 集成。
这个方法论的启示:不是所有 agent 都应该是 SaaS。如果你的目标是"个人助理",单用户架构会让系统简单一个数量级。简单意味着更少的 bug、更低的运维成本、更快的迭代速度。多租户可以之后加(通过 ACP Adapter 或 Gateway 的 user_id 隔离),但不应该从第一天就成为架构约束。
ACP Adapter:标准化方向
acp_adapter/ 目录(7 个文件,1776 行)实现了 Agent Client Protocol 的适配层。这是 Hermes 向标准化方向迈出的一步。
ACP 的设计意图:让外部系统通过标准化的 HTTP + SSE 接口与 Hermes 交互,而不需要了解 Hermes 的内部实现。具体而言:
server.py(726 行):HTTP 服务器,暴露会话管理和消息处理的 REST 端点session.py(475 行):ACP 会话管理,独立于 Gateway 的 session 系统tools.py(214 行):将 Hermes 的工具能力暴露为 ACP 标准格式events.py(175 行):SSE 事件流,让客户端实时接收 agent 的输出
ACP Adapter 的意义不在于它目前的完成度,而在于它代表的方向:一个 agent 不应该只能通过 CLI 或特定消息平台接入。标准化的协议让 agent 成为一个可组合的服务——其他 agent 可以调用它,工作流引擎可以编排它,Web 应用可以嵌入它。
这和 Gateway 的 16 个平台类型走的是不同的路:Gateway 是"Hermes 主动连接平台",ACP 是"外部系统主动连接 Hermes"。两者互补。
Batch Trajectory + RL:从工具到训练数据
batch_runner.py(1287 行)和 environments/ 目录揭示了 Hermes 的另一个演化方向:agent 不只是使用模型,还可以为训练模型生成数据。
Batch Runner 的工作流:
给定一组任务描述
→ 为每个任务创建独立的 AIAgent 实例
→ agent 自主完成任务,产生完整的对话轨迹
→ 轨迹包含:system prompt、user message、tool calls、tool results、assistant responses
→ 轨迹可以用于 SFT(supervised fine-tuning)或 RL(reinforcement learning)
trajectory_compressor.py 将原始轨迹压缩为适合训练的格式——去除冗余的上下文、标准化工具调用格式、添加奖励信号。
environments/ 目录下是 Atropos RL 环境的接口,让 Hermes 的对话轨迹可以直接用于 Tinker-Atropos 的 RL pipeline。
这个方向的意义:传统的 agent 框架是模型的消费者——它们使用模型的能力来完成任务。Hermes 试图成为模型的生产者之一——它产生的对话轨迹可以用来训练下一代工具调用模型。这形成了一个更大的闭环:模型 → agent → 轨迹 → 训练 → 更好的模型 → 更好的 agent。
代码库的诚实评估
一本好的技术书不应该只赞美它分析的系统。以下是对 Hermes 代码库强弱的诚实评估。
最强的部分
1. 编排层的完整性
run_agent.py 的 9431 行不是意大利面条代码——它是一个经过深思熟虑的同步编排器。11 个回调接口让同一个编排逻辑适配 5 种入口。IterationBudget 的压力预警(70%/90%)在自主性和安全性之间取得了精确的平衡。Fallback Provider 链让系统在模型不可用时自动降级而非崩溃。
2. 四层稳定性防御
从 SafeWriter(防 broken pipe)到 jitter retry(防 SQLite 锁竞争),从 IterationBudget(防无限循环)到 Gateway 断线重连(防网络抖动),四层防御形成了一个完整的容错体系。这是一个真正在 $5 VPS 上 7x24 运行过的系统——稳定性不是设计出来的,是跑出来的。
3. 技能系统的简洁
用 Markdown + YAML frontmatter 实现过程性知识管理,用"索引注入 + 按需检索"控制 token 开销,用"使用时 patch"实现自我改进——这个设计在简洁和功能之间的平衡是出色的。没有数据库、没有 schema validation、没有 migration——一个 Markdown 文件就是一个技能。
最弱的部分
1. 单文件过大
run_agent.py(9431 行)和 cli.py(8736 行)都超出了合理的单文件规模。虽然内部有清晰的方法分组,但新开发者面对一个近万行的文件仍然会感到迷茫。gateway/run.py(7620 行)同样如此。这三个文件占了核心代码的 40% 以上。
理想的做法是将它们拆分为多个模块(如 run_agent/orchestrator.py、run_agent/callbacks.py、run_agent/fallback.py),但在快速迭代的项目中,"先让它工作,再让它美观"是合理的选择。
2. 测试覆盖的不均匀
400+ 个测试文件是一个可观的数字,但覆盖并不均匀。编排层的核心路径(run_conversation 的主循环、fallback 切换、background review 触发)的测试依赖大量 mock,真实的端到端测试(实际调用 LLM API)相对稀缺。Gateway 的多平台集成测试也主要依赖模拟——很难在 CI 中测试真实的 Telegram bot。
3. 文档与代码的间隙
代码中的注释和 docstring 质量参差不齐。run_agent.py 的关键方法有详细注释,但许多工具实现和平台适配器的注释稀少。agent/ 目录下的模块有些缺少模块级 docstring。这增加了新贡献者的理解成本。
4. 配置系统的隐含约束
config.yaml 的配置项之间存在许多隐含的依赖关系(如 api_mode 影响 prompt_caching 的行为,terminal_backend 影响 file_tools 的可用性),这些关系没有被显式文档化或运行时验证。配置错误的反馈往往是 agent 行为异常,而非明确的错误消息。
社区与 agentskills.io 开放标准
Hermes 的技能格式不是封闭的私有格式。通过 agentskills.io 开放标准,技能可以在不同 agent 框架之间互通。
这个标准化的意义:
- 社区贡献:用户创建的高质量技能可以分享到 Skills Hub,其他用户直接使用
- 跨框架互通:一个为 Hermes 编写的技能(Markdown + YAML frontmatter)可以被其他支持该标准的 agent 框架加载
- 质量筛选:社区使用形成的正反馈循环——被频繁使用和改进的技能自然浮现
技能的开放标准化是 Learning Loop 赌注的自然延伸:如果 agent 能从自己的经验中学习,那么 agent 之间共享经验就是下一步。
四个赌注的回望
全书写完,回看第 1 章定义的四个赌注:
Learning Loop 是 Hermes 最独特的赌注。在 2026 年初的 agent 生态中,几乎没有其他框架实现了完整的"创建 → 使用 → 改进"闭环。技能系统、nudge 机制、background review 的组合让这个赌注有了坚实的工程基础。
CLI-First 被证明是一个正确的架构约束。它让 Hermes 的部署极其轻量(pip install),测试极其简单(subprocess),数据管理极其直观(一个目录)。Gateway 的 16 个平台类型证明 CLI-First 不等于 CLI-Only。
Personal Long-Term 是 Hermes 与 ChatGPT、Claude 等云 AI 助手的根本分野。你的数据在你的机器上,你的技能在你的文件系统里,你的记忆不会因为服务商的策略调整而消失。这在隐私意识日益增强的趋势下,可能会成为越来越重要的优势。
Run Anywhere 是最容易验证的赌注——Hermes 确实可以在 $5 VPS 上运行。SQLite + 文件系统的零依赖架构、IterationBudget 的资源控制、上下文压缩的 token 管理,这些工程选择共同支撑了这个赌注。
留给读者的问题
本书分析的是 Hermes 在 2026 年 4 月的状态。Agent 工程仍然是一个快速演化的领域,以下问题值得持续关注:
- 技能质量控制:当 agent 积累了数百个技能后,如何识别和清理低质量或过时的技能?目前依赖 agent 自身的判断力,但这个判断力和使用的模型强相关
- 多模型下的行为一致性:Hermes 支持任意 OpenAI/Anthropic 兼容模型,但不同模型对 system prompt 中
SKILLS_GUIDANCE的遵从程度差异很大。如何确保 Learning Loop 在弱模型上也能工作? - 从 Personal 到 Collaborative:单用户假设极大简化了系统,但团队场景是真实需求。ACP Adapter 和 Gateway 的 user_id 隔离是否足够支撑轻量级的多用户场景?
- Trajectory → Training 的闭环:batch_runner 和 environments 目前是实验性的。从"agent 产生轨迹"到"轨迹训练出更好的模型"到"更好的模型驱动更好的 agent"这个完整闭环,还需要多少工程工作?
这些问题没有标准答案。它们是 Hermes 的下一个版本、以及所有 agent 构建者,都需要面对的挑战。
设计赌注回扣:本章回扣了全部四个设计赌注。Learning Loop 被提炼为"技能比工具更重要"的方法论。CLI-First 被提炼为"入口决定架构"的方法论。Personal Long-Term 被提炼为"单用户 agent 与多租户平台是不同的系统"的方法论。Run Anywhere 在代码库评估中得到了验证——零外部依赖和四层稳定性防御是它的工程基础。
版本演化说明
本章分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 设计哲学的提炼基于全书 22 章的工程分析。ACP Adapter 和 batch/RL 闭环在 v0.8.0 中处于早期阶段,预计在后续版本中会有显著扩展。
附录 A:核心类与模块索引
本索引按五层架构组织,覆盖 Hermes Agent 的核心文件和关键类/函数。行号基于 v0.8.0。
入口层
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
hermes_cli/main.py | main() | — | CLI 命令入口,解析子命令并路由 |
cli.py | run_cli() | — | 交互式 TUI 主循环,prompt_toolkit 驱动 |
cli.py | _handle_slash_command() | — | Slash command 解析与分发 |
gateway/run.py | GatewayRunner | 461 | Gateway 主类,管理多平台连接 |
gateway/run.py | handle_message() | — | 接收平台消息并路由到 AIAgent |
batch_runner.py | BatchRunner | — | 批量轨迹生成,用于 RL 训练 |
cron/scheduler.py | CronScheduler | — | croniter 驱动的定时任务调度 |
编排层
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
run_agent.py | AIAgent | 416 | 核心编排器,9431 行,全系统心脏 |
run_agent.py | AIAgent.__init__() | 433 | 50+ 参数,七阶段初始化决策链 |
run_agent.py | AIAgent.run_conversation() | — | 主循环:构建消息 → 调用模型 → 执行工具 → 循环 |
run_agent.py | IterationBudget | 168 | 线程安全的迭代预算,防止 agent 失控 |
run_agent.py | _SafeWriter | 111 | Broken pipe 保护,包装 stdout/stderr |
run_agent.py | _spawn_background_review() | 1718 | 后台线程启动 review agent,自动审查对话 |
run_agent.py | _build_system_prompt() | — | 拼装 identity + memory + skills + context |
model_tools.py | _discover_tools() | 132 | 导入所有工具模块,触发自注册 |
model_tools.py | get_tool_definitions() | 234 | 按 enabled/disabled toolset 过滤工具 schema |
model_tools.py | handle_function_call() | 459 | 路由工具调用到具体 handler |
model_tools.py | _run_async() | 81 | async→sync 桥接 |
编排支撑(agent/)
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
agent/prompt_builder.py | build_system_prompt() | — | 多块拼装 system prompt(983 行) |
agent/prompt_builder.py | build_skills_system_prompt() | 529 | 扫描技能目录,生成索引列表 |
agent/prompt_builder.py | SKILLS_GUIDANCE | 164 | 引导模型自主创建和改进技能的指令 |
agent/prompt_caching.py | — | — | Prompt cache 优化策略(72 行) |
agent/memory_manager.py | MemoryManager | — | 外部 memory provider 编排器;抽象目标包含 builtin provider(367 行) |
agent/memory_provider.py | MemoryProvider | — | 记忆后端 ABC,17 个方法(231 行) |
agent/builtin_memory_provider.py | BuiltinMemoryProvider | — | 内置记忆(MEMORY.md + USER.md)(114 行) |
agent/context_compressor.py | ContextCompressor | — | 上下文压缩,保护首尾消息(696 行) |
agent/model_metadata.py | ModelMetadata | — | 模型能力探测与缓存(1001 行) |
agent/usage_pricing.py | UsagePricing | — | Token 成本追踪(656 行) |
agent/retry_utils.py | retry_with_backoff() | — | 指数退避重试策略(57 行) |
agent/skill_utils.py | parse_frontmatter() | 52 | 解析技能文件的 YAML frontmatter |
agent/skill_utils.py | get_all_skills_dirs() | 226 | 返回技能搜索路径列表 |
agent/skill_utils.py | skill_matches_platform() | 92 | 平台条件过滤 |
agent/smart_model_routing.py | — | — | 智能模型路由选择(194 行) |
agent/anthropic_adapter.py | build_anthropic_client() | — | Anthropic API 客户端构造(1373 行) |
agent/credential_pool.py | CredentialPool | — | API key 池管理与轮换(1207 行) |
agent/context_references.py | — | — | 上下文引用追踪 |
agent/redact.py | — | — | 敏感信息脱敏 |
agent/trajectory.py | — | — | 对话轨迹格式化(56 行) |
agent/title_generator.py | — | — | 会话标题自动生成(125 行) |
能力层
工具注册与调度
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
tools/registry.py | ToolRegistry | 48 | 工具注册表单例(335 行) |
tools/registry.py | ToolEntry | 24 | 工具元数据:名称、handler、toolset、schema |
toolsets.py | — | — | 工具分组定义与可用性控制(637 行) |
核心工具实现
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
tools/terminal_tool.py | execute_command() | — | 命令执行,6 种后端(1627 行) |
tools/file_tools.py | read_file() / write_file() | — | 文件读写操作(835 行) |
tools/browser_tool.py | browse() | — | 浏览器自动化(2178 行) |
tools/delegate_tool.py | delegate() | — | 子代理委托(978 行) |
tools/mcp_tool.py | — | — | MCP 外部能力接入(2186 行) |
tools/skills_tool.py | skill_view() | 787 | 技能检索(1376 行) |
tools/skill_manager_tool.py | skill_manage() | 569 | 技能创建/改进/删除(742 行) |
tools/code_execution_tool.py | execute_code() | — | Python/JS 代码执行(1347 行) |
tools/session_search_tool.py | session_search() | — | 跨会话 FTS5 搜索(504 行) |
tools/memory_tool.py | MemoryStore | — | 内置记忆存储(560 行) |
tools/process_registry.py | ProcessRegistry | — | 后台进程追踪(990 行) |
状态层
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
hermes_state.py | SessionDB | 115 | SQLite 持久化:sessions + messages + FTS5(1304 行) |
hermes_state.py | WAL 并发安全 | 164 | BEGIN IMMEDIATE + 15 次 jitter retry |
平台层
Gateway 核心
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
gateway/platforms/base.py | BasePlatformAdapter | 470 | 平台适配器 ABC(1696 行) |
gateway/session.py | SessionStore | 503 | 会话管理(1081 行) |
gateway/config.py | — | — | Gateway 配置解析(957 行) |
Gateway 平台适配器与支撑文件
| 文件 | 平台 |
|---|---|
gateway/platforms/telegram.py | Telegram(webhook + polling) |
gateway/platforms/discord.py | Discord(多 guild、线程) |
gateway/platforms/slack.py | Slack(多 workspace、线程) |
gateway/platforms/whatsapp.py | WhatsApp(Twilio 桥接) |
gateway/platforms/signal.py | Signal(signal-cli 桥接) |
gateway/platforms/matrix.py | Matrix(E2E、spaces) |
gateway/platforms/email.py | Email(IMAP/SMTP) |
gateway/platforms/feishu.py | 飞书 |
gateway/platforms/wecom.py | 企业微信 |
gateway/platforms/dingtalk.py | 钉钉 |
gateway/platforms/homeassistant.py | Home Assistant IoT |
gateway/platforms/mattermost.py | Mattermost |
gateway/platforms/sms.py | SMS(Twilio) |
gateway/platforms/webhook.py | 自定义 Webhook |
gateway/platforms/api_server.py | REST API 端点 |
ACP Adapter
| 文件 | 类/函数 | 行 | 用途 |
|---|---|---|---|
acp_adapter/entry.py | — | — | ACP 入口模块(85 行) |
acp_adapter/server.py | — | — | ACP HTTP 服务器(726 行) |
acp_adapter/session.py | — | — | ACP 会话管理(475 行) |
acp_adapter/tools.py | — | — | 工具能力暴露(214 行) |
acp_adapter/events.py | — | — | SSE 事件流(175 行) |
acp_adapter/auth.py | — | — | 认证(24 行) |
acp_adapter/permissions.py | — | — | 权限控制(77 行) |
支撑文件
| 文件 | 用途 |
|---|---|
hermes_constants.py | 全局常量(105 行) |
hermes_logging.py | 日志配置 |
hermes_time.py | 时间工具函数 |
utils.py | 通用工具函数 |
mcp_serve.py | MCP 服务器模式(Hermes 作为 MCP server) |
batch_runner.py | 批量轨迹生成(1287 行) |
trajectory_compressor.py | 轨迹压缩(训练数据处理) |
rl_cli.py | RL 训练 CLI |
附录 B:关键执行流程图
本附录收录 6 个关键流程的 Mermaid 图,覆盖从单次对话到系统级防御的核心路径。每个图标注了对应的源码文件和章节引用。
B.1 单次对话流程
对应章节:第 3 章(请求旅程)、第 4 章(AIAgent 内核)
sequenceDiagram
participant U as 用户
participant E as 入口层<br/>(CLI / Gateway)
participant A as AIAgent<br/>(run_agent.py)
participant L as LLM API
participant T as 工具系统<br/>(model_tools.py)
participant S as SessionDB<br/>(hermes_state.py)
U->>E: 发送消息
E->>A: run_conversation(user_message)
A->>A: _build_system_prompt()<br/>(identity + memory + skills + context)
A->>S: 加载会话历史
A->>A: 检查 IterationBudget
loop 工具调用循环
A->>L: API 调用(messages + tools schema)
L-->>A: 流式响应(text + tool_calls)
A-->>E: stream_delta_callback(token)
alt 模型返回 tool_call
A->>A: budget.consume()
A->>T: handle_function_call(name, args)
T->>T: registry.get(name) → handler
T-->>A: 工具执行结果
A->>A: 追加 tool_result 到 messages
Note over A: 检查 budget 压力<br/>70% → caution<br/>90% → warning
else 模型返回纯文本
Note over A: 退出循环
end
end
A->>S: 保存会话(messages + metadata)
A->>A: 检查 nudge 计数器<br/>(memory / skill)
opt nudge 触发
A->>A: _spawn_background_review()<br/>(后台线程)
end
A-->>E: 返回最终响应
E-->>U: 显示响应
B.2 工具发现与调度
对应章节:第 6 章(工具系统)、第 7 章(工具剖面)
graph TB
subgraph "启动时:工具发现"
A["model_tools._discover_tools()"] --> B["导入 tools/*.py 模块"]
B --> C["每个模块执行 registry.register()"]
C --> D["ToolRegistry 单例<br/>存储 ToolEntry 列表"]
end
subgraph "初始化时:工具过滤"
D --> E["get_tool_definitions()"]
E --> F{"enabled_toolsets /<br/>disabled_toolsets"}
F -->|匹配| G["生成 tool schema<br/>(JSON Schema 格式)"]
F -->|排除| H["跳过"]
G --> I["AIAgent.tools 列表"]
end
subgraph "运行时:工具调度"
I --> J["LLM 返回 tool_call"]
J --> K{"name 在<br/>valid_tool_names 中?"}
K -->|是| L["handle_function_call()"]
K -->|否| M["返回错误提示"]
L --> N{"entry.is_async?"}
N -->|是| O["_run_async()<br/>async→sync 桥接"]
N -->|否| P["直接调用 handler"]
O --> Q["返回工具结果"]
P --> Q
end
subgraph "特殊路径"
L --> R{"是 MCP 工具?"}
R -->|是| S["mcp_tool.py<br/>MCP 协议调用"]
L --> T{"是 Memory Provider 工具?"}
T -->|是| U["MemoryManager<br/>路由到 provider"]
end
style D fill:#f96,stroke:#333,stroke-width:2px
B.3 Memory Provider 生命周期
对应章节:第 11 章(Memory Provider 架构)
stateDiagram-v2
[*] --> 加载: load_memory_provider(name)
加载 --> 可用性检查: is_available()
state 可用性检查 <<choice>>
可用性检查 --> 注册: 可用
可用性检查 --> 跳过: 不可用(缺少 API key 等)
注册 --> 初始化: MemoryManager.add_provider()
初始化 --> 就绪: initialize_all(session_id, platform, user_id)
state 就绪 {
[*] --> 等待调用
等待调用 --> 工具注册: get_tool_schemas() → 追加到 AIAgent.tools
等待调用 --> 系统提示: system_prompt_block() → MemoryManager.build_system_prompt()
等待调用 --> 上下文注入: prefetch() / queue_prefetch() → 注入 recalled context
等待调用 --> 每轮开始: on_turn_start()
每轮开始 --> 等待调用
等待调用 --> 每轮同步: sync_turn()
每轮同步 --> 等待调用
等待调用 --> 会话结束: on_session_end()
会话结束 --> 等待调用
end
就绪 --> 清理: shutdown()
清理 --> [*]
跳过 --> [*]
B.4 Gateway 会话管理
对应章节:第 14 章(Gateway)
graph TB
subgraph "消息到达"
A["平台消息<br/>(Telegram / Discord / ...)"] --> B["BasePlatformAdapter<br/>.handle_message()"]
B --> C["提取 SessionSource<br/>(platform + user_id + chat_id)"]
end
subgraph "会话路由"
C --> D["SessionStore.get_or_create()"]
D --> E{"会话存在?"}
E -->|是| F{"过期?"}
E -->|否| G["创建新会话"]
F -->|是| H["重置会话<br/>保留记忆"]
F -->|否| I["复用会话"]
G --> J["分配 session_id"]
H --> J
I --> J
end
subgraph "Agent 调用"
J --> K["复用或构造 AIAgent<br/>(注入平台回调)"]
K --> L["agent.run_conversation()"]
L --> M["响应"]
end
subgraph "响应投递"
M --> N{"响应长度"}
N -->|短| O["单条消息"]
N -->|长| P["分段发送<br/>(Telegram 4096 字符限制)"]
N -->|含代码| Q["代码块格式化"]
O --> R["platform.send()"]
P --> R
Q --> R
end
subgraph "并发控制"
S["同一用户连续消息"] --> T["队列化处理<br/>防止 race condition"]
U["不同用户并行消息"] --> V["独立 session<br/>并行处理"]
end
style D fill:#9cf,stroke:#333,stroke-width:2px
B.5 上下文压缩流程
对应章节:第 12 章(上下文管理)
graph TB
A["run_conversation() 每轮循环开始"] --> B["估算当前 messages 的 token 数"]
B --> C{"token 数 > threshold?<br/>(默认 50% context window)"}
C -->|否| D["正常继续"]
C -->|是| E["触发压缩"]
E --> F["标记保护区域"]
F --> G["protect_first_n = 3<br/>(system prompt + 初始消息)"]
F --> H["protect_last_n = 20<br/>(最近的对话)"]
G --> I["中间区域:可压缩"]
H --> I
I --> J["对可压缩区域生成摘要"]
J --> K["使用同一模型<br/>或轻量模型"]
K --> L["替换:多条消息 → 1 条摘要消息"]
L --> M["重新计算 token 数"]
M --> N{"仍然超过阈值?"}
N -->|是| O["降低 protect_last_n<br/>再次压缩"]
N -->|否| P["压缩完成"]
P --> Q["注入压缩标记<br/>[CONTEXT COMPRESSED]"]
style E fill:#fc9,stroke:#333,stroke-width:2px
B.6 四层稳定性防御
对应章节:第 10 章(SessionDB)、第 19 章(并发模型)、第 20 章(进程生命周期)、第 21 章(运行时容错)
graph TB
subgraph "第 1 层:进程生命周期 (Ch20)"
A1["信号处理<br/>SIGTERM → 优雅关停"]
A2["Process Registry<br/>追踪子进程"]
A3["清理守护线程<br/>回收资源"]
A4["atexit 注册<br/>最终清理"]
end
subgraph "第 2 层:运行时防御 (Ch21)"
B1["SafeWriter<br/>broken pipe 保护"]
B2["API 重试 + 指数退避<br/>429/500/timeout"]
B3["Fallback Provider 链<br/>主模型不可用时切换"]
B4["Gateway 断线重连<br/>自动恢复连接"]
B5["平台消息投递重试<br/>send() 失败重试"]
end
subgraph "第 3 层:数据安全 (Ch10)"
C1["SQLite WAL 模式<br/>读写并发"]
C2["BEGIN IMMEDIATE<br/>写锁定"]
C3["Jitter Retry × 15<br/>锁竞争退避"]
C4["事务回滚<br/>部分写入恢复"]
end
subgraph "第 4 层:架构防御 (Ch19)"
D1["IterationBudget<br/>防无限循环"]
D2["70% / 90% 压力预警<br/>budget 消耗提醒"]
D3["async→sync 桥接<br/>线程隔离"]
D4["daemon 线程<br/>background review 自动终止"]
end
EXT["外部故障"] --> B1
EXT --> B2
EXT --> B4
INT["内部风险"] --> D1
INT --> D2
SYS["系统事件"] --> A1
SYS --> A2
DATA["数据竞争"] --> C1
DATA --> C3
style A1 fill:#fc9
style B2 fill:#f96
style C3 fill:#9cf
style D1 fill:#9f9
以上流程图使用 Mermaid 格式,可在 mdbook 中直接渲染。图中的行号和文件引用基于 Hermes Agent v0.8.0。
附录 C:术语表
本术语表收录 Hermes Agent 中频繁出现的专有名词和技术概念,按字母顺序排列。
| 术语 | 定义 | 首次出现 |
|---|---|---|
| BasePlatformAdapter | Gateway 平台适配器的抽象基类(gateway/platforms/base.py:470)。定义了 connect()、disconnect()、send() 等核心方法。当前 16 个 Gateway 平台类型共享这一 ABC。 | 第 14 章 |
| Compression(上下文压缩) | 当对话 token 数超过阈值时,ContextCompressor 将中间消息压缩为摘要,保护首尾消息不被压缩。阈值默认为 context window 的 50%。 | 第 12 章 |
| ContextCompressor | 上下文压缩器类(agent/context_compressor.py)。负责估算 token 数、标记保护区域、生成摘要替换原始消息。 | 第 12 章 |
| Gateway | Hermes 的消息平台网关子系统。一个 Gateway 进程可同时连接多个消息平台(Telegram、Discord 等),接收消息并路由到同一个 AIAgent 内核。 | 第 14 章 |
| IterationBudget | 线程安全的迭代预算类(run_agent.py:168)。通过 consume() / refund() 控制 agent 的工具调用次数上限(默认 90 次),在 70%/90% 时注入压力预警。 | 第 4 章 |
| Learning Loop(学习闭环) | Hermes 的四个设计赌注之一。指 agent 从工作经验中创建技能、在使用中改进技能、通过 nudge 和 background review 主动持久化知识的完整闭环。 | 第 1 章 |
| Memory Nudge | 编排器内置的定期检查机制。每 10 个 user turn 检查记忆更新,每 10 个 tool iteration 检查技能创建。达到阈值时触发 _spawn_background_review()。 | 第 4 章 |
| MemoryProvider | 记忆后端的抽象基类(agent/memory_provider.py),定义 17 个方法。Hermes 提供 8 个可插拔实现(Honcho、Hindsight、Mem0 等)。 | 第 11 章 |
| Profile | 配置隔离机制。每个 profile 是一组独立的配置文件(config.yaml、skills/、state.db),通过 hermes --profile work 切换。用于同一台机器上运行多个独立的 agent 实例。 | 第 17 章 |
| Prompt Cache | 利用 Anthropic / OpenAI API 的 prompt caching 特性,将 system prompt 的稳定部分缓存在 API 端,减少重复传输的 token 成本。agent/prompt_caching.py 负责优化缓存命中率。 | 第 5 章 |
| Session | 一次连续对话的单位。每个 session 有唯一 ID,关联一组 messages。session 持久化在 SessionDB(SQLite)中,支持跨会话搜索。 | 第 10 章 |
| SessionDB | 基于 SQLite 的会话持久化层(hermes_state.py:115)。包含 sessions 表、messages 表和 messages_fts 虚拟表(FTS5 全文检索)。使用 WAL 模式 + jitter retry 处理并发。 | 第 10 章 |
| Skill(技能) | Hermes "Learning Loop" 的核心载体。技能是 Markdown 文件 + YAML frontmatter,存储在当前 HERMES_HOME/skills/ 目录下;默认 profile 下表现为 ~/.hermes/skills/。包含 agent 从工作经验中提炼的过程性知识,支持条件加载和按需检索。 | 第 8 章 |
| Terminal Backend(终端后端) | agent 执行命令的环境。Hermes 支持 6 种:Local(本地)、Docker(容器)、SSH(远程)、Daytona(serverless)、Singularity(HPC)、Modal(云函数)。通过 tools/terminal_tool.py 统一接口。 | 第 16 章 |
| Toolset | 工具分组。每个工具属于一个 toolset(如 terminal、web、memory、code_execution)。用户可以按 toolset 粒度启用或禁用工具,控制 agent 的能力范围。 | 第 6 章 |
| ToolRegistry | 工具注册表单例(tools/registry.py:48)。工具在模块加载时调用 registry.register() 自注册。注册表存储 ToolEntry 列表,包含工具名、handler、toolset 和 JSON Schema。 | 第 6 章 |
本术语表基于 Hermes Agent v0.8.0。术语的定义聚焦于工程含义而非产品描述。
附录 D:主题阅读导航
本附录按读者兴趣和常见问题,推荐章节组合。每条路径标注了核心章节(必读)和扩展章节(选读)。
按角色推荐
| 读者角色 | 核心章节 | 扩展章节 | 说明 |
|---|---|---|---|
| 全栈开发者(想完整理解系统) | Ch02 → Ch03 → Ch04 → Ch05 | 全书顺序阅读 | Ch04 是枢纽,理解它后其他章节自然贯通 |
| Agent 构建者(想做类似产品) | Ch01 → Ch04 → Ch06 → Ch08 → Ch11 | Ch14, Ch19, Ch23 | 聚焦设计决策和 trade-off |
| SRE / 运维(关心稳定性) | Ch20 → Ch21 → Ch10 → Ch19 | Ch14, Ch22 | 四层防御体系 |
| AI 研究者(关心记忆与学习) | Ch08 → Ch10 → Ch11 → Ch12 | Ch05, Ch04, Ch15 | Learning Loop 的完整实现路径 |
| 平台开发者(想接入新平台) | Ch14 → Ch04(回调部分) | Ch20, Ch21 | BasePlatformAdapter 是核心 |
| 贡献者(想参与开源) | Ch02 → Ch22 → Ch06 | Ch04, Ch17 | 仓库地图 + 测试体系 + 工具系统 |
按主题推荐
"agent 怎么从经验中学习?"
Ch08 技能系统 — 技能的数据模型、生命周期、条件加载
→ Ch04 AIAgent 内核 — nudge 机制和 background review
→ Ch05 提示词系统 — SKILLS_GUIDANCE 如何引导模型创建技能
→ Ch11 Memory Provider — 记忆后端如何补充技能系统
"怎么让 agent 跑在远程服务器上?"
Ch16 终端后端 — 6 种执行环境(Local → Docker → SSH → Daytona → Singularity → Modal)
→ Ch14 Gateway — 通过消息平台远程控制 agent
→ Ch15 定时调度 — 无人值守的自动化任务
→ Ch20 进程生命周期 — systemd 服务和信号处理
"这个系统怎么处理长对话?"
Ch12 上下文压缩 — token 估算、保护区域、摘要替换
→ Ch04 AIAgent 内核 — IterationBudget 的压力预警
→ Ch05 提示词系统 — prompt cache 优化
→ Ch10 SessionDB — 会话持久化和跨会话搜索
"多平台适配怎么做到的?"
Ch14 Gateway — GatewayRunner + BasePlatformAdapter + SessionStore
→ Ch04 AIAgent 内核 — 11 个回调接口
→ Ch13 CLI/TUI — CLI 入口的回调实现对比
→ Ch21 运行时容错 — 断线重连和消息投递重试
"工具系统怎么设计的?"
Ch06 工具系统 — ToolRegistry + 自注册 + toolset 过滤
→ Ch07 工具剖面 — terminal、browser、delegate、mcp 四种典型工具的深度分析
→ Ch09 子代理委托 — delegate 工具如何创建子 AIAgent
→ Ch16 终端后端 — terminal 工具的 6 种后端实现
"怎么保证 agent 稳定运行?"
Ch20 进程生命周期 — 信号处理、Process Registry、清理守护线程
→ Ch21 运行时容错 — SafeWriter、API 重试、fallback、断线重连
→ Ch10 SessionDB — WAL 模式、jitter retry、事务管理
→ Ch19 并发模型 — async→sync 桥接、线程隔离、IterationBudget
"配置和多实例隔离怎么做?"
Ch17 配置系统 — config.yaml 解析、Profile 机制、环境变量覆盖
→ Ch18 模型抽象 — Provider 兼容层、模型能力探测
→ Ch02 仓库地图 — 了解配置影响的各层
"测试体系是什么样的?"
Ch22 测试体系 — 400+ 测试文件、conftest.py 隔离、分层测试策略
→ Ch02 仓库地图 — tests/ 目录结构与生产代码的镜像关系
→ Ch21 运行时容错 — 容错代码如何被测试
章节依赖关系速查
以下表格标注每章的前置依赖,帮助非顺序阅读时判断是否需要先读其他章节。
| 章节 | 前置依赖 | 可独立阅读 |
|---|---|---|
| Ch01 设计赌注 | 无 | 可以 |
| Ch02 仓库地图 | 无 | 可以 |
| Ch03 请求旅程 | Ch02 | 建议先读 Ch02 |
| Ch04 AIAgent 内核 | Ch03 | 建议先读 Ch03 |
| Ch05 提示词系统 | Ch04 | 建议先读 Ch04 |
| Ch06 工具系统 | Ch04 | 建议先读 Ch04 |
| Ch07 工具剖面 | Ch06 | 需要先读 Ch06 |
| Ch08 技能系统 | Ch05, Ch06 | 建议先读 Ch05 |
| Ch09 子代理委托 | Ch04 | 建议先读 Ch04 |
| Ch10 SessionDB | Ch04 | 可独立阅读 |
| Ch11 Memory Provider | Ch10 | 建议先读 Ch10 |
| Ch12 上下文压缩 | Ch11 | 建议先读 Ch11 |
| Ch13 CLI/TUI | Ch04 | 建议先读 Ch04 |
| Ch14 Gateway | Ch04, Ch13 | 建议先读 Ch04 |
| Ch15 定时调度 | Ch14 | 建议先读 Ch14 |
| Ch16 终端后端 | Ch07 | 建议先读 Ch07 |
| Ch17 配置系统 | 无 | 可独立阅读 |
| Ch18 模型抽象 | Ch04 | 建议先读 Ch04 |
| Ch19 并发模型 | Ch04 | 建议先读 Ch04 |
| Ch20 进程生命周期 | Ch14 | 建议先读 Ch14 |
| Ch21 运行时容错 | Ch19, Ch20 | 需要先读 Ch19, Ch20 |
| Ch22 测试体系 | Ch02 | 可独立阅读 |
| Ch23 设计哲学 | Ch01 + 至少 Part 2-4 | 建议全书读完后再读 |
本导航基于 Hermes Agent v0.8.0 的全书结构。章节内容和依赖关系在后续版本中可能调整。