Hermes Cloud:基于双层容器架构的AI代理托管平台

Hermes Cloud:基于双层容器架构的AI代理托管平台 从零到一实现 Hermes Cloud 托管平台的技术实践背景Hermes Agent 是 Nous Research 推出的一款功能强大的 AI 代理框架它支持终端操作、代码执行、浏览器自动化、工具调用等丰富能力。然而在日常使用中我们面临一个实际问题团队中有多位用户需要各自独立的 Hermes 实例且每个实例需要独立的配置、持久化存储和 Dashboard 访问凭据。手动为每位用户启动 Docker 容器、管理端口分配和环境配置既繁琐又容易出错。为了解决这个问题我们决定开发Hermes Cloud—— 一个轻量级的云托管平台实现 Hermes 实例的自动化创建、管理和生命周期控制。架构设计概览Hermes Cloud 采用经典的前后端分离架构整体技术栈如下┌─────────────────────────────────────────┐ │ Vue 3 SPA (Frontend) │ │ 用户管理 / 实例管理 / Dashboard 访问 │ └─────────────────┬───────────────────────┘ │ REST API (JSON) ┌─────────────────▼───────────────────────┐ │ Spring Boot 3.3 (Backend) │ │ 鉴权 / 用户管理 / 实例编排 / 持久化 │ └─────────────────┬───────────────────────┘ │ Docker Java API ┌─────────────────▼───────────────────────┐ │ Docker Daemon │ │ ┌──────────┐ ┌──────────────────┐ │ │ │ Sandbox │ │ Hermes Agent │ │ │ │ Container│◄───│ Container │ │ │ │ (Sandbox)│ │ (AI 代理引擎) │ │ │ └──────────┘ └──────────────────┘ │ └─────────────────────────────────────────┘后端技术选型Spring Boot 3.3 Java 17 —— 成熟稳定开发效率高docker-java 3.3.6—— Docker 远程 API 的 Java 客户端SQLite—— 嵌入式数据库零运维负担Session 鉴权—— 基于 HttpSession 的管理员登录前端技术选型Vue 3 Vue Router —— 响应式 UIVite—— 快速构建纯 CSS 组件 —— 无第三方 UI 库依赖核心设计双层容器架构这是 Hermes Cloud 最关键的设计决策。我们采用Sandbox Hermes Agent 双容器模式而非将所有功能塞进一个容器。要理解为什么这么设计需要先搞清楚两个容器各自扮演什么角色。Sandbox 容器 —— 执行环境Sandbox 容器的镜像用的是nikolaik/python-nodejs:python3.11-nodejs20。它的职责极其简单跑代码。# 容器配置一览镜像: nikolaik/python-nodejs:python3.11-nodejs20 功能: 提供 Python Node.js 运行环境 内存: 1GB 限制 能力: 所有 Linux Capability 被 Drop 安全: no-new-privileges, PIDs limit256持久化: 用户数据卷挂载到 /workspace 启动命令:sleepinfinity(常驻等待)看项目里创建 Sandbox 的代码ContainerHelper.java:57-82ListStringcmdsnewArrayList();cmds.add(bash);cmds.add(-lc);cmds.add(sleep infinity);Sandbox 启动后什么都不做就是sleep infinity—— 躺在那等活干。它就是一个干净的远程服务器里面装了 Python 3.11、Node.js 20、以及标准的 Linux 工具链。Sandbox 里有什么Python 3.11 pipNode.js 20 npm标准的 Linux 工具链bash, curl, git 等用户的数据文件通过/workspace卷挂载Sandbox 里没有什么Hermes Agent 的配置文件和密钥LLM API Key任何 AI 相关的逻辑Hermes Agent 容器 —— AI 引擎Hermes Agent 是 Nous Research 开发的 AI 代理框架镜像为nousresearch/hermes-agent:latest。它的职责是思考、决策、调度。# 容器配置一览镜像: nousresearch/hermes-agent:latest 功能: AI 代理引擎提供 LLM 交互、工具调用、Dashboard 端口:8642(API)9119(Dashboard)连接: terminal.backenddocker://sandbox-{user}它的运行模式是典型的 ReActReasoning Acting循环输入: 你的问题 ↓ LLM 推理 (调用 DeepSeek / Claude / OpenAI 等) ↓ 决定要做什么 (执行命令? 写代码? 搜索?) ↓ 调用对应的工具 ↓ 把工具执行结果送回 LLM ↓ 继续推理... 直到完成任务 ↓ 输出: 最终答案关键区别Hermes 本身不执行代码。它只做决策。真正 run 命令、跑脚本、装包这些脏活累活都丢给 Sandbox 去做。看创建 Hermes 容器时注入的环境变量就一目了然ContainerHelper.java:96-97StringbackendEnvterminal.backenddocker://sandboxContainerName;Hermes 启动后第一件事就是配置我的终端后端是docker://sandbox-xxx这意味着当 Hermes 决定执行pip install torch→ 它告诉 Sandbox 去装当 Hermes 决定运行python train.py→ 它告诉 Sandbox 去跑当 Hermes 决定git clone一个仓库 → 它告诉 Sandbox 去 cloneHermes 里有什么Hermes Agent 二进制 所有运行时依赖LLM 配置API Key、model 选择、provider 配置工具集代码执行、浏览器、文件操作、Web Search 等对话历史、记忆系统Dashboard Web 服务端口 9119从用户的config.yaml中可以清楚看到这种分工terminal:backend:docker://sandbox-kkz# ← 终端后端指向 Sandboxdocker_image:nikolaik/python-nodejs:# ← Sandbox 用的镜像python3.11-nodejs20model:default:deepseek-v4-flashprovider:deepseekbase_url:https://api.deepseek.com/v1# ← LLM 配置在 Hermes 侧一个完整的例子用户问“帮我写一个 Python 脚本下载这个网页的所有图片”Hermes 的思考过程: 1. LLM: 用户需要下载网页图片需要用 Python 实现 2. Hermes: 先装依赖 → 向 Sandbox 发送: pip install requests beautifulsoup4 → Sandbox 执行安装返回结果 3. Hermes: 写脚本 → 向 Sandbox 发送: cat /workspace/downloader.py EOF → Sandbox 写入文件 4. Hermes: 运行脚本 → 向 Sandbox 发送: python /workspace/downloader.py https://example.com → Sandbox 运行下载图片到 /workspace 5. Hermes: 任务完成汇总结果给用户Sandbox 只负责执行不关心为什么要执行Hermes 只负责决策不关心具体怎么执行。各司其职。运行时视角如果 attach 到两个容器的进程查看Sandbox 容器 (PID 1): └── sleep infinity ← 躺在那等待 Hermes 发指令 Hermes 容器 (PID 1): ├── hermes gateway run ← 主进程AI 推理循环 ├── hermes dashboard ← Web 控制台端口 9119 └── (通过 docker:// 连接到 Sandbox)Sandbox 就是一个干净的空房间Hermes 是住在隔壁的指挥官。指挥官通过墙壁上的管道docker://给空房间下达指令。为什么不合并成一个容器这是最常被问到的问题。表面上看合并确实更简单但深入推敲几个场景就能看出问题1. 升级冲突Hermes Agent 更新频率很高Nous Research 经常发新版而 Sandbox 环境python-nodejs几乎不变。如果合并每次升级 Hermes 都要重新构建整个镜像python-nodejs 层也得一起打包下载想给用户换个 Sandbox 环境python → golang必须重新打包整个 Hermes 镜像实际上根本做不到——Hermes Agent 是 Nous 发布的官方镜像你没法往里面塞自定义 runtime双层容器下升级就是一行命令的事docker pull nousresearch/hermes-agent:latestSandbox 不受任何影响。2. 安全边界合并后用户代码和 AI 引擎在同一个进程空间。用户上传一段恶意 Python 脚本可以读取 Hermes 的内存和配置可能窃取 LLM API Key逃逸风险面更大双层容器下Sandbox 被严格约束HostConfig.newHostConfig().withMemory(1024*1024*1024L)// 1GB 内存上限.withCapDrop(Capability.ALL)// 丢弃所有特权能力.withSecurityOpts(no-new-privileges)// 禁止提权.withPidsLimit(256L)// 限制进程数即使 Sandbox 被攻破也只是个一无所有的沙箱无法触及 Hermes 引擎和 API Key。3. 生命周期解耦实际运维中常见一个场景用户搞坏了 Sandbox 环境误删系统文件、搞乱了 Python 包需要重启 Sandbox。双层架构下你可以独立重启 SandboxHermes 的连接自动重连对话会话不受影响。合并的话只能一起重启AI 会话状态全部丢失。4.docker://协议是原生能力Hermes 原生支持docker://container-name作为 terminal backend。这是 Nous Research 的设计特性不是我们的 hack。合并容器等于浪费了这个能力。为什么不合并也不是过度工程双容器是否增加了复杂度从代码量看ContainerHelper里两个createContainerCmd调用而已// 创建 SandboxStringsandboxIdcontainerHelper.createSandboxContainer(sandboxName,...);// 创建 Hermes比 Sandbox 多了一个端口绑定逻辑StringhermesIdcontainerHelper.createHermesContainer(hermesName,...);端口映射、卷挂载、环境注入这些逻辑本来就是每个容器独立的合并成一个大容器也不会减少多少代码。用微小的代码成本换来了灵活性和安全性这笔账很划算。一句话总结Sandbox 你的手和脚执行Hermes 你的大脑决策大脑决定要做什么手脚去执行。大脑不需要亲自搬砖手脚也不需要知道为什么要搬砖。它们各司其职而且任何一个坏了都不影响另一个。端口分配策略每个用户实例占用 2 个端口起始端口从 9000 开始步长 10用户 0: 9000 (API) → 容器 8642 9001 (Dashboard) → 容器 9119 用户 1: 9010 (API) → 容器 8642 9011 (Dashboard) → 容器 9119 ...API 端口将 Host 的{basePort}映射到 Hermes 容器的8642内部 APIDashboard 端口将{basePort 1}映射到9119Web 控制台。端口分配采用最大端口 步长策略而非固定范围避免了端口复用冲突。实例完整生命周期创建实例 (POST /api/instances)创建实例是一个多步骤的编排过程每一步都有异常处理和清理回滚1. 校验用户是否存在且启用 2. Ping Docker Daemon 确认连接 3. 计算端口偏移 4. 创建用户数据目录 5. 拉取 Sandbox 镜像带指数退避重试 6. 创建 Sandbox 容器Capability 降级、内存限制、卷挂载 7. 拉取 Hermes Agent 镜像 8. 创建 Hermes 容器端口绑定、Dashboard 凭据注入 9. 启动 Sandbox → 启动 Hermes 10. 持久化实例元数据到 SQLite如果第 8 步失败第 6 步创建的 Sandbox 容器会被清理如果第 9 步启动失败已创建的容器和数据目录都会被回滚。这种全链路回滚机制保证了系统不会产生孤儿容器。实例管理操作操作API行为启动POST …/start逐一 start Hermes Sandbox 容器停止POST …/stop逐一 stop 两个容器 (8s timeout)删除DELETE …stop → remove 容器 → 清除数据目录 → 删除 DB 记录状态刷新GET …?refreshtrueinspect 容器真实状态同步到 DBDocker 网络拓扑Hermes 容器通过docker://sandbox-name协议连接到 Sandbox 容器。这个连接利用 Docker 的内部 DNS 解析两个容器在同一个 Docker 网络中通过容器名直接通信┌─────────────┐ docker://sandbox-xxx ┌─────────────┐ │ Hermes │───────────────────────────▶│ Sandbox │ │ (AI 引擎) │ │ (执行环境) │ └─────────────┘ └─────────────┘ │ │ :8642/api /workspace │ (数据卷) Dashboard :9119镜像拉取的可靠性设计初次部署或新节点启动时需要拉取 Docker 镜像。网络不稳定或镜像较大时拉取很容易超时。我们实现了带指数退避的重试机制// 核心参数pull-timeout-seconds:420// 每次拉取超时 7 分钟pull-retry-count:4// 最多重试 4 次pull-retry-base-delay-ms:800pull-backoff-multiplier:2// 指数退避0.8s → 1.6s → 3.2s → 6.4s拉取流程先检查本地是否已有镜像inspectImageCmd有则直接返回没有则启动拉取等待pullTimeout秒拉取成功后再次 inspect 确认镜像可用失败则按指数退避等待后重试直到耗尽重试次数这在实际部署中非常关键因为 Hermes Agent 镜像nousresearch/hermes-agent:latest体积较大首次拉取往往需要数分钟。用户与鉴权系统管理员认证采用简单的 Session 鉴权方案无需 JWT 或 OAuth配置文件定义管理员用户名/密码默认 admin/admin123登录成功后在 Session 中设置hermes_admin标记AdminAuthInterceptor拦截所有/api/*请求除/api/auth外前端路由守卫beforeEach检查/api/auth/me确保登录状态用户管理每个用户有独立的 Dashboard 凭据{userId:alice,displayName:Alice,email:aliceexample.com,dashboardUsername:alice_dash,dashboardPassword:[自动生成或手动设置],enabled:true}Dashboard 用户名密码在创建实例时注入到 Hermes Agent 容器的环境变量中容器启动后即可通过 Basic Auth 访问 Dashboard。持久化方案选择 SQLite 而非 MySQL/PostgreSQL基于以下考虑零运维—— 无需安装数据库服务嵌入在应用中单机部署—— 平台面向团队使用无高并发需求轻量—— 仅存储用户元数据和实例状态数据量极小文件级备份—— 直接复制hermes-cloud.db即可完成备份两张核心表-- 用户表CREATETABLEusers(user_idTEXTPRIMARYKEY,display_nameTEXT,emailTEXT,phoneTEXT,dashboard_usernameTEXTNOTNULL,dashboard_passwordTEXTNOTNULL,enabledINTEGERNOTNULLDEFAULT1,noteTEXT,created_atTEXT,updated_atTEXT);-- 实例表CREATETABLEinstances(user_idTEXTPRIMARYKEY,hermes_container_idTEXT,sandbox_container_idTEXT,data_volumeTEXT,api_portINTEGER,dashboard_portINTEGER,dashboard_usernameTEXT,dashboard_passwordTEXT,statusTEXT,terminal_backend_urlTEXT,order_indexINTEGER);Dashboard 访问与安全性每个实例的 Dashboard 通过独立的端口暴露。我们做了三层保护平台层—— 需要管理员登录 Hermes Cloud 才能看到实例列表容器层—— Dashboard 端口随机分配9000非固定端口应用层—— Hermes Dashboard 内置 Basic Auth需要用户名密码前端实现前端采用纯 Vue 3 SPA通过 Vite 构建为静态资源嵌入 Spring Boot。前端架构frontend/ ├── src/ │ ├── api.js # API 封装统一的 fetch 错误处理 │ ├── main.js # 入口路由守卫 │ ├── App.vue # 主布局侧边栏 顶栏 内容区 │ └── views/ │ ├── Login.vue # 管理员登录 │ ├── Users.vue # 用户 CRUD 创建实例 │ └── Instances.vue # 实例管理启动/停止/删除/Dashboard关键交互逻辑用户管理页Users.vue搜索过滤按用户 ID、显示名、邮箱、电话新建/编辑用户Modal 表单一键创建实例创建后跳转到实例管理页更新 Dashboard 凭据实例管理页Instances.vue实例列表显示容器 ID、端口、状态、Terminal 链接启动/停止/删除操作同步状态逐个 inspect 容器真实状态打开 Dashboard新标签页打开http://localhost:{dashboardPort}状态同步是一个值得注意的设计点。实例状态在 SQLite 中持久化但 Docker 容器可能在外部被操作比如手动 docker stop。我们提供了两种刷新方式普通刷新仅拉取 DB 中的元数据快速同步状态对每个实例执行 Docker inspect获取容器真实状态慢但准确Docker Compose 部署version:3.8services:hermes-cloud:image:maven:3.9.9-eclipse-temurin-17container_name:hermes-cloudports:-8080:8080volumes:-./:/workspace-/var/run/docker.sock:/var/run/docker.sockenvironment:-SPRING_PROFILES_ACTIVElinuxworking_dir:/workspacecommand:[mvn,-q,-DskipTests,spring-boot:run]关键点将 Host 的 Docker socket 挂载到容器内使得容器内的应用可以直接与 Host 的 Docker Daemon 通信实现容器内管理容器Docker in Docker 的变体。本地开发体验在 Windows 上开发时我们遇到了一些平台差异配置WindowsLinux/WSLDocker Hostnpipe:////./pipe/docker_engineunix:///var/run/docker.sock数据路径D:/my-workspace/..../hermes-dataProfileapplication-windows.ymlapplication-linux.yml通过 Spring Profile 机制隔离这些差异启动时指定对应 profile# Windowsmvn-DskipTests-Dspring-boot.run.profileswindows spring-boot:run# Linux / WSLmvn-DskipTests-Dspring-boot.run.profileslinux spring-boot:run对于不想装 JDK 的用户直接docker compose up --build一行命令即可启动全部服务因为 Compose 内置了SPRING_PROFILES_ACTIVElinux。总结与展望Hermes Cloud 在不到 2000 行代码内实现了一个功能完整的 AI Agent 托管平台。回顾整个实现几个关键经验值得分享双层容器架构是最正确的早期决策 —— 它将 AI 引擎与执行环境解耦让系统具备天然的安全边界和灵活的升级路径。全链路异常回滚虽然代码看起来啰嗦每个步骤都要 try-catch-cleanup但在容器编排场景下必不可少。孤儿容器会持续占用端口和磁盘手动清理非常痛苦。SQLite 是正确的选择—— 对于团队规模的部署引入一个独立的数据库服务是不必要的复杂度。SQLite 的零运维特性让整个平台的部署简化到只需要一条 docker run 命令。指数退避重试在镜像拉取场景下至关重要 —— 网络抖动是常态而非异常一个好的重试策略能显著提升首次部署的成功率。后续可以考虑的方向支持自定义 Sandbox 镜像用户上传自己的环境资源监控 DashboardCPU/内存/磁盘用量实例快照与恢复基于 WebSocket 的终端直连