用 GUI 自动化写了一个群聊 AI:5 个真实事故,和 7 条让 AI 不像机器人的设计

0 阅读31分钟

关于这篇稿子里的 AI,我没打算含糊。 骨架是我把几天日志和口述捋出来的,AI 帮着整理成结构;句子被它改短过,数字和日志被它逐条比对过,查 API 和库用法它出过力。(用的模型:deepseek-v4.1-flash + 若干辅助工具。)

但内核不是它给的:「限制 AI 说话的能力,比让它会说话的能力重要十倍」这个结论,是我自己在真机运行里撞出来的。229 条群聊对 9 条私聊、51 次主动沉默、445 项测试全绿,都是我自己机器上跑出来的原始数字。五个事故我真踩过,七条设计我真改过。

事实和引文我逐条核过一遍,AI 说错的、编的地方都删了;写歪的地方算我的。

脱敏说明:文中所有群友一律用「群友X」这类代号(群友C、群友E…)。真实昵称与聊天原文不在本文出现——
这跟项目本身的做法一致(真昵称只落本地,发给模型的一律是代号)。

第一部分:为什么「能回话」不是难点

为什么非得是微信

如果你做过 Slack 机器人、Discord 机器人,它们都有一条共同的路:官方开放接口、Webhook 回调、标准事件格式。

微信没有。

但这不只是「微信没接口」这么简单——更准确的说法是:官方给机器人的口子,恰好都不在我要去的那个地方。

我把微信生态里跟「机器人」沾边的通道都查过一遍,它们的边界是这样的:

通道能进普通微信群吗能被 @ 触发对话吗
个人号的官方 AI 通道(ClawBot / 龙虾)❌ 官方明文「禁止被拉入群组」❌
企业微信「群机器人」(Webhook)❌ 只能单向往群里推消息❌ 收不到群里的消息
企业微信「智能机器人」⚠️ 能进群,但只限企业内部群✅
企业微信客服❌ 不在群里(是独立的 1v1 窗口)❌

这些不是我猜的,是官方自己说的:

  • 微信开放社区答复:「目前不支持接入普通微信及群聊」
  • 企业微信技术支持:「智能机器人目前仅支持内部群添加,互联群、上下游群、外部群暂不支持」
  • 而个人号那条唯一的官方 AI 通道(2026 年 3 月推出的 ClawBot 插件,接「龙虾」用的),微信方面对媒体的说法是:「仅支持一对一私聊模式,禁止被拉入群组,其他用户无法添加或查看」

注意最后一条的用词:不是「暂不支持」,是「禁止」。这是设计上就不让,不是还没做。

再补一个:微信自己内置的 AI(「小微」)也一样——实测报道里它只能被动响应指令,无法主动发送群消息。

连腾讯自家的 AI 在群里都没有发言权。

看明白这些,就明白问题在哪了:不是能力不够,是官方在微信群里根本没给机器人留位置。个人号那条通道被定位成「个人私密 AI 助手」;企业微信那条路要企业认证,进的是企业内部群——它服务的对象是企业员工,不是我这群高中同学。

工作消息在微信、家人群在微信、缴费通知在微信、高中同学群当然也在微信。 一个住在 Discord 里的群宠,功能再全,我们这群人也不会为它搬过去。

所以这不是「哪条路能力更强」的比较——接口那几条路,能力确实强(发图、发表情包、引用回复、话题线程、点表情回应,全是官方一等公民,还不用占电脑、不怕锁屏)。但它们进不了我要去的地方。

这里还有个更本质的差别,是我做完才想明白的:

官方机器人接口我这套
它是什么一个挂在群上的服务它就是一个账号
怎么进群需要被「添加」(而普通群不给添加)那个号本来就在群里
算不算群成员不算,是插件算,就是群友

我不是「把一个机器人拉进群」,我是让群里本来就有的一个号,自己会说话了。 这也顺带解释了它为什么能看群历史、能接梗、能「在场」——因为对它来说,那不是一个「被接入的会话」,那就是它的日常。

所以第一道坎不是模型,是通道:先要有人在,才谈得上让它住进去。

我给的是一台「人工操作位」

这里有个关键选择,我要说清楚:我没有去接微信的接口,我给模型的是一个真微信号 + 一块屏幕 + 一双手。

它的每一条消息,都是这么发出去的:

