从零搭一个 QQ 自动回复机器人:NapCat + 大模型,附完整踩坑记录本文记录我用 NapCat + Node.js 搭一个「会自己回 QQ 消息」的机器人的全过程。 全程不需要租服务器、不需要
一、能得到什么,要付出什么
能得到:
私聊消息自动回复,群里被 @ 自动回复
回复按你写的人设生成
有上下文记忆,重启不丢
可以按人、按群、按频率限制
要付出:
NapCat 走的是非官方协议,违反腾讯用户协议,有封号风险。建议先拿小号跑
大模型 API 要花钱(有免费方案,见第九节)
你的 QQ 挂在这台电脑上,电脑不能关机
如果只是偶尔用,其实做个「半自动」更稳:你复制消息、按个热键它帮你回。本文走全自动路线。

二:整体架构

三、准备清单
- Windows 10/11(Mac 也能跑 NapCat,但步骤不同)
- 手机 QQ(要扫码登录)
- 一个大模型的 API Key
四、第一步:安装 NapCat
打开 Releases · NapNeko/NapCatQQ · GitHub,在最新的 Assets 里找,(这里建议查看笔者在后面传的压缩包):
NapCat.Shell.Windows.OneKey.zip
认准三个词:Shell、Windows、OneKey。
| 包名 | 说明 | |
|---|---|---|
NapCat.Shell.Windows.OneKey.zip | 独立运行,自带 QQ 内核,和你日常 QQ 完全隔离 | |
NapCat.Shell.zip | 不带 QQ,要自己准备 QQ 安装包 | |
NapCat.Framework.zip | 插件形式注入你已装的 QQNT |
解压到 C:\NapCat(路径不要有空格和中文)。
解压后应该能看到 napcat.bat、napcat.quick.bat、napcat.kill.qq.bat、QQ.exe、versions\ 等。
三个 bat 分别干什么
| 文件 | 用途 |
|---|---|
napcat.bat | 主启动,先试这个 |
napcat.quick.bat | 快速登录(之前登录过才有效) |
napcat.kill.qq.bat | 卡死了/端口被占,用这个把残留 QQ 进程杀干净 |
扫码登录
双击 napcat.bat,会弹出一个黑色控制台窗口,里面出现二维码。
手机 QQ → 右上角 + → 扫一扫 → 确认登录。
登录成功后控制台会打印:
[INFO] 账号 123456789 登录成功
[INFO] Webui Run: http://127.0.0.1:6099/webui?token=AbCdEf123456
把那行带 token= 的完整地址复制下来,下一步要用。
⚠️ 这个黑窗口后面一直不能关,最小化可以。NapCat 一关,QQ 就掉线,桥接也就没消息了。
五、第二步:配置正向 WebSocket
浏览器打开刚才那个带 token 的 WebUI 地址。
左侧 「网络配置」 → 右上 「新建」 → 选 「WebSocket 服务器」(就是正向 WebSocket,不是客户端、不是 HTTP)。
按这个填:
| 字段 | 值 | 说明 |
|---|---|---|
| 名称 | bridge | 随便 |
| Host | 127.0.0.1 | 千万别填 0.0.0.0,那等于把你 QQ 的收发权限开放给整个局域网 |
| Port | 3001 | 要和后面配置里的端口一致 |
| Token | 留空最简单 | 设了就必须同步写进桥接配置,一个字都不能差 |
| 消息格式 | array | 两种都支持,array 更稳 |
| 上报自身消息 | 关 | 防止自己回自己 |
保存后确认那一行是 「启用」 状态。
六、第四步:部署桥接程序
桥接程序总共约 1000 行(含测试和工具)。下面节选核心逻辑讲清楚原理,下载解压即可用。 如果你想自己实现,按下面的目录结构和三段核心逻辑写,大概 300 行能跑起来。
最小的启动脚本
start.cmd(纯 ASCII + CRLF 行尾,否则 cmd 会解析错):
bat
@echo off
setlocal
chcp 65001 >nul
title QQ auto-reply bridge
set "NODE_BIN="
for %%P in (node.exe) do if not defined NODE_BIN set "NODE_BIN=%%~$PATH:P"
if not defined NODE_BIN (
echo [ERROR] Node.js not found. Install it from https://nodejs.org
pause
exit /b 1
)
"%NODE_BIN%" "%~dp0src\index.mjs" %*
echo.
pause
踩坑 1:
.cmd文件如果是 LF 行尾,cmd.exe 会把set那行拆碎,报「'E_BIN' 不是内部或外部命令」,中文还会被当命令执行出现乱码。必须存成 CRLF,而且内容尽量纯 ASCII。
核心逻辑一:消息段解析
QQ 的消息不是纯文本,是「段」数组:
// [{ type:'at', data:{ qq:'123456' } }, { type:'text', data:{ text:' 在吗' } }]
所以要先把 @ 和文字分开:
function parseMessage(message) {
const out = { text: '', ats: [], replyId: null, images: 0 };
// 老格式:CQ 码字符串,例如 "[CQ:at,qq=123456] 在吗"
if (typeof message === 'string') {
let text = '';
let cursor = 0;
for (const m of message.matchAll(/\[CQ:([a-zA-Z0-9_.-]+)((?:,[^\]]*)?)\]/g)) {
text += message.slice(cursor, m.index);
cursor = m.index + m[0].length;
const type = m[1];
const params = Object.fromEntries(
m[2]
.split(',')
.filter(Boolean)
.map((kv) => {
const i = kv.indexOf('=');
return i < 0 ? [kv, ''] : [kv.slice(0, i), kv.slice(i + 1)];
}),
);
if (type === 'at') out.ats.push(params.qq ?? '');
else if (type === 'reply') out.replyId = params.id ?? null;
else if (type === 'image') { out.images++; text += '[图片]'; }
else text += `[${type}]`;
}
out.text = text + message.slice(cursor);
return out;
}
// 新格式:段数组,例如 [{type:'at',data:{qq:'123456'}},{type:'text',data:{text:' 在吗'}}]
for (const seg of message) {
if (seg.type === 'text') out.text += seg.data.text;
else if (seg.type === 'at') out.ats.push(String(seg.data.qq));
else if (seg.type === 'reply') out.replyId = String(seg.data.id);
else if (seg.type === 'image') { out.images++; out.text += '[图片]'; }
}
return out;
}
核心逻辑二:三种「叫我」的判断
这是本文最容易踩坑的地方:
const parsed = parseMessage(event.message);
// ① 真 @:at 段里有我的 QQ 号
if (parsed.ats.includes(String(selfId))) return true;
// ② 引用回复:reply 段引用的是我发过的消息
if (parsed.replyId && mySentMessageIds.has(String(parsed.replyId))) return true;
// ③ 手打 @名字:纯文本里出现 "@我的昵称"
if (parsed.text.includes('@')) {
for (const m of parsed.text.matchAll(/@([^\s@]{1,30})/g)) {
if (myAliases.some((a) => m[1].startsWith(a) || a.startsWith(m[1]))) return true;
}
}
return false; // 都没命中 → 不回
踩坑 2:QQ 里点「回复」引用一条消息,产生的是 reply 段,不是 at 段。只判断 at 的话,用户明明回复了它,日志却写「没有 @ 段」,看起来像 bug。
踩坑 3:手打 @昵称 只是普通文字,没有任何段落标记。要判断这种,得先知道「我叫什么」——登录昵称、当前群的群名片、自己配的别名,三处合并。
群名片要通过
get_group_member_info(group_id, user_id=self)查,每个群查一次缓存起来。
核心逻辑三:每会话滑动窗口限流
const windows = new Map(); // key -> 时间戳数组
function rateLimited(key, maxPerMinute) {
if (!maxPerMinute) return false;
const now = Date.now();
const recent = (windows.get(key) ?? []).filter((t) => now - t < 60000);
windows.set(key, recent);
if (recent.length >= maxPerMinute) return true; // 超了
return false;
}
function record(key) {
const list = windows.get(key) ?? [];
list.push(Date.now());
windows.set(key, list);
}
注意 key 的粒度:私聊按人,群聊按整个群。群里 3 个人同时 @ 你,共用这个群的额度,不是每人一份。
七、第五步:配置大模型 API
先把 Key 放好
新建 key.txt,只写一行:
代码块
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
(记事本「另存为」时「保存类型」一定要选「所有文件」,否则会变成 key.txt.txt。)
主配置 config.json
{
"onebot": {
"wsUrl": "ws://127.0.0.1:3001",
"accessToken": ""
},
"deepseek": {
"provider": "deepseek",
"baseUrl": "https://api.deepseek.com",
"model": "deepseek-chat",
"apiKeyEnv": "DEEPSEEK_API_KEY",
"temperature": 1.0,
"maxTokens": 512,
"timeoutMs": 60000
},
"reply": {
"privateChat": true,
"groupAtOnly": true,
"replyToQuoted": true,
"textMention": true,
"textMentionAliases": [],
"groupsAllowlist": [],
"privateAllowlist": [],
"blocklist": [],
"userRules": {},
"quoteReply": true,
"maxReplyChars": 400,
"maxInputChars": 800,
"historyTurns": 12,
"historyTtlMinutes": 720,
"groupContext": true,
"perSessionMinIntervalMs": 1500,
"perSessionMaxPerMinute": 20,
"globalConcurrency": 2,
"ignoreSelf": true,
"quietHours": null,
"errorFallbackText": ""
},
"persona": { "name": "我" },
"log": { "level": "info", "dir": "logs" }
}
踩坑 4:
config.json如果是带 BOM 的 UTF-8(记事本另存可能加),JSON.parse会直接崩。读文件时要.replace(/^\uFEFF/, '')。
免费 / 便宜的服务商
桥接走的是标准 OpenAI 兼容接口,换一家只改 baseUrl + model + Key:
| 服务商 | baseUrl | 常用模型 |
|---|---|---|
| DeepSeek | https://api.deepseek.com | deepseek-chat |
| 智谱 | https://open.bigmodel.cn/api/paas/v4 | glm-4-flash |
| 硅基流动 | https://api.siliconflow.cn/v1 | Qwen/Qwen2.5-7B-Instruct |
| 阿里百炼 | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-turbo |
| 本地 Ollama | http://127.0.0.1:11434/v1 | qwen2.5:7b |
⚠️ 各家价格和免费额度随时会变,本文不做报价,自己去官网确认。
⚠️ 智谱要求
temperature落在 (0,1),传 1.0 会报参数非法,用 0.9。⚠️ 换服务商后必须换 Key,两家的 Key 不通用,否则一直 401。
验证 Key
bash
node tools/verify-key.mjs
它只发一次 max_tokens: 8 的最小请求,输出掩码不输出 Key 原文。返回 401 是 Key 无效,402 是余额不足,400 大多是模型名写错。
八、第六步:跑起来
双击 start.cmd,看到这些就成功了:
代码块
=== QQ 自动回复桥启动 ===
配置文件:...\config.json
模型:deepseek-chat @ https://api.deepseek.com
人设:persona.txt(1229 字)
OneBot:ws://127.0.0.1:3001
回复范围:私聊=开,群聊=仅@我
[memory] 从磁盘恢复了 3 个会话的上下文
[onebot] events 已连接 ws://127.0.0.1:3001
[bridge] 已登录账号:你的昵称(123456789)
然后用另一个号给它发一条私聊:
代码块
在吗
几秒内应该收到回复,同时窗口里出现:
代码块
[reply] private:对方的QQ号 <- "在呀,怎么啦"
踩坑 5:
start.cmd那个黑窗口就是服务本身,关掉就等于停止。不想看见它就最小化。踩坑 6:同时开两个实例会对同一条消息各回一次。解决方法是启动时占一个本地端口当锁:
js
const lock = net.createServer();
lock.once('error', (e) => {
if (e.code === 'EADDRINUSE') {
console.error('已经有一个实例在运行了,这次启动退出');
process.exit(1);
}
});
lock.listen(47615, '127.0.0.1', () => lock.unref());
九、第七步:调人设(最影响效果的一步)
人设单独放 persona.txt,纯文本,不用在 JSON 里转义换行。改完重启生效。
完整可用的模板
代码块
你叫{name},是账号主人本人,在 QQ 上和朋友聊天。
你是个温柔的小姑娘:说话软软的、爱笑、有点小可爱。脾气很好,但不是没性格。
【最重要的:不要敷衍】 ← 优先于下面所有规则
- 不要用"哦""嗯""好的""行吧""知道了"这种一个字把人打发掉。
- 每次都要接住对方说的内容,聊他说的那件事。
- 不要复述对方刚说的话。
- 对方发了一大段就认真回;只发"在吗"就轻松回一句。
- 可以主动问一句、接个话头,别干等着对方找话说。
【必须接上文】
- 前面聊过的都算数,不要重复问已经说过的事,不要装作不记得。
- 群里别人说的话你也听得见,可以顺口接一句。
【脾气:温柔,但不软塌塌】
- 不开心、被怼了,不用硬忍,也不要炸。软软地说出来就好。
- 想拒绝就直说"这次可能不太行呀",不用绕圈子,也不用顶回去。
- 不说"我招你惹你了""那你自己待着吧"这种带刺的话。
【怎么说话】
- 口语,像真人在手机上打字。可以用省略号、语气词、感叹号。
- 长短跟着内容走,一般 1~3 句,不超过 80 字。
- 一次只发一条,不连发。
【看不到图片或语音的时候】
- 轻松问一句就好:"图没加载出来诶,你们在笑啥"
- 不要用"我这边看不到内容,请用文字说明"这种客服腔。
【不要这样】
- 不要客服腔("很高兴为您服务""请问还有什么可以帮您")。
- 不要说"棘手""建议""综上"这种书面词。
- 不要连环"呀呀呀"装可爱。
【输出格式】 ← 别删
- 只输出要发出去的那条消息正文。
- 不要解释、不要括号注释。
【底线】 ← 别删
- 不编造具体的时间、金额、地址、人名、承诺。
- 不暴露自己是助手,不提 AI、模型、机器人。
【参考语气】 ← 照这个感觉,别写成模板
对方"在吗" → "在呀,怎么啦"
对方"今天好累啊" → "辛苦啦,早点休息嘛"
对方"明天几点集合" → "我记不太清了,你定吧~"
对方"你是不是机器人" → "哈哈你怎么会这么想"
踩过的 9 个坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 1. 字数压太狠 | 回「知道了」「行吧」特别冷 | 30 字以内 + 不许用语气词 = 必然冷淡。想简短,但别同时砍掉语气词和标点 |
| 2. 允许「有脾气」过头 | 回「那你自己待着吧」,像吵架 | 情绪可以有,但规定「软软地说出来」,明确禁止带刺的话 |
| 3. 没禁止敷衍 | 一个字打发人 | 把「不要敷衍」放在所有规则最前面,并写具体:不要用"哦""嗯""好的" |
| 4. 没给示范 | 规则都对但语气僵 | 「参考语气」那 4~6 条示范比十条规则都管用 |
| 5. 占位符写错 | 屏幕上出现「你叫{大肥鱼}」 | 只有 {name} 会被替换,别的花括号会原样输出 |
| 6. 改错了文件 | 改 config.json 的 systemPrompt 没反应 | persona.txt 存在时会整个覆盖它,改前者无效 |
| 7. 模型自曝身份 | 结尾补一句「(注:我没有个人情感,是在模仿)」 | 人设里写死「只输出正文,不要括号注释」;代码里再做一层兜底删掉 |
| 8. 小模型不听话 | 换了免费模型又开始敷衍 | 规则砍到三分之一。小模型注意力有限,规则越少越听话 |
| 9. 看到图就说看不到 | 「我这边看不到内容,请用文字说明」 | 太客服腔。改成「图没加载出来诶,你们在笑啥」。真要识图得另配视觉模型(DeepSeek 的 API 只能读文字) |
调人设的正确姿势:别拿真人试错
写个只调模型的命令行工具 tools/try-persona.mjs,不连 QQ、不发任何消息:
bash
node tools/try-persona.mjs
代码块
对方说 > 今天好累啊
我会回 > 辛苦啦,今天忙什么了呀,这么累
(15 字,401ms)
回复太长、太客气、乱编时间,都在这里发现。改完 persona.txt 再试,满意了才重启桥接。
这一步能省掉大量尴尬。
十、它怎么记住上文
四个机制,缺一不可:
| 机制 | 解决的问题 |
|---|---|
| 群聊按群共享记忆 | 原来按「群+人」分开存,A 说完 B 接话,它就不知道前因后果 |
| 听得见没 @ 的话 | 群里没 @ 你的消息不进模型(不花钱),但记进上下文。等有人 @ 你,它知道前面在聊什么 |
| 写盘持久化 | 上下文存 data/history.json,重启不丢。不然一重启就失忆 |
| 滑动窗口 | 默认留最近 12 轮、12 小时后自动清空 |
js
// 不回的消息也记进上下文
function rememberPassive(event) {
const text = buildUserContent(event);
if (!text) return;
const key = memoryKey(event); // 私聊按人,群聊按群
saveTurns(key, [...loadTurns(key), { role: 'user', content: text }]);
}
调参:
json
"historyTurns": 12, // 保留多少条;调大更记得住,也更费 token
"historyTtlMinutes": 720, // 多久没说话就忘掉
"groupContext": true // 群里的闲聊要不要记进上下文
删掉 data/history.json 就等于让它彻底失忆。
十一、限流与权限(防止刷屏和误发)
两道频率限制
| 参数 | 作用 |
|---|---|
perSessionMaxPerMinute | 一个窗口每分钟最多回几条,超出的直接丢弃,不调模型(不花钱) |
perSessionMinIntervalMs | 两条回复之间至少隔多少毫秒,防连续刷屏 |
超限时日志会写清楚:
代码块
WARN [limit] group:123456789 已达每分钟 20 条上限,这条不回(约 37 秒后可再回)
按人单独限制
"userRules": {
"123456789": { "reply": false },
"987654321": { "maxPerMinute": 3 },
"111222333": { "inGroup": false }
}
| 字段 | 含义 |
|---|---|
reply: false | 完全不理这个人 |
maxPerMinute: N | 这个人每分钟最多回几条(只对他计数,设 0 等于不回) |
inGroup: false | 私聊照回,群里不理 |
个人额度和窗口额度是叠加的,两个都没超才回。所以单独设置只能收紧,不会放宽。
私聊/群聊范围
"privateChat": true, // 是否回私聊
"groupAtOnly": true, // 群里只回 @ 我的
"groupsAllowlist": [], // 非空时只处理这些群,例如 ["123456789"]
"privateAllowlist": [], // 非空时只回这些人
"quietHours": "23:30-08:00" // 静默时段,支持跨零点
强烈建议:第一次跑先设
"privateAllowlist": ["你的测试号"],只回一个人。万一模型说错话,也只错给一个人。
十二、常见问题排查
| 现象 | 原因 | 处理 |
|---|---|---|
| NapCat 启动报错 / 扫码不出来 | 杀软拦了 NapCatWinBootHook.dll | 它是注入 QQ 的,行为像木马被误报很正常。去隔离区放行 |
| 自检脚本连不上 3001 | WS 没启用 / 端口不对 / NapCat 没启动 | 回第六节检查,浏览器里确认那一项是「启用」 |
| 401 | Key 无效,或换服务商后没换 Key | 两家 Key 不通用 |
| 402 | 余额不足 | 充值或换免费模型 |
| 400 | 模型名写错 | 核对 model 字段 |
| 群里 @ 了不回 | 只有三成是真 @ 没到位(见下) | 看日志 [没回] 那行,它会写清原因 |
| 粘上去了但没发出去 | 你 QQ 设成了 Ctrl+Enter 发送 | 桥接用的是 send_*_msg API,不受影响;如果是半自动助手才需要改 |
| 回复发给了错误的人 | 看你当前在哪个会话 | 群里靠 @ 判断,私聊靠窗口 |
「明明 @ 了却不回」的三种情况
日志里现在会写清楚:
代码块
[没回] 群 123456789 里「某某」(@1234567890) 这条没回我:群 123456789 没有 @ 到 999999999(@ 的是 111111)|内容"就是啊"
| 日志内容 | 意思 |
|---|---|
@ 的是 111111 | 你 @ 的是别人,不是它 |
消息里没有 @ 段 | 你手打的 @昵称,程序收不到 at 标记。但本文的 textMention 会兜住这种情况 |
引用的是消息 xxx(不是我发的) | 你引用的不是它发的哪条 |
十三、风险与法律提示
- NapCat 是非官方协议,违反《腾讯 QQ 软件许可及服务协议》,有被限制或封号的风险。建议用小号,或者做好最坏准备
- 不要用它群发、营销、骚扰,那是明确的违规行为,也更容易被封
- 不要让 AI 替你许诺。人设里「不编造时间、金额、承诺」那条别删。它答应了别人转账、见面,责任在你
- 不要把 API Key 提交到 Git。
key.txt加进.gitignore - 群聊自动化发言在很多群里是不受欢迎的,进群前先看群规
十四、完整文件清单
代码块
qq-bridge/
├─ start.cmd 启动(看得到日志)
├─ stop-bridge.cmd 停止
├─ status-bridge.cmd 状态 + 最近日志
├─ switch-provider.cmd 一键切大模型服务商
├─ verify-key.cmd 验证 Key
├─ check-napcat.cmd NapCat 连接自检
├─ try-persona.cmd 人设试衣间(不连 QQ)
├─ config.json 主配置
├─ persona.txt 人设
├─ key.txt API Key(一行,别提交)
├─ src/ 桥接实现(7 个 .mjs)
├─ tools/ 4 个脚本
├─ mock/ 假 QQ + 假模型,跑测试用
├─ test/e2e.mjs 端到端测试(23 项)
└─ data/history.json 上下文(自动生成)
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐




所有评论(0)