QQ 群机器人是接入 QQ 开放平台的官方机器人账号,能够以 WebSocket 长连接的方式常驻服务器,接收群内 @ 消息并自动回复,也可以由脚本触发向群内推送通知。本教程以一个名为「希怡」的 AI 群聊机器人为例,基于 Python 与 QQ 官方 WebSocket 网关协议,完整实现群聊 AI 问答、主备大模型自动切换、联网搜索工具调用、群友长期记忆、群通知推送等全部能力,并给出部署运行与常见问题排查方案。示例代码已脱敏,替换为自己的凭证后即可直接运行。

前置教程

如想快速开始学习本教程,你可能需要先完成以下前置教程:

资源下载

完整示例项目(含全部源码与机器人头像,已脱敏):

其他官方入口:

1. 认识 QQ 机器人与整体架构

1.1 QQ 机器人能做什么

QQ 机器人开放平台(q.qq.com)允许个人开发者注册机器人账号。机器人被添加进 QQ 群后,平台会把群内 @ 机器人的消息通过 WebSocket 长连接实时推送给开发者的服务程序,服务程序处理后可以调用接口向群里回复消息。除此之外,机器人还支持主动消息(不针对某条消息的推送),但平台对每个群的主动消息设有每日额度,而「回复用户消息」的被动回复不受额度限制,因此本教程将问答设计为被动回复、通知设计为主动消息。

1.2 希怡的整体架构

示例项目「希怡」是一个 3 岁半人设的群聊机器人,整体链路如下图:

graph LR A[群友 @希怡 提问] --> B[腾讯机器人网关] B -->|WebSocket 推送| C[希怡服务程序] C -->|function calling| D[联网搜索 MCP] C -->|对话补全| F[主大脑 dots] C -->|失败自动切换| G[备用大脑 glm-4.7-flash] C -->|纯文本回复| B H[skill 或脚本] -->|HTTP 通知接口| C

工作流程为:QQ 官方网关把群消息推给希怡服务程序;程序将问题交给 LLM 大脑,大脑可以在回答前调用联网搜索工具查资料;拿到答案后以纯文本回复到群里。整条链路中最关键的四个设计是:WebSocket 网关的断线自愈、主备大脑的自动切换、并发闸门与超时预算、以及 Markdown 到纯文本的输出清洗。

2. 创建机器人并获取凭证

2.1 注册机器人

打开 QQ 机器人开放平台,使用 QQ 登录后进入「机器人管理后台」,按引导创建机器人。创建成功后会得到两个关键凭证:AppID 与 AppSecret。AppSecret 仅在创建时完整展示,请立即保存到本地。

2.2 开通群聊能力

进入机器人的能力配置页面,申请并开通「群聊」能力。群聊能力开通后,把机器人添加进目标群:在 QQ 群设置中选择「群机器人」,搜索机器人名称并添加(需要群管理员权限)。

2.3 记录凭证

将 AppID 与 AppSecret 填入示例项目的 config.py:

# QQ 机器人开放平台凭证(https://q.qq.com 「我的机器人」页面获取)
APP_ID = "你的AppID"
APP_SECRET = "你的AppSecret"

注意:AppSecret 是机器人的身份凭证,泄露后他人可以完全控制机器人,不要将配置文件上传到公开仓库或发送给他人。

3. 项目结构与运行环境

3.1 项目结构

从资源下载章节获取示例代码并解压,目录结构如下:

xiyi-qq-bot/
├── main.py            # 入口:run 常驻运行 / notify 单次推送通知
├── config.py          # 全部配置(凭证、大脑、提示词、端口)
├── qq_gateway.py      # QQ WebSocket 网关:收群消息、心跳、断线重连
├── qq_api.py          # access_token 管理 + 群消息发送 + Markdown 清洗
├── brain.py           # LLM 大脑:主备切换、并发闸门、工具循环
├── mcp_tools.py       # 联网搜索 MCP 客户端(open-websearch):长连接 + 只读白名单
├── memory.py          # 群友长期记忆:SQLite + 滚动摘要
├── store.py           # 群 openid 记录:自动发现群并持久化
├── requirements.txt   # 依赖清单
├── start.bat          # Windows 一键启动脚本
├── asset/             # 机器人头像
└── data/              # 运行时自动生成(群记录等)

