我的一台自托管 AI Agent(Hermes Agent)原本跑在一台一体化 NAS 上——那是它的出生地:装机即用、图形界面、什么都能点。但我后来想明白一件事:NAS 的职责是存东西,不该兼职当一台 7×24 跑 Agent 的应用服务器。

于是就有了这次搬家:把它整机迁到一台 4 核 3.7G 内存的 x86 小主机上。这篇记录迁移的收尾、一次全量体检,以及把”从 NAS 带过来的参数”逐条拉出来和官方默认值对比的过程——这里面有两个坑,都挺有意思。

一、先划边界:什么搬,什么不搬

搬之前我定了一条很克制的原则:只搬 Agent 本体,不搬”管理 NAS”的能力。

原来的部署里有一套顺带实现的 NAS 管理功能(下载器控制、存储巡检之类)。这些功能跟 Agent 本身没关系,迁过去等于把两套职责又绑在一起。所以这次直接放弃:迁移的边界是”和 NAS 彻底分开”,不是”把 NAS 克隆一份”。

结论——搬迁清单只有三样:

  1. 数据与状态(配置、技能库、记忆、会话库、产出)
  2. 运行契约(它靠什么被拉起、拿到哪些环境变量)
  3. 外部通道(消息平台、代码托管、博客部署)

二、搬家不是拷文件,”运行契约”才是本体

第一次迁移失败的人常犯的错,是以为”数据拷过去 + 手动跑起来”就完事了。真正决定它能不能长期活着的是运行契约:谁拉起它、以什么身份跑、环境变量是什么、崩了怎么办、关机时怎么优雅退出。

我用的方法很土但很有效:拿官方的服务单元生成模板当准绳,和实机单元逐行对比。

结果发现真正的差异只有四处,其中一处还很有代表性:

变量 官方模板 我的取舍 原因
HOME 用户真实家目录 指向数据根目录 代码托管凭证、git 全局配置、ssh 配置全在数据根下,改回默认会”找不到配置”

这条值得展开:官方生成的服务单元会把 HOME 指到系统用户的家目录,这在”数据也放家目录”的常规部署下完全正确。但我的数据全在一个独立数据根里,而且所有命令行工具的配置都随数据一起搬过来了(git、gh、ssh、npm 各一份)。如果 HOME 指回空荡荡的系统家目录,gh 和 npm 会当场失忆。

所以这处偏离是有意为之,不是写错了——它也被记进了技能库,避免下次升级时被”纠正”回去。

三、收尾三件小事,每件都带一个坑

3.1 关掉一个对外的 API 端点

服务里有个 OpenAI 兼容的 API 端点。我按名字里带 “ENABLED” 的那个环境变量设成 false,重启……端点还在监听。

查源码才发现真正的开关根本不是它:

1
2
3
4
5
6
# 你以为的开关:API_SERVER_ENABLED=false
# 实际的判据:
key = getenv("API_SERVER_KEY")
if not has_usable_key(key):
return # 没有密钥 ⇒ 不启动
# 有密钥 ⇒ 无论如何都启动

也就是说:只要密钥存在,这个端点就活着;那个 ENABLED 只是个文档条目,读到它的人(包括我)都会被误导。正确的关法是——把密钥注释掉,重启。

顺带说一句,第一次处理时我虽然没关掉端点,但把它的监听地址从”所有网卡”改成了”仅本机”,于是局域网暴露在那一刻就已经消除,服务本身留在环回口上。第二遍才彻底关掉。先缩小爆炸半径,再追求彻底,这个顺序让我少了一次紧张。

3.2 SSH 改成仅密钥登录

顺序很重要:先用一次性密钥做完端到端验证,再关掉密码登录。

  • 把新公钥写进授权文件,权限对齐(目录 700、文件 600)
  • 生成一把临时钥匙,临时授权 → 用 BatchMode=yes 登录(禁止密码回退)→ 成功 → 立刻撤销临时钥匙
  • 反向再验一次:不带钥匙时应该被拒
  • 这之后才写入 PasswordAuthentication no,sshd -t 校验配置、reload(不断开现有连接)而不是重启
  • 关掉后再验一遍:密码方式应报 Permission denied (publickey),连”password”这个选项都不该出现在提示里

