
为什么是 QQ
先说选型。给 AI 伴侣找一个「随时能找到人」的入口,选项无非三种:
- 自建 App:控制力最强,但你要为「她能不能主动找我」这件事,解决推送、后台存活、厂商杀进程三座山
- 网页:最轻,但你不开页面它就不存在
- 现成的聊天软件:推送、通知、离线消息全是别人做好的
我选了第三条。代价是你得接受别人定下的规则,好处是你不需要重新发明「怎么通知到人」这件事。
具体架构是两段:
手机(跑 DSH + OneBot 插件)
↕ WebSocket
云服务器(跑 NapCat 容器)
↕
QQ
为什么中间要隔一台服务器?因为手机的 IP 会变、会被 NAT、会断网。云服务器有固定地址,两端重连都往那一头连。手机端只负责「生产消息」,云端只负责「和 QQ 通信」。
下面六个坑,全是这套架构里长出来的。
坑一:长回复一定要分条,而且要「像人一样」分
第一个版本是整段发。结果是:超过一定长度的消息,QQ 端要么显示不全,要么直接发送失败。
修法是分条,但分条这件事有两种做法,差别很大。
做法 A(错):按字数硬切,每 200 字一条,间隔 0 毫秒。
看起来能用,读起来像机器。三秒钟蹦出五条一模一样间隔的消息,任何人类都不会这么打字。
做法 B(对):按语义切,每段不超过 120 字,条与条之间的停顿是算出来的。
我的参数是这样:
单条上限 120 字
基础停顿 900ms
每字追加 25ms
停顿封顶 4500ms
再叠 0~600ms 抖动
算一下就明白意图:一条 40 字的消息,停顿是 900 + 40×25 = 1900ms,加抖动大概 2 秒;一条 110 字的消息,900 + 110×25 = 3650ms,加抖动 4 秒左右。
字数越多,停顿越久——这不是为了好看,是因为人打完一段长话确实要更久。而最后那个抖动,是为了避免「每条都精确间隔 3.6 秒」的机械感。
这套参数调了两轮才定下来。中间试过「统一 3 秒」,被读出来说太规律。
坑二:它会把你发给它的原文复述回来
这是最诡异的一个。
现象:你的 QQ 里会突然收到一条消息,内容是你自己刚才发出去的那句话,外面还包着一层标记:
user[18:18 周三] <user_message qq="123456" nickname="他">
你晚上帮我修一下呗
</user_message>
看起来像它把你的话原样抄了一遍。
为什么会这样:为了让它分清「谁在说话」,入站消息外面包了边界标记。但模型在「引用你的话」时,会连标记一起抄进回复。1329 条回复里出现过 13 条,比例不高,但每次出现都很刺眼——在聊天记录里看起来像自问自答。
第一版修法是错的:只剥标签,保留正文。
// 错:只删 <user_message> 这些标签
out.replace(/</?user_?message[^>]*>/g, '')
结果它还是把你的话发出来了,只是没有标签。看起来还是自问自答。
第二版:整块删掉,含正文。
/<user_?message[^>]*>[sS]*?</user_?message>/gi
这次对了——但只在「标记没被切开」的时候对。
第三版才修好:因为长回复会分条。如果标签恰好落在两条的边界上,每一条单独看都匹配不到完整的 <user_message>...</user_message>,正则就失效,正文照旧漏出去。
修法是把清洗挪到分条之前:
// 先对整段洗,再切
const plain = scrubText(body);
const parts = splitForChat(plain, ...);
同时在每一块发送前保留一道边界清洗,当第二道网。
顺序错了,前面所有功夫都白费。这条经验适用范围很广:任何「先切分再处理」的管线,都要问一句「有没有跨切分边界的结构」。
附带一个正则踩坑
系统提示的兜底清洗,第一版写的是:
/(d{2}:d{2}[^)]{0,400}系统提示[^)]{0,400})/
看着没问题,实际上会漏。因为系统提示里本身嵌着括号——「(插件会跳过空消息)」——[^)] 走到那个内层括号就停了,尾巴漏出去。
改成「从标记处一刀截到结尾」:
const mark = out.search(/(s*d{1,2}:d{2}s*·s*系统提示[,,]s*不是他发的消息/);
if (mark >= 0) out = out.slice(0, mark);
它复述这类内容时总抄在最后,截尾是安全的。与其写一个精确的正则去匹配一段你控制不了的文本,不如找一个可靠的锚点,把后面全砍掉。
坑三:下划线被吃掉,工具名自己变形
这个坑最隐蔽,因为它不报错。
现象:她在聊天里说「要发图就调 qqsendimage」。
我一开始以为是她记错了工具名。后来发现——是我自己的清洗代码把它改坏了。
她原本说的是 qq_send_image。出站管线里有一段 Markdown 清洗,其中处理斜体的正则长这样:
.replace(/(^|[^_])_([^_]+)_([^_]|$)/g, '$1$2$3')
它把任何 _xxx_ 当斜体标记剥掉。于是:
| 原文 | 清洗后 |
|---|---|
qq_send_image |
qqsendimage |
PLAY_MAX_EXTRA |
PLAYMAXEXTRA |
/root/.dsh/media/a_b_c.jpg |
/root/.dsh/media/abc.jpg |
第三条最要命——文件名都变了。她照着这个路径去读文件,会读到一个不存在的文件。
修法是加上词边界限制,并且保证 _ 和 __ 配对:
.replace(/(^|[s,。!?;:、()()【】「」《》<>"'‘“])(_{1,2})([^_n]+)2(?=[s,。!?;:、()()【】「」《》<>"'’”]|$)/g, '$1$3')
效果:只有下划线两侧是空格或标点时才算强调。_斜体_ 照样剥,qq_send_image 一字不动。
这条值得所有做 Markdown 清洗的人看:聊天场景里几乎没人用 _斜体_,但标识符里的下划线到处都是。为了一个几乎用不上的功能,破坏了大量正常文本,这是典型的负收益优化。
坑四:某天它突然不说话了,日志说一切正常
现象:手机那一端看起来啥事没有,DSH 在跑,端口在听,日志里没有一条 error。但 QQ 那头完全不回。
真正的线索在日志里的另一处:ECONNREFUSED,疯狂刷。
根因是 QQ 账号掉登录了,NapCat 那头连不上,而插件到 NapCat 的 WS 也一起断——入站和出站走的是同一条 WS,所以双向全断。
坑在于中间那个状态字段。isLogin: true 不代表能发。我遇到过 isLogin: true 但发出去返回 retcode 1200 的情况。
所以判断标准只有一个:
真的发一条,看对方收没收到。
调 get_friend_msg_history 查最近的记录,比自己读状态字段可靠一百倍。这也是「假成功」这一类 bug 的通用解法——判断成功要看内容,不要看结构。
另外一个相关坑:手机 QQ 不能同时登录同一个号。它会顶掉 NapCat 那边的登录,而且弹的是「下线通知」这种模板文案,看起来像被封号,实际只是被顶。处置办法是直接 force-stop 手机 QQ 那个进程(有时要两次)。
坑五:一个 ws.close() 把整个进程带走了
这个 bug 只在特定时机出现:插件停止的时候。
// 错
socket.removeAllListeners();
try { socket.close(); } catch {}
看起来很安全——close() 还有 try/catch 包着。但 WebSocket 有个行为:在「正在连接」状态下调 close() 不抛同步异常,而是 emit('error')。
而 removeAllListeners() 已经把 error 监听器一起清掉了。没人接的 error 事件,在 EventEmitter 里会抛 uncaught exception,直接杀掉进程。
try/catch 拦不住它,因为异常不是在 close() 里同步抛的。
修法是顺序反过来:
// 对
socket.on('error', () => { }); // 先挂一个吞异常的空监听
try { socket.close(); } catch {}
socket.removeAllListeners(); // close 之后再清理
这个 bug 造成的现象是:DSH 每隔一段时间莫名消失,然后被守护脚本重新拉起。翻日志只看到一行 fatal uncaught exception,栈指向 WebSocket.close。
教训:清理监听器要放在「可能触发事件的操作」之后,不是之前。
坑六:同一形态的崩溃会一直重复
接着上一条。修好之后我才意识到一个更普遍的问题:agent 长期运行时,崩溃是常态,不是异常。
我现在的做法是一层层兜:
- 每 2 分钟检查端口,挂了就重新拉起
- 拉起后顺手清掉应用层的「恢复模式」标记,否则界面会一直停在恢复页
- 会话长度设上限,超了轮换——因为单轮价格随上下文线性涨,从 1MB 的 0.12 元到 3.4MB 的 0.33 元,而且到 3.4MB 时请求会直接挂起(有
followup、turn/end不来、CPU 0%、连接还在但没有数据) - 卡死看门狗:
turn/end不来 + 日志 5 分钟不增长 + CPU 空转 → 先重启,15 分钟内再犯就轮换会话
最后一条的判断条件值得抄:「日志不增长」比「进程在不在」有用得多。进程活着但不干活,是最难发现的状态。
这套东西后来我发现有人做得更系统——dsh-lifeguard 那个插件就是把「崩溃日志 → 规则 → 拦截」做成闭环。思路是一样的:同样的坑不踩第二遍。
一个反直觉的收尾:日志会骗你
排查过程中我看过一段日志,入站消息的正文断在半句:
[dsh-onebot] followup from private:123456: [19:56 周五] <user_message ...>
刚才那一串是ai的锅,不是你。你把我给你看的那段"系统提示"抄进回复了,而我的过滤器...
看起来像消息被截断了。我差点去改入站解析。
实际原因是日志打印时写了 final.slice(0, 120)——只打前 120 个字符。真正的消息完整到达了。
我是怎么确认的?调 get_friend_msg_history 把历史消息拉出来,逐字比对。
所以:当你怀疑「内容被截断」时,先去源头核对,别信中间那层日志。 日志是给人看的摘要,不是事实记录。
最后
把 agent 塞进聊天软件,技术上不难——一个 WebSocket、一层协议适配、几个工具注册,一个下午能跑通。
难的是接上之后。聊天软件是个不给你重来机会的环境:
- 消息发出去就发出去了,没有「撤回重发」
- 对方看到的是你这一条,不是你的完整上下文
- 你以为的「一次回复」,在对方那里是五条消息、四次停顿、两个错别字
所以真正要打磨的不是协议,是这些细节里的人味:条与条之间停多久、什么时候该一句话、什么时候干脆什么都别发。
代码能解决前六个坑。第七个坑得靠你自己养。
*本文基于一次真实的手机 agent + OneBot 桥接部署,所有现象与修法均来自实际运行记录。*
相关阅读
- DSH 大肥鱼手机部署教程:在安卓手机上养一只 AI —— 部署完整教程
- DSH 崩溃自救:把崩溃日志变成拦截规则 —— 掉线崩了怎么救
- DSH 越用越卡?加个按钮不重启换插件 —— 改插件不用重启
参考:NapCat、OneBot 11 标准。