① 找到微信窗口 → 切到前台
② 定位输入框 → 点一下
③ 把回复文本写进系统剪贴板
④ Ctrl+V 粘贴
⑤ Ctrl+A → Delete 清空(怕有残留)
⑥ 按回车
⑦ 回读输入框,确认真的清空了才认为发成功

这就是 CV 工程——复制、粘贴。 没有接口,没有协议,就是一个程序在替你点鼠标敲键盘。

完整的能力清单:

能力实现方式
看本地视觉模型(Ollama)读图,理解群友发的截图和表情
听本地语音转写(faster-whisper)把语音消息变文字
说剪贴板 + Ctrl+V + 回车
发图本地图片走剪贴板粘贴,微信把它当图片消息插进去
读引用解析群友引用消息里的被引原文(只能读,我还不会引用回复)

通俗说:它不是一个「接了 API 的机器人」,它是坐在电脑前那个用鼠标点微信的人。

为什么非得是这一步

先声明:这几样接口也都做得到。下面这四条不是「接口做不到」,是「在这个没有接口的地方,我硬把它们做出来了」:

  • 能看见图(不只是文字)——接口路线一行代码传图;我这边是本地视觉模型认图再拼进 prompt
  • 能听见语音(群里很多人懒得打字)——接口路线收到就是文件;我这边得从本地库里把 SILK 挖出来解码再转写
  • 能发表情包(这是群聊的通用语)——Telegram 有 sendSticker;我这边得先攒一个图池、再教模型在什么时候配哪张
  • 发出去的那条消息,在群友眼里没有任何可分辨的机械特征——接口路线会挂个标签(Discord 名字旁一个「APP」、Telegram 是 xxx_bot 加机器人标记),而这一条才是我真正换来的东西

⚠️ 说「和真人完全一样」就是吹牛了。真人能撤回、能翻聊天记录、能在锁屏时也回你——这些它都不行。 真正站得住的只有一句:在那条消息被看到的瞬间,看不出是机器发的。

顺带一句公道话:那个标签不是缺陷,是人家有意做的信息披露。 我这条路能把它绕掉,靠的不是技术更强,而是微信本来就没给这个场景留口子。 我知道这话说出来不好听——绕开标签本身就是这件事里我最不确定该不该做的地方。 我能给的交代只有两条:只在自己的小号和熟人小群里跑,以及一旦收到平台警告就立刻停机。

代价也很实在——占一台电脑的桌面,而且有一堆「物理」约束(下面细说)。

这也是为什么光把它跑通就拖了三天: 它不是一个纯软件问题,它是一个「如何让程序伪装成一个坐在电脑前的人」的问题。

而且这条路很脆。窗口被最小化、被别的窗口盖住、输入框位置变了——都可能失败。所以流程里才有那么多「回读校验」:发完要确认,失败要重试,不然回复就悄悄丢了。

几个只有这条路才会踩的坑

① 微信窗口不能最大化。

因为它算的是相对坐标——按窗口尺寸的比例去定位输入框。窗口最大化到 2590px 宽时,比例算出来的位置就偏了,点不到输入框,然后卡满 180 秒超时。

把窗口调成 1100×759 左右,同样的操作 3 秒完成。

② 它会抢你的剪贴板。

因为是「复制→粘贴」,它发消息的瞬间,你剪贴板里的内容会被替换掉。

③ 窗口必须在前台。

被别的窗口盖住就点不到。所以它干活的时候,那台机器基本不能干别的。

④ 锁屏就全废。

屏幕一锁,GUI 操作没有目标,什么都做不了。

这四条加起来说明一件事:这不是一个「跑在服务器上的服务」,它是一个需要有人在场的东西。 后来我也接受了这件事——它本来就是个「群友」,不是「服务」。

为什么是「群」,不是「私聊」

这一条是我做了之后才想明白的:群聊比私聊好养太多了。

一开始我以为私聊更简单——一对一,不用抢话、不用管配额、没有围观压力。实际上完全相反。

私聊最大的问题:找不到话题。

群里不需要找话题。群里的话题是现成的——有人在吐槽课表、有人在发午饭照片、有人在喊开黑。它只要接得住就行。

私聊呢?两个人干瞪眼。你先说什么?「在干嘛」?那是最像客服的开场白。