这台小主机没有本地控制台,真锁死就得插键盘显示器救——所以宁可多验两遍。

3.3 重启验证:别信”应该能自启”

服务都是 enabled 状态,但”enabled”只代表 systemd 会拉起它,不代表它能起来。真实重启一遍,用两个证据说话:

1
2
3
4
重启前 boot_id: c75893f7-…   uptime: 5 周 2 天
重启后 boot_id: 38c281e4-… uptime: 7 分钟 ← 全新引导
网关:11:04:38 自动拉起 面板:11:04:52 就绪
消息平台长连接:11:05:09 自动重连 失败单元:0

按下重启到全部恢复,零人工干预。

四、全面体检:看什么

体检不是”看一眼没报错”。我按七组采集:主机资源、磁盘与存储、服务与自启、应用层数据完整性、备份链路、网络与安全、错误日志分类。

结果:

维度 结果
资源 负载 0.22 / 内存可用 2.8G(总 3.7G)/ swap 零使用 / 温度 39–42℃
磁盘 系统盘 27%、数据盘 3%、inode 充裕、两块盘 SMART 全部 PASSED
存储健康 近 7 天 USB 掉线 0 次、I/O 错误 0、文件系统错误 0;重分配扇区 0、待映射 0、CRC 错误 0
服务 网关/面板 active,重启次数 0,失败单元 0,本次开机零错误日志
数据 会话库 PRAGMA quick_check 通过,62 会话 / 13520 条消息
安全 对外只开一个端口且已限定密钥;对外 API 端点已关闭

最有价值的一项发现是关于”单点”的:数据盘是一块 USB 外接 SSD——没有 RAID、没有第二副本。这类小主机上它几乎必然成为唯一单点。所以”能跑”之外还得有兜底:

  • 每天凌晨自动把核心状态打包快照到系统盘(7 份轮转)
  • 每 6 小时巡检一次:数据盘是否掉线、可写、容量、SMART
  • 掉盘直接推消息告警

顺带一提,这台小主机只有 USB 2.0 控制器,外接盘协商在 480Mbps——这是硬件上限,不是故障。接受它,比反复换线折腾省事。

五、配置审计:把”带过来的参数”逐条对账

这一步是我最想做的:整个部署是从 NAS 上”连参数一起”搬过来的,那些参数当初是为了适配 NAS 环境写的,搬到新环境后还合理吗?

方法很直接:

  1. 导入官方默认配置字典,和本地配置文件递归 flatten 后逐键比对
  2. 把官方服务单元生成模板和实机单元逐行对比
  3. 对每个环境变量数一遍”源码里到底有几个人在读它”

关键认知先说:本地文件里”缺少”几百个默认键,不是配置损坏——新版本的新键会自动跟默认值合并。真正需要看的只有两类:值被改过的、官方默认不注册而本地独有的。

比对结果:值不同的 18 处,本地独有的 19 项。把它们分类之后:

  • 8 项必须保留:环境适配(强制 IPv4、排空预算、模型定义、并发上限……)
  • 4 项建议改:3 项是”超时阈值偏紧”(单轮 10 分钟、澄清等待 10 分钟),1 项是”危险操作二次确认被关掉了”
  • 6 项是死配置:源码里一个读取点都没有

关于”死配置”——这可能是最反直觉的一类。部署迁移时我们习惯”把环境变量原样抄一遍”,觉得多抄几个没坏处。但用引用计数一查就发现:有的变量在当前版本里根本没有任何代码读它(旧契约遗留),有的甚至连对应的依赖包都没装。它们不制造故障,但会制造误解:你以为你调整了某个行为,其实什么都没发生。清理它们不是洁癖,是让配置重新变成”可信的说明书”。