3.2 安装依赖

推荐使用 conda 创建独立环境并配合 uv 安装依赖:

conda create -n xiyi python=3.10
conda activate xiyi
cd xiyi-qq-bot
uv pip install -r requirements.txt

依赖只有五个:fastapi 与 uvicorn 用于本地通知接口,httpx 用于调用 LLM 与 QQ 接口,websockets 用于 QQ 网关长连接。

4. 核心实现讲解

4.1 WebSocket 网关:接入与群消息接收

QQ 官方网关的接入流程分为四步:用 AppID 与 AppSecret 换取 access_token,请求网关地址,建立 WebSocket 连接后发送鉴权包(op 2 Identify),随后按服务端给定的间隔发送心跳(op 1)保活。断线后凭借 session_id 与最新消息序号发送恢复包(op 6 Resume)续接会话,会话失效则清空后重新鉴权:

async def _session(self):
    token = await qq_api.get_access_token()
    url = ...  # 请求 https://api.sgroup.qq.com/gateway 获得网关地址
    async with websockets.connect(url) as ws:
        hello = json.loads(await ws.recv())          # op 10:心跳间隔
        interval = hello["d"]["heartbeat_interval"] / 1000
        if self.session_id:
            await ws.send(json.dumps({"op": 6, "d": {   # 断线恢复
                "token": f"QQBot {token}",
                "session_id": self.session_id,
                "seq": self.last_seq}}))
        else:
            await ws.send(json.dumps({"op": 2, "d": {   # 首次鉴权
                "token": f"QQBot {token}",
                "intents": 1 << 25,                      # 群聊和单聊事件
                "shard": [0, 1],
                "properties": {"os": "server", "browser": "eogee-bot", "device": "eogee-bot"}}}))
        heartbeat = asyncio.create_task(self._heartbeat(ws, interval))
        try:
            async for raw in ws:
                await self._dispatch(ws, json.loads(raw))
        finally:
            heartbeat.cancel()

收到群消息事件 GROUP_AT_MESSAGE_CREATE 后,交由独立协程处理,处理前把 @ 标记从正文中清除,并自动把群 openid 记录到本地(用于后续群通知的目标解析):

async def _on_group_message(self, data: dict):
    group_openid = data.get("group_openid", "")
    raw = (data.get("content") or "").strip()
    content = re.sub(r"@[\S]+", "", raw).strip()   # 去掉 @ 标记,只留正文
    _record_group(group_openid)                     # 自动发现群并持久化
    reply = await brain.answer(self.rt, user_key, content)
    await qq_api.send_group_message(group_openid, reply, msg_id=data.get("id"))

4.2 消息发送:主动消息、被动回复与纯文本清洗

回复群消息时把收到的消息 id 作为 msg_id 传入,即为被动回复,不受主动消息额度限制且必须在 5 分钟内发出;不传 msg_id 则是主动消息,适合通知场景但受每日额度约束。超长回复按 450 字符自动分段:

async def send_group_message(group_openid, content, msg_id=None):
    content = to_plain(content).strip()
    chunks = [content[i:i + 450] for i in range(0, len(content), 450)]
    for seq, chunk in enumerate(chunks, 1):
        body = {"msg_type": 0, "content": chunk, "msg_seq": seq}
        if msg_id:
            body["msg_id"] = msg_id
        await _post_message(group_openid, body)

LLM 的回答常带 Markdown 语法,而 QQ 群不渲染 Markdown,星号与井号会原样露出。发送前统一清洗:加粗斜体去符号、链接转「文字:网址」、无序列表转圆点、代码块去围栏。清洗函数 to_plain 用一组正则实现,放在发送的唯一出口 send_group_message 内,保证所有出站消息都被覆盖。

4.3 LLM 大脑:主备切换与超时预算