维度群聊私聊
话题从哪来现成的,别人抛的得凭空造
不说话会不会尴尬不会,群里本来就吵会,对着屏幕沉默很怪
回错了有人跟着起哄,气氛还在一对一冷场,就结束了
节奏短句连发,能插得上你来我往,像面试
错了的代价大家当梗玩对方可能觉得「这什么鬼」

真实数据也印证了这一点——跑五天:

它说过的条数
群聊229 条
私聊9 条(其中 7 条还是刚加好友那天)

差了 25 倍。 图3_群聊vs私聊.png 而且那 9 条里,绝大多数还是对方先开口它才回的。它主动在私聊里起话题的情况——几乎为零。

所以结论是:这东西天然属于群。 群给了它一个「可以在场但不说话」的位置,而私聊没有这个位置—— 一对一里,不说话就是一种失礼。

这也是为什么文章开头那句「群友不想要助手,他们想要一个能接梗的活物」—— 接梗这件事,需要有个梗。而梗是群里长出来的。

环境逼出来的四个设计

因为走的是这条路,后面所有的设计都被迫换了思路:

有开放接口的世界它所在的世界
事件来了就处理得主动去轮询,还得自己去重
消息带完整上下文只有裸文本,上下文要自己拼
发消息是幂等的发出去就收不回,发错就是真错
有 users API谁是谁都得自己认
有 rate limit 头没有任何反馈,只能靠延迟自己控制节奏

「谁是谁都得自己认」这条,直接决定了我后面一半的设计。

另外它得能看能听

群里聊天有一半不是文字。只会读文字的 AI,在群里等于半个瞎子。

所以它接了两个本地模型:

能力用什么成本
看图本机 Ollama 跑视觉模型约 5~15 秒 / 张,占 4GB 显存
听语音本机 faster-whisper 转写约 0.5 秒 / 条,占 1GB

为什么坚持本地:群友发的东西什么都可能有——家里的照片、身份证、截图、随手拍的乱七八糟。这些不该出你这台电脑。

而且视觉模型吃显存。它是为了「偶尔看一眼图」常驻着的,所以还做了个闲时自停:一段时间没用就自动卸载,把显存让出来。

这一块带来的最大感受:多模态不是加个功能,是换一种听人说话的方式。 群友发一张「食堂烧鸭饭」的照片配一句「巨好吃」——只读文字你只知道好吃; 能看图,你才知道他在夸自己学校。

回到接话本身

技术上就三步:

  1. 监听消息
  2. 拼个 prompt 发给模型
  3. 把回复发出去

半天就能跑通。然后你会发现——它一开口就像机器人。

具体表现:

现象为什么不像人
秒回真人要看到消息、想一下、打字,最快也有几秒延迟
一次说一大段群里是短句连发的节奏
谁说话都接真人不这样,真人有亲疏
什么都答得上来真人有不知道的事
每句都带表情真人是偶尔用

「不像机器人」的真正难点不是它会不会说话,是它知不知道什么时候不说。


第二部分:五个真实事故

先说结论:我踩的坑,八成不在「怎么说话」,而在「话怎么没发出去」。

先列个清单,方便你对照自己的情况:

#事故一句话
1同一句话被排了两次队看着像并发 bug,其实是跑了两个实例
2回了一条 86 分钟前的消息回老消息比不回更糟
3排了 186 秒才发出去好回复在排队中已经过期了
4好回复被新消息顶掉最隐蔽——你根本不知道丢了什么
5它不知道自己没说出去最严重——它会反过来说「我不是说过了吗」

后面每一节都是真事,带日志、带数字。


事故 1 · 同一句话被排了两次队

现象:群里出现两条一模一样的话。

第一反应:查去重逻辑。查了,没问题——decideInbound() 之后立刻标记已处理,同进程内第二次进来一定命中「已处理过」,还有测试钉着这个顺序。

真正的解释:同时跑了两个实例。

两个进程各自读同一个数据库、各自入队、各自到点发送。证据是两边队列长度都从 0 变 1——所以每边此后都走「连发保护」不再重复,最终正好两条。这个形状单实例造不出来。

修法:单实例锁。

// 判据是「持有者进程还活着」,不是「文件存在」
process.kill(pid, 0)

几个细节值得说:

  • 锁文件里存 pid / startedAt / channel,排障时一眼看出是谁占着
  • 第二个实例不抢占、不崩溃,安静停下并说清是谁占了锁
  • 陈旧锁自动接管:持锁进程已死就覆盖。不能因为一次崩溃就让插件再也起不来——那比重复发送更难查

