从整体架构看 Hermes Agent:一个会自我沉淀经验的终端智能体
第一次打开 Hermes Agent,很多人会把它归类成"带工具调用的命令行 AI 助手"。这个判断并不算错,但它只描述了最外层的使用体验。真正深入源码之后,你会发现它更像一个面向长期运行的 Agent 运行时------模型只是其中一个组件,工具、会话、记忆、技能、插件、安全策略、多平台入口和状态恢复共同构成了完整系统。

这篇文章不会立刻深入某个具体模块的实现,而是先画出一张"源码地图",让你在阅读整个项目时,知道每一块代码为什么存在,以及它们之间怎么配合。后续系列文章会沿着这张地图逐层展开,建议先订阅收藏,再慢慢消化。
文章目录
- [从整体架构看 Hermes Agent:一个会自我沉淀经验的终端智能体](#从整体架构看 Hermes Agent:一个会自我沉淀经验的终端智能体)
-
- [一、源码地图:读懂 Hermes 的第一份指南](#一、源码地图:读懂 Hermes 的第一份指南)
- 二、一张图看懂整体架构:多入口、单运行时
- 三、逐层拆解:六条主线的分工与配合
-
- [1. 入口层:不是为了多写几个聊天程序](#1. 入口层:不是为了多写几个聊天程序)
- [2. 运行时层:AIAgent 是门面,conversation_loop 是主轴](#2. 运行时层:AIAgent 是门面,conversation_loop 是主轴)
- [3. 能力层:工具注册 + Toolset 共同定义"模型能做什么"](#3. 能力层:工具注册 + Toolset 共同定义“模型能做什么”)
- [4. 状态层:SessionDB 让 Agent 不只是"一次性问答"](#4. 状态层:SessionDB 让 Agent 不只是“一次性问答”)
- [5. 经验层:Memory、Skills、Session Search 各司其职](#5. 经验层:Memory、Skills、Session Search 各司其职)
- [6. 扩展层:插件、Hooks、Middleware、Provider Profile](#6. 扩展层:插件、Hooks、Middleware、Provider Profile)
- 四、安全层:能力越强,边界就越要清晰
- 五、阅读源码的正确姿势:先画边界,再看函数
- 六、一个真实改造案例:新增内部工单查询能力
- 七、常见误区与总结
一、源码地图:读懂 Hermes 的第一份指南
面对一个庞大的开源项目,最忌讳的就是随便点开一个文件从头读到尾。Hermes 既有一个上万行的 cli.py,也有 TypeScript 前端、消息平台适配、SQLite 会话管理......如果找不到主线,很容易在细节中迷失。
下面的表格列出了理解 Hermes Agent 必须优先掌握的核心文件和目录,它们是整个项目的骨架:
| 文件或目录 | 角色 | 为什么重要 |
|---|---|---|
run_agent.py |
AIAgent 门面 |
代理对象的公共入口,保留大量测试和外部 patch 点 |
agent/conversation_loop.py |
会话循环 | 真正执行模型请求、工具调用、重试、收尾的核心引擎 |
model_tools.py |
工具编排层 | 把 registry 中的工具转成模型 schema,并执行 tool call |
tools/registry.py |
工具注册中心 | 负责工具自注册、AST 发现、可用性缓存 |
toolsets.py |
工具集合定义 | 决定哪些工具在什么场景暴露给模型 |
cli.py |
经典 CLI | prompt_toolkit 终端交互、命令处理、审批、状态栏 |
ui-tui/ |
Ink TUI 前端 | TypeScript/React 终端 UI |
tui_gateway/server.py |
TUI Python 后端 | JSON-RPC 方法、session、prompt、slash、审批 |
gateway/ |
消息平台入口 | Telegram、Discord、Slack 等平台适配和授权 |
hermes_state.py |
SessionDB | SQLite + FTS5 会话存储、恢复和搜索 |
hermes_cli/plugins.py |
插件系统 | hooks、middleware、插件工具、CLI 子命令扩展 |
这 11 个入口覆盖了交互、运行、能力、状态、扩展五大块。把它们串起来,就得到了 Hermes 的主干架构。
二、一张图看懂整体架构:多入口、单运行时
下面这张图是我从源码抽象出来的整体架构,它比文件列表更直观:
#mermaid-svg-5Y5euR8ElmfmMCN8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5Y5euR8ElmfmMCN8 .error-icon{fill:#552222;}#mermaid-svg-5Y5euR8ElmfmMCN8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5Y5euR8ElmfmMCN8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .marker.cross{stroke:#333333;}#mermaid-svg-5Y5euR8ElmfmMCN8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5Y5euR8ElmfmMCN8 p{margin:0;}#mermaid-svg-5Y5euR8ElmfmMCN8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster-label text{fill:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster-label span{color:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster-label span p{background-color:transparent;}#mermaid-svg-5Y5euR8ElmfmMCN8 .label text,#mermaid-svg-5Y5euR8ElmfmMCN8 span{fill:#333;color:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .node rect,#mermaid-svg-5Y5euR8ElmfmMCN8 .node circle,#mermaid-svg-5Y5euR8ElmfmMCN8 .node ellipse,#mermaid-svg-5Y5euR8ElmfmMCN8 .node polygon,#mermaid-svg-5Y5euR8ElmfmMCN8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .rough-node .label text,#mermaid-svg-5Y5euR8ElmfmMCN8 .node .label text,#mermaid-svg-5Y5euR8ElmfmMCN8 .image-shape .label,#mermaid-svg-5Y5euR8ElmfmMCN8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-5Y5euR8ElmfmMCN8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .rough-node .label,#mermaid-svg-5Y5euR8ElmfmMCN8 .node .label,#mermaid-svg-5Y5euR8ElmfmMCN8 .image-shape .label,#mermaid-svg-5Y5euR8ElmfmMCN8 .icon-shape .label{text-align:center;}#mermaid-svg-5Y5euR8ElmfmMCN8 .node.clickable{cursor:pointer;}#mermaid-svg-5Y5euR8ElmfmMCN8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .arrowheadPath{fill:#333333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5Y5euR8ElmfmMCN8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5Y5euR8ElmfmMCN8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5Y5euR8ElmfmMCN8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster text{fill:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 .cluster span{color:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5Y5euR8ElmfmMCN8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5Y5euR8ElmfmMCN8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-5Y5euR8ElmfmMCN8 .icon-shape,#mermaid-svg-5Y5euR8ElmfmMCN8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5Y5euR8ElmfmMCN8 .icon-shape p,#mermaid-svg-5Y5euR8ElmfmMCN8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5Y5euR8ElmfmMCN8 .icon-shape .label rect,#mermaid-svg-5Y5euR8ElmfmMCN8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5Y5euR8ElmfmMCN8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5Y5euR8ElmfmMCN8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5Y5euR8ElmfmMCN8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户
经典 CLI / cli.py
Ink TUI / ui-tui
Dashboard / xterm + PTY
消息平台 Gateway
AIAgent / run_agent.py
tui_gateway/server.py
PTY bridge
agent/conversation_loop.py
模型提供商
工具系统
SessionDB / hermes_state.py
Memory / Skills / Session Search
tools/registry.py
toolsets.py
hooks / middleware / plugins
观察这张图要注意一个细节:箭头的方向代表的不是数据流,而是"最终回到哪里" 。不管是 CLI、TUI、Dashboard 还是 Telegram 消息,所有的对话最终都流向同一个 AIAgent 和同一套 conversation_loop.py。工具不是散落在各个入口里的,而是通过统一的 registry 和 toolset 暴露;会话状态不是由各个界面私有的 SQLite 维护,而是全部写入 SessionDB;扩展能力也不是让你去改核心代码,而是通过插件、hooks、middleware 和 provider profile 接入。
这就是 Hermes 最核心的设计思想:
- 多入口,单运行时
- 多工具,单注册体系
- 多平台,单会话状态
- 多扩展,明确边界
理解这一点之后,再去看那些动辄数千行的文件,就不会觉得它们是在"重复造轮子",而是不同入口在同一个底座上的具体表达。
三、逐层拆解:六条主线的分工与配合
架构图把复杂的系统压缩成了几个大方块,但每个方块内部都有自己的设计考量。下面我们从六个维度逐层拆解,看看每一层的核心问题和解决方案。
1. 入口层:不是为了多写几个聊天程序
Hermes 的入口种类之多,可能让第一次看项目的人犯迷糊:为什么既有 cli.py,又有 Ink TUI,还有 Dashboard、Desktop,甚至 Gateway 消息平台?
- 经典 CLI :基于
prompt_toolkit,面向习惯终端操作的用户,处理键盘输入、命令解析、审批流程。 - Ink TUI:基于 React/Ink,在终端内提供更结构化的面板布局,支持多栏显示。
- Dashboard :通过 xterm.js + PTY 直接嵌入真实
hermes --tui进程,而不是用 React 重新写一个聊天界面。 - Gateway:对接 Telegram、Discord、Slack、WhatsApp 等消息平台,把对话请求转发给 Agent。
- Desktop :独立 Electron + React 桌面应用,使用
tui_gateway提供的后端协议,但本身并不内嵌 TUI 终端。
这里面最关键的设计决策是:Dashboard 的主聊天界面没有重新实现 transcript 和 composer,而是嵌入了真实的 TUI。为什么?因为对话体验涉及 slash command、审批、输入中断、流式输出、工具进度和历史恢复等大量细节,如果每个入口都自己实现一遍,这些逻辑很快就会分叉、互不一致。让聊天体验的"单一事实来源"保持在 Ink TUI 中,其他入口负责做好各自擅长的包装和上下文,才是长期可维护的做法。
启发:如果你要新增一个用户可触发的动作,不要第一反应是"在哪个界面加按钮"。先问清楚:这个动作的语义属于命令、工具、会话状态还是配置?语义落好之后,再让 CLI、TUI、Gateway 或 Dashboard 各自派生入口。这样动作就不会成为某个界面的特例,而是整个系统的通用能力。
2. 运行时层:AIAgent 是门面,conversation_loop 是主轴
run_agent.py 里的 AIAgent 类,从外面看提供了 chat() 和 run_conversation() 两个入口。但如果只读这个文件,你会发现很多方法其实只是做了转发,实际逻辑已经拆到了 agent/ 包中。例如 run_conversation() 最终调用的是 agent.conversation_loop.run_conversation()。
这是一种务实的重构策略:保留 run_agent.py 作为公共 API 和测试 patch 点,避免大规模破坏已有依赖,同时把具体实现逐步拆解到独立模块,降低单文件复杂度。
真正的核心会话循环可以简化成以下伪代码:
text
准备系统提示、用户消息、历史、记忆、工具 schema
while 没超过 max_iterations 且还有 iteration budget:
调用模型
如果模型返回 tool_calls:
执行工具
把工具结果追加为 role=tool
继续下一轮
否则:
得到最终 assistant 文本
持久化并返回
当然,生产环境的实现远比这复杂。Hermes 还需要处理:
- provider 的速率限制和 fallback model 切换
- 空响应、截断响应、invalid JSON arguments 的容错
- reasoning-only assistant message(模型只输出思考过程,不返回最终文本)
- Codex Responses API 和严格 Chat Completions provider 之间的字段差异
- 工具调用去重、并发判断、
delegate_task上限控制 - 中断、预算管理、checkpoint、记忆同步以及 hooks 和 middleware 的触发
这些复杂度都围绕一个事实:Agent 循环不是一次 HTTP 请求,而是一个可能持续很多轮、不断改变外部世界、需要可恢复和可审计的执行过程。 所以会话循环的设计,必须把"可靠执行"放在第一位,而不是简单拼几次 API 调用。
3. 能力层:工具注册 + Toolset 共同定义"模型能做什么"
Hermes 的工具系统并不在一个文件里罗列所有工具。每个工具模块(比如 tools/file_tools.py)在顶层调用 registry.register() 把自己注册进去;tools/registry.py 用 AST 扫描找出哪些模块真正注册了工具,然后导入它们,让注册动作自动发生。model_tools.py 再从 registry 取出工具定义,过滤成模型 API 能接受的 JSON Schema。
整个链路如下:
#mermaid-svg-ItHA2ijAUoHyk2AV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ItHA2ijAUoHyk2AV .error-icon{fill:#552222;}#mermaid-svg-ItHA2ijAUoHyk2AV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ItHA2ijAUoHyk2AV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ItHA2ijAUoHyk2AV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ItHA2ijAUoHyk2AV .marker.cross{stroke:#333333;}#mermaid-svg-ItHA2ijAUoHyk2AV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ItHA2ijAUoHyk2AV p{margin:0;}#mermaid-svg-ItHA2ijAUoHyk2AV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster-label text{fill:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster-label span{color:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster-label span p{background-color:transparent;}#mermaid-svg-ItHA2ijAUoHyk2AV .label text,#mermaid-svg-ItHA2ijAUoHyk2AV span{fill:#333;color:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV .node rect,#mermaid-svg-ItHA2ijAUoHyk2AV .node circle,#mermaid-svg-ItHA2ijAUoHyk2AV .node ellipse,#mermaid-svg-ItHA2ijAUoHyk2AV .node polygon,#mermaid-svg-ItHA2ijAUoHyk2AV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ItHA2ijAUoHyk2AV .rough-node .label text,#mermaid-svg-ItHA2ijAUoHyk2AV .node .label text,#mermaid-svg-ItHA2ijAUoHyk2AV .image-shape .label,#mermaid-svg-ItHA2ijAUoHyk2AV .icon-shape .label{text-anchor:middle;}#mermaid-svg-ItHA2ijAUoHyk2AV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ItHA2ijAUoHyk2AV .rough-node .label,#mermaid-svg-ItHA2ijAUoHyk2AV .node .label,#mermaid-svg-ItHA2ijAUoHyk2AV .image-shape .label,#mermaid-svg-ItHA2ijAUoHyk2AV .icon-shape .label{text-align:center;}#mermaid-svg-ItHA2ijAUoHyk2AV .node.clickable{cursor:pointer;}#mermaid-svg-ItHA2ijAUoHyk2AV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ItHA2ijAUoHyk2AV .arrowheadPath{fill:#333333;}#mermaid-svg-ItHA2ijAUoHyk2AV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ItHA2ijAUoHyk2AV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ItHA2ijAUoHyk2AV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ItHA2ijAUoHyk2AV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ItHA2ijAUoHyk2AV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ItHA2ijAUoHyk2AV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster text{fill:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV .cluster span{color:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ItHA2ijAUoHyk2AV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ItHA2ijAUoHyk2AV rect.text{fill:none;stroke-width:0;}#mermaid-svg-ItHA2ijAUoHyk2AV .icon-shape,#mermaid-svg-ItHA2ijAUoHyk2AV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ItHA2ijAUoHyk2AV .icon-shape p,#mermaid-svg-ItHA2ijAUoHyk2AV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ItHA2ijAUoHyk2AV .icon-shape .label rect,#mermaid-svg-ItHA2ijAUoHyk2AV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ItHA2ijAUoHyk2AV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ItHA2ijAUoHyk2AV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ItHA2ijAUoHyk2AV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tools/*.py 顶层 registry.register
ToolRegistry
discover_builtin_tools AST 扫描
model_tools.py
模型 tools schema
模型选择 tool_calls
handle_function_call
工具 handler
但工具被 registry 发现,还不代表模型在任何场景都能看到它。toolsets.py 扮演了关键的"门禁"角色。它定义了不同场景下允许暴露的工具集合,例如:
_HERMES_CORE_TOOLS:CLI 和主要消息平台默认继承的核心工具集,包含文件、终端、搜索等。_HERMES_WEBHOOK_SAFE_TOOLS:webhook 这类不可信入口的保守工具集,通常只暴露 Web、vision、clarify 等低风险能力。
Toolset 的本质是 安全和上下文的控制。模型可用的工具越多,提示就越长,选择越困难,误调用概率也越高。尤其是在 webhook 场景下,如果默认就给外部 payload 暴露文件读写和终端命令,提示注入的后果会直接变成本地执行风险。
常见错误 :很多人在新增工具时,只做了
registry.register(),忘了把工具加入对应的toolsets.py。结果工具在 registry 里明明存在,模型却永远看不到。正确理解应该是:
- registry 解决的是"系统知道有这个工具"。
- toolset 解决的是"当前会话允许模型看到这个工具"。
4. 状态层:SessionDB 让 Agent 不只是"一次性问答"
Hermes 的会话状态由 hermes_state.py 中的 SessionDB 统一管理。它使用 SQLite 作为存储引擎,并启用 FTS5 做全文搜索。存的不只是最终的问答文本,还包括 session metadata、完整的消息历史、模型配置、来源信息、tool calls、reasoning 等所有可以支持恢复和检索的状态。
为什么不直接用 JSONL?因为 Hermes 要支持的功能远远超过简单的日志记录:
/resume:恢复一个旧的会话,继续对话/history:查看历史会话列表/title:给会话设置标题/branch:从当前会话的某个点分叉出新的会话session_search:在过去的对话中搜索关键字- Gateway 多平台共享同一套会话记录
- Dashboard 和 TUI 列出最近会话
这些需求需要索引、并发写入、迁移支持和故障恢复。hermes_state.py 中甚至专门处理了 WAL 模式在 NFS/SMB/FUSE 等网络文件系统上不可用的情况,必要时回退到 DELETE journal mode。同时它还能处理 malformed schema 的恢复,尽量保护 canonical 的 sessions/messages 数据,只重建派生的 FTS 索引层。
SessionDB 不是缓存,是用户的长期资产。 任何对数据库结构的修改,都必须极其保守,确保向下兼容。
5. 经验层:Memory、Skills、Session Search 各司其职
Hermes 的"长期经验"并不是单一的一个"记忆"概念,而是拆成了三个不同的维度:
| 类型 | 适合保存什么 | 典型模块 |
|---|---|---|
| Memory | 用户偏好、事实、画像、项目常识 | agent/memory_manager.py、plugins/memory/ |
| Skills | 可复用流程、领域方法、工具使用规程 | skills/、optional-skills/、tools/skills_tool.py |
| Session Search | 过去对话和执行轨迹 | tools/session_search_tool.py、hermes_state.py |
- Memory 适合存: "用户通常用 pnpm""这个项目的主分支叫 main""用户偏好中文回答"。
- Skills 适合存: "做代码评审时按什么步骤""发布前要检查哪些文件""某个 API 的调用流程"。
- Session Search 适合回答:"上次我们怎么处理那个内存泄漏的 bug?"
把这三者混在一起,会产生大量噪声。所有事实都写成技能,技能文件会无限膨胀;所有流程都塞进 memory,检索会严重失真;所有历史都靠模型上下文硬塞,prompt 会爆炸。Hermes 的设计倾向是把长期经验按类型分开存储,在需要时再按需注入到提示或工具调用中。
6. 扩展层:插件、Hooks、Middleware、Provider Profile
Hermes 的扩展面很大,但它并不是允许用户随意修改核心文件。项目提供了几条明确的扩展路径:
- 普通插件 (
hermes_cli/plugins.py):可以注册工具、hooks、middleware、CLI 子命令。 - Memory provider 插件 (
plugins/memory/):接入不同的长期记忆后端(如 Chroma、Weaviate 等)。 - Model provider 插件 (
plugins/model-providers/):注册自定义的ProviderProfile,接入新模型。 - Observer hooks:只读观测运行时事件,用于日志、监控、审计。
- Middleware:改写 LLM 请求、工具请求或包裹执行过程,实现策略控制。
- MCP:把外部进程和服务作为标准化的工具接入。
这套扩展边界的价值在于 避免核心文件被插件名字污染 。如果某个插件需要新能力,正确做法是扩展通用的 plugin surface(比如增加一个新的 hook 点),而不是在 run_agent.py 或 cli.py 里写 if plugin_name == 'xxx'。
四、安全层:能力越强,边界就越要清晰
Hermes 能执行终端命令、读写文件、控制浏览器、发送消息、接收 webhook、运行后台任务。这些能力如果没有边界,就不是生产力工具,而是安全灾难。
当前版本的安全体系至少包含这几道防线:
- 危险命令审批:在执行某些高风险命令之前,CLI/TUI/Gateway 会触发审批流程,需要用户确认。
- 工作目录控制 :CLI 使用进程的当前工作目录,Gateway 通过
terminal.cwd限制操作范围。 - 平台授权 :
gateway/authz_mixin.py可以过滤允许交互的用户、聊天或群组。 - Toolset 降权:Webhook 等不可信入口默认只获得安全工具集,不直接暴露终端和文件能力。
- 依赖锁定 :
pyproject.toml中核心依赖精确锁定或设置上界,避免供应链攻击。 - Secret 管理 :API key 等敏感信息放在
.env中,普通配置放在config.yaml里,避免误提交。
这些防线不是互相替代的关系。审批不能替代平台授权,依赖锁定不能替代工具审批,工作目录限制也不能替代入口降权。一个真正可靠的 Agent 安全体系,必须是多层防线的叠加。
五、阅读源码的正确姿势:先画边界,再看函数
读 Hermes 这种大型项目,最容易犯的错误就是直接打开最大的文件从头看起。这样你很快会陷入细节,纠结某个 callback 为什么存在、某个字段为什么要清理、某个平台为什么有特殊分支。
我建议先画四条边界,建立坐标系:
第一条边界:入口边界
cli.py、ui-tui/、gateway/、Dashboard 和 Desktop 都是入口,但它们不应该各自拥有一套 Agent 逻辑。判断一个改动是否合理,先看它是不是只属于"入口体验"。如果只是按钮、快捷键、布局、颜色、前端状态,就放在入口层;如果涉及会话、工具、模型请求、记忆、审批,就应该回到共享运行时。
第二条边界:能力边界
模型能做什么,不是由 prompt 里一句"你可以使用工具"决定的,而是由 tools/registry.py、model_tools.py 和 toolsets.py 共同决定。新增能力时,不要只问"函数写在哪里",还要问:"什么入口能看到它?什么配置能禁用它?什么依赖检查能隐藏它?什么安全策略能拦截它?"
第三条边界:状态边界
会话、记忆、技能、历史搜索都是状态,但粒度不同。SessionDB 保存执行轨迹,Memory 保存长期事实,Skills 保存复用流程,Context Files 保存项目静态约定。把这些状态混在一起,短期可能省事,长期会让召回和维护完全失控。
第四条边界:扩展边界
插件工具、observer hook、middleware、model-provider plugin、MCP、Gateway platform adapter 都是扩展,但各自的能力不同。只是记录运行时事件,用 observer;要改写请求或执行,用 middleware;要新增模型后端,用 provider plugin;要接消息平台,用 Gateway adapter。选错扩展点,代码就会不可逆地渗透进核心文件。
如果你先把这四条边界画清楚,后面读函数会轻松很多。你会理解 run_agent.py 中的那些转发方法并不是多余的间接层;你会明白 Dashboard 嵌入 TUI 不是在偷懒,而是在刻意避免第二套聊天实现;你也会看懂 webhook safe tools 不是功能缺失,而是入口降权的安全设计。
六、一个真实改造案例:新增内部工单查询能力
理论讲完,来看一个具体的场景。假设团队想让 Hermes 能够查询内部工单系统。最粗暴的做法是在 run_agent.py 里加一个特殊判断:如果用户问题中包含"工单"关键词,就调用内部 API。
这个方法很快,但架构上完全错误:
- 模型不知道这个能力的存在,无法在 tool_choice 中主动选择
- TUI 和 Gateway 无法以统一的方式展示工具调用
- 权限控制和错误处理无法复用
- 其他入口(比如 webhook)完全不知道这个新能力应该被排除
更合理的改造路线是:
- 写一个插件工具(如果能力只在团队内部使用)。
- 插件通过
ctx.register_tool()注册一个ticket_search工具。 - 明确 schema 参数,例如
query、status、assignee。 - handler 返回结构化的 JSON 字符串,失败也要返回可读的错误信息。
- 根据安全需要,把工具放进一个特定的 toolset,默认不加入 webhook safe tools。
- 如果工单系统需要 OAuth 或 token,把 secret 放在
.env或 Hermes 的 secret 管理路径下。 - 给 CLI、TUI、Gateway 都跑一条端到端测试,确保工具调用能被展示、记录和恢复。
这个案例很好地体现了 Hermes 架构的价值:能力一旦进入工具层,所有入口自然受益;状态写入 SessionDB,可搜索、可恢复;插件可以独立启用或禁用;工具调用能被 observer 记录;middleware 也可以统一施加策略。 你不需要为每个界面单独写一次"工单查询"逻辑。
七、常见误区与总结
最后,把最常见的几个认知偏差收拢一下:
-
误区一:把 Hermes 当成一个 CLI 项目
如果只从
cli.py看 Hermes,会误以为所有逻辑都该放进 CLI。实际上 CLI 只是入口之一。一个新能力如果需要 TUI、Gateway、Dashboard 复用,应该落在命令 registry、工具 registry、AIAgent、tui_gateway RPC 或插件扩展点,而不是只写在HermesCLI.process_command()里。 -
误区二:工具越多越强
工具越多,模型越容易误选,schema 越长,安全面越大。Toolset 存在的意义就是把能力和场景匹配起来。Webhook 和本地 CLI 不应该看到同样的工具集合。
-
误区三:Dashboard 应该用 React 重写聊天界面
Dashboard 的主聊天嵌入真实 TUI,是为了避免维护第二套 transcript/composer。React 可以负责侧栏、模型选择、状态面板,但主聊天路径应该与 TUI 保持一致。
-
误区四:插件需要新能力就直接改核心
插件系统的规则恰恰相反。插件需要能力时,应扩展通用 hook、middleware 或 ctx method,而不是把插件名写进
if判断。
Hermes Agent 的架构可以概括为:多入口、单运行时;多工具、单注册体系;多状态、统一会话数据库;多扩展、明确边界;高能力、多层安全。
理解它时,不要先被文件数量吓住。先抓住六条主线:
- 入口:CLI、TUI、Dashboard、Gateway
- 运行时 :
AIAgent和 conversation loop - 能力:工具 registry 和 toolset
- 状态:SessionDB、Memory、Skills
- 扩展:插件、hooks、middleware、provider、MCP
- 安全:审批、授权、工作目录、依赖和入口降权
后续系列文章会分别展开这些层。读完整个系列之后,你再回头看这个仓库,就不会只是看到一个复杂的项目,而是能看出它为什么这样复杂,以及每种复杂度分别服务什么目标。
如果你也在维护一个长期运行的 Agent 系统,或者正在给 Hermes 做二次开发,欢迎在评论区分享你的架构取舍和经验。下一篇我会深入 conversation_loop.py,拆解它的重试、中断和记忆同步机制,敬请期待。