大脑配置了主备两个 OpenAI 兼容服务商。每次提问按「主路重试一次、备路重试一次」的顺序尝试,任一环节失败自动切换。每次调用设有 45 秒超时,单次提问总预算 210 秒,预算耗尽立即止损,确保兜底回复赶得上 QQ 被动回复的 5 分钟窗口:

for provider in providers:               # 主备依次尝试
    for attempt in (1, 2):               # 每家失败后 3 秒重试一次
        if not _budget_left(deadline, call_timeout):
            break                        # 总预算耗尽,放弃剩余尝试
        try:
            text = await _answer_with_tools(provider, messages, tools)
            break
        except Exception as exc:
            log.warning("服务商 %s 失败:%s", provider["name"], exc)
            if attempt == 1:
                await asyncio.sleep(3)

全部失败时返回一句人设口吻的兜底话术(如「呜~希怡的小脑袋现在有点转不动了」),失败的提问不会写入记忆,避免产生「我答过」的错误记忆。

4.4 function calling 工具循环:先联网搜索再回答

大脑挂载了联网搜索 MCP 工具(open-websearch,npm 开源包),群友想让希怡查最新消息时,模型会先调用搜索工具查资料再组织回答。工具循环最多进行 4 轮,每轮把模型返回的 tool_calls 交给 MCP 客户端执行、把结果以 tool 角色回传,直到模型给出纯文本答案;轮数用尽后再发起一次不带工具的强制总结,保证一定有结果:

for _ in range(max_rounds):
    message = await _chat(provider, messages, tools)
    tool_calls = message.get("tool_calls")
    if not tool_calls:
        return message["content"].strip()
    messages.append(message)
    for call in tool_calls:
        result = await rt.mcp.call_tool(call["function"]["name"],
                                        json.loads(call["function"]["arguments"]))
        messages.append({"role": "tool",
                         "tool_call_id": call["id"],
                         "content": result})

部分模型偶尔会把工具调用以纯文本形式输出(正文里出现成段的调用标记)而非规范的 tool_calls 字段。这类输出会被识别并视为本次应答失败,按主备链路自动切换服务商,避免乱码直接发到群里。

4.5 联网搜索 MCP 客户端:stdio 接入与只读白名单

MCP 客户端以常驻任务持有连接(MCP 协议的会话上下文必须在同一任务内存活),其他协程通过队列提交工具调用请求。本教程选用 open-websearch,这是一个无需 API Key 的开源联网搜索 MCP 服务,npm 安装后以 stdio 本地命令方式接入,任何读者都可以复现:

# config.py:open-websearch 以 stdio 本地命令接入
MCP_TRANSPORT = "stdio"
MCP_COMMAND = "node"
MCP_ARGS = '["-y", "open-websearch@latest"]'
MCP_ENV = '{"MODE": "stdio", "SEARCH_MODE": "auto"}'
params = StdioServerParameters(command=self.command, args=self.args,
                               env={**get_default_environment(), **self.env})
async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        await self._serve(session)   # 初始化、拉取工具列表、常驻服务调用队列

为安全起见仅开放只读工具白名单(search、fetchWebContent 等检索类),任何写入类工具一律拒绝。工具返回内容超过 3000 字符自动截断,防止长文撑爆对话上下文:

while True:
    name, args, fut = await self._queue.get()   # 其他协程提交的调用请求
    result = await session.call_tool(name, args)
    fut.set_result(self._extract(result))       # 截断后回传给等待方

4.6 群友长期记忆:SQLite 与滚动摘要

记忆让机器人跨重启记住每位群友。实现分两层:最近 5 轮对话原文直接进入上下文;更早的对话由 LLM 浓缩成 150 字以内的第三人称「记忆要点」存入摘要表,每次提问随系统提示词一起注入。这样既控制了上下文长度,又保留了长期印象:

ctx = await memory.load_context(user_key, recent_rounds)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if ctx["summary"]:
    messages.append({"role": "system",
                     "content": f"你和这位群友过去的记忆要点:\n{ctx['summary']}"})
messages += ctx["messages"] + [{"role": "user", "content": question}]