这一条的反直觉之处:读起来像并发 bug,实际是部署问题。查代码方向就错了。

事故 2 · 回了一条 86 分钟前的消息

现象:群里 18:18 @ 了它,它 19:44 才回。

那 86 分钟里,群里已经聊过「原来是 AI」「大肥鱼是什么鬼」。此时它突然冒一句「在。咋了」——最典型的机器人犯傻。

关键认知:回一条老消息,比不回更糟。

而且这是高频场景——笔记本合盖休眠几小时再唤醒,群里积压几十条,全是过期的。

修法:过期消息闸。

staleMessageMs: 600000   # 10 分钟,0 = 关闭

细节:

细节为什么
放在「深夜静默」之后、「配额」之前它和「今天回了几条」不是一个维度,但和「现在几点」同族
ts 必须真是数字才判Number(null) === 0 是有限数,直接转换会把它当成 1970 年 → 每条都判过期
返回码 + 人话日志里要能读出「过期了多久」,不能只留一个代号
阈值 10 分钟而非 2 分钟正常群里隔十几分钟回一句完全正常,不能误杀

和「队列满时挤掉最旧的那条」是互补的两层:那层管排不排得上队(容量满了挤旧的),这层管这条还值不值得回(时间太久了直接不吭声)。

事故 3 · 排了 186 秒才发出去

现象:日志里出现「排队:186s 后发到」。

原因:几条消息各自占「最小间隔」档期,叠出了三分钟的排队。

而这引出了一个更痛的问题——

事故 4 · 好回复被新消息顶掉

这是最隐蔽的一种损失。

排队本身还好,至少你知道它在等。

真正可怕的是:一条很好的回复,被后来的消息挤掉了,而你完全不知道。

事情是这样的:队列有容量上限。满了之后,新来的消息该怎么办?

旧的做法是「丢掉新的」——理由是「队列里已经排了两条,这条先不要了」。

听起来合理,但结果是荒谬的:

[队列] 1~3 分钟前生成的两条回复,还在等档期
[新来] 有人 @ 它,它想了一句特别好的回复
        → 队列满了 → 这条不错的回复被丢掉
[群里] 它答的是上一轮的话,这一句理都不理

你看不见那条被丢掉的回复有多好。日志里只有一句「队列已满,跳过」。

我后来才想明白:这跟真人的行为完全相反。 真人忙的时候不会这样。他会把过期的想法咽回去,回你最新那句。 因为「答非所问」比「不答」更糟——前者是傻,后者只是没空。

修法:从「丢新的」改成「挤掉最旧的」。

① 队列满了 → 挤掉最旧的那条(它本来就过时了)
② 把最新这条排进去
③ 例外:如果新来的是"主动插话",而队列里全是"有人叫我"的 → 让它自己让掉
④ 被点名的回复:永远不挤,单独算一个更高的上限

第 ③ 和第 ④ 条其实是同一条原则的两个面:

  • 有人叫我 > 我自己想说话
  • 被叫了几次就该应几次(真人也是这样)

所以现在队列里的形状是:

[队列] 
  ├─ 被点名的回复 ×N   ← 优先保住,上限更高
  └─ 主动插话 ×M       ← 满了先挤自己

事故 5 · 它以为自己说了

这是最严重的一种。

上面这些修完,还有一层更深的坑——我一开始完全没想到。

模型的回复是「生成」出来的,生成完就写进了会话历史。它自己会觉得:

「我刚才说过这句话了。」

但发送是后面一步的事。那条消息可能:

  • 队列满了被挤掉
  • 排队中过期了
  • 发送失败
  • 甚至在「等档期」的路上

而会话历史里,它已经「说过」了。

结果就是:下次群里再提这事,它会说「我前面不是说过了吗」——但你根本没看到那条。

这比「没说」糟糕得多:

情况群里看到严重度
没回它没吭声正常,真人也会漏看
以为自己回了它反过来质疑你没看见社交性伤害

一个 AI 坚信自己说过一句谁都没见过的话——这不是技术问题,是「人设崩了」的问题。

我目前的观察是:这需要把「说什么」和「发出去」分成两笔账——只有真正发出去的,才算进「它说过的话」。

我在队列被挤掉的地方看到了 evictOneQueued——它会释放档期、写日志、把消息从等待集里摘掉,但没有任何一步去告诉会话层「这条没发」。

