云计算百科
云计算领域专业知识百科平台

从零搭一个 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 上下文(自动生成)

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从零搭一个 QQ 自动回复机器人:NapCat + 大模型,附完整踩坑记录本文记录我用 NapCat + Node.js 搭一个「会自己回 QQ 消息」的机器人的全过程。 全程不需要租服务器、不需要
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!