回答完成后把本轮问答追加进记忆表;当窗口外积累了旧对话,就调用一次摘要接口合并旧要点。整个过程尽力而为,摘要失败不影响本次回复。

4.7 并发闸门:多群友同时提问

每条群消息都会创建独立协程处理,但大脑入口设有并发闸门:最多 3 个提问同时进入 LLM 处理环节,其余提问先进先出排队。这样即使群里瞬间涌入大量提问,打到 LLM 服务商的并发也是可控的,不容易触发限流。排队超过 240 秒仍未轮到的提问直接放弃处理,回复一句繁忙提示——240 秒加上约 1 分钟的处理与发送时间,仍能兜在 QQ 被动回复的 5 分钟窗口之内:

try:
    await asyncio.wait_for(semaphore.acquire(), timeout=queue_wait_timeout)
except asyncio.TimeoutError:
    return BUSY_REPLY          # 「现在找希怡的人太多啦,过一会儿再问我嘛~」
try:
    ...                        # 正常处理
finally:
    semaphore.release()

5. 群通知接口:CLI 与 HTTP 两种用法

通知是发给整个群的主动消息,典型场景是让脚本或 Agent 在任务完成后播报。项目提供两种等价方式:

# 方式一:命令行
python main.py notify --content "新教程已发布:xxx"

# 方式二:本地 HTTP 接口(服务运行中时)
curl -X POST http://127.0.0.1:8901/notify -H "Content-Type: application/json" -d "{\"content\":\"新教程已发布\"}"

目标群的解析顺序为:调用时显式指定 > config.py 中的 NOTIFY_GROUP_OPENID > 自动记录的最近活跃群。由于群 openid 在群友首次 @ 机器人时自动记录,通常无需手工配置。

6. 部署运行与开机自启

日常开发直接运行入口程序,服务会同时启动 WebSocket 网关与本地通知接口:

python main.py run

生产环境建议注册为开机自启,Windows 下双击 start.bat 即可运行,也可以用任务计划程序注册自启:

schtasks /Create /TN xiyi /TR "C:\xiyi-qq-bot\start.bat" /SC ONLOGON

Linux 服务器部署时,将 start.bat 换成 systemd 服务单元(Restart=always 保证崩溃自动拉起)即可,代码本身不依赖任何 Windows 特性。

7. 常见问题排查

群友 @ 了机器人但没有任何回复:先看服务日志有没有「群消息」字样。日志没有记录,说明消息根本没到达机器人——最常见的场景是群友使用了「引用回复」形态发送,QQ 平台不会把引用回复作为群 @ 事件下发给机器人,让群友改用普通 @ 即可。日志有记录但回复失败,则根据错误码检查凭证与额度。

回复内容出现星号井号等符号:说明输出清洗逻辑未生效,确认回复走的是 send_group_message 的统一出口,而不是自行调用底层接口。

通知发送失败:主动消息受 QQ 平台每日额度限制,控制台可查看剩余额度;另一个常见原因是机器人尚未被拉进目标群,或群友还没有 @ 过它(群 openid 未记录)。

Token 鉴权失败(401):access_token 有效期约 2 小时,实现中已做缓存与过期前刷新;若仍频繁 401,检查 AppSecret 是否已在开放平台重置而配置未同步。

8. 常见问题解答

答:可以。大脑只要求 OpenAI 兼容的 /chat/completions 接口,DeepSeek、Kimi、GLM、本地 vLLM 等均可直接替换 config.py 中的主备配置;主备切换逻辑保证单一服务商限流或欠费时机器人仍然可用。

答:不会。每个群友的记忆按成员标识相互隔离,A 群友聊过的内容不会出现在对 B 群友的回答里;同一个群友的记忆则跨重启保留在本地 SQLite 数据库中。若希望「删除某人记忆」或「查看记忆内容」,直接操作数据文件或增加管理接口即可。

答:不一定。服务程序只需要能与腾讯网关、LLM 服务商建立出站连接,放在本地电脑上开机自启也能用,但电脑关机机器人就会离线;放在云服务器上则可以做到 7×24 在线,且固定 IP 便于在开放平台配置白名单,生产环境建议上服务器。