Gellow 旋转头像
One Summer's Day
钢琴 · 轻音乐
0:000:00
随机遍历 · 9 首
Insert Coin To Load
M
Loading Markdown

正在渲染文章与目录。

markdown / full reading page

QQ开放平台机器人

一只猫娘机器人

我在 QQ 群里养了一只机器人 ​

最开始,我只是想在 QQ 群里放一个机器人。

不用多聪明,能在被 @时回句话,再听懂几个简单的命令就好。那时我想象中的完成,大概是终端里亮起一行启动日志,我在群里发出 /ping,它隔着网络回我一个 pong。一来一回,像敲了敲门,门里也确实有人。

后来才知道,让程序回一句话不难,让它像个真正的群友一样长久待在那里,却是另一回事。

QQrobot 就是在这种“不如再加一点”的念头里慢慢长出来的。它以 Python 3.13 为底,用 NoneBot2 组织事件和插件,接上 QQ 官方适配器,再让 FastAPI 守着 Webhook 的入口。QQ 平台把群里的消息推到服务器,机器人处理完,又通过平台接口把回答送回去。依赖由 uv 管理,运行环境则被装进 Docker;到了公网这一头,还有 Nginx、Caddy 或 Tailscale Funnel 可以接力。乍一看阵仗不小,其实每一层都是后来遇到一个具体问题,才在那里多放了一块砖。

/ping 是最早也最朴素的那一块。它没有任何戏剧性,只会回答 pong,可每逢刚改完回调、换过服务器,或者机器人忽然沉默时,我还是会先去敲它一下。健康检查接口 /healthz 也差不多,只不过它面对的是浏览器和监控程序。返回的 {"status":"ok"} 只说明服务进程还活着,并不保证 QQ 凭据正确,也不保证平台真的能把消息送到这里。这个区别有点像窗户里亮着灯,不等于门铃一定好用;平时觉得较真,排障时却能让人少走很多弯路。

机器人真正有了些性格,是接入 AI 以后。

现在,在群里直接 @机器人 你的问题,或者发送 /ask 你的问题,都可以和它聊几句。背后的客户端使用 OpenAI Chat Completions 兼容格式,没有把自己锁死在某一家模型服务上;当前默认配置偏向 DeepSeek,但只要接口兼容,替换服务地址、模型名和密钥就能换一颗“脑子”。为了适应群聊节奏,请求默认关闭思考模式,并限制回复长度;如果回答实在太长,程序会尽量在完整句子的末尾收住,而不是说到半截突然断气。

可聊天若只有眼前一句,多少还是显得失忆。最初,我给它做了一点短期记忆:个人对话按照“群聊 + 用户”隔离,保留最近 10 轮;与此同时,它还能参考本群最近 20 条文本消息。这样,它既不至于把甲和乙的对话混在一起,也能多少听懂大家刚才共同聊到了什么。模型每次最多接收 2000 字的问题,忙碌时会直接请后来的人稍等,避免并发请求把这个小机器人挤得喘不过气。

但短期记忆有一种很微妙的遗憾。今天聊得再熟,过一阵子,旧消息被新的对话挤出窗口,它还是会忘记。于是最近,我又给它添了一层长期记忆。

这层记忆并不是把所有聊天原封不动地永久保存。每累计大约 4 轮对话,也就是 8 条用户与机器人的消息,程序才会把尚未处理的一小批对话交给模型,请它提炼其中明确而稳定的事实,例如一个人的长期偏好、正在做的项目,或值得在以后相关话题里想起来的信息。提炼结果作为简短事实和关键词写入 SQLite;下一次提问时,再根据当前问题的词项重合程度,从最近至多 100 条长期记忆中找出最相关的 5 条交给模型参考。它记住的不是整场谈话,而更像是谈话过去以后,脑海里留下的几件事。

当然,“记住”本身也是一种权限。长期记忆仍然按照“群聊 + 用户”隔离,在这个群里认识的你,不会被擅自搬到另一个群里。疑似含有 password、API key、token、AppSecret、Cookie、密码或密钥的内容会在入库前被过滤;单条事实最多 200 字,每名用户在每个群中最多保留 100 条。提炼失败时,处理游标不会贸然前移,下次还有机会重试,而不是安静地把那段对话漏掉。

与此同时,记忆管理也从群聊命令里退了出去。短期上下文和长期记忆都由机器人自动维护,普通用户不能在群里翻看或随意操作,只有服务器管理员能直接管理数据库。少了一个方便的 /新对话,却换来了一条更清楚的边界:群聊是聊天的地方,后台才是维护机器记忆的地方。

这些记忆写进 SQLite,却不会原样留下 QQ 用户和群聊标识。两者先经过 SHA-256 摘要,再成为数据库中的索引。内容会被清理并限制长度,旧的短期记录随着新消息写入而修剪。它算不上什么宏大的隐私工程,只是觉得,既然没有必要保存一个人的原始标识,那就别保存。

