1291 lines
70 KiB
HTML
1291 lines
70 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Hermes Agent v0.8.0 · 源码解析</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0e14;
|
|
--bg2: #0f1419;
|
|
--border: #1c2430;
|
|
--text: #d4d8e0;
|
|
--dim: #6b7280;
|
|
--dim2: #9ca3af;
|
|
--blue: #60a5fa;
|
|
--green: #4ade80;
|
|
--yellow: #fbbf24;
|
|
--red: #f87171;
|
|
--purple: #a78bfa;
|
|
--orange: #fb923c;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html { scroll-behavior: smooth; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
line-height: 1.75;
|
|
font-size: 15px;
|
|
}
|
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 48px 32px 96px; }
|
|
|
|
/* Header */
|
|
header { border-bottom: 1px solid var(--border); padding-bottom: 28px; margin-bottom: 40px; }
|
|
h1 { font-size: 34px; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 14px; }
|
|
.logo-dot {
|
|
width: 12px; height: 12px; border-radius: 50%; background: var(--green);
|
|
box-shadow: 0 0 16px var(--green); animation: pulse 2s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.lede { color: var(--dim2); font-size: 15px; margin-top: 12px; max-width: 780px; }
|
|
.meta-row {
|
|
display: flex; flex-wrap: wrap; gap: 20px; margin-top: 16px;
|
|
font-size: 12px; color: var(--dim); font-family: "SF Mono", "Monaco", monospace;
|
|
}
|
|
.meta-row span strong { color: var(--blue); font-weight: 500; }
|
|
|
|
/* TOC */
|
|
nav.toc {
|
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
|
padding: 20px 24px; margin-bottom: 40px;
|
|
}
|
|
nav.toc h4 {
|
|
font-size: 11px; color: var(--dim); text-transform: uppercase;
|
|
letter-spacing: 1.5px; margin-bottom: 12px; font-weight: 600;
|
|
}
|
|
nav.toc ol {
|
|
list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
gap: 8px 24px; counter-reset: toc-counter;
|
|
}
|
|
nav.toc li { counter-increment: toc-counter; }
|
|
nav.toc li a {
|
|
color: var(--dim2); text-decoration: none; font-size: 13.5px;
|
|
display: block; padding: 2px 0;
|
|
}
|
|
nav.toc li a::before {
|
|
content: counter(toc-counter, decimal-leading-zero) " ";
|
|
color: var(--dim); font-family: "SF Mono", monospace; font-size: 11px;
|
|
}
|
|
nav.toc li a:hover { color: var(--blue); }
|
|
|
|
/* Sections */
|
|
section { margin: 56px 0; scroll-margin-top: 24px; }
|
|
h2 {
|
|
font-size: 24px; color: #fff; font-weight: 600; margin-bottom: 16px;
|
|
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
|
}
|
|
h2 .num {
|
|
font-family: "SF Mono", monospace; color: var(--blue); font-size: 14px;
|
|
font-weight: 400; margin-right: 12px;
|
|
}
|
|
h3 {
|
|
font-size: 17px; color: var(--blue); margin: 28px 0 10px; font-weight: 600;
|
|
}
|
|
h4 {
|
|
font-size: 14px; color: var(--yellow); margin: 20px 0 8px;
|
|
font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
p { margin-bottom: 14px; color: var(--text); }
|
|
ul, ol { margin: 10px 0 14px 20px; color: var(--text); }
|
|
li { margin-bottom: 6px; }
|
|
strong { color: #fff; font-weight: 600; }
|
|
|
|
/* Code + citations */
|
|
code {
|
|
background: var(--border); padding: 2px 8px; border-radius: 4px;
|
|
color: var(--yellow); font-size: 13px;
|
|
font-family: "SF Mono", "Monaco", "Consolas", monospace;
|
|
}
|
|
pre {
|
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 16px 20px; margin: 14px 0 20px; overflow-x: auto;
|
|
font-size: 12.5px; line-height: 1.7;
|
|
}
|
|
pre code { background: transparent; padding: 0; color: var(--text); font-size: 12.5px; }
|
|
|
|
.cite {
|
|
display: inline-block; background: rgba(96, 165, 250, 0.1);
|
|
border: 1px solid rgba(96, 165, 250, 0.3); color: var(--blue);
|
|
padding: 1px 8px; border-radius: 4px; font-family: "SF Mono", monospace;
|
|
font-size: 11.5px; margin: 0 2px;
|
|
}
|
|
|
|
/* Tables */
|
|
table {
|
|
width: 100%; border-collapse: collapse; margin: 14px 0 20px;
|
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
|
overflow: hidden; font-size: 13.5px;
|
|
}
|
|
th {
|
|
background: rgba(96, 165, 250, 0.08); color: var(--blue);
|
|
text-align: left; padding: 10px 14px; font-weight: 600;
|
|
border-bottom: 1px solid var(--border); font-size: 12.5px;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
td { padding: 10px 14px; color: var(--text); border-bottom: 1px solid var(--border); vertical-align: top; }
|
|
td:first-child { color: var(--yellow); font-family: "SF Mono", monospace; font-size: 12.5px; white-space: nowrap; }
|
|
tr:last-child td { border-bottom: none; }
|
|
|
|
/* Architecture ASCII */
|
|
.ascii-art {
|
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
|
padding: 20px; margin: 14px 0 20px; white-space: pre;
|
|
font-family: "SF Mono", "Monaco", monospace; font-size: 12px;
|
|
line-height: 1.5; color: var(--dim2); overflow-x: auto;
|
|
}
|
|
.ascii-art .b { color: var(--blue); }
|
|
.ascii-art .g { color: var(--green); }
|
|
.ascii-art .y { color: var(--yellow); }
|
|
.ascii-art .p { color: var(--purple); }
|
|
.ascii-art .r { color: var(--red); }
|
|
|
|
/* Callouts */
|
|
.callout {
|
|
background: rgba(96, 165, 250, 0.06);
|
|
border-left: 3px solid var(--blue);
|
|
padding: 14px 20px; margin: 16px 0; border-radius: 4px;
|
|
color: var(--text); font-size: 14px;
|
|
}
|
|
.callout.warn { background: rgba(245, 158, 11, 0.06); border-left-color: var(--yellow); }
|
|
.callout.danger { background: rgba(248, 113, 113, 0.06); border-left-color: var(--red); }
|
|
.callout strong { color: var(--yellow); }
|
|
.callout.warn strong { color: var(--yellow); }
|
|
.callout.danger strong { color: var(--red); }
|
|
|
|
/* Stats grid */
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin: 14px 0 20px; }
|
|
.stat {
|
|
background: var(--bg2); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 16px 18px;
|
|
}
|
|
.stat-label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; }
|
|
.stat-value { color: #fff; font-size: 22px; font-weight: 600; font-family: "SF Mono", monospace; }
|
|
|
|
/* Tags */
|
|
.tag {
|
|
display: inline-block; background: var(--border); color: var(--blue);
|
|
padding: 3px 12px; border-radius: 20px; font-size: 11.5px;
|
|
margin-right: 6px; margin-bottom: 6px; font-family: "SF Mono", monospace;
|
|
}
|
|
|
|
/* Footer */
|
|
footer {
|
|
margin-top: 64px; padding-top: 24px; border-top: 1px solid var(--border);
|
|
color: var(--dim); font-size: 12.5px; text-align: center;
|
|
}
|
|
footer a { color: var(--blue); text-decoration: none; }
|
|
footer a:hover { text-decoration: underline; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<header>
|
|
<h1><span class="logo-dot"></span>Hermes Agent v0.8.0 · 源码解析</h1>
|
|
<p class="lede">
|
|
Nous Research 开源的自进化 AI agent。本文基于本地 <code>git clone</code> 的 v0.8.0 源码
|
|
(commit <code>d6785dc</code>,2026-04-12),结合 <code>AGENTS.md</code>、<code>RELEASE_v0.8.0.md</code>
|
|
和 842 个 Python 文件的结构化阅读。所有技术结论都标注了 <span class="cite">file:line</span> 引用。
|
|
</p>
|
|
<div class="meta-row">
|
|
<span>version <strong>0.8.0</strong></span>
|
|
<span>commit <strong>d6785dc</strong></span>
|
|
<span>python files <strong>842</strong></span>
|
|
<span>markdown files <strong>542</strong></span>
|
|
<span>repo size <strong>162 MB</strong></span>
|
|
<span>test count <strong>~3000</strong></span>
|
|
<span>license <strong>MIT</strong></span>
|
|
</div>
|
|
</header>
|
|
|
|
<nav class="toc">
|
|
<h4>目录</h4>
|
|
<ol>
|
|
<li><a href="#tldr">一句话总结</a></li>
|
|
<li><a href="#evidence">取证方式</a></li>
|
|
<li><a href="#entry">三个入口脚本</a></li>
|
|
<li><a href="#arch">整体架构</a></li>
|
|
<li><a href="#layout">仓库结构地图</a></li>
|
|
<li><a href="#aiagent">核心:AIAgent 类</a></li>
|
|
<li><a href="#loop">Agent 主循环</a></li>
|
|
<li><a href="#registry">工具注册表</a></li>
|
|
<li><a href="#model_tools">工具编排层</a></li>
|
|
<li><a href="#session">会话 + FTS5 搜索</a></li>
|
|
<li><a href="#gateway">消息网关</a></li>
|
|
<li><a href="#platforms">22 个平台适配器</a></li>
|
|
<li><a href="#backends">6 种 Terminal 后端</a></li>
|
|
<li><a href="#skills">Skills 系统</a></li>
|
|
<li><a href="#slash">Slash 命令中心化</a></li>
|
|
<li><a href="#profile">Profile 多实例</a></li>
|
|
<li><a href="#v08">v0.8 重要变化</a></li>
|
|
<li><a href="#docker">Docker 运行时</a></li>
|
|
<li><a href="#takeaways">设计洞察</a></li>
|
|
<li><a href="#no-ui">没有 Web UI 的真相</a></li>
|
|
<li><a href="#real-deploy">部署实战 · LXC + Docker 双层隔离</a></li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- ============================== -->
|
|
<section id="tldr">
|
|
<h2><span class="num">01</span>一句话总结</h2>
|
|
<p>
|
|
Hermes Agent 是一个<strong>多平台入口、同一个 agent 循环、可插拔 LLM provider、可插拔 terminal 后端、
|
|
可插拔 skills + MCP + plugins</strong> 的 Python agent 框架。它的最关键设计不是 LLM 能力,而是
|
|
<strong>把"跟 agent 说话"、"agent 跑代码"、"agent 用工具"、"agent 记忆"四件事完全正交</strong>:
|
|
你可以从 CLI / Telegram / Slack / Discord / WhatsApp / Signal / 邮件 / Matrix / 钉钉 / 飞书 / WeChat
|
|
任意入口发消息,同一个 <code>AIAgent</code> 实例处理,代码执行可以转发到本地 / Docker / SSH 远程 /
|
|
Daytona / Modal / Singularity 任意沙箱,而模型 provider 从 OpenRouter / Nous / Anthropic / OpenAI /
|
|
z.ai / Kimi / MiniMax / Google AI Studio / HuggingFace / 任意 OpenAI 兼容 custom endpoint 任选。
|
|
</p>
|
|
<p>
|
|
所有这些都可以在运行时用 <code>/model</code> 切换,不重启,不丢上下文。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="evidence">
|
|
<h2><span class="num">02</span>取证方式 · 不是 AI 猜的</h2>
|
|
<p>
|
|
本文的每一条技术断言都基于对实际源码的阅读,格式是 <span class="cite">file:line</span> 引证。
|
|
证据来源:
|
|
</p>
|
|
<ul>
|
|
<li><code>git clone https://github.com/NousResearch/hermes-agent.git</code> 到
|
|
<code>~/Projects/research/20260413-hermes-source-analysis/source/</code></li>
|
|
<li>顶层仓库文件:<code>AGENTS.md</code>(20 KB 开发者指南)、<code>README.md</code>、
|
|
<code>RELEASE_v0.8.0.md</code>(209 PR / 82 issue 合并日志)、<code>Dockerfile</code>、
|
|
<code>pyproject.toml</code>、<code>docker/entrypoint.sh</code></li>
|
|
<li>核心代码文件:<code>run_agent.py</code>(10 578 行)、<code>cli.py</code>(9 920 行)、
|
|
<code>gateway/run.py</code>(8 844 行)、<code>hermes_state.py</code>(1 238 行)、
|
|
<code>tools/registry.py</code>、<code>model_tools.py</code></li>
|
|
</ul>
|
|
<div class="callout">
|
|
<strong>为什么要讲取证</strong>:AI 分析源码最容易出的问题是"听起来对但其实瞎编"。
|
|
本文写作时使用的读法是<em>先读 → 再写 → 不反过来</em>。若发现任何引用错误,以 GitHub 上
|
|
<code>v0.8.0</code> 标签为准,反馈到项目 issues。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="entry">
|
|
<h2><span class="num">03</span>三个入口脚本</h2>
|
|
<p>
|
|
<code>pyproject.toml</code> <span class="cite">pyproject.toml:112-115</span> 定义了 3 个可执行入口:
|
|
</p>
|
|
<table>
|
|
<tr><th>命令</th><th>指向</th><th>作用</th></tr>
|
|
<tr><td>hermes</td><td>hermes_cli.main:main</td><td>交互式 CLI + 所有 <code>hermes *</code> 子命令</td></tr>
|
|
<tr><td>hermes-agent</td><td>run_agent:main</td><td>单次 agent 调用(脚本模式、批处理、数据生成)</td></tr>
|
|
<tr><td>hermes-acp</td><td>acp_adapter.entry:main</td><td>ACP 协议服务器(给 VS Code / Zed / JetBrains 用)</td></tr>
|
|
</table>
|
|
<p>
|
|
核心类 <code>AIAgent</code> 定义在 <span class="cite">run_agent.py:492</span>。
|
|
交互 CLI 的 <code>HermesCLI</code> 定义在 <span class="cite">cli.py:1574</span>。
|
|
消息网关的 <code>GatewayRunner</code> 定义在 <span class="cite">gateway/run.py:512</span>。
|
|
<strong>这三个类是理解 Hermes 的三把钥匙</strong>。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="arch">
|
|
<h2><span class="num">04</span>整体架构</h2>
|
|
<div class="ascii-art"><span class="y"> ┌─────────────────────────────────────────────┐</span>
|
|
<span class="y"> │</span> <span class="g">用户入口</span> <span class="y">│</span>
|
|
<span class="y"> │</span> CLI · Telegram · Slack · Discord <span class="y">│</span>
|
|
<span class="y"> │</span> WhatsApp · Signal · Email · Matrix <span class="y">│</span>
|
|
<span class="y"> │</span> DingTalk · Feishu · WeCom · WeChat · 22+ <span class="y">│</span>
|
|
<span class="y"> └──────────────────┬──────────────────────────┘</span>
|
|
│
|
|
<span class="b">[gateway/run.py + gateway/platforms/*]</span>
|
|
│
|
|
▼
|
|
<span class="b">┌─────────────────────────────────────────────────────────────────────┐</span>
|
|
<span class="b">│</span> <span class="g">AIAgent 核心(run_agent.py:492)</span> <span class="b">│</span>
|
|
<span class="b">│</span> ┌──────────────┬──────────────┬──────────────┬──────────────┐ <span class="b">│</span>
|
|
<span class="b">│</span> │ 系统提示词 │ 会话存储 │ 内存管理 │ 工具编排 │ <span class="b">│</span>
|
|
<span class="b">│</span> │ agent/ │ hermes_state │ agent/ │ model_tools │ <span class="b">│</span>
|
|
<span class="b">│</span> │ prompt_ │ .py (FTS5) │ memory_ │ + tools/ │ <span class="b">│</span>
|
|
<span class="b">│</span> │ builder.py │ │ manager │ registry.py │ <span class="b">│</span>
|
|
<span class="b">│</span> └──────────────┴──────────────┴──────────────┴──────────────┘ <span class="b">│</span>
|
|
<span class="b">│</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="p">主循环</span>:run_conversation() :7528 <span class="b">│</span>
|
|
<span class="b">│</span> while api_call_count < max_iterations: <span class="b">│</span>
|
|
<span class="b">│</span> call LLM with tool schemas <span class="b">│</span>
|
|
<span class="b">│</span> if response.tool_calls: <span class="b">│</span>
|
|
<span class="b">│</span> handle_function_call(...) <span class="b">│</span>
|
|
<span class="b">│</span> append tool results <span class="b">│</span>
|
|
<span class="b">│</span> else: return response.content <span class="b">│</span>
|
|
<span class="b">└──────────────────┬──────────────────┬───────────────────────────────┘</span>
|
|
│ │
|
|
<span class="g">LLM 层</span> <span class="y">工具执行层</span>
|
|
│ │
|
|
▼ ▼
|
|
<span class="g">┌─────────────────────┐</span> <span class="y">┌──────────────────────────────────────────┐</span>
|
|
<span class="g">│</span> OpenRouter <span class="g">│</span> <span class="y">│</span> tools/*.py(40+ 工具) <span class="y">│</span>
|
|
<span class="g">│</span> Nous Portal <span class="g">│</span> <span class="y">│</span> ┌────────────────────────────────────┐ <span class="y">│</span>
|
|
<span class="g">│</span> Anthropic <span class="g">│</span> <span class="y">│</span> │ 终端(terminal_tool.py) │ <span class="y">│</span>
|
|
<span class="g">│</span> OpenAI + Codex <span class="g">│</span> <span class="y">│</span> │ ↓ 可选 6 种后端 │ <span class="y">│</span>
|
|
<span class="g">│</span> Google AI Studio <span class="g">│</span> <span class="y">│</span> │ local · docker · ssh · │ <span class="y">│</span>
|
|
<span class="g">│</span> z.ai / GLM <span class="g">│</span> <span class="y">│</span> │ modal · daytona · singularity │ <span class="y">│</span>
|
|
<span class="g">│</span> Kimi / Moonshot <span class="g">│</span> <span class="y">│</span> └────────────────────────────────────┘ <span class="y">│</span>
|
|
<span class="g">│</span> MiniMax <span class="g">│</span> <span class="y">│</span> · 文件(file_tools.py) <span class="y">│</span>
|
|
<span class="g">│</span> HuggingFace <span class="g">│</span> <span class="y">│</span> · 网络(web_tools, browser_tool) <span class="y">│</span>
|
|
<span class="g">│</span> xAI / Grok <span class="g">│</span> <span class="y">│</span> · 代码执行(code_execution_tool) <span class="y">│</span>
|
|
<span class="g">│</span> custom(任意 OAI) <span class="g">│</span> <span class="y">│</span> · 子代理(delegate_tool) <span class="y">│</span>
|
|
<span class="g">└─────────────────────┘</span> <span class="y">│</span> · MCP 客户端(mcp_tool.py,2195 行) <span class="y">│</span>
|
|
<span class="y">└──────────────────────────────────────────┘</span></div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="layout">
|
|
<h2><span class="num">05</span>仓库结构地图</h2>
|
|
<p>
|
|
来源:<code>AGENTS.md</code> 第 12-64 行 + 实际 <code>ls</code> 验证。
|
|
</p>
|
|
<table>
|
|
<tr><th>路径</th><th>行数</th><th>责任</th></tr>
|
|
<tr><td>run_agent.py</td><td>10 578</td><td>AIAgent 类 + 主循环 + 重试 / fallback / 预算管理</td></tr>
|
|
<tr><td>cli.py</td><td>9 920</td><td>HermesCLI 类 + 交互 TUI(prompt_toolkit + Rich)</td></tr>
|
|
<tr><td>gateway/run.py</td><td>8 844</td><td>GatewayRunner + 消息分发 + agent 会话缓存</td></tr>
|
|
<tr><td>tools/mcp_tool.py</td><td>2 195</td><td>MCP 客户端(OAuth 2.1 PKCE + 动态工具发现)</td></tr>
|
|
<tr><td>tools/terminal_tool.py</td><td>1 777</td><td>终端执行编排(后台进程、审批、环境切换)</td></tr>
|
|
<tr><td>hermes_state.py</td><td>1 238</td><td>SessionDB(SQLite + FTS5 全文搜索)</td></tr>
|
|
<tr><td>tools/delegate_tool.py</td><td>1 103</td><td>子代理派发(并行工作流)</td></tr>
|
|
<tr><td>model_tools.py</td><td>577</td><td>工具编排层 + sync→async 桥接</td></tr>
|
|
<tr><td>toolsets.py</td><td>655</td><td>工具集定义 + 平台启用策略</td></tr>
|
|
<tr><td>tools/registry.py</td><td>335</td><td>单例工具注册表 + dispatch</td></tr>
|
|
<tr><td>agent/</td><td>—</td><td>prompt_builder, context_compressor, prompt_caching, memory_manager, trajectory, display</td></tr>
|
|
<tr><td>hermes_cli/</td><td>—</td><td>所有 <code>hermes *</code> 子命令 + 配置 + 皮肤 + setup 向导</td></tr>
|
|
<tr><td>tools/</td><td>—</td><td>40+ 工具文件(每个自注册到 registry)</td></tr>
|
|
<tr><td>tools/environments/</td><td>3 285</td><td>6 种终端后端(base + local/docker/ssh/modal/daytona/singularity)</td></tr>
|
|
<tr><td>gateway/platforms/</td><td>—</td><td>22 个消息平台适配器</td></tr>
|
|
<tr><td>skills/</td><td>—</td><td>26 个顶层分类 / 78 个 bundled skill</td></tr>
|
|
<tr><td>acp_adapter/</td><td>—</td><td>ACP 协议服务(VS Code / Zed / JetBrains 集成)</td></tr>
|
|
<tr><td>cron/</td><td>—</td><td>调度器(jobs.py + scheduler.py)</td></tr>
|
|
<tr><td>tinker-atropos/</td><td>—</td><td>RL 训练子模块(Tinker + Atropos)</td></tr>
|
|
<tr><td>tests/</td><td>—</td><td>~3000 个 pytest 测试</td></tr>
|
|
</table>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="aiagent">
|
|
<h2><span class="num">06</span>核心:AIAgent 类</h2>
|
|
<p>
|
|
<code>class AIAgent</code> 定义在 <span class="cite">run_agent.py:492</span>。构造函数接受 50+ 参数,
|
|
但核心字段几个:
|
|
</p>
|
|
<table>
|
|
<tr><th>字段</th><th>默认</th><th>说明</th></tr>
|
|
<tr><td>model</td><td>""(运行时注入)</td><td>模型名,如 <code>anthropic/claude-opus-4.6</code></td></tr>
|
|
<tr><td>max_iterations</td><td>90 <span class="cite">run_agent.py:527</span></td><td>工具调用循环上限</td></tr>
|
|
<tr><td>iteration_budget</td><td>IterationBudget(90) <span class="cite">run_agent.py:619</span></td><td>跨主 agent + 所有子代理共享的预算</td></tr>
|
|
<tr><td>platform</td><td>None</td><td>"cli" / "telegram" / "discord" / ...,用于注入平台格式提示</td></tr>
|
|
<tr><td>session_id</td><td>自动生成</td><td>SessionDB 外键</td></tr>
|
|
<tr><td>enabled_toolsets / disabled_toolsets</td><td>None</td><td>白/黑名单控制可用工具集</td></tr>
|
|
<tr><td>fallback_model</td><td>None</td><td>主 provider 失败后的备选</td></tr>
|
|
<tr><td>credential_pool</td><td>None</td><td>多 key 自动轮替</td></tr>
|
|
<tr><td>skip_context_files / skip_memory</td><td>False</td><td>批处理/RL 场景不注入用户人格</td></tr>
|
|
</table>
|
|
<p>
|
|
注意 <span class="cite">run_agent.py:504-505</span> 的 <code>_context_pressure_last_warned</code> —
|
|
这是一个<strong>类级别</strong>的 dict,用来跨实例去重"上下文压力警告"。原因写在注释里:
|
|
<em>gateway 会为每条消息创建一个新 AIAgent 实例,所以实例级的 flag 每次都重置</em>,需要类级共享状态。
|
|
这个细节暴露了 Gateway 的真实调用模式:<strong>每消息一个实例</strong>,但实例是从 cache 里拿的(见
|
|
<span class="cite">gateway/run.py:577-584</span> 的 <code>_agent_cache</code>)。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="loop">
|
|
<h2><span class="num">07</span>Agent 主循环</h2>
|
|
<p>
|
|
主循环在 <code>run_conversation()</code>,入口 <span class="cite">run_agent.py:7528</span>,
|
|
核心 while 在 <span class="cite">run_agent.py:7850</span>:
|
|
</p>
|
|
<pre><code>while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) or self._budget_grace_call:
|
|
self._checkpoint_mgr.new_turn()
|
|
if self._interrupt_requested:
|
|
interrupted = True
|
|
break
|
|
api_call_count += 1
|
|
...
|
|
# 调 LLM
|
|
response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
|
|
if response.tool_calls:
|
|
for tool_call in response.tool_calls:
|
|
result = handle_function_call(tool_call.name, tool_call.args, task_id)
|
|
messages.append(tool_result_message(result))
|
|
else:
|
|
return response.content</code></pre>
|
|
<p>
|
|
这个简化版是 <code>AGENTS.md</code> 第 112-122 行给的示意。真实代码在 7850-10213 之间展开了
|
|
~2000 行复杂度,处理:
|
|
</p>
|
|
<ul>
|
|
<li>中断(<span class="cite">run_agent.py:7855</span>)— 用户发新消息时打断当前循环</li>
|
|
<li>迭代预算(<span class="cite">run_agent.py:7871</span>)— <code>consume()</code> 消费 1 次,共享预算</li>
|
|
<li><code>_budget_grace_call</code>(<span class="cite">run_agent.py:7869</span>)— 预算耗尽给模型 1 次总结机会</li>
|
|
<li><code>step_callback</code> 发给 gateway 做 <code>agent:step</code> hook(<span class="cite">run_agent.py:7878-7902</span>)</li>
|
|
<li>Skill 使用计数器 <code>_iters_since_skill</code>(<span class="cite">run_agent.py:7906-7908</span>)—
|
|
每 N 轮未用 skill 会 nudge 模型考虑 <code>skill_manage</code></li>
|
|
<li>消息消毒:<code>_sanitize_messages_surrogates()</code>(<span class="cite">run_agent.py:356</span>)
|
|
和 <code>_sanitize_messages_non_ascii()</code>(<span class="cite">run_agent.py:413</span>)—
|
|
处理奇怪 unicode 导致的 provider 报错</li>
|
|
</ul>
|
|
|
|
<div class="callout warn">
|
|
<strong>设计原则</strong>:AGENTS.md 第 339-347 行明确写 <em>Prompt Caching Must Not Break</em>。
|
|
主循环的一条铁律是——<strong>绝对不能中途改变过去的 context、工具集或系统提示</strong>。
|
|
破坏 cache 意味着 10x 成本。唯一允许改 context 的时刻是<strong>自动上下文压缩</strong>
|
|
(<code>agent/context_compressor.py</code>)。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="registry">
|
|
<h2><span class="num">08</span>工具注册表(插件化核心)</h2>
|
|
<p>
|
|
Hermes 的工具系统是<strong>"tool 文件自注册"模型</strong>。每个 <code>tools/*.py</code>
|
|
在模块 import 时调用 <code>registry.register(...)</code> 把自己挂上去。看 <code>tools/registry.py</code>
|
|
的类定义:
|
|
</p>
|
|
<pre><code><span class="cite">tools/registry.py:48</span>
|
|
class ToolRegistry:
|
|
"""Singleton registry that collects tool schemas + handlers from tool files."""
|
|
|
|
def __init__(self):
|
|
self._tools: Dict[str, ToolEntry] = {}
|
|
self._toolset_checks: Dict[str, Callable] = {}
|
|
|
|
def register(
|
|
self,
|
|
name: str,
|
|
toolset: str,
|
|
schema: dict, # OpenAI function schema
|
|
handler: Callable, # 实际执行函数
|
|
check_fn: Callable = None, # 可用性检查(env 变量等)
|
|
requires_env: list = None,
|
|
is_async: bool = False,
|
|
description: str = "",
|
|
emoji: str = "",
|
|
max_result_size_chars: int | float | None = None,
|
|
): ...</code></pre>
|
|
<p>
|
|
关键设计:
|
|
</p>
|
|
<ul>
|
|
<li><strong>单例</strong>:<span class="cite">tools/registry.py:290</span> <code>registry = ToolRegistry()</code>,
|
|
模块级对象,任意文件 <code>from tools.registry import registry</code> 拿到同一个</li>
|
|
<li><strong>check_fn</strong>:<span class="cite">tools/registry.py:117-143</span> <code>get_definitions()</code> 先跑 check,
|
|
失败的 tool 不会出现在发给 LLM 的 schema 列表里。这是 Hermes 实现"API key 缺失时工具消失"的机制</li>
|
|
<li><strong>MCP 动态注入</strong>:<span class="cite">tools/registry.py:95-110</span> 有 <code>deregister()</code>,
|
|
专门给 MCP 服务发 <code>notifications/tools/list_changed</code> 时 nuke-and-repave 用</li>
|
|
<li><strong>dispatch() 同时支持同步 / 异步</strong>:<span class="cite">tools/registry.py:149-166</span>
|
|
— 如果 <code>entry.is_async</code>,从 <code>model_tools._run_async()</code> 桥接跑</li>
|
|
<li><strong>tool_error / tool_result helper</strong>:<span class="cite">tools/registry.py:309-335</span>
|
|
强制所有 tool handler 返回 JSON 字符串,消除了几百次 <code>json.dumps()</code> 样板</li>
|
|
</ul>
|
|
<div class="callout">
|
|
<strong>import 依赖链(防循环)</strong> <span class="cite">tools/registry.py:7-14</span>:
|
|
<code>tools/registry.py</code> 不依赖任何其他文件 →
|
|
<code>tools/*.py</code> 依赖 registry →
|
|
<code>model_tools.py</code> 依赖 registry + 所有 tool 文件 →
|
|
上层 <code>run_agent.py</code> / <code>cli.py</code> / <code>batch_runner.py</code> 依赖 model_tools。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="model_tools">
|
|
<h2><span class="num">09</span>工具编排层(sync/async 桥接)</h2>
|
|
<p>
|
|
<code>model_tools.py</code> 的顶部(<span class="cite">model_tools.py:1-22</span>)自述为
|
|
"thin orchestration layer over the tool registry"。它只做两件事:
|
|
</p>
|
|
<ol>
|
|
<li><strong>触发 tool 发现</strong>:import 所有 <code>tools/*.py</code>,让它们 self-register</li>
|
|
<li><strong>sync→async 桥接</strong>:<code>_run_async()</code> 位于 <span class="cite">model_tools.py:81-120+</span></li>
|
|
</ol>
|
|
<p>
|
|
<code>_run_async()</code> 背后有一个很讲究的设计 —— <strong>三种 loop 策略</strong>:
|
|
</p>
|
|
<table>
|
|
<tr><th>场景</th><th>策略</th><th>为什么</th></tr>
|
|
<tr><td>CLI 主线程(无 running loop)</td><td>持久共享 loop <code>_tool_loop</code> <span class="cite">model_tools.py:39-56</span></td><td>缓存的 httpx/AsyncOpenAI 客户端需要绑定到活 loop,<code>asyncio.run()</code> 每次 create-and-close 会触发 "Event loop is closed" GC 报错</td></tr>
|
|
<tr><td>Gateway 已经在 async 栈里</td><td>开一次性 thread pool,在新线程里 <code>asyncio.run()</code> <span class="cite">model_tools.py:108-113</span></td><td>不跟现有 running loop 冲突</td></tr>
|
|
<tr><td>并行 tool 执行的 worker 线程</td><td>每线程持久 loop(ThreadLocal)<span class="cite">model_tools.py:59-78</span></td><td>避免多 worker 抢同一个 loop,同时保持客户端缓存有效</td></tr>
|
|
</table>
|
|
<p>
|
|
这段代码读下来能看出 Hermes 是经过大量实战打磨的 —— <code>_get_worker_loop()</code> 的文档注释
|
|
直接说"这是为了避免某个 PR 引入的 'Event loop is closed' bug"。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="session">
|
|
<h2><span class="num">10</span>会话存储 + FTS5 搜索</h2>
|
|
<p>
|
|
<code>hermes_state.py</code> 的 <code>SessionDB</code> 类是整个 agent 的记忆骨架。路径:
|
|
</p>
|
|
<pre><code><span class="cite">hermes_state.py:32</span> DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
|
<span class="cite">hermes_state.py:34</span> SCHEMA_VERSION = 6
|
|
<span class="cite">hermes_state.py:115</span> class SessionDB</code></pre>
|
|
<h4>表结构</h4>
|
|
<p>
|
|
<code>sessions</code> 表 <span class="cite">hermes_state.py:41-69</span> 存每个会话的元数据:
|
|
</p>
|
|
<ul>
|
|
<li><code>id, source</code> — 会话 ID + 来源("cli" / "telegram" / "discord" / ...)</li>
|
|
<li><code>parent_session_id</code> — <strong>父会话链</strong>,上下文压缩时切片新会话,父子关系保持</li>
|
|
<li><code>input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens</code> — 5 种 token 统计</li>
|
|
<li><code>estimated_cost_usd, actual_cost_usd, cost_status, cost_source, pricing_version</code> — 成本追踪</li>
|
|
<li><code>billing_provider, billing_base_url, billing_mode</code> — 计费 provider 单独记录(因为 OAuth pool 会轮换)</li>
|
|
</ul>
|
|
<p>
|
|
<code>messages</code> 表 <span class="cite">hermes_state.py:71-85</span> 存每条消息:
|
|
<code>session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_details, codex_reasoning_items</code>。
|
|
</p>
|
|
<p>
|
|
注意 <code>reasoning_details</code> 和 <code>codex_reasoning_items</code> 是两种不同 provider 的 thinking 格式
|
|
—— Anthropic thinking blocks 和 OpenAI Codex reasoning item,各自持久化。
|
|
</p>
|
|
<h4>FTS5 全文搜索</h4>
|
|
<p>
|
|
<span class="cite">hermes_state.py:93-112</span> 定义了 FTS5 虚拟表 + 3 个触发器:
|
|
</p>
|
|
<pre><code>CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
content,
|
|
content=messages,
|
|
content_rowid=id
|
|
);
|
|
|
|
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
|
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
END;
|
|
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
|
|
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
END;
|
|
CREATE TRIGGER messages_fts_update AFTER UPDATE ON messages BEGIN
|
|
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
|
|
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
|
END;</code></pre>
|
|
<p>
|
|
<code>content=messages, content_rowid=id</code> 是 FTS5 的<strong>外部内容模式</strong>(external content table)—
|
|
索引数据<strong>不重复存储</strong>,直接引用 messages 表的 id。节省一半磁盘。
|
|
</p>
|
|
<p>
|
|
<code>_sanitize_fts5_query()</code> <span class="cite">hermes_state.py:938</span> 实现了 FTS5 查询消毒
|
|
—— 原因:FTS5 自己有查询语法,用户输入的 <code>-</code>、<code>"</code>、<code>(</code>、<code>)</code>
|
|
要么 escape 要么会抛错。这个函数的注释明确说明"wrap unquoted hyphenated and dotted terms in quotes so FTS5's
|
|
tokenizer doesn't split them"(因为 FTS5 会按点和连字符分词)。
|
|
</p>
|
|
<h4>高并发写</h4>
|
|
<p>
|
|
<span class="cite">hermes_state.py:123-136</span> 的类常量:
|
|
</p>
|
|
<ul>
|
|
<li><code>_WRITE_MAX_RETRIES = 15</code></li>
|
|
<li><code>_WRITE_RETRY_MIN_S = 0.020</code>(20 ms)</li>
|
|
<li><code>_WRITE_RETRY_MAX_S = 0.150</code>(150 ms)</li>
|
|
<li><code>_CHECKPOINT_EVERY_N_WRITES = 50</code>(每 50 次写触发一次 PASSIVE WAL checkpoint)</li>
|
|
</ul>
|
|
<p>
|
|
注释解释:SQLite 自带 busy handler 用确定性 sleep 导致"convoy effect"(高并发时互相卡成一队),
|
|
所以 Hermes 在应用层做<strong>抖动重试</strong>,让多个 writer 自然错开。这是细节工程能力的体现。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="gateway">
|
|
<h2><span class="num">11</span>消息网关</h2>
|
|
<p>
|
|
<code>class GatewayRunner</code> <span class="cite">gateway/run.py:512</span>。
|
|
启动入口 <code>async def start_gateway()</code> <span class="cite">gateway/run.py:8634</span>。
|
|
</p>
|
|
<h4>关键实例字段</h4>
|
|
<ul>
|
|
<li><code>adapters: Dict[Platform, BasePlatformAdapter]</code> <span class="cite">gateway/run.py:536</span>
|
|
— 每个启用的平台加载一个 adapter(telegram / discord / slack / ...)</li>
|
|
<li><code>session_store = SessionStore(...)</code> <span class="cite">gateway/run.py:553</span>
|
|
— 会话持久化,wrap SessionDB</li>
|
|
<li><code>_running_agents: Dict[str, AIAgent]</code> <span class="cite">gateway/run.py:573</span>
|
|
— 每 session 的活 agent,用于中断/查询</li>
|
|
<li><code>_pending_messages</code> <span class="cite">gateway/run.py:575</span>
|
|
— 中断期间用户发的新消息先排队</li>
|
|
<li><strong><code>_agent_cache</code></strong> <span class="cite">gateway/run.py:583</span>
|
|
— <strong>这是 prompt caching 能用的关键</strong>。每 session 缓存 <code>(AIAgent, config_signature)</code>,
|
|
同一个 session 复用同一个 instance,避免重建 system prompt 和内存,保证 Anthropic prefix cache 命中</li>
|
|
<li><code>_session_model_overrides</code> <span class="cite">gateway/run.py:588</span>
|
|
— <code>/model</code> 指令切换模型,每 session 一份</li>
|
|
<li><code>_pending_approvals</code> <span class="cite">gateway/run.py:591</span>
|
|
— 危险命令审批的待响应状态</li>
|
|
</ul>
|
|
<h4>临时配置注入</h4>
|
|
<p>
|
|
<span class="cite">gateway/run.py:540-549</span>:
|
|
</p>
|
|
<pre><code>self._prefill_messages = self._load_prefill_messages()
|
|
self._ephemeral_system_prompt = self._load_ephemeral_system_prompt()
|
|
self._reasoning_config = self._load_reasoning_config()
|
|
self._service_tier = self._load_service_tier()
|
|
self._show_reasoning = self._load_show_reasoning()
|
|
self._busy_input_mode = self._load_busy_input_mode()
|
|
self._restart_drain_timeout = self._load_restart_drain_timeout()
|
|
self._provider_routing = self._load_provider_routing()
|
|
self._fallback_model = self._load_fallback_model()
|
|
self._smart_model_routing = self._load_smart_model_routing()</code></pre>
|
|
<p>
|
|
这些都标注为<strong>"ephemeral, injected at API-call time only and never persisted"</strong> —
|
|
典型做法:保留功能,但不污染持久化会话,这样切换配置不会破坏历史 cache。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="platforms">
|
|
<h2><span class="num">12</span>22 个平台适配器</h2>
|
|
<p>
|
|
<code>gateway/platforms/</code> 目录实际文件清单(<code>ls</code> 核对):
|
|
</p>
|
|
<pre><code>api_server.py base.py bluebubbles.py dingtalk.py
|
|
discord.py email.py feishu.py helpers.py
|
|
homeassistant.py matrix.py mattermost.py signal.py
|
|
slack.py sms.py telegram.py telegram_network.py
|
|
webhook.py wecom.py wecom_callback.py wecom_crypto.py
|
|
weixin.py whatsapp.py</code></pre>
|
|
<p>
|
|
<code>base.py</code> 定义 <code>BasePlatformAdapter</code> 抽象基类,其他文件继承实现。
|
|
<code>ADDING_A_PLATFORM.md</code> 在同目录下给开发者文档。
|
|
</p>
|
|
<p>
|
|
特殊的几个:
|
|
</p>
|
|
<ul>
|
|
<li><code>api_server.py</code> — REST API 入口,暴露 OpenAI 兼容 <code>/v1/*</code>(就是你跑起来的那个)</li>
|
|
<li><code>webhook.py</code> — 通用 webhook(接 Home Assistant、自定义集成)</li>
|
|
<li><code>wecom.py + wecom_callback.py + wecom_crypto.py</code> — 企业微信,因为要做 XML 加密所以拆 3 个文件</li>
|
|
<li><code>telegram_network.py</code> — 专门处理 Telegram 网络层的 retry / dedup / fallback IP(中国访问常见问题)</li>
|
|
<li><code>bluebubbles.py</code> — iMessage 桥接(通过 BlueBubbles 项目)</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="backends">
|
|
<h2><span class="num">13</span>6 种 Terminal 后端</h2>
|
|
<p>
|
|
<code>tools/environments/</code> 目录(共 3285 行代码):
|
|
</p>
|
|
<table>
|
|
<tr><th>后端</th><th>文件 / 行数</th><th>用途</th></tr>
|
|
<tr><td>base</td><td>base.py / 579</td><td>抽象基类 + 共用工具方法</td></tr>
|
|
<tr><td>local</td><td>local.py / 314</td><td>直接在 Hermes 进程宿主机跑(默认,不安全)</td></tr>
|
|
<tr><td>docker</td><td>docker.py / 560</td><td>每个 task 起独立容器,用完销毁</td></tr>
|
|
<tr><td>ssh</td><td>ssh.py / 258</td><td>远程执行(把 Hermes 留本地,命令转发到 sandbox VM)</td></tr>
|
|
<tr><td>modal</td><td>modal.py / 434 + managed_modal.py / 282</td><td>Modal.com 无服务器沙箱</td></tr>
|
|
<tr><td>daytona</td><td>daytona.py / 229</td><td>Daytona workspace</td></tr>
|
|
<tr><td>singularity</td><td>singularity.py / 262</td><td>HPC / 学术 Singularity 容器(给 GPU 集群用)</td></tr>
|
|
<tr><td>file_sync</td><td>file_sync.py / 168</td><td>本地 ↔ 远程文件同步层(ssh/modal 共用)</td></tr>
|
|
</table>
|
|
<div class="callout">
|
|
<strong>为什么这是 Hermes 的亮点</strong>:传统 agent 把代码执行和 agent 本体绑死,要隔离就整个进程全隔离。
|
|
Hermes 把它<em>解耦</em>—— agent 跑在便宜的 VPS,代码执行可以转发到贵的 Modal GPU 或远程 SSH 沙箱,
|
|
按需付费。对比昨天讨论的 Manus 架构,Hermes 更灵活(你能选),Manus 更省心(强制 microVM)。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="skills">
|
|
<h2><span class="num">14</span>Skills 系统</h2>
|
|
<p>
|
|
Skills 是 Hermes 的"过程性记忆"—— markdown 文件描述某类任务的步骤。<code>skills/</code> 目录:
|
|
</p>
|
|
<table>
|
|
<tr><th>顶层分类</th><th>包含</th></tr>
|
|
<tr><td>apple</td><td>apple-reminders / imessage / findmy / apple-notes / ...</td></tr>
|
|
<tr><td>research</td><td>blogwatcher / polymarket / llm-wiki / arxiv / research-paper-writing</td></tr>
|
|
<tr><td>gaming</td><td>minecraft-modpack-server / pokemon-player / ...</td></tr>
|
|
<tr><td>software-development</td><td>(多个)</td></tr>
|
|
<tr><td>devops, mlops, data-science</td><td>(多个)</td></tr>
|
|
<tr><td>creative, diagramming, email, feeds, gifs, github, leisure,
|
|
media, mcp, note-taking, productivity, red-teaming, smart-home,
|
|
social-media, autonomous-ai-agents, domain, dogfood, index-cache,
|
|
inference-sh</td>
|
|
<td>—</td></tr>
|
|
</table>
|
|
<p>
|
|
26 个顶层 + 78 个 bundled skill 文件(入口日志 <em>"78 total bundled"</em> 匹配)。
|
|
每个 skill 是 <code>SKILL.md</code>,YAML frontmatter 带 metadata。例:
|
|
</p>
|
|
<pre><code>---
|
|
name: dogfood
|
|
description: Systematic exploratory QA testing of web applications ...
|
|
version: 1.0.0
|
|
metadata:
|
|
hermes:
|
|
tags: [qa, testing, browser, web, dogfood]
|
|
related_skills: []
|
|
---
|
|
|
|
# Dogfood: Systematic Web Application QA Testing
|
|
...</code></pre>
|
|
<p>
|
|
来源:<span class="cite">skills/dogfood/SKILL.md:1-11</span>
|
|
</p>
|
|
<p>
|
|
Skills 的<strong>注入策略</strong>:<code>agent/skill_commands.py</code> 扫描 <code>~/.hermes/skills/</code>,
|
|
用户打 <code>/skillname</code> 触发时,注入为<strong>用户消息</strong>(不是系统提示词),
|
|
这样不破坏 prompt caching —— 见 <code>AGENTS.md:135</code>(原文:
|
|
<em>"Skill slash commands: scans skills dir, injects as user message (not system prompt)
|
|
to preserve prompt caching"</em>)。
|
|
</p>
|
|
<p>
|
|
此外,skills 还支持"自我进化"—— 独立仓库
|
|
<code>hermes-agent-self-evolution</code> 用 DSPy + GEPA 对 skill 做基于轨迹的 prompt 优化。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="slash">
|
|
<h2><span class="num">15</span>Slash 命令中心化注册</h2>
|
|
<p>
|
|
所有 <code>/command</code> 定义在 <code>hermes_cli/commands.py</code> 的
|
|
<code>COMMAND_REGISTRY</code>(AGENTS.md:137-148)。
|
|
一次定义,多处消费:
|
|
</p>
|
|
<ul>
|
|
<li>CLI 分发:<code>cli.py</code> <code>HermesCLI.process_command()</code> <span class="cite">cli.py:5192</span></li>
|
|
<li>Gateway 分发:<code>gateway/run.py</code> + <code>GATEWAY_KNOWN_COMMANDS</code> 冻结集</li>
|
|
<li>Gateway 帮助:<code>gateway_help_lines()</code> 自动生成 <code>/help</code></li>
|
|
<li>Telegram 菜单:<code>telegram_bot_commands()</code> 生成 BotCommand 数组</li>
|
|
<li>Slack 子命令:<code>slack_subcommand_map()</code> 生成 <code>/hermes sub</code> 路由</li>
|
|
<li>自动补全:<code>COMMANDS</code> flat dict 喂给 <code>SlashCommandCompleter</code></li>
|
|
<li>CLI 帮助:<code>COMMANDS_BY_CATEGORY</code> 喂给 <code>show_help()</code></li>
|
|
</ul>
|
|
<p>
|
|
加一条 slash 命令只需要在 <code>CommandDef</code> 数组里加一行 +
|
|
各自 <code>process_command()</code> 里加 <code>elif canonical == "mycommand":</code> 分支。
|
|
加别名更简单 —— 只动 <code>aliases</code> 元组,<strong>所有下游自动同步</strong>
|
|
(dispatch、help、Telegram 菜单、Slack 映射、autocomplete)。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="profile">
|
|
<h2><span class="num">16</span>Profile 多实例支持</h2>
|
|
<p>
|
|
Hermes 支持<strong>多个完全隔离的实例</strong>,每个有独立 <code>HERMES_HOME</code>。核心机制:
|
|
</p>
|
|
<pre><code><span class="cite">hermes_cli/main.py</span> 中的 _apply_profile_override()
|
|
在任何模块 import 之前设置 HERMES_HOME 环境变量
|
|
→ 所有 119+ 个 get_hermes_home() 调用自动 scope 到活 profile</code></pre>
|
|
<p>
|
|
安全规则(AGENTS.md:376-420):
|
|
</p>
|
|
<ul>
|
|
<li>禁止硬编码 <code>Path.home() / ".hermes"</code>(会破坏 profiles)</li>
|
|
<li>用 <code>get_hermes_home()</code> 读路径,<code>display_hermes_home()</code> 展示</li>
|
|
<li>平台 adapter 连接时用 <code>acquire_scoped_lock()</code> 防两个 profile 共用同一个 bot token</li>
|
|
<li>Profile 列表本身<em>不</em>存在 HERMES_HOME,存在 <code>Path.home() / ".hermes" / "profiles"</code>,
|
|
这样 <code>hermes -p coder profile list</code> 能看到所有 profile(包括非活动的)</li>
|
|
</ul>
|
|
<div class="callout warn">
|
|
<strong>真实修过 5 个 bug</strong>:AGENTS.md 第 427 行提到"This was the source of 5 bugs fixed in PR #3575"
|
|
—— Nous Research 自己踩过坑。硬编码 <code>~/.hermes</code> 会破坏 profile 隔离,
|
|
测试里也要 mock <code>Path.home()</code> + 设 <code>HERMES_HOME</code> 才能跑 profile 测试
|
|
(<span class="cite">tests/conftest.py</span> 的 <code>_isolate_hermes_home</code> autouse fixture)。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="v08">
|
|
<h2><span class="num">17</span>v0.8.0 重要变化</h2>
|
|
<p>
|
|
来源:<code>RELEASE_v0.8.0.md</code>(2026-04-08 发布,209 PR / 82 issue 合并)。挑出最关键的:
|
|
</p>
|
|
<h4>A. 自我进化的证据</h4>
|
|
<p>
|
|
<strong>#6120</strong> — "Self-Optimized GPT/Codex Tool-Use Guidance"。原文:
|
|
<em>"The agent diagnosed and patched 5 failure modes in GPT and Codex tool calling through
|
|
automated behavioral benchmarking"</em>。这句话很重:<strong>agent 用自己的行为基准测试发现自己的问题,
|
|
然后自己写了修复</strong>。不是团队加提示词,是 agent 闭环在做迭代。
|
|
</p>
|
|
<h4>B. 后台任务自动通知</h4>
|
|
<p>
|
|
<strong>#5779</strong> — <code>notify_on_complete</code>。起一个长任务(训练、测试、部署),
|
|
agent 不需要 polling,完成时自动收通知。这是长时间任务场景的核心优化。
|
|
</p>
|
|
<h4>C. 闲置超时替代墙钟超时</h4>
|
|
<p>
|
|
<strong>#5389</strong> — 原文:<em>"Gateway and cron timeouts now track actual tool activity
|
|
instead of wall-clock time"</em>。这个改动让"跑 20 分钟的 build 不会被超时杀掉",
|
|
前提是它一直在有工具活动。
|
|
</p>
|
|
<h4>D. 实时模型切换</h4>
|
|
<p>
|
|
<strong>#5181 / #5742</strong> — <code>/model</code> 从 CLI、Telegram、Discord、Slack 都能切。
|
|
Telegram 和 Discord 还给了 inline 按钮 picker。
|
|
</p>
|
|
<h4>E. 安全加固打包</h4>
|
|
<p>
|
|
<strong>#5944 + #5613 + #5629</strong> — "Security Hardening Pass":
|
|
</p>
|
|
<ul>
|
|
<li>SSRF 保护统一化</li>
|
|
<li>时序攻击(timing attack)缓解</li>
|
|
<li>tar 遍历防护(解压恶意包)</li>
|
|
<li>凭证泄漏防护</li>
|
|
<li>cron 路径遍历加固</li>
|
|
<li>跨会话隔离</li>
|
|
<li>所有 terminal 后端的 workdir 消毒</li>
|
|
</ul>
|
|
<h4>F. MCP OAuth 2.1 PKCE</h4>
|
|
<p>
|
|
<strong>#5420</strong> — 完整的 OAuth 2.1 (带 PKCE) 接入任意 MCP server。
|
|
<strong>#5305</strong> — 自动 OSV 漏洞库扫描 MCP 扩展包(防供应链投毒)。
|
|
</p>
|
|
<h4>G. Google AI Studio 原生 provider</h4>
|
|
<p>
|
|
<strong>#5577</strong> — Gemini 直连,不再必须过 OpenRouter。还集成了 models.dev 注册表
|
|
自动检测任意 provider 的 context length。
|
|
</p>
|
|
<h4>H. 集中式日志 + 结构验证</h4>
|
|
<p>
|
|
<strong>#5430</strong> — <code>~/.hermes/logs/agent.log + errors.log</code>,
|
|
<code>hermes logs</code> 命令跟踪过滤。<strong>#5426</strong> — 启动时验证 <code>config.yaml</code> 结构。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="docker">
|
|
<h2><span class="num">18</span>Docker 运行时</h2>
|
|
<h4>Dockerfile</h4>
|
|
<p>
|
|
来源:<code>Dockerfile:1-46</code>(46 行,简短)。三阶段构建:
|
|
</p>
|
|
<ol>
|
|
<li><code>ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie</code> 作为 uv 源 <span class="cite">Dockerfile:1</span></li>
|
|
<li><code>tianon/gosu:1.19-trixie</code> 作为 gosu 源 <span class="cite">Dockerfile:2</span>
|
|
— gosu 是用来丢 root 权限的轻量替代 <code>su</code></li>
|
|
<li><code>debian:13.4</code> 作为最终 base <span class="cite">Dockerfile:3</span></li>
|
|
</ol>
|
|
<p>
|
|
关键环境变量 <span class="cite">Dockerfile:7-10</span>:
|
|
</p>
|
|
<ul>
|
|
<li><code>PYTHONUNBUFFERED=1</code> — 实时刷日志</li>
|
|
<li><code>PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright</code> —
|
|
<em>"Store Playwright browsers outside the volume mount so the build-time install survives
|
|
the /opt/data volume overlay at runtime"</em>。这是典型的 Docker volume 覆盖细节问题 —
|
|
如果装在 <code>/opt/data</code> 下,runtime mount 覆盖会抹掉安装</li>
|
|
</ul>
|
|
<p>
|
|
APT 包列表 <span class="cite">Dockerfile:14-15</span>:
|
|
<code>build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps</code>
|
|
—— 注意:<strong>这里<em>没有</em> git</strong>。这是我们昨天部署时踩到的坑,
|
|
npm install 需要 git 但 Dockerfile 没装,导致 build 失败,需要 sed 补丁。
|
|
</p>
|
|
<h4>entrypoint.sh</h4>
|
|
<p>
|
|
<span class="cite">docker/entrypoint.sh:1-64</span>:
|
|
</p>
|
|
<ul>
|
|
<li><strong>root → hermes 降权</strong>(<span class="cite">docker/entrypoint.sh:11-30</span>):
|
|
如果以 root 启动,先检查并重映射 hermes 用户的 UID/GID(通过 <code>HERMES_UID</code>/<code>HERMES_GID</code>
|
|
环境变量),fix 数据卷所有权,然后 <code>exec gosu hermes "$0" "$@"</code></li>
|
|
<li><strong>目录结构初始化</strong>(<span class="cite">docker/entrypoint.sh:42</span>):
|
|
<code>mkdir -p $HERMES_HOME/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home}</code>
|
|
—— 最后那个 <code>home</code> 很巧妙,注释解释:<em>"Without it those tools [git, ssh, gh, npm] write
|
|
to /root which is ephemeral and shared across profiles"</em></li>
|
|
<li><strong>首次启动 seed 默认文件</strong>:如果 <code>.env</code> / <code>config.yaml</code> / <code>SOUL.md</code>
|
|
不存在,从 <code>INSTALL_DIR</code> 的 example 文件复制</li>
|
|
<li><strong>同步 bundled skills</strong>(<span class="cite">docker/entrypoint.sh:60-62</span>):
|
|
<code>python3 $INSTALL_DIR/tools/skills_sync.py</code> 基于 manifest,保留用户编辑</li>
|
|
<li>最后 <code>exec hermes "$@"</code></li>
|
|
</ul>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="takeaways">
|
|
<h2><span class="num">19</span>设计洞察(主观)</h2>
|
|
<p>
|
|
读完之后几个主观结论 —— 不是源码里写的,是我看源码之后的归纳:
|
|
</p>
|
|
<h4>1. 模块化的纪律比任何单点技术重要</h4>
|
|
<p>
|
|
Hermes 能支持 22 个消息平台 + 6 种终端后端 + 11 个 LLM provider + 78 个 skill,不是因为每个都写得多好,
|
|
而是因为<strong>工具注册表 + 抽象基类 + slash 命令中心化</strong>让新增成本低到可接受。
|
|
看 <code>BasePlatformAdapter</code>、<code>BaseEnvironment</code>、<code>ToolRegistry</code>、
|
|
<code>COMMAND_REGISTRY</code> 这 4 个抽象,就明白为什么 v0.8 能在一个周期合并 209 个 PR。
|
|
</p>
|
|
<h4>2. 真正的性能优化都在"不重建"上</h4>
|
|
<p>
|
|
Gateway 的 <code>_agent_cache</code>、<code>_tool_loop</code> 的持久 loop、Anthropic 的 prompt cache 保护……
|
|
所有优化都是"能不重建就不重建"。这让 Hermes 对 prompt caching 友好的 LLM(Anthropic)比对不友好的
|
|
便宜 10 倍。这不是理论 —— <code>AGENTS.md:339-347</code> 把这个原则写成<em>铁律</em>。
|
|
</p>
|
|
<h4>3. 它把"agent 能跑哪"和"agent 用啥模型"解耦了</h4>
|
|
<p>
|
|
传统 agent 把两件事绑一起。Hermes 的终端后端和 LLM provider 是两个独立的抽象:
|
|
你可以把 agent 放在 $5 VPS 上,代码执行转发到 Modal 上的 GPU 实例,模型调 Anthropic Opus,
|
|
三者都可以独立换。这个解耦是真正的产品差异化。
|
|
</p>
|
|
<h4>4. 它承认自己是"一个会用工具的 LLM",而不是"有 LLM 的应用"</h4>
|
|
<p>
|
|
主循环本质就是 <code>while: LLM call → handle tool calls → repeat</code>。
|
|
Hermes 的所有复杂度都在<em>工具生态、平台适配、状态持久化、安全边界</em>上 ——
|
|
agent loop 本身 2000 行里大部分是错误处理、重试、审批、中断、预算、流式回调,而不是"AI 逻辑"。
|
|
这符合"agent 是一个模式,不是一种算法"的设计哲学。
|
|
</p>
|
|
<h4>5. 自进化不是魔法,是 DSPy + 基准测试闭环</h4>
|
|
<p>
|
|
v0.8 的 "Self-Optimized GPT/Codex Tool-Use Guidance"(#6120)揭示了自进化的实现路径:
|
|
跑 benchmark → 识别失败模式 → 用 DSPy/GEPA 优化 prompt → 回灌。
|
|
独立仓库 <code>hermes-agent-self-evolution</code> 印证这点。
|
|
换句话说,<strong>自进化是工程,不是玄学</strong>。
|
|
</p>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="no-ui">
|
|
<h2><span class="num">20</span>没有 Web UI 的真相 · v0.7 → v0.8 拆分</h2>
|
|
<p>
|
|
读到这里你可能发现一件事 —— 本文讲了很多"platform adapter",
|
|
但始终<strong>没讲一个网页聊天界面</strong>。这不是遗漏,是 v0.8 的事实:
|
|
<strong>Hermes 上游 v0.8 已经把 Web UI 整个删掉了</strong>。
|
|
</p>
|
|
<h3>证据对比</h3>
|
|
<table>
|
|
<tr><th>版本</th><th>web/ 子项目</th><th>docker-compose.deploy.yml 有 hermes-web 服务?</th><th>Docker 镜像</th></tr>
|
|
<tr><td>v0.7.x(2026-04-06 本地快照)</td><td>✅ 有</td><td>✅ 有(ports 4410:80)</td><td>hermes-agent-hermes-agent + hermes-agent-hermes-web(两个容器)</td></tr>
|
|
<tr><td><strong>v0.8.0(本文分析版本)</strong></td><td>❌ <strong>整个被删</strong></td><td>❌ 连文件都没了</td><td>只剩 hermes-agent(单容器)</td></tr>
|
|
</table>
|
|
<p>
|
|
v0.8.0 发布日:<strong>2026-04-08</strong>(见 <code>RELEASE_v0.8.0.md:3</code>)。
|
|
</p>
|
|
|
|
<h3>那浏览器里能访问什么?</h3>
|
|
<p>
|
|
<code>gateway/platforms/api_server.py:1736-1754</code> 列出 v0.8 API Server 的**全部** 17 个路由
|
|
(实际 grep <code>router\.add_</code> 得到):
|
|
</p>
|
|
<pre><code>GET /health
|
|
GET /v1/health
|
|
GET /v1/models <span class="g">← OpenAI 兼容 models 列表</span>
|
|
POST /v1/chat/completions <span class="g">← OpenAI 兼容聊天(支持 stream)</span>
|
|
POST /v1/responses <span class="g">← OpenAI Responses API 兼容</span>
|
|
GET /v1/responses/{id}
|
|
DEL /v1/responses/{id}
|
|
|
|
GET /api/jobs <span class="g">← cron 任务管理</span>
|
|
POST /api/jobs
|
|
GET /api/jobs/{id}
|
|
PATCH /api/jobs/{id}
|
|
DEL /api/jobs/{id}
|
|
POST /api/jobs/{id}/pause
|
|
POST /api/jobs/{id}/resume
|
|
POST /api/jobs/{id}/run
|
|
|
|
POST /v1/runs
|
|
GET /v1/runs/{run_id}/events <span class="g">← 结构化事件 SSE 流</span></code></pre>
|
|
<p>
|
|
<strong>0 个 HTML 路由,0 个 static 路由,0 个 template 渲染</strong>。
|
|
你在浏览器里访问根路径 <code>/</code> 会直接得到 404,因为 API Server 没有配 root handler。
|
|
</p>
|
|
|
|
<h3>仓库里还有静态网页,但 Hermes 不 serve 它们</h3>
|
|
<p>
|
|
v0.8 仓库里确实还有两个 HTML 相关目录,但都<strong>不会</strong>被 Hermes 运行时加载:
|
|
</p>
|
|
<table>
|
|
<tr><th>目录</th><th>内容</th><th>用途</th></tr>
|
|
<tr><td><code>landingpage/</code></td><td>665 行 HTML + 521 JS + 1178 CSS + banner/icons</td><td>Nous 官网营销页(hermes-agent.nousresearch.com)— 拉人下载用,不是聊天 UI</td></tr>
|
|
<tr><td><code>website/</code></td><td>Docusaurus 项目(docusaurus.config.ts + docs/)</td><td>官方文档站(hermes-agent.nousresearch.com/docs)— 静态生成,独立部署</td></tr>
|
|
</table>
|
|
<p>
|
|
也就是说:<strong>这两个目录是上游自己官网用的源码,不会被 Hermes 自己 serve,更不会跟着 docker compose 起来</strong>。
|
|
</p>
|
|
|
|
<h3>那人们怎么跟 Hermes 聊?</h3>
|
|
<p>
|
|
v0.8 把"对话界面"完全交给三类外部入口:
|
|
</p>
|
|
<ol>
|
|
<li><strong>CLI 终端</strong>(<code>hermes</code> 命令 + prompt_toolkit TUI)— 本地最直接</li>
|
|
<li><strong>22 个消息平台</strong>(Telegram / Discord / Slack / WhatsApp / Signal / Email / Matrix / DingTalk / Feishu / WeCom / WeChat / ...)— Gateway 网关转发</li>
|
|
<li><strong>REST API</strong>(<code>/v1/*</code> OpenAI 兼容)— 配合任意第三方前端
|
|
(Open WebUI / LobeChat / 自建前端)</li>
|
|
</ol>
|
|
|
|
<h3>为什么 v0.8 删了 Web UI(推测)</h3>
|
|
<ul>
|
|
<li>Web UI 生态已经很成熟(Open WebUI / LobeChat / AnythingLLM / ...),任意一个都比自建强</li>
|
|
<li>删了之后维护面积减少,可以专心做 agent core + platform adapter + skills 这三件 Hermes 独有的事</li>
|
|
<li>Hermes 既然已经是 OpenAI 兼容 API,第三方前端塞进去就能用,没必要自己造一个二流前端</li>
|
|
<li>符合 UNIX 哲学:"做一件事做好",UI 不是 agent 核心能力</li>
|
|
</ul>
|
|
|
|
<div class="callout">
|
|
<strong>所以</strong>:你直接浏览器打开 <code>hermes.milejoy.com</code>(我们部署的实例)默认是看不到聊天界面的 —
|
|
看到的是我们自己写的介绍页 + 隔离层 Basic Auth。如果要聊天 UI,方案是<strong>自建一层聊天前端调用 /v1/*</strong>
|
|
或者<strong>挂一个 Open WebUI 容器</strong>指向 Hermes API。
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================== -->
|
|
<section id="real-deploy">
|
|
<h2><span class="num">21</span>部署实战 · 公司 VPS LXC + Docker 双层隔离</h2>
|
|
<p>
|
|
本文不光是源码解析,也是一次真部署的实录。目标是把 Hermes v0.8 跑到一台已经运行其他服务
|
|
(LobeChat + PostgreSQL + RustFS 等)的公司 VPS 上,<strong>且不允许 Hermes 意外碰到邻居</strong>。
|
|
以下是实际方案和踩过的坑,**每一条都来自真实 debug 现场**。
|
|
</p>
|
|
|
|
<h3>最终架构</h3>
|
|
<div class="ascii-art"> <span class="y">Internet</span>
|
|
│
|
|
<span class="b">Nginx + Let's Encrypt (443)</span>
|
|
│ Basic Auth (boss/mile)
|
|
│ proxy_set_header Authorization "Bearer …"
|
|
▼
|
|
<span class="b">┌──────────────────────────────────────────────────────┐</span>
|
|
<span class="b">│</span> <span class="g">Incus LXC 容器 hermes-box (Debian 13)</span> <span class="b">│</span>
|
|
<span class="b">│</span> security.privileged = true <span class="b">│</span>
|
|
<span class="b">│</span> security.nesting = true <span class="b">│</span>
|
|
<span class="b">│</span> cpu=4 · mem=6GB · 快照 fresh <span class="b">│</span>
|
|
<span class="b">│</span> IP: 10.146.223.10 (incusbr0, NAT) <span class="b">│</span>
|
|
<span class="b">│</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">┌───────────────────────────────────────┐</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ Docker 容器 hermes-agent │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ image: hermes-agent:latest (2.5GB) │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ privileged + seccomp:unconfined │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ apparmor:unconfined │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ 0.0.0.0:8642 (API Server) │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ Hermes v0.8 + Poe (custom provider) │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">│ 78 bundled skills │</span> <span class="b">│</span>
|
|
<span class="b">│</span> <span class="y">└───────────────────────────────────────┘</span> <span class="b">│</span>
|
|
<span class="b">└──────────────────────────────────────────────────────┘</span>
|
|
|
|
<span class="g">LobeChat(同一宿主,不同 bridge 网络,完全隔离)</span></div>
|
|
|
|
<h3>为什么要 LXC(不只是 Docker)</h3>
|
|
<p>
|
|
直接 <code>docker run</code> 的问题是 — Docker 默认 bridge 和宿主网络之间有一定可见性,
|
|
错误命令可以<strong>访问到邻居容器</strong>(LobeChat 的 PostgreSQL 端口)。我们想要的是:
|
|
</p>
|
|
<ol>
|
|
<li><strong>独立 namespace</strong>:Hermes 看不到宿主进程、宿主文件系统、邻居容器</li>
|
|
<li><strong>独立网络</strong>:Hermes 的 docker0 和宿主的 docker0 是<em>两条不同的 bridge</em></li>
|
|
<li><strong>一键回滚</strong>:出事 <code>incus restore hermes-box fresh</code>,整个环境秒回基线</li>
|
|
<li><strong>一键销毁</strong>:<code>incus delete hermes-box --force</code> 不留任何污染</li>
|
|
<li><strong>运维栈一致</strong>:跟同一人之前的 HiClaw LXC 部署同栈,少一套技术要学</li>
|
|
</ol>
|
|
<p>
|
|
Incus 选 Debian 13 作为 rootfs,<code>security.nesting=true</code> 允许容器内部再跑 Docker,
|
|
形成<strong>LXC → Docker → Hermes runtime</strong> 三层。
|
|
</p>
|
|
|
|
<h3>9 条踩坑速查</h3>
|
|
<p>
|
|
这不是抱怨清单,是给下一个想做同样事情的人节省 4 小时。
|
|
</p>
|
|
|
|
<h4>坑 1 · 上游 Dockerfile 漏装 git</h4>
|
|
<p>
|
|
<code>Dockerfile:14-15</code> 的 apt 包列表:
|
|
</p>
|
|
<pre><code>build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps</code></pre>
|
|
<p>
|
|
<strong>没有 git</strong>。但 <code>npm install</code> 阶段需要 git 拉 git 类型依赖,
|
|
build 在第 17 步失败:<code>npm ERR! syscall spawn git / errno ENOENT</code>。
|
|
</p>
|
|
<p>
|
|
<strong>修法</strong>:sed 补一行
|
|
</p>
|
|
<pre><code>sed -i 's/procps/procps git/' Dockerfile</code></pre>
|
|
|
|
<h4>坑 2 · docker-compose env_file 不支持行内注释</h4>
|
|
<p>
|
|
<code>.env</code> 里写:
|
|
</p>
|
|
<pre><code>TELEGRAM_BOT_TOKEN= # 等你从 @BotFather 拿到后填</code></pre>
|
|
<p>
|
|
结果 docker compose 把<em>整个 <code># 等你从 @BotFather 拿到后填</code></em> 当作 TOKEN 的值,
|
|
Hermes 拿去尝试连 Telegram 时报 <code>telegram.error.InvalidToken</code>,容器 crash loop。
|
|
</p>
|
|
<p>
|
|
<strong>修法</strong>:所有行内注释独立成一行。env_file 解析只认行首 <code>#</code>。
|
|
</p>
|
|
|
|
<h4>坑 3 · API_SERVER 绑 0.0.0.0 必须 API_SERVER_KEY</h4>
|
|
<p>
|
|
日志:<code>Refusing to start: binding to 0.0.0.0 requires API_SERVER_KEY</code>。
|
|
这是 Hermes 的硬约束(<code>gateway/platforms/api_server.py</code>),
|
|
防止无 key 的 API server 对外暴露。
|
|
</p>
|
|
<pre><code>API_SERVER_KEY=$(openssl rand -hex 32)</code></pre>
|
|
|
|
<h4>坑 4 · docker compose restart 不重读 env_file</h4>
|
|
<p>
|
|
改了 <code>.env</code> 之后 <code>docker compose restart</code> 依然用老环境变量。必须 <code>down && up -d</code>
|
|
才会重读。这是 Docker Compose 的设计,不是 bug,但容易让人白花 10 分钟 debug。
|
|
</p>
|
|
|
|
<h4>坑 5 · Incus 容器 DHCP 拿不到 IPv4</h4>
|
|
<p>
|
|
<code>incus list</code> IPv4 列永远是空的,容器 eth0 只有 IPv6。
|
|
理论上 <code>incusbr0</code> 的 dnsmasq 应该分配 IPv4,实测 DHCPDISCOVER 发出但无 OFFER 回来。
|
|
可能跟宿主已有的 Docker FORWARD DROP + UFW 规则有关,<code>iptables -I FORWARD</code> 没修好。
|
|
</p>
|
|
<p>
|
|
<strong>修法</strong>:跳过 DHCP,直接写静态 IP 到容器内的 <code>/etc/systemd/network/eth0.network</code>:
|
|
</p>
|
|
<pre><code>[Match]
|
|
Name=eth0
|
|
|
|
[Network]
|
|
Address=10.146.223.10/24
|
|
Gateway=10.146.223.1
|
|
DNS=8.8.8.8</code></pre>
|
|
|
|
<h4>坑 6 · Docker-in-LXC BuildKit 的 spawn sh EACCES</h4>
|
|
<p>
|
|
在 LXC 容器里跑 <code>docker compose build</code>,npm 某些原生包(better-sqlite3)postinstall 报:
|
|
</p>
|
|
<pre><code>npm ERR! code EACCES
|
|
npm ERR! syscall spawn sh
|
|
npm ERR! path /opt/hermes/node_modules/better-sqlite3</code></pre>
|
|
<p>
|
|
<strong>尝试无效的修法</strong>:<code>security.privileged=true</code>、<code>security.nesting=true</code>、
|
|
<code>security.syscalls.intercept.*</code>、<code>raw.lxc: lxc.apparmor.profile=unconfined</code>、
|
|
<code>DOCKER_BUILDKIT=0</code>(legacy builder 又被 Dockerfile 的 <code>--chmod</code> 卡住)。
|
|
</p>
|
|
<p>
|
|
<strong>最终有效的修法</strong>:<strong>build 不在 LXC 里做</strong>。改为:
|
|
</p>
|
|
<ol>
|
|
<li>宿主机直接 <code>docker build -t hermes-agent:latest ./source</code>(已知能成功)</li>
|
|
<li><code>docker save hermes-agent:latest | gzip -1 > /tmp/hermes.tar.gz</code>(~2.5GB)</li>
|
|
<li><code>incus file push /tmp/hermes.tar.gz hermes-box/tmp/</code></li>
|
|
<li><code>incus exec hermes-box -- docker load -i /tmp/hermes.tar.gz</code></li>
|
|
<li>LXC 内的 compose 从 <code>build:</code> 改为 <code>image: hermes-agent:latest</code></li>
|
|
<li>build 完成后清宿主镜像 <code>docker rmi hermes-agent:latest</code>(保持宿主干净,只让 LXC 里有)</li>
|
|
</ol>
|
|
<p>
|
|
Build 在宿主跑,运行时在 LXC,<strong>隔离边界一点没破</strong>。宿主只多了短暂的 tar 文件。
|
|
</p>
|
|
|
|
<h4>坑 7 · Python socket.socketpair() 在 Hermes 启动时 EACCES</h4>
|
|
<p>
|
|
容器启动后立刻 crash,Python traceback:
|
|
</p>
|
|
<pre><code>File "/usr/lib/python3.13/asyncio/selector_events.py", line 120, in _make_self_pipe
|
|
self._ssock, self._csock = socket.socketpair()
|
|
PermissionError: [Errno 13] Permission denied</code></pre>
|
|
<p>
|
|
<strong>原因</strong>:Docker default seccomp 在 LXC 嵌套里拦了 <code>socketpair()</code>。
|
|
</p>
|
|
<p>
|
|
<strong>修法</strong>:<code>docker-compose.yml</code> 给 Hermes 容器加:
|
|
</p>
|
|
<pre><code> privileged: true
|
|
security_opt:
|
|
- seccomp:unconfined
|
|
- apparmor:unconfined</code></pre>
|
|
|
|
<h4>坑 8 · v0.8 官方 landing/website 路径变化</h4>
|
|
<p>
|
|
v0.7 的 <code>docker-compose.deploy.yml</code> 引用 <code>./web</code> 作为单独服务,
|
|
v0.8 这个目录不存在了(见第 20 章)。如果你还用老 compose 文件 build,第一步就会:
|
|
</p>
|
|
<pre><code>unable to prepare context: path "./source/web" not found</code></pre>
|
|
<p>
|
|
<strong>修法</strong>:compose 文件只留 <code>hermes-agent</code> 服务,暴露 8642 端口即可。
|
|
</p>
|
|
|
|
<h4>坑 9 · Hermes v0.8 默认拒绝浏览器 CORS</h4>
|
|
<p>
|
|
<strong>关键 debug 时刻</strong>。写完聊天 UI 部署上线后,用 curl 测 <code>/v1/chat/completions</code> 返回 200,
|
|
但浏览器打开 UI 点发送按钮返回 <strong>HTTP 403</strong>。
|
|
</p>
|
|
<p>
|
|
<strong>根因</strong>:<code>gateway/platforms/api_server.py:183-201</code> 的 CORS middleware 对带
|
|
<code>Origin</code> header 的请求默认拒绝(curl 不带 Origin 能过,浏览器必带 Origin 被拦)。
|
|
<code>_origin_allowed()</code> 在 <code>api_server.py:393-401</code>,检查 <code>self._cors_origins</code>:
|
|
</p>
|
|
<pre><code>def _origin_allowed(self, origin: str) -> bool:
|
|
if not origin:
|
|
return True # <span class="g">← curl 这类非浏览器,放行</span>
|
|
if not self._cors_origins:
|
|
return False # <span class="r">← 浏览器且未配允许列表,拦</span>
|
|
return "*" in self._cors_origins or origin in self._cors_origins</code></pre>
|
|
<p>
|
|
<strong>修法</strong>:<code>.env</code> 加一行
|
|
</p>
|
|
<pre><code>API_SERVER_CORS_ORIGINS=https://hermes.milejoy.com</code></pre>
|
|
<p>
|
|
多个 origin 用逗号分隔。代码读 env 在 <code>api_server.py:322-323</code>。改完
|
|
<code>docker compose down && docker compose up -d</code> 让 env 重读。
|
|
</p>
|
|
|
|
<h3>安全影响分析</h3>
|
|
<table>
|
|
<tr><th>维度</th><th>有效的隔离</th><th>失去的保护</th></tr>
|
|
<tr><td><strong>文件系统</strong></td><td>✅ Hermes 看不到宿主和邻居容器的 /opt</td><td>容器内 root 能 mount / chroot</td></tr>
|
|
<tr><td><strong>进程</strong></td><td>✅ PID namespace 完全独立</td><td>—</td></tr>
|
|
<tr><td><strong>网络</strong></td><td>✅ 独立 bridge 10.146.223.0/24,跟 LobeChat docker0 不互通;宿主 Nginx 反代是唯一入口</td><td>—</td></tr>
|
|
<tr><td><strong>seccomp / AppArmor</strong></td><td>—</td><td>❌ 都 unconfined(为了让 socket.socketpair 能用)</td></tr>
|
|
<tr><td><strong>kernel 漏洞</strong></td><td>—</td><td>❌ 共享宿主 kernel,kernel exploit 可穿透到宿主</td></tr>
|
|
<tr><td><strong>销毁</strong></td><td>✅ <code>incus delete --force</code> 秒清整个容器 + docker 层 + volume</td><td>—</td></tr>
|
|
</table>
|
|
<p>
|
|
对单用户内部场景<strong>够用</strong>。如果你要做 Manus 那种"每个用户独立 sandbox 防恶意 prompt"的场景,
|
|
这套方案的 kernel 共享就是痛点 — 那时应该上 <strong>Firecracker / Kata Containers / gVisor</strong>,
|
|
不是 LXC。
|
|
</p>
|
|
|
|
<h3>跟 Manus(Firecracker)对比</h3>
|
|
<table>
|
|
<tr><th>维度</th><th>Manus 公共服务</th><th>Hermes 本次部署</th></tr>
|
|
<tr><td>隔离技术</td><td>每 session 一个 Firecracker microVM</td><td>共用 LXC 容器(长命)</td></tr>
|
|
<tr><td>启动时间</td><td>~1-2s(microVM 冷启)</td><td>~0.5s(LXC 容器本就启着)</td></tr>
|
|
<tr><td>kernel 隔离</td><td>✅(独立 kernel)</td><td>❌(共享宿主 kernel)</td></tr>
|
|
<tr><td>用户模型</td><td>多租户陌生人</td><td>单用户 / 内部团队</td></tr>
|
|
<tr><td>成本模型</td><td>按 session 付费</td><td>固定资源预留</td></tr>
|
|
<tr><td>运维复杂度</td><td>需要 orchestrator + session 生命周期管理</td><td><code>incus</code> 命令 + 快照</td></tr>
|
|
</table>
|
|
<p>
|
|
结论:<strong>"威胁模型决定隔离技术"</strong>。不要看到 Manus 用 Firecracker 就觉得自己也得用 —
|
|
先问自己这三个问题:
|
|
</p>
|
|
<ol>
|
|
<li>会不会有陌生人用你的 agent?(不会 → LXC 就够)</li>
|
|
<li>agent 会跑用户提供的代码吗?(不会 → LXC 就够)</li>
|
|
<li>kernel exploit 是不是你的现实威胁?(不是 → LXC 就够)</li>
|
|
</ol>
|
|
|
|
<h3>最终交付</h3>
|
|
<p>
|
|
部署好之后的公司实例:
|
|
</p>
|
|
<ul>
|
|
<li>URL:<a href="https://hermes.milejoy.com" target="_blank" rel="noopener">hermes.milejoy.com</a></li>
|
|
<li>访问:HTTP Basic Auth(两个账号:<code>boss</code> / <code>mile</code>)</li>
|
|
<li>UI:自建<strong>玻璃拟态聊天前端</strong>(Cormorant Garamond 古金色 + 流式 SSE + Markdown + 工具进度 chip + localStorage 历史)</li>
|
|
<li>API:<code>/v1/chat/completions</code> OpenAI 兼容,Nginx 自动注入 Bearer,浏览器零密钥</li>
|
|
<li>后端:Poe GPT-5(主) + OpenRouter(备用)</li>
|
|
<li>Skills:78 bundled + Hermes 自己的 agent 记忆 / 学习循环</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<footer>
|
|
· Hermes Agent v0.8.0 源码解析 · 2026-04-13 ·<br>
|
|
源码取证:<code>~/Projects/research/20260413-hermes-source-analysis/source/</code>(git commit <code>d6785dc</code>)·<br>
|
|
上游仓库:<a href="https://github.com/NousResearch/hermes-agent" target="_blank" rel="noopener">github.com/NousResearch/hermes-agent</a> ·
|
|
官方文档:<a href="https://hermes-agent.nousresearch.com/docs/" target="_blank" rel="noopener">hermes-agent.nousresearch.com/docs</a> ·
|
|
运行实例:<a href="https://hermes.milejoy.com" target="_blank" rel="noopener">hermes.milejoy.com</a>
|
|
</footer>
|
|
|
|
</div>
|
|
</body>
|
|
</html>
|