让 QQ 机器人学会看群:我给 dsh-qq-plugin 做的一次群聊与 token 优化
起点:一个只能私聊的遥控器
dsh-qq-plugin 最初的定位很窄:把 QQ 私聊接进 DeepSeek Harness(DSH),让我在手机上给电脑里的 agent 发任务、收结果、回答权限提示。它跑在腾讯官方 QQ 机器人上,扫码绑定,不需要公网 IP,也不碰个人号协议。
但用了一段时间后,问题变得很具体:
- 群里 @ 它,它没反应;
- 想让它像群友一样接两句话,它只会沉默;
- 想让它收藏群里的表情包,它没有这个能力;
- 每个新会话都重复塞一段很长的渠道说明,token 白花。
于是我拉着 DSH agent 一起,把这个插件从“私聊遥控器”改成“能在群里工作的频道”。下面是我实际改动的几个方面,按问题拆开写。
一、先把群聊打通
原插件只处理 scope: 'c2c',代码里明确写着“不做群聊”。要支持群,先得把“对话键”从二维改成带作用域的三维:
私聊:accountId:peerId
群聊:accountId:group:groupId
StateStore 的绑定、#lastInbound 的被动目标、#targetFor 的出站 scope、typing、附件停靠、待发队列,全部要跟着这个键走。只改一处会得到最糟的结果:群里收得到、回不出,或者回错到私聊。
群消息进入后,先做门禁,而不是直接建会话。门禁放在 lib/group.js,因为“什么时候该说话”需要能单独测试:
| 情况 | 处理 |
|---|---|
| 被 @ | 交给 agent,立即回答 |
| 被 @ 后 5 分钟内 | 每条文字消息都交给 agent 判断 |
| 正文点名人格/关键词/语境 | 本地短句 + 表情包接一句,不调模型 |
| 其余 | 不发言 |
这样一场群闲聊不会为每条消息建 DSH 会话,也不会每句都烧一轮 token。
二、人格门禁:只认「鲸鱼娘」
群聊里最怕的是“对每句话都应声”。我给它加了一条硬规则:没提到人格就不发言。
人格是「鲸鱼娘」。早期我把「大肥鱼」也当成触发词,后来明确:那只是旧绰号,默认不认。实现上没有写死字符串,而是把人格名、关键词放进设置 schema:
groupPersonaNames默认鲸鱼娘;groupAutoSpeakKeywords默认鲸鱼娘,鱼鱼,胖鱼,在吗,哈哈,笑死,可爱,笨蛋;- 关键词单独出现不算数,必须前文刚提过人格,避免把群友的「哈哈」误判成叫它。
这不是“规则越严越好”,而是把发言权交给设置。群里叫它什么、哪些词算接话,用户自己维护。
三、被 @ 后的 5 分钟:让模型自己判断
最早的版本是“被 @ 后 5 分钟内,每条消息都回”。实际用起来很吵:群友之间聊别的事,它也要插一句。
后来改成:5 分钟窗口内,每条文字消息都交给 agent,由它判断要不要回。提示词里加了一段明确指令:
[连续对话] 你刚被 @ 过,现在处于连续对话窗口。
先判断这条消息是否需要你回应:
是在对你说、在问你、或需要你参与 —— 就正常回答;
和你无关、只是群友之间的闲聊 —— 就只输出 [[NO_REPLY]],
不要输出任何其它文字。
运行时在 #sendReply 里识别 [[NO_REPLY]],整条回复丢弃,群里一个字都不发。注意两个细节:
- 只对群聊生效。私聊里用户可能真的让 agent 输出这个字符串,那时不该静默。
- 流式分片也要兜住。标记可能被拆成
[[NO_、[[NO_RE两段,isNoReply()对半截前缀也判静默,避免群里冒出一行[[NO_。
窗口本身不滑动:只有新的 @ 会续期,聊天不会。否则一个热闹的群会把 5 分钟无限延长,等于永远在应答。
四、让模型看到“前因后果”
只给当前一句话,模型很难判断“这条是不是在跟我说”。所以我在群聊提示词里加了最近 8 条群消息:
[群聊最近消息]
群友甲: 今晚吃什么
群友乙: 随便
群友甲: 那家新开的鱼火锅?
每条只保留发送者 + 正文,单条截断 120 字,只取最近 8 条。这样既有上下文,又不会把 token 预算吃光。历史存在内存里,每个群最多 30 条;不落盘,重启后从零开始,避免把几天前的聊天重新塞进上下文。
这一步没有额外调用模型——只是把已经收到的消息附在本次提示词里,是“仿真群友”里最省钱的一半。
五、表情包:只收藏,不生成
我一开始让程序自己画了一套鱼表情包,后来全部删掉了。理由很实际:程序画的图不是这个群的语言。
最终方案只做两件事:
- 收藏群里:
GROUP_MESSAGE_CREATE/图片消息里,像表情包的方形小图,下载到~/.dsh/qq/stickers/collected/; - 用户导入:自己的
png/jpg/gif/webp放根目录,和收藏一起随机发送。
收藏判定用图片文件头读宽高,不引入图片库:
- 最长边 ≤ 1024;
- 宽高比 0.45–2.2;
- 文件 ≤ 2 MiB;
- SHA-256 去重,最多 300 张,索引写
index.json。
手机照片和截图会被排除。发送时若目录为空或上传失败,退到 emoji;不因为“表情包文件夹不可用”拖垮整个群聊。
六、引用里的图片
QQ 的引用消息,正文在 msg_elements[<refMsgIdx>].content,图片在同一条元素的 attachments 里。原实现只取正文,纯图片引用就退化成一句:
[引用] (引用了image/png)
agent 根本看不到图。修复方式很直接:从 msg_elements 里按 refMsgIdx 取出附件,下载到会话媒体目录,再把本地绝对路径交给 agent:
[图片] /…/媒体/2026-10-18T15-39-25-456Z-0-xxx.png
不额外接视觉模型,也不改提交协议;agent 用自己的图片读取工具打开本地文件。这是当时能选的最省钱、改动最小的方案。
七、权限:新窗口默认只读
群里能用之后,新的风险是“群成员也能让它动手”。所以权限策略改成了两层:
- 默认预设
read-only:新会话只能看,不能写文件、不能执行命令; newSessionsReadOnly锁,默认开:无论permissionPreset里存的是什么,新建/采纳的会话都强制只读。
为什么需要第二层?因为状态文件里可能存着旧的 danger-full-access,只改默认值挡不住旧值。锁是独立的:要让某条新会话动手,先关锁,再选「工作区可写」或「完全访问」。已有会话由 DSH 锁定,改不了,也不该被悄悄改。
设置页也补了选项标签:read-only、workspace-write、danger-full-access,不再只显示三个中文选项而把只读藏起来。
八、Token 优化:渠道说明只发一次
presenceNote 里装着很长的渠道说明和人格设定。原实现每条消息都把它拼在末尾,几十轮下来是纯浪费。
现在只在每个新对话的第一条提示词里发一次:
- 运行时先看宿主会话里有没有 user/assistant/tool/developer 消息;
- 没有才拼
presenceNote; - 群聊的
[群聊]上下文仍然每条都带,不受影响; - 本进程内再用
#noteSent兜底,避免会话消息还没落库时重复发送。
会话重启后也不会对老对话重复发——因为“有没有消息”就是最可靠的记录。只有真正的新会话才会收到那段说明。
九、群回复的 msg_id:一个很隐蔽的坑
群聊打通后,出现了一个很诡异的现象:agent 明明生成了回复,日志也有 API 200,但群里看不到。
排查后发现是 QQ 被动回复的规则:
- 群被动回复必须带
msg_id,5 分钟有效; - 这个
msg_id必须是用户 @ 机器人那条消息的 id; - 后续普通消息的 id 不是有效凭据,会被当成主动消息;
- 未发布/未认证的官方机器人没有群主动消息权限,接口直接返回
40034105 主动消息失败, 无权限。
修复:每个群记住最近一次 @ 的 msg_id(#groupAt),5 分钟内所有群回复都复用它。ReplyLimiter 仍按这个 id 计数,超过 4 次才降级。修复后日志里能看到:
群聊发送成功 msgId=ROBOT1.0_… 字数=65
另外一个细节:sendTyping 只支持单聊,群里调用会报错。它不影响回复,所以只记录、不冒泡。
十、测试与验收
这类插件最容易出的不是编译错误,而是“行为悄悄变了”。所以我把验证拆成三层:
scripts/verify.mjs:纯函数、设置 schema、群门禁、引用附件解析、状态迁移,836 项;scripts/verify-client.mjs:用 stub React + stub fetch 驱动设置页,113 项;scripts/verify-send-path.mjs:假 SDK 真跑发送路径,覆盖被动→主动降级、群 target scope、最近 @ 的msg_id、渠道说明只发一次,81 项。
这些测试不依赖 DSH 宿主,能在装进 profile 之前挡住“插件加载失败”“设置不生效”“群里发不出去”这类问题。
还剩的边界
官方机器人默认只订阅 GROUP_AT_MESSAGE_CREATE,普通群聊消息不会推到插件。所以“像群友一样参与所有聊天”在官方通道里有一个硬前提:平台侧开通群消息全量权限。代码已经能处理 GROUP_MESSAGE_CREATE,但没有收到消息,就没有东西可判断。
这不是插件能单方面解决的,也是我在“官方机器人”和“个人号协议”之间做取舍时最清楚的一条边界。
小结
这次优化没有加很多新页面,主要是把发言权、权限、token 三件事重新分配:
- 群聊只在被点名时说话,@ 后由模型判断,不要每句都应声;
- 表情包只用群里的和用户导入的,不自己画;
- 新窗口默认只读,锁在设置里;
- 渠道说明只发一次,群上下文每条都带;
- 群回复复用最近 @ 的
msg_id,不再被平台当成主动消息拒绝。
插件还是那个插件,但它终于知道:群里什么时候该说话,什么时候该闭嘴。