至于它该用什么口吻说话、应该知道哪些固定信息,我没有把这些东西埋进 Python 字符串,而是分别放进 prompts/persona.md 和 prompts/knowledge.md。一个像性格底稿,一个像随身的小册子。修改以后重启机器人,它就会以新的方式开口;用 Docker 部署时,则需要重新构建镜像。这种分离并不复杂,却让“塑造角色”和“修改程序”不必总是纠缠在一起。

不过,会回答、会记得,仍然只是一个坐在角落里等人点名的机器人。真正让它更像群友的变化,是它最近开始学着自己加入聊天。

自主回复默认是关闭的,而且只允许服务器管理员在后台开启。运行 scripts/manage_autoreply.py,可以查看状态、打开或关闭功能,也可以调整随机插话概率、群冷却时间、会话持续时间和回复字数。这些配置和会话状态都放在 chat_memory.sqlite3 里,修改后立即生效,不必为了把 15% 改成 10% 就重启整个容器。

它的主动并不是毫无节制地抢话。普通消息里只要出现“猫猫”“甘城”“甘城猫猫”“机器人”“bot”或“robot”,程序就认为有人叫它,并建立一段会话。会话采用滑动窗口,默认持续 60 秒:只要对话继续,每次发言都会把期限往后推。一个人先叫它,之后其他群友也可以叫它加入同一段共享群聊上下文,但程序仍然知道最新一句是谁说的,不会把别人的话错安到当前用户头上。若有人说“我没说你”“不用回复”“别说话”或“拜拜”,它也听得懂退场的意思,会安静结束相应会话。

当群里没有活跃会话时,每条普通文本消息默认有 15% 的概率让它自然插一句。它只参考最近大约 5 条群消息,不加载任何人的私人对话历史,也不能假装有人刚刚提问或叫了它。成功说过一句以后,这个群会进入 180 秒冷却;命令、@消息、超过 500 字的长文本以及 QQ 回推的机器人自身消息都会被忽略。尤其是最后一条很重要——机器人不能把自己的话当成新指令,再一路和自己聊下去。

这套主动参与还依赖 QQ 平台真正推送 GROUP_MESSAGE_CREATE。如果开放平台只订阅了 GROUP_AT_MESSAGE_CREATE,它看到的世界就只有别人 @它的时刻,所谓随机插话自然无从发生。一个概率算法能不能工作,有时并不取决于那几行随机数,而取决于平台愿不愿意让它听见房间里的声音。

群聊毕竟不总在认真谈话。有时大家只是想听首歌。表面上,点歌只是输入 /点歌 歌名;藏在命令后面的事情却热闹得多。机器人会并行搜索酷狗、网易云和 QQ 音乐,把三个来源各不相同的结果整理成统一结构。若在歌名后加上“列表”,它会把不同渠道的可播放结果交替排进前十名,免得一个来源独占整页;选好以后,再用 歌名*序号 播放。没有序号时,它就从第一首可播放的歌开始找。

外部音乐接口并不总是可靠:可能超时,可能找不到试听地址,也可能某一天突然改变脾气。因此,单个渠道失败不会拖垮整个命令,另外两个仍会继续工作;真正发送音频前,还要确认链接可用。默认模式无需登录;若使用自己的酷狗会员,则通过 KUGOU_VIP_ENABLED 开启旁路服务,把 Cookie、接口地址和音质留在服务器 .env 中,绝不放进仓库。程序不能许诺上游永不出错,它能做的是让错误留在该待的位置,并把发生了什么说清楚。

后来,我又给它添了每日抽签。发送 /抽签,它会选出当天的 Neuro 风格运势并附上一张图片。同一个人同一天得到的结果固定,第一次抽取会被写进 SQLite;当天再来一次,它不会重新摇签,只会提醒你已经抽过了。数据库放在 Docker 数据卷中,所以容器重建以后,运势记录也不会跟着失忆。

再后来,群里多了一把装着虚拟子弹的左轮。大家可以用 /轮盘签到 领取金币,以 /装弹 1 20 发起挑战,也可以在命令末尾 @一位群友指定对手。接受以后,双方轮流 /开枪;若轮到的人超过 30 秒没有动作,另一方就能用 /结算 收尾。金币、胜负、排行和进行中的对局同样保存在 SQLite 与 Docker 数据卷里,用户标识依旧只留摘要。每个群同一时间只能开一局,状态变化由服务层约束,而不是仅靠聊天文本维持一种脆弱的默契。它对生产力大概毫无帮助,却意外地最像一个真正会发生在群里的功能。

这些命令也被收进 QQ 单聊底部的自定义菜单里:AI 问答、点歌、今日抽签和连通测试,不必每次都靠记忆手敲。菜单不会在每次启动时重复覆盖,只有明确运行同步脚本时才更新全局配置。自动化有时不是越勤快越好,知道什么时候不该动,也是一种稳定。

