一、能得到什么,要付出什么

能得到:

私聊消息自动回复,群里被 @ 自动回复

回复按你写的人设生成

有上下文记忆,重启不丢

可以按人、按群、按频率限制

要付出:

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随便
Host127.0.0.1千万别填 0.0.0.0,那等于把你 QQ 的收发权限开放给整个局域网
Port3001要和后面配置里的端口一致
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常用模型
DeepSeekhttps://api.deepseek.comdeepseek-chat
智谱https://open.bigmodel.cn/api/paas/v4glm-4-flash
硅基流动https://api.siliconflow.cn/v1Qwen/Qwen2.5-7B-Instruct
阿里百炼https://dashscope.aliyuncs.com/compatible-mode/v1qwen-turbo
本地 Ollamahttp://127.0.0.1:11434/v1qwen2.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 的,行为像木马被误报很正常。去隔离区放行
自检脚本连不上 3001WS 没启用 / 端口不对 / NapCat 没启动回第六节检查,浏览器里确认那一项是「启用」
401Key 无效,或换服务商后没换 Key两家 Key 不通用
402余额不足充值或换免费模型
400模型名写错核对 model 字段
群里 @ 了不回只有三成是真 @ 没到位(见下)看日志 [没回] 那行,它会写清原因
粘上去了但没发出去你 QQ 设成了 Ctrl+Enter 发送桥接用的是 send_*_msg API,不受影响;如果是半自动助手才需要改
回复发给了错误的人看你当前在哪个会话群里靠 @ 判断,私聊靠窗口

「明明 @ 了却不回」的三种情况

日志里现在会写清楚:

代码块

[没回] 群 123456789 里「某某」(@1234567890) 这条没回我:群 123456789 没有 @ 到 999999999(@ 的是 111111)|内容"就是啊"
日志内容意思
@ 的是 111111你 @ 的是别人,不是它
消息里没有 @ 段你手打的 @昵称,程序收不到 at 标记。但本文的 textMention 会兜住这种情况
引用的是消息 xxx(不是我发的)你引用的不是它发的哪条

十三、风险与法律提示

  1. NapCat 是非官方协议,违反《腾讯 QQ 软件许可及服务协议》,有被限制或封号的风险。建议用小号,或者做好最坏准备
  2. 不要用它群发、营销、骚扰,那是明确的违规行为,也更容易被封
  3. 不要让 AI 替你许诺。人设里「不编造时间、金额、承诺」那条别删。它答应了别人转账、见面,责任在你
  4. 不要把 API Key 提交到 Git。key.txt 加进 .gitignore
  5. 群聊自动化发言在很多群里是不受欢迎的,进群前先看群规

十四、完整文件清单

代码块

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        上下文(自动生成)

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