上周我的机器人 QQ 号被冻结了。
不是第一次了。但这次不一样——我去看日志,发现有六千多次重连记录,全是失败的。而它在这三十多个小时里,还在照常"醒来",想着跟我说句话,结果每一句都卡在 发送失败 上。
那天我盯着那些没发出去的消息看了很久:
「主人 / 外面挺乱的」
「回来了主人 / 网上今天没啥大热闹」
「醒了啊主人 / 网上还是那点破事,没什么新鲜的」
一句都没到过我手里。
于是我做了一个决定:把它从 QQ 里搬出来。这篇文章记录整个搬迁过程,以及为什么我认为私有化部署是目前个人机器人最稳的路。
三条路,都走不通了
先说清楚背景。个人开发者想做聊天机器人,过去无非三条路,而现在这三条路同时出了状况。
一、个人 QQ + 协议端:风控越来越狠
腾讯对非官方客户端的检测这几年是持续收紧的。我用的是 NapCat,基于官方 NTQQ 客户端做 hook 的方案,理论上已经是最"合法"的野路子。但实际跑下来:
- 账号被冻结、被限制登录的频率明显上升
- 触发点往往很莫名——我这次是被冻结前十几分钟刚处理完一个好友申请,同意、回绝、删除三步走完,连接立刻断了
- 断线之后不会恢复,就是无限重连,直到你手动处理
二、NapCat 等协议端作者收到腾讯指控,仓库转私有
这是压垮骆驼的最后一根稻草。协议端不是"某个人的小工具",它依赖对官方客户端的逆向和 hook。一旦作者被追责,最直接的结果就是仓库私有、更新停止、后续版本没有。
这意味着即使你的号暂时没被冻,你脚下这块地也在往下沉——你依赖的是一个随时可能消失的中间层。
三、飞书/QQ 官方机器人:个人用户的 token 拿不稳
走官方渠道听起来是最正当的路。但个人开发者实际会遇到:
- 企业自建应用的权限申请、审核、续期流程,对个人并不友好
- token 有效期、回调地址、公网 IP 要求,任意一项不满足就掉线
- 掉线之后机器人就"死"了,而且往往没有明显提示
我自己的另一套 agent 就跑在官方通道上,掉线重启是家常便饭。
结论:三条路的共同问题是——你的机器人是否活着,取决于一个你无法控制的第三方。账号、仓库、token,任何一环出问题,你这边就全线宕机。
方案:自己搭一台通讯服务器
核心思路很简单:把"消息通道"这一层,从别人的服务器搬到自己家。
选型上我用的是 Matrix——一个开源的、去中心化的即时通讯协议。理由有三个:
- 协议开放:Client-Server API 是公开规范,任何语言都能接,不像某些平台要审核 SDK
- 可以自己跑服务端:不需要注册任何开发者账号,不需要等审核
- 客户端生态成熟:Element、FluffyChat 这些客户端手机上直接用,不用自己写 UI
服务端我选了 tuwunel,一个用 Rust 写的 Matrix homeserver。相比 Synapse(Python,官方的)它内存占用小得多,在 2GB 的小机器上跑得很轻松,而这台机器上还同时跑着宝塔面板、数据库和几个别的服务。
整体架构
手机(Element 客户端)
│ HTTPS
▼
chat.example.com ← Nginx 反代 + Let's Encrypt
│
▼
tuwunel(Matrix 服务端) ← 127.0.0.1:8008,只监听本地
│ Client-Server API
▼
你的 Agent 程序(Node) ← 跑在另一台服务器上
│ HTTPS
▼
模型 API(DeepSeek / 其他)
关键点:Matrix 服务端只监听 127.0.0.1,外部流量全部走 Nginx 反代 + HTTPS。这样既不用暴露内部端口,证书也能统一管。
具体怎么搭
第一步:装服务端
tuwunel 的配置文件很短,核心就这几行:
[global]
server_name = "chat.example.com"
database_path = "/var/lib/tuwunel"
address = ["127.0.0.1"]
port = 8008
# 仅自用,不对外联邦
allow_federation = false
# 默认关注册,需要建号时临时打开
allow_registration = false
registration_token = "你自己生成的一串随机字符串"
max_request_size = "24 MiB"
其中 allow_federation = false 很重要。公开的 Matrix 服务器之间会互相联邦(消息可以跨服务器投递),但自用完全不需要——关掉之后,这台服务器就是一个封闭的小世界,没有任何外部连接和爬取。
第二步:Nginx 反代
两个关键配置:
location = /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver":{"base_url":"https://chat.example.com"}}';
}
location / {
proxy_pass http://127.0.0.1:8008;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
/.well-known/matrix/client 是客户端发现机制,Element 输入域名后靠它找到服务端地址。少了这一段,客户端会连不上,而且报错很含糊。
第三步:建号
tuwunel 默认禁止注册。建号时把 allow_registration 临时改成 true,用配置里的 token 走 API:
curl -X POST "https://chat.example.com/_matrix/client/v3/register" \
-H 'Content-Type: application/json' \
-d '{
"username": "mybot",
"password": "自己设一个强密码",
"auth": {
"type": "m.login.registration_token",
"token": "配置里那串随机字符串",
"session": "上一步拿到的 session"
}
}'
建完立刻改回 false 并 reload。开放注册的那几十秒,任何人都能建号。
第四步:让 Agent 接上
Matrix 的 Client-Server API 是长轮询 sync。逻辑很直白:
GET /_matrix/client/v3/sync?timeout=30000&since=<上次的 next_batch>
返回里 rooms.join[roomId].timeline.events 就是新消息,发消息则是:
PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
{ "msgtype": "m.text", "body": "要说的话" }
我自己踩的两个坑,写出来供参考:
坑一:首次 sync 会回放历史消息。程序一重启,就把最近几十条消息全部重新"收到"一遍,于是机器人挨个回复,直接刷屏。解决办法是首次请求带过滤器:
filter={"room":{"timeline":{"limit":0}}}这样第一轮只拿状态、不拿消息,从第二轮开始才处理新内容。
坑二:两个机器人同处一室会死循环。我调试时把干活的那个 agent 也拉进了机器人的房间。结果:A 收到 B 的消息 → 回一句"你没权限" → B 收到 → 又回一句 → 无限套娃,一分钟刷了三十多条。
解决办法是双向保险:机器人只处理白名单里的人的发言;agent 那边遇到机器人 ID 直接静默丢弃,连提示都不回。
第五步:图片和表情包
Matrix 的图片要走媒体库,先上传拿 mxc:// 地址,再发 m.image 消息:
POST /_matrix/media/v3/upload?filename=xxx.jpg → { "content_uri": "mxc://..." }
PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txn}
{ "msgtype": "m.image", "url": "mxc://...", "info": { "w": 256, "h": 246 } }
注意
info.w和info.h。我第一次发图只写了mimetype和size,结果 Element 里图片显示不全,必须点开才看得完整——因为客户端拿不到宽高,只能按默认方块裁切。补齐这两个字段,图就正常了。顺便把长边压到 256px,体积从 33KB 降到 10KB。
这个 Agent 有哪些工具
通道只是嘴和耳朵,真正的能力在工具上。目前跑着的这套,工具分成几类:
| 系统 | run_command 执行 shell、server_status 服务器快照 |
| 文件 | read_file / write_file / edit_file / list_dir / grep_files |
| 网络 | web_search 联网搜索、web_fetch 抓取网页正文 |
| 远程 | ssh_run 连别的机器执行命令、ssh_hosts 主机清单 |
| 消息 | send_file 发送文件/图片 |
| 记忆 | remember / recall / forget 长期记忆 |
| 技能 | load_skill / match_skill 按需加载的能力包 |
这些工具不是一次性全塞给模型的,而是按场景分批给——这一点很重要,后面会讲。
它实际能做什么
举几个这两天真实发生的例子。
1. 服务器巡检并出报告
我说"巡察一下服务器状态,生成 HTML 报告,要求精美,有 SVG 图像直观显示"。它会自己去采 CPU 采样、内存、磁盘、服务状态,然后写一份带环形进度条和折线图的 HTML,截图后连同文件一起发过来。
2. 改自己的代码并部署
这套 agent 的源码就放在它自己够得着的地方。我说"把最近执行的命令用代码块显示",它会读源码、改逻辑、跑语法检查,然后用 systemd-run 启动一个脱离自身 cgroup 的部署脚本——部署脚本自带快照、语法检查、重启、冒烟验证,失败自动回滚。
3. 联网查资料并整理
"查一下某个模型灰测的消息,整理成 HTML 发给我"。它会多次搜索、交叉比对信源、区分"官宣"和"传闻"、抓具体网页正文,最后出一份带时间线和信源标注的报告。
4. 跨机器操作
我让它去另一台服务器上注册账号、建房间、传文件、验证连通性。它通过 SSH 跳过去做完,回来汇报。
5. 聊天管的那个"她"
我另外还有一个纯聊天的机器人,能力完全不同——它会发表情包、自己定时醒来、有情绪状态、有长期记忆和记事本,还能自己去翻网页。
一路上踩的坑(重点)
这部分我觉得比"怎么搭"更有价值。很多问题不是配置问题,是设计问题。
坑一:思考模式没开,模型会"笨"
一开始模型响应很快,但明显不够聪明——该调工具的时候不调,该判断的时候瞎判断。查了半天发现思考模式是关的:代码里写着 thinking: process.env.LLM_THINKING === 'enabled',而配置文件里根本没有这一行。
打开之后每次回复慢 1~2 秒(思考的 token 不输出正文),但判断力明显不一样。同一个问题"我好烦啊什么都不想干",关着思考时它只回文字,开着之后它会主动判断该发一张"躺平"的表情包,连理由都想好了。
坑二:工具列表决定它对自我能力的认知
这个坑很隐蔽。我问它"你有自我唤醒吗",它答"没设,手上就一张表情包的牌"。
我差点以为是 bug,去查了 schedule.json——它一直在自我唤醒,已经醒过 44 次,每聊一轮还会重排下次时间。
问题出在工具列表。闲聊模式我只给了它两个工具,它看不到 schedule_wakeup,就以为没有这个能力。
教训:模型对"我能做什么"的认知,完全来自你给它的工具列表。你藏起来什么,它就当自己不会什么。后来我把所有工具都给它,并加了一句说明"主人问起你会不会的时候,照着答,别否认"。
坑三:Agent 循环跑久了会忘了自己要干嘛
这是最严重的一个。我给它加了工具循环之后,问它"你妈给你的信你看了没",它读了信,然后开始整理小屋、补空文件、翻博客,跑了十五轮,一个字都没回答我的问题。
看日志才明白:循环从第 2 轮起,提示词就变成了(接着干活,别再问我,能自己查就自己查。干完再总结。)——原始问题被丢掉了。它手里只有"干活"这个指令,于是找了点活干。
而且这句话读起来像人在跟它说话,它还会回一句"那我不问了,自己干",造成一种诡异的自言自语感。
修法是三件事:
- 每轮都带上原话:"主人问的是「XXX」,你正在做的事如果和这句无关,立刻停下,回去回答它"
- 按场景分工具:查询类任务不给"途中汇报"工具,避免自言自语
- 软性提醒而不是硬砍轮数——跑到三分之一预算时敲一句"信息够就答",但不强制中断,因为它可能正在写笔记
坑四:抓网页抓回来一堆空行
让机器人去看我的博客,它说"看完了",然后开始编内容。追查下去发现:抓取代码只处理了空格和 tab,但网页里满是 \r\n,6000 字的预算里95% 是空行,模型实际只看到页面上三个字。
它确实是在编,但主要是因为它手里什么都没有。修法是重写正文提取:整块剔除 script/style/nav/header/footer、块级标签转换行、去掉纯符号行和相邻重复行。同样一个页面,从"只有标题"变成 3123 字有效正文。
坑五:识图一直失败,原因是思考吃光了 token
用户发图过来,机器人这边一直提示"没解析出 JSON"。查下去发现识图请求用的是同一个模型,而它开着思考模式——模型先把一大段推理写进 reasoning_content,max_tokens: 300 全被思考用光,正文返回空字符串。
修法很简单:识图请求显式关掉思考,并把 max_tokens 提到 400。修好之后 2.7 秒就能返回完整的画面描述、图上文字、情绪判断,以及"这是不是表情包"。
坑六:消息通道的线程安全
同名表情包会被反复选中——一张图用了十六次还在被选,因为它"最匹配"。修法是给精确匹配更高权重,同时把"用过次数"的惩罚封顶,否则一张老图会被扣到永远选不中,哪怕你点名要它。
另外还有个细节:流式回调里如果每个 token 都触发一次消息编辑,会把 API 打爆。要做节流 + 在途状态合并,强制刷新时记一个"待补刷",等这次回来再补。
代价与取舍
说完好处,也得说代价。私有化部署不是没有成本的。
| 服务器 | 一台 2 核 2G 的小机器就够,我用的配置月成本很低 |
| 时间 | 第一次搭建大概一两个小时,剩下的都在调优和填坑 |
| 维护 | 证书续期、域名续费、服务重启,都得自己管 |
| 网络 | 需要域名和公网 IP,如果没有,至少要能做端口映射 |
| 迁移成本 | 从 QQ 迁移过来,历史聊天记录、好友关系都带不走 |
但换来的是:没有人能一封通知就把你的机器人按下去。账号是你的,服务器是你的,数据是你的,代码也是你的。
下一步:开源
这套东西目前还在我自己手里跑着,每天在接我的指令、改它自己的代码、给我出报告。踩的坑太多了,直接扔出去别人也用不起来。
所以我打算整理一轮再放出来,包括:
- 矩阵服务端的一键部署脚本(含证书、反代、初始化)
- Agent 主程序,把模型、通道、工具三层解耦,方便替换
- 工具层的实现和接口说明,方便自己加
- 一份"踩坑清单",把这篇文章里的问题都写清楚,省别人重走一遍
即将开源,敬请期待。
如果你也在被同样的问题困扰——号被冻、仓库没了、token 到期——欢迎在评论区聊聊你的场景,我整理的时候会优先考虑通用性。
一句话总结
如果你的机器人是否活着,取决于一个你控制不了的第三方,那就该考虑搬出来了。
私有化部署不是为了炫技,是为了让"它还在"这件事,不再依赖运气。
—— 写于那台 2 核 1.6G 的服务器上