写到这里,机器人看上去已经挺会玩了。但真正费时间的,往往不是这些能被用户看见的功能,而是它们周围那圈不起眼的边界。

比如启动配置。AppID 必须是数字字符串,AppSecret 不能留空,账号不能重复;项目明确使用 Webhook,所以 use_websocket 必须为 false,签名验证也不允许关闭。运行 uv run bot.py --check,程序会检查配置和插件能否加载,却不会主动请求 QQ API。这只能证明行李大致收拾齐了,不能证明车票有效、道路畅通,但把本地错误挡在出门之前,总归是一件好事。

又比如 QQ 平台用来确认回调地址的 Webhook challenge。项目保留了适配器原有的 Ed25519 签名逻辑,同时补正 JSON 响应类型,并加入诊断记录:账号是否匹配、传入签名是否有效、响应签名是否通过、HTTP 状态码和处理耗时。日志不会写出 AppSecret、请求正文或完整签名。排障当然需要线索,可不能因为急着找门锁哪里坏了,就顺手把钥匙的拓印贴在门外。

部署也远不止“容器启动成功”。在已有反向代理的服务器上,机器人只需绑定本机 127.0.0.1:18080,再让 Nginx、Caddy 或 Tailscale Funnel 把 HTTPS 请求带进来;在新服务器上,则可使用项目自带的 Caddy 配置,让它自动申请和续期证书。镜像不包含 .env,机器人以非 root 用户运行,证书和业务数据库都放在持久卷里。

项目记录的实际部署位于阿里云 ECS:代码在 /opt/qqrobot,Docker Compose 管理机器人,Nginx 负责 https://bot.gellow.top/qq/webhook 的公网入口,证书交给 Certbot 续期。另一条路是本地 E5 主机配合动态 IPv6,但那里的不确定性更多:AAAA 记录要随着地址变化更新,防火墙和路由器必须放行;若域名还留有 A 记录,也得指向真正可用的 IPv4 服务。最要紧的是,本机可以访问,不代表 QQ 平台也能抵达一台纯 IPv6 主机。最终仍需从外部网络验证 HTTPS,再到开放平台完成真实回调;若公网入站或平台可达性过不去,就得请一台有稳定公网入口的云主机做转发。

这些经历让我越来越觉得,部署不是把程序从电脑搬到服务器,而是替它安排一条能被外界可靠找到、又不至于门户大开的路。

为了让以后修改这条路时不至于反复踩回旧坑,项目里的测试也从最初的 43 项长到了 58 项。它们会验证错误配置被拒绝且不会泄露密钥,检查 Webhook challenge 和签名响应,确认未签名事件进不了门,也模拟签名正确的群消息最终触发 /ping、AI 问答和抽签。AI 客户端的鉴权失败、限流、超时、畸形响应、并发拒绝和长度截断有人守着;短期记忆的隔离与裁剪、长期记忆的提炼游标、相关召回、敏感信息过滤和失败重试,也各自有对应场景。

新增的自主回复测试则盯着另一类更像“行为”的东西:默认开关必须关闭,呼叫名称能否识别,会话是否只在正确范围内延续,60 秒窗口能否滑动刷新,明确拒绝后是否真的沉默,15% 抽样和 180 秒冷却有没有越界,旧数据库结构又能否幂等迁移。测试中的 QQ 查询和消息发送依然使用模拟接口,不会因为一次本地回归,真的半夜跑去群里自说自话。

回头看,QQrobot 的目录里已经有了 AI、音乐、运势、轮盘、菜单、短期与长期记忆、自主回复,以及一套越来越像样的部署和运维说明。若把它们列成表格,它只是一个功能逐渐丰富的 QQ 机器人;可对我来说,它更像一道慢慢有了答案的问题:一个“在我电脑上能跑”的小程序,要怎样才能走进真实群聊,并在那里安稳地生活?

答案没有多神秘。给外部接口留出失败的余地,给重要数据找一个不会随容器消失的住处,不保存原本就不需要保存的身份信息,把凭据和日志的界线画清楚,把主动开口的冲动关进概率、会话和冷却里,再用测试守住那些已经做对的事情。

它当然还没有毕业。音乐上游仍会变化,QQ 平台的权限和可达性仍需在真实环境中持续确认,长期记忆可能过时,自主回复也要在“像群友”和“别太吵”之间反复寻找分寸。可至少现在,有人在群里 @它时,它能接住一句话;聊过一阵以后,它可能还记得你喜欢什么;没有人点名时,它偶尔也会听着大家的话题,挑一个不那么突兀的时机插上一句。

那个最初只会回答 pong 的小程序,就这样一边踩坑,一边添砖加瓦,慢慢在群里找到了自己的位置。

有时候我甚至觉得,它已经不是被“部署”在那里了。

它开始学会,怎样住在那里。

23333333333333333333333333333333333333333.

Back To Blog