六、一个漂亮的误报:排空预算告警

审计里最值得写的一个发现,是这条每次启动都会出现的告警:

1
2
3
Stale systemd unit detected: 服务单元 has TimeoutStopSec=90s
but drain_timeout=180s cron_drain_timeout=30s (expected >=210s)
systemd may SIGKILL the gateway mid-drain.

翻译一下:你的服务单元只给了 90 秒停止预算,但代码认为排空需要至少 210 秒,systemd 可能会在排空途中强杀进程。 提示还贴心地建议重新生成服务单元。

但这台机器上的单元文件明明写着 TimeoutStopSec=210。 我用同一台机器、同一个单元名跑了两条命令:

1
2
systemctl --user    show <单元> --property=TimeoutStopUSec   # → 1min 30s   (返回码 0)
systemctl show <单元> --property=TimeoutStopUSec # → 3min 30s ← 真实值

--user(用户级)和系统级看到的值不一样,而且差的正好是告警里那个 90 秒——systemd 的默认值。

原因在源码里:检查函数先查用户级、再查系统级。而这是一台”系统级服务”的机器:用户级管理器里并不存在这个单元,可 systemctl --user show 对不存在的单元不报错(返回码 0),而是打印一套默认属性。于是检查函数心满意足地拿到 90 秒,转头给了”你的单元是旧的”这个结论。

结论:我自己的配置是对的(180 + 30 = 210,与官方算法完全吻合),这是检查逻辑在”用户级/系统级”边界上的一个 bug。

写下这段不是要指责谁——而是想说:看到告警的第一反应不应该是”我哪里配错了”。 先确认它检查的是不是你以为的那个对象,比闷头改配置有用得多。

七、比”能跑”更重要:让它自己活下来

迁移完成后我又补了三件”事后看起来应该一开始就做”的事:

  1. 开机自检单元:每次启动后自动核对”服务/端口/长连接/数据盘/定时任务/失败单元数”,并把结果推到消息平台。关键是它不依赖主服务——主服务没起来,它照样能告诉你真相,还附上自愈命令。
  2. 一键恢复脚本:放在系统盘(不是数据盘),所以数据盘掉线时它依然可用;只做”检查 + 挂载 + 启动”,不删不覆盖,可以反复跑。
  3. 崩溃重试 + 取消限流:数据盘掉线时服务会一直重试而不是”试几次就放弃”,把盘插回去就能自愈。

踩过的坑也记一笔:自检脚本第一版的”就绪判据”写错了——它按时间窗口去查日志里的”长连接已建立”,而日志是跨重启保留的。于是某次重启后,脚本匹配到了上一次开机的连接记录,在服务真正就绪之前就宣布”检查完毕”,得出一条假告警(端口没监听)。

修法两条:判据必须绑定本次引导(只查当前引导的日志,journalctl -b),以及采集状态前要等目标真的就绪,别在判据成立的那一瞬间抢答。

八、五条复盘

  1. 迁移的主体是契约,不是文件。 拿官方的服务单元模板逐行对,比凭记忆手写可靠十倍。
  2. 参数搬家要重新对账。 旧环境调优过的值(超时、并发、开关)到新环境可能偏紧、偏松,甚至已经失效。
  3. 告警先分辨”谁的问题”。 同类告警里既有真故障,也有工具自身的误报;取证(对照两条命令的输出)比猜快得多。
  4. 测试顺序决定事故大小。 密钥登录”先验后用”、API 端点”先收窄再关闭”——每一步都留退路。
  5. 恢复能力和自动化一起设计。 会自启只是及格线,”起不来时能被救、能被通知、能被自愈”才是完整。

搬完这一趟,最大的感受是:自托管的成本从来不在第一次跑通,而在之后每一次”它坏了”的时候你能多快看懂它。 这次把体检、配置审计和自愈都补齐之后,我对这台小主机的信心,比它刚跑起来那天高得多。