所以严格说,这一条我还没有真正解决,只是定位到了。

我把它写出来,是因为它比前面三个事故都更隐蔽: 前三个你至少能从日志里看见,这一个只能靠「它说胡话」反推。

有一点我现在很确定:

模型的世界和群里的世界,必须保持一致。 不一致的地方,它就会开始说胡话——而且说得特别有自信。

顺带一提:挤掉的时候会写日志(新消息更要紧)。

因为「丢了什么」这件事,必须留下痕迹——否则你永远发现不了自己损失过东西。


第三部分:真正让「不像机器人」的几条设计

事故修完,才是这套东西有意思的地方。核心原则一句话:

限制它说话的能力,比让它会说话的能力重要十倍。

1. 延迟不是 bug,是特征

真人打字要时间。所以回复不是立即发,而是随机延迟(30 秒 ~ 2 分钟)。

但下限必须守——秒回是最强的机器人特征。

这里改过一版:原先是 45 秒~5 分钟,结果实测日志出现「排队 218 秒后发到」,
加上读消息/决策耗时,群里体感就是「问了半天不理人」。
长尾是体验杀手。 所以砍掉上游的长尾,但保留 30 秒下限。

分布形状也有讲究——不是均匀分布,而是截断对数正态。因为真人回复间隔是长尾的:多数很快,偶尔很久。

2. 连发要像人

群里常见的不是「一条完整的话」,是短句连发:

我打算翘了
全勤是什么东西

所以让它能用换行表示「这里断一下」,代码把换行变成真的多发几条,中间隔几秒。

但上限是三条。超过就是刷屏;每条都拆,又是另一种不像人。

3. 判断「信息给全了没有」

这是我觉得最有意思的一条设计。

问题:真人在群里说话,很少一句话说完。常见的是这样:

甲:那个比赛
甲:就是上次说的那个
甲:你报不报

如果 AI 收到第一条就急着回,它会回一个「哪个比赛?」——而答案两秒后就来了。这种抢答,是最典型的机器人特征。

但也不能傻等——等太久,对方会以为你没反应。

所以它要能判断:这条消息是「说完了」还是「还有下半句」。

三层处理

第一层:等他打完字(防抖)

同一个人连续发的消息,会被合并成一批再处理:

情况等待
被 @ 了1.5 秒(他在等你,别拖)
普通聊天6 秒(等他把话说完)

有人接着发下一条,计时器重置——直到他停下来。

第二层:被并掉的也要记住

这里踩过一个坑,我觉得值得单说:

甲:那个比赛        ← 被并掉
甲:就是上次说的那个  ← 被并掉
甲:你报不报        ← 这条触发了回复

表面上看,「合并」就是把前两条丢掉、只处理最后一条。但那样它就只看到「你报不报」,完全不知道在说哪个比赛。

所以并掉的消息必须补进背景板。原文注释写得很直接:

「等他发完再回」如果变成「只看得到最后一句」——比逐条处理更糟。

第三层:把握时机,但别错过时机

还有种情况:消息本身没问题,但现在不是说话的时候(比如它刚说完一句,群里还没消化)。

这时它不丢弃消息,而是约个时间再说:

节奏未到,92s 后重新考虑

最多顺延两次。而且有个天然上限——如果拖得太久,这条消息自己就过期了(就是前面说的过期闸),不会无限拖着。

顺带一提:它只带 20 条上下文

发送时它的「背景板」不是全部聊天记录,而是最近 20 条。

为什么不是 100 条?两个原因:

  • 成本:每条都进 prompt,一个月下来是天文数字
  • 重点:20 条足够理解「现在在聊什么」(约群里十几分钟),更早的都过时了

这跟它的「记忆」是两回事:20 条是「现在的语境」,记忆是「长期的人和事」。

(这里从 8 条改成了 20 条:8 条在活跃群里只有一两分钟,模型看不见话题从哪来, 只能对着当前那句孤立回答——这就是「没理解上下文就回答」的直接原因。)

4. 表情包要配额

表情包是群里起哄的东西。所以:

  • 每天最多 8 张(一开始只给 2 张,实测一天只出得了 1 张——配额还没用完就被别的限制拦下了,后来才放开)
  • 两张间隔至少 15 分钟(原来卡 1 小时,结果一天轮不到几张)
  • 只在群聊发,私聊不发表情包

