起因
一个很小的需求:群里有人 @ 它,它就回一句话。
需求小,但它长成了别的东西。
它长什么样
小号 QQ
↓ OneBot v11 正向 WS (127.0.0.1:3001)
NapCat(便携版 QQ,快速登录,不用扫码)
↓
bot.mjs(零依赖 Node 24 脚本)
↓
GLM-4.7-Flash
最终它能做的事:
| 能力 | 说明 |
|---|---|
| 群里唤醒 | @ 它,或直接说「阿狐」「小狐」 |
| 私聊必回 | 私聊不设限,长文本不截断 |
| 分人人设 | 每个 user_id 有独立档案,行为和语气都对得上 |
| 滚动摘要记忆 | 超过 12 轮时,把最早的 6 轮压进摘要再丢掉原文 |
| 记忆落盘 | 每轮应答后立刻写到 X 盘,拔盘自动降级为纯内存 |
| 掉线私聊 | 掉线恢复只私聊主人,群里的其他人一个字不提 |
| 自己爬起来 | 看门狗每 30 秒探活,死了就拉起来 |
「分人人设」是花时间最多的部分。机器人对每个人应该像不同的人——对主人是一种语气,对朋友是另一种。这个后来演变成了每个用户一份独立档案(年龄、生日、家乡、性格、说话方式)。
第一天:能跑
第一天是顺利的。搭好 NapCat、接上 OneBot、脚本连上模型,群里 @ 它就能回话。
顺利的部分只持续了一天。
第二天:它不理人了
群里有人来问「阿狐今天怎么不说话了」。
去翻日志,发现两千条重连记录,全是:
连接失败,5 秒后重连
连接失败,5 秒后重连
连接失败,5 秒后重连
连了 15 分钟,全失败。但 QQ 那边一点提示都没有——客户端显示的好好的,消息也发得出去。
问题出在 ws://127.0.0.1:3001 这个端口根本没在监听。NapCat 进程活着,QQ 进程活着,但 OneBot 的 WS 服务死了。程序层面完全看不出来,只有端口是死的。
这就是「假活」——进程在,服务没了。
而当时的看门狗逻辑是「端口不通可能是正在启动,等一下」,于是永远不动手。
解决
加了一条升级策略:端口挂超过 3 分钟、且进程还在 → 判定假活 → 强杀掉 NapCat 和 QQ,重新快速登录拉起。
修完当天晚上就触发了:
19:41 机器人对 3001 干重连了 70 次(67 分钟)
看门狗终于动手了。
第三天:看门狗自己崩了
第二天刚修好的看门狗,第三天 20:04 首次触发升级策略——直接把整个看门狗进程带崩了。
崩的原因有两个,都是很典型的 Node 坑:
坑 1:目录排序取最后一个
选 NapCat 启动脚本的时候,代码是这么写的:
const versions = fs.readdirSync(versionsDir);
const latest = versions[versions.length - 1]; // ❌
versions 目录里混着一个 setting.json 文件。按字典序排,setting.json 排在 9.9.33-52230 后面——于是取到了 versions\setting.json\resources\... 这种鬼路径。
修法是"只认目录,而且必须真的包含 _silent_start.bat",返回前用 existsSync 校验。
坑 2:spawn 的失败是异步的
try {
spawn('cmd.exe', [...]);
} catch (e) {
log('启动失败');
} // ❌ 这个 catch 永远抓不到东西
spawn 的失败是异步 error 事件抛的,try/catch 接不住。必须挂 child.on('error', ...)。
修完之后,结果是:NapCat 被强杀了,看门狗也崩了,没人拉起。停机 140 分钟。
那天晚上 22:20
重开看门狗。这次它自己跑完了全流程:
22:20 重启看门狗 → 它发现 NapCat/QQ 全没了 → 按正确路径拉起 → 32 秒后端口恢复 → 机器人重连 → 私聊报「掉线 140 分钟」
无人值守自愈,验证通过。
还有一个更隐蔽的 bug
重连逻辑里有这么一段:
ws.onerror = () => { ws.close(); }; // ❌
这里的 ws 是模块级变量。如果旧连接的 error 事件在新连接已经建立之后才触发,这个 close() 关掉的就是那个新的、健康的连接。
结果就是:明明连上了,程序却以为断了,然后又去连——并行重连循环互相踩,日志刷得满天飞。
改成用局部变量捕获 socket,并且在 onclose 里加一句"我还是当前连接吗"的校验。
重连退避也从固定 5 秒改成了指数退避:
3s → 6s → 12s → 24s → 48s → 封顶 60s
26 分钟的断线原本能刷 305 行噪音,现在安静了。
交学费清单
这些东西,没有一个是"看文档能看出来的":
- 官方
launcher.bat不能用——它靠注册表找 QQ,会找到版本不匹配的C:\QQNT。必须用_silent_start.bat直连便携版路径 %cd%在解析期就展开了——cd还没执行,路径全错。bat 里要用%~dp0- bat 和 vbs 里不能写 UTF-8 中文注释——cmd 按 GBK 解析会直接炸,纯 ASCII
- 杀进程要按路径过滤——
taskkill /IM QQ.exe会把我自己的 QQ 一起杀掉 - PowerShell 过滤条件会匹配到查询命令自己——命令行里含
bot.mjs字样 → 自己杀自己,报4294967295 - 手写 JSON 带 UTF-8 BOM,NapCat 解析失败会静默回退默认——token 悄悄变了,谁都不知道
spawn的错误是异步的,try/catch抓不到- readdirSync 排序取最后一个——目录里有同名文件就取到鬼路径
数据
修完之后统计了一次日志(两天,1459 行,16 个进程实例混写):
| 指标 | 数值 |
|---|---|
| 应答成功率 | 53.8%(107/199) |
| 失败原因 | 1305 过载 55 次 / 1302 限流 19 次 / 超时 18 次 |
| 延迟 p50 | 18.8s |
| 延迟 p90 | 51.1s |
| 最长单次 | 312s |
53.8% 这个数字不是代码问题。 免费档模型的并发池子就那么挤,实测 4 次里中 3 次过载。
但它也逼出了一堆工程细节:
- 全局串行队列(免费档并发低,乱并发只会互相饿死)
- 1302/1305/超时的分级退避:3/6/10/15 秒
- 75 秒总时限,超了兜底回一句「大脑暂时过载」
安全
这个项目最要紧的一条纪律,写在备忘录里了:
绝对不能把 3001 / 6099 暴露到公网。
3001是 OneBot 控制口,没有 token,谁连上谁就能用这个号发消息6099是 NapCat 管理面板
而接入网站这件事,结论也是一样的:
要接就在本机另起一个带鉴权的 HTTP 接口,网站后端走内网或隧道调它,别让公网直连这两个端口。
顺便一提,NapCat 的 webui.json 里那两个 host / accessControlMode 字段其实是不生效的——改完重启,端口照样监听 ::,非白名单 IP 照样放行。真正的防线是 Windows 防火墙(默认入站全拦)。这一点花了不少时间才确认:不要去折腾那两个字段,也不要手滑给 QQ 点"允许访问"。
后记
现在它在后台跑着,每天开机自启,自己看自己,自己爬起来。
老实说,做这个项目的过程里,我学到的东西比"接个 API 让它回话"多得多:
- 进程活着不等于服务活着。 端口探活是唯一可信的信号
- 自愈逻辑要能自愈。 看门狗自己崩掉,等于没有看门狗
- 先确认要拉起的东西真的能起来,再去杀掉现在这个。 顺序反了,故障会被放大
第三条是那天晚上的教训——那次是先杀了 NapCat,拉起又因为路径 bug 失败,一次 3 分钟的卡死被放大成了 140 分钟全黑。杀的动作成功了、拉起的动作失败了,损失比原本大得多。
现在这个顺序改过来了:先解析出替代启动路径、确认文件真的存在,再动手杀。