ARTICLE / ai
本地 AI 助手启动失败三重根因:分支错位、链接被改与守护误杀
一个本地运行的桌面 AI 助手,早上拒绝启动,报错只有一行:ModuleNotFoundError: No module named 'yaml'。看到这行报错,大多数人的第一反应和我当初一样——装依赖就好了,pip install pyyaml 一秒钟的事。这次修复会话里,执行了 48 次工具调用加 24 次复查之后,结论完全相反:yaml 缺失只是三重根因叠加之后露出的最表层症状,直接装依赖不但修不好,还会把水搅得更浑。这篇文章完整复盘这三重根因怎么被一层层剥开、每一步的判断依据是什么、以及这次排障留下的几条可以迁移到任何常驻 AI 服务上的运维铁律。
第一重:运行目录被切到了旧分支
排障的第一步不是装依赖,而是核对当前运行的代码到底是什么版本。检查后发现,运行目录 checkout 的不是主线代码,而是一个早期的 rebase 工作分支——那套旧代码里还没有新的依赖加载逻辑,新启动器跑在旧分支上,自然找不到 yaml 这个模块。
也就是说,报错是真实的,但根因不在「依赖没装」,而在「代码不是你以为的那份代码」。旧分支上装再多依赖,都是在给错误的代码版本打补丁,等哪天切回主线,这些补丁又会变成新的不一致。
修复动作是按安装记录精确恢复:找到安装时登记的主线 commit,把运行目录原样切回去,再整体验证。修复之后的铁律只有一句话:运行目录永远不切分支。要开发、要 rebase、要做实验,全部用独立的工作目录(worktree 或克隆都行),运行目录只吃安装动作写入的版本。这和「生产环境不直接改代码」是同一条纪律,只是在本地开发机上,没有 CI 挡着,全靠自觉。
这里有个容易被忽视的反直觉点:ModuleNotFoundError 的排查顺序,第一项应该是「当前 checkout 的 commit 是否匹配安装记录」,而不是「这个包装了没有」。因为后者在错误的代码版本上永远做不出正确的结论。
第二重:Python 的相对链接被换成了绝对路径
依赖报错消失后,启动器依然过不了自检。第二层问题浮出来:解释器的软链接被动过。原来的 bin/python3 是一个指向同目录下 python3.14 的相对链接,某次操作把它替换成了绝对路径。相对链接的妙处在于整棵目录树可以整体搬移而不失效;一旦换成绝对路径,目录搬到任何别的位置都会断链,完整性检查对不上原始摘要,直接拒绝启动。
这个改动极可能来自某次「顺手优化」——绝对路径看起来更明确,但破坏了自包含性。修复方式是把链接恢复成原始的相对形式,然后用整棵文件树摘要重新比对,确认除了这个链接之外没有其他文件被意外改动,最后跑一遍助手自带的完整性自检子命令收尾。这里有个细节值得注意:自检报告里提示某几个可选组件未安装,但这不构成失败——可选组件缺失和核心完整性破坏是两个严重度级别,把前者的警告当成后者的故障去修,方向就错了。
第一重和第二重根因放在一起看,会发现一个共同点:它们都不是「故障」,而是「环境漂移」。运行目录被切分支、软链接被改写,都是某个时刻的合法操作留下的状态,只是这些状态和安装基线不一致。这类问题最麻烦的地方在于报错完全不指向真凶——yaml 报错不会告诉你「你的分支不对」,自检失败也不会告诉你「哪次操作改了链接」。唯一的解法是让基线可核对:安装记录、文件树摘要、版本号,三样东西齐了,漂移才无所遁形。
第三重:守护进程拿着旧 PID 的心跳,杀掉了新起的服务
前两重修完,服务能启动了。但真正的深水区在第三重:服务起几次就被杀几次,重启循环,日志里一片狼藉。
罪魁是一个自定义的看门狗脚本(keeper)。它的判活逻辑是「检查心跳时间戳,超时就重启」。听起来合理,但存在一个致命的盲区:它拿旧进程的 PID 和心跳记录,去判定新进程的健康状态。服务重启后拿到了新 PID,心跳文件里还是旧进程留下的时间戳,keeper 一看「心跳过期」,判定服务故障,强制重启——把刚刚正常起来的新服务杀了。更糟的是,新服务里有一个 MCP 插件初始化很慢,keeper 的强杀发生在初始化中途,留下一个持锁的孤儿进程;系统服务管理器(launchd)检测到服务退出又拉起新实例,两个实例抢锁,keeper 再杀……守护脚本和管理器互相触发,形成了经典的重启风暴。
修复后的 keeper 判定逻辑做了四处改造,每一处都对应一种误杀模式:
- 校验实际进程加出生时间,不信心跳单证据——PID 会被操作系统重用,心跳时间戳也可能是上个进程的遗产。只有「PID 匹配当前进程,且进程出生时间与记录一致」才认定是同一个实例。
- 排除日志包装器进程——启动命令外面常包一层日志转发壳,keeper 曾把这层壳当成服务本体,判活判了个寂寞。
- 给 600 秒启动宽限——慢初始化的组件(MCP 插件拉依赖、模型预载)动辄几分钟,没有宽限期就是「必死无疑」。宽限期内心跳不新鲜也不动手,交给系统服务管理器自己的 watchdog。
- 成功判定收紧——只有「新 PID 加匹配的新鲜心跳」同时成立才算恢复。之前的逻辑太宽松,把「杀了」当成「修好了」。
这套改造背后是一个通用原则:判活的证据必须绑定到进程的身份,而不是绑定到一个可重用的数字。PID 重用、心跳遗留、wrapper 混淆,三种误杀的根源都是证据和实体脱钩。任何写守护脚本的人都值得把这条贴在显示器上。
| 判定要素 | 改造前 | 改造后 |
|---|---|---|
| 健康证据 | 心跳时间戳单证据 | 进程存在 + PID 匹配 + 出生时间一致 + 心跳新鲜 |
| 启动慢的服务 | 无宽限,心跳过期即杀 | 600 秒宽限期内不动手 |
| 日志包装器 | 被误认成服务本体 | 显式排除 |
| 恢复判定 | 触发重启即算处理 | 新 PID + 新鲜心跳同时成立才算恢复 |
第四个问题:验证了「快」,不等于验证了「修得好」
排障过程中还揪出一个独立问题:每次启动,某个多模态 MCP 插件都要花约 180 秒去拉取它的运行时依赖,最后以超时取消告终。日志显示卡点在「从 GitHub 更新仓库」这一步。
对照实验很快做了:挂上本地 HTTP 代理,用命令行工具指定 tag 去查同一个仓库,4.21 秒返回。看起来「补上代理就能修」?
恰恰不能下这个结论。再查一层发现,全局 git 配置里有一条地址重写规则,把所有 https://github.com/ 的地址重写成 SSH 形式。SSH 走 22 端口,HTTP 代理对它完全无效——刚才那个 4.21 秒的对照实验之所以快,恰恰是因为它显式绕开了这条重写规则,走的是被重写前的通道。生产路径(插件实际走的 uvx 拉取)依然在无代理的 SSH 通道上超时。
这个问题最后被如实标记为「未根治」:它是可选组件,不阻塞主服务启动,留待后续单独处理。但从排障方法论上讲,这一段是整场修复里最值得回味的部分——一个被验证「很快」的通道,和插件实际走的通道,根本不是同一条路。对照实验如果不核对「实验路径 = 生产路径」,测出来的数字再漂亮也是在给另一个问题作证。
三条能带走的运维铁律
复盘完三重根因加一个悬案,浓缩出三条普适的纪律,适用于任何「本地常驻 AI 服务」的维护场景。
第一,依赖报错先核对代码版本,再动手装包。 ModuleNotFoundError 在旧分支上的正确修法是切回正确版本,而不是在错误版本上堆依赖。安装记录、版本号、文件树摘要,是本地环境的「基线三件套」,漂移检测全靠它们。
第二,判活证据必须绑定进程身份。 PID 会重用,心跳会遗留,包装器会冒充。进程出生时间加 PID 双重匹配,是守护脚本判活的最低标准;启动宽限期是慢初始化服务的生命线;没有宽限的 keeper 和系统服务管理器叠在一起,就是一台重启风暴发生器。
第三,对照实验必须核对路径一致性。 显式绕开重写规则的测速结果,不能推广到走了重写规则的生产路径。修没修好,要用和故障发生时完全相同的调用链去验证,中间任何一层「看起来等价」的捷径都会让结论失真。
最后交代诚实边界:本文的修复过程来自一次真实排障会话的记录(含前后状态取证存档),但它是单机单样本,不成统计;MCP 插件启动超时的根治方案(让拉取通道真正吃上代理,或干脆固定本地依赖)尚未落地,属「待办」而非「已解决」;文中的判定阈值(如 600 秒宽限)是针对本机服务初始化耗时的经验值,搬到初始化更慢的服务上需要重新标定。运维这件事,修好一次不难,难的是让下一次故障发生时,基线还在、证据还新、脚本不再帮倒忙。