QQ 群机器人是接入 QQ 开放平台的官方机器人账号,能够以 WebSocket 长连接的方式常驻服务器,接收群内 @ 消息并自动回复,也可以由脚本触发向群内推送通知。本教程以一个名为「希怡」的 AI 群聊机器人为例,基于 Python 与 QQ 官方 WebSocket 网关协议,完整实现群聊 AI 问答、主备大模型自动切换、联网搜索工具调用、群友长期记忆、群通知推送等全部能力,并给出部署运行与常见问题排查方案。示例代码已脱敏,替换为自己的凭证后即可直接运行。
前置教程
如想快速开始学习本教程,你可能需要先完成以下前置教程:
- 大语言模型LLM服务商Api服务调用教程,机器人的回答能力来自 LLM 服务商 API,本教程的主备大脑均通过该方式调用。
- OpenAI兼容AP的I概念与用法详解,机器人大脑使用 OpenAI 兼容的 /chat/completions 接口对话,function calling 工具循环也基于该接口。
- Tool工具调用技术分析与应用教程,希怡回答前自动联网搜索的能力依赖 function calling 机制,本教程会将其落地为多轮工具循环。
- Python+FastAPI在Windows环境下创建一个基础后端服务教程,群通知接口基于 FastAPI 构建,本教程会用到相同的本地服务模式。
资源下载
完整示例项目(含全部源码与机器人头像,已脱敏):
- xiyi-qq-bot 示例代码.zip,本教程的完整可运行项目,解压后按第三章填写配置即可使用。
其他官方入口:
- QQ 机器人开放平台,注册机器人、获取 AppID 与 AppSecret、开通群聊能力的官方管理端。
1. 认识 QQ 机器人与整体架构
1.1 QQ 机器人能做什么
QQ 机器人开放平台(q.qq.com)允许个人开发者注册机器人账号。机器人被添加进 QQ 群后,平台会把群内 @ 机器人的消息通过 WebSocket 长连接实时推送给开发者的服务程序,服务程序处理后可以调用接口向群里回复消息。除此之外,机器人还支持主动消息(不针对某条消息的推送),但平台对每个群的主动消息设有每日额度,而「回复用户消息」的被动回复不受额度限制,因此本教程将问答设计为被动回复、通知设计为主动消息。
1.2 希怡的整体架构
示例项目「希怡」是一个 3 岁半人设的群聊机器人,整体链路如下图:
工作流程为: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 便于在开放平台配置白名单,生产环境建议上服务器。
举手提问