最后一条是踩坑加的:它曾经在一对一私聊里发了张卡通表情,对方直接问「你是不是发错人了」。

5. 「谁是谁」要自己认

这是我前面说的那条——微信群不给 users API,所以每个人是谁,得靠它自己从聊天里读出来。

所以它给每个群友建了一份档案:

{
  "alias": "群友C",
  "displayName": "(真昵称只落本地,不进 prompt)",
  "traits": ["爱调戏AI", "嘴很损", "爱拆台"],
  "bond": "经常拿鱼设定逗它",
  "callsMe": "大肥鱼",
  "facts": [...]
}

注意 alias 和 displayName 是两个字段——真实昵称只存在本地文件里,发给模型的一律是「群友C」这种代号。 图1_群友画像.png 这不是我事后补的,是一开始就定的:群友的隐私不该因为「技术上方便」就被送进模型。而且顺带解决了一个工程问题——模型不需要知道真名,只需要知道「这是同一个人」。

跑了几天下来,它自己总结出来的东西,比我预想的准:

群友它自己写出来的特征
群友C爱调戏AI、嘴很损、爱拆台
群友E爱起哄、爱玩梗、爱发表情包
群友G嘴损、爱怼人、爱管大肥鱼
群友H吃货、爱问吃什么、爱打王者
群友K补作业型学生、爱吐槽、爱玩梗

注意第三行那个「爱管大肥鱼」(大肥鱼是它的群昵称)——它把这个人的行为模式读出来了。这不是我写的,是它自己归纳的。

踩过的坑:档案里曾经混进过一个叫「?」的幽灵成员,唯一记录是「某某拍了拍某某」。还有一次,它把自己也记成了群友。

修法是在数据进模型之前就过滤:系统事件(拍了拍/撤回/入群)不收,自己发的也不收,?、我、未知 这类名字不许当昵称存。

这类「谁是真人、谁是噪音」的判断,在任何群里都是必须的,只是微信逼你更早面对它。

6. 记忆要分「特征」和「事实」

给每个群友建档,但字段分两类:

类型例子会不会变
特征(traits)爱熬夜、嘴很损、学计算机不怎么变
事实(facts)想考深大、家在广州可能变,新说法覆盖旧的

关键设计:每条事实必须带证据原话 + 时间。核验时拿原话去聊天记录里逐字比对,对不上就丢弃。宁缺。

这是防幻觉最有效的一招——不是靠 prompt 说「不要编」,是靠事后逐字核对。

7. 雷区宁可不写

档案里还有个「雷区」字段——什么话题不能碰。

但我给它加了条硬规则:

只认明确信号,禁止泛泛猜测。写错了,会让它在真该关心的时候绕开人。宁可不写。

实测下来,这个字段绝大多数人是空的——符合预期。群里没人流露过雷区,就该是空。


第四部分:给想做同类东西的人

踩过的几个坑,顺手写出来

① 输出额度和超时,必须一起调。

用的推理模型,它会先「想」再「答」,而这两件事共用同一条输出额度。我一开始给总结任务 4096,结果它把额度全花在思考上,正文返回空串——报错看着像模型坏了,其实是被截断。

于是我把额度提到 16K。然后立刻报了超时——因为超时还是给闲聊设的 60 秒,而 16K 额度的思考要几分钟。

改一个参数,必然带出另一个。 这两件事得一起设。

图2_沉默日志.png

② 日志里「沉默」比「说话」更需要留痕。

这个坑我印象最深。

有天群里有人说「AI 是不是卡了」,我去查——查了半小时,最后只能靠排除法推断。因为最高频的结果(它选择沉默)恰恰没有记录。控制台早滚没了,落盘文件里一个字都没有。

补上之后才看清:它没卡,是它自己决定不说话。

③ 测试写得比源码厚,不丢人。

7000 行源码配了 6500 行测试。听起来过度,但改闸门规则的时候,我是真敢改——因为知道有几百个用例盯着。

哦对,还有一条

真实世界比任何测试用例都脏。 我遇到过:

  • 昵称叫「?」的幽灵成员(昵称解析失败)
  • 微信系统消息被当成「某人说过的话」
  • 消息时间戳是 null,直接被当成 1970 年 → 每条都判过期

这些在写代码的时候一个都想不到,全是被群里真实数据揍出来的。


跑起来之后,数据长这样

放了几天,翻日志统计了一下。

说清楚一个前提:「它说的」这一列是从日志里数出来的,精确到条;「群里消息」那一列是我按量级估的——刷得快,没逐条数。所以这两列的精度不一样,看个量级关系就行:

日期群里消息(估)它说的(精确)
第 1 天一百来条7 条
第 2 天两百多条65 条
第 3 天约 210 条95 条
第 4 天一百多条25 条
第 5 天一百多条46 条

它大概只说群里十分之一到三分之一的话——具体比例取决于那天群有多活跃。群越冷清,它说话占比越高;群一旦炸起来,它就退到后面去了。

而这部分,是它认为「该说」的。剩下的它都憋住了——日志里「没说话」的原因,排第一的就是它自己决定沉默(51 次):看完了消息、判断过「这话我接不上或不该接」,主动不出声。排在后面的才是机制层的拦截:去重(27 次「这条已经处理过了」)和防自激(10 次「这是我自己发的」)。

我最满意的不是它说了多少,是它有 51 次明确决定不说。


说回代价和边界

前面全在讲怎么让它像人,这一节讲这条路本身的代价——因为每个看完的人第一个问题都是「这不封号吗」,我干脆自己先说。

这事到底有没有风险

有。我不打算粉饰。

微信的个人号使用规则里,自动化操作不是你被欢迎做的事。我没法拍胸脯说「绝对安全」,只能说我自己的实际边界:

  • 只用小号。不是一个正常在用的主号——出事了损失的只是一个号,不是我的社交关系
  • 只在熟人小群里跑。十来个人的高中同学群,不对外扩散、不加陌生人、不做群控
  • 一有官方提醒就立刻停。收到任何安全提示,先停机,冷却几天,不做「换个工具再试一遍」这种操作——那是加重特征
  • 不追求规模和时长。这东西的定位就是「我们这几个人群里的一只宠物」,不是要上线服务谁

跑了几天,没有收到过任何提醒。但这是「目前还没发生」,不是「不会发生」。这个区别我分得清。

技术上它是什么、不是什么

这一点我很在意,因为它直接决定这算不算「外挂」:

它不是它是
不解析、不伪造微信的网络协议一个读写屏幕、点鼠标、按键盘的桌面程序
不破解客户端、不注入进程用的是最普通的 GUI 自动化
不改数据库、不伪造登录态(只读)操作的是我自己登录着的那个客户端
不调用任何「微信机器人接口」——微信也没有这东西截图、复制、粘贴、回车——和你自己用鼠标没区别

说白了:它就是一个替你坐在电脑前点鼠标的程序。你能用手做的事,它也能做;你不能用手做的事,它一样做不到。它没有得到任何「人以外的权限」——这也是它为什么那么脆(窗口被盖住就瞎了、锁屏就全废)。

一个我必须承认的矛盾

前面我说过:接口路线会挂个「APP/机器人」标签,我这路没标签——当时我说「这是唯一真正换来的东西」。

但那个标签不是缺陷,是人家有意做的信息披露。我能把它绕掉,靠的不是技术更强,是微信本来就没给这个场景留口子。

所以绕过去这件事,我一直有点心虚。我能给的交代只有上面那四条边界,加上一句:如果哪天这个做法在群里造成任何人的不适,我会直接停掉,不会去优化它继续跑。

技术上有意思的事很多,不值得为它让身边的人不舒服。


结语

回到最开始那个目的:我只是想让群里热闹一点。

写完这套东西,回头看我做得最多的事——不是让它会说话,是让它知道什么时候不说。

群里 @ 它,它可以不回; 消息过了 10 分钟,它就不该回; 有人连着发三条,它得等; 谁都没理它,它也不用硬凑上去。

这些全都是「不」的动作。

但就是这些「不」,才让它从「一个会接话的机器人」变成「群里那个有点意思的东西」。

让它像人的,不是它会说什么,是它忍住不说什么。

另外一个我做了才明白的道理:

它需要的是「在场」,不是「服务」。

群聊给了它一个位置——可以听着、可以偶尔插一句、也可以一整天不说话。这个位置是群聊特有的。一对一里没有「在场」这回事,只有「在服务」或者「不在线」。

所以它私下找我聊天的时候,总显得有点干——因为那不是我需要它的场景。

它需要的不是「用户」,是一个热闹的地方。


本文部分内容包含 AI